This commit is contained in:
Chen 2026-04-08 13:56:27 +08:00
parent 1a127deeaf
commit 945f7830ca
15 changed files with 1230 additions and 29 deletions

22
LICENSE
View file

@ -1,5 +1,21 @@
Copyright (C) 2020-2025 by Dynalist Inc.
MIT License
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted.
Copyright (c) 2025 Chen
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -17,7 +17,9 @@
"title": "Sync to Typecho",
"publish": "publish",
"adding": "adding",
"addSuccess": "add success"
"addSuccess": "add success",
"advancedOptions": "Advanced Options",
"alias": "Alias"
},
"error": {
"requestFailed": "Request failed, please check the network or API configuration",
@ -31,10 +33,33 @@
"field": {
"title": "title",
"tag": "tag",
"file": "file",
"domainName": "domain name",
"category": "category",
"typechoUser": "Typecho user, the user who uses the operation",
"article": "article",
"allArticle": "all article"
},
"file": {
"uploadFile": "Upload File",
"deleteFile": "Delete File",
"copyUrl": "Copy URL to Clipboard",
"uploadingFile": "Uploading file...",
"uploadSuccess": "File uploaded successfully",
"uploadFailed": "File upload failed",
"uploadError": "Error occurred during file upload",
"deleteSuccess": "File deleted successfully",
"deleteFailed": "File deletion failed",
"deleteError": "Error occurred during file deletion",
"deleteConfirm": "Are you sure you want to delete the file {fileName}?",
"copySuccess": "URL for {fileName} has been copied to clipboard",
"copyFailed": "Copy failed: URL is empty",
"itemsPerPage": "Items per page",
"fileType": "Type",
"fileSize": "Size",
"enablePasteUpload": "Auto Upload Pasted Images",
"enablePasteUploadDesc": "Enable automatic upload and Markdown link insertion when pasting images with Ctrl+V",
"removeMetadata": "Remove Metadata",
"removeMetadataDesc": "Automatically remove YAML metadata (--- ... ---) from the top of the article during publication"
}
}

View file

@ -17,7 +17,9 @@
"title": "同步到Typecho",
"publish": "发布",
"adding": "添加中",
"addSuccess": "添加成功"
"addSuccess": "添加成功",
"advancedOptions": "高级选项",
"alias": "别名"
},
"error": {
"requestFailed": "请求失败,请检查网络或 API 配置",
@ -26,15 +28,39 @@
"readFileError": "读取文件错误,请检查文件内容",
"noTypechoUser": "未设置 Typecho 用户",
"failedToFetchArticles": "获取文章列表失败",
"failedToFetchPostDetails": "获取文章详情失败"
"failedToFetchPostDetails": "获取文章详情失败",
"noFiles": "没有找到文件"
},
"field": {
"title": "标题",
"tag": "标签",
"file": "文件",
"domainName": "域名",
"category": "分类",
"typechoUser": "Typecho 用户,操作时使用的用户",
"article": "文章",
"allArticle": "全部文章"
},
"file": {
"uploadFile": "上传文件",
"deleteFile": "删除文件",
"copyUrl": "复制URL到剪贴板",
"uploadingFile": "文件上传中...",
"uploadSuccess": "文件上传成功",
"uploadFailed": "文件上传失败",
"uploadError": "文件上传出错",
"deleteSuccess": "文件删除成功",
"deleteFailed": "文件删除失败",
"deleteError": "文件删除出错",
"deleteConfirm": "确定要删除文件 {fileName} 吗?",
"copySuccess": "已复制 {fileName} 的URL到剪贴板",
"copyFailed": "复制失败URL为空",
"itemsPerPage": "每页显示",
"fileType": "类型",
"fileSize": "大小",
"enablePasteUpload": "粘贴图片自动上传",
"enablePasteUploadDesc": "启用后Ctrl+V 粘贴图片将自动上传并插入 Markdown 链接",
"removeMetadata": "去除元数据",
"removeMetadataDesc": "发布时自动去除文章顶部的 YAML 元数据 (--- ... ---)"
}
}

36
main.ts
View file

@ -1,16 +1,24 @@
import { Plugin, addIcon } from "obsidian";
import { App, MarkdownView, Notice, Plugin, addIcon } from "obsidian";
import { settingTab } from "./setting/setting_tab";
import { PushModal } from "./view/push";
import { CategoryView, VIEW_TYPE as ArticleViewType } from "./view/article_type";
import { EditorView } from "@codemirror/view";
import { Extension } from "@codemirror/state";
import {
CategoryView,
VIEW_TYPE as ArticleViewType,
} from "./view/article_type";
import {
TypechoPluginSettings,
DEFAULT_SETTINGS,
} from "./setting/plugin_settings";
import i18n from "./utils/i18n";
import { Util } from "./utils/util";
let settings: TypechoPluginSettings;
let pluginInstance: TypechoPlugin;
export default class TypechoPlugin extends Plugin {
async onload() {
pluginInstance = this;
addIcon(
"typecho",
`<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1745386247342" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1491" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M517.04420574 882.47531656c-289.98573089 0-391.40007045-98.3302631-391.40007043-379.40894092 0-281.09368555 101.41433957-379.39393331 391.40007043-379.39393327 289.98573089 0 391.40007045 98.3002478 391.40007044 379.39393327 0 281.07867783-101.41433957 379.40143714-391.40007044 379.40143709zM306.28397227 386.32169964h421.50545924v-58.3648341H306.28397227v58.37233801z m0 145.92709302h301.07640006V473.8914624H306.28397227v58.36483409z m0 145.92709298h361.28717777v-58.37233793H306.2914761v58.37233793z" p-id="1492"></path></svg>`
@ -29,14 +37,14 @@ export default class TypechoPlugin extends Plugin {
this.registerView(ArticleViewType, (leaf) => new CategoryView(leaf));
this.openPluginView();
this.registerPasteListener(this.app);
}
onunload() {
this.app.workspace.getLeavesOfType(ArticleViewType);
}
onunload() {}
async loadSettings() {
settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
return settings;
}
async saveSettings() {
@ -52,8 +60,26 @@ export default class TypechoPlugin extends Plugin {
});
}
}
private registerPasteListener(app: App) {
this.registerEvent(
this.app.workspace.on("editor-paste", async (evt, editor) => {
await Util.upload.handleImagePaste(
app,
editor,
evt.clipboardData!
);
})
);
}
}
export function getSettings() {
return settings;
}
export async function saveSettings() {
if (pluginInstance) {
await pluginInstance.saveSettings();
}
}

View file

@ -1,7 +1,7 @@
{
"id": "typecho",
"name": "Typecho",
"version": "0.1.1",
"version": "0.1.5",
"minAppVersion": "0.15.0",
"description": "Post the file to Typecho",
"author": "Chen",

View file

@ -1,6 +1,6 @@
{
"name": "typecho",
"version": "0.1.0",
"version": "0.1.5",
"description": "将笔记同步至typecho",
"main": "main.js",
"scripts": {

View file

@ -7,6 +7,9 @@ export interface TypechoPluginSettings {
screenName: string;
mail: string;
};
enablePasteUpload: boolean;
removeMetadata: boolean;
slugMapping: Record<string, string>;
}
export const DEFAULT_SETTINGS: TypechoPluginSettings = {
Host: "",
@ -17,4 +20,7 @@ export const DEFAULT_SETTINGS: TypechoPluginSettings = {
screenName: "",
mail: "",
},
enablePasteUpload: false,
removeMetadata: false,
slugMapping: {},
};

View file

@ -29,7 +29,9 @@ export class settingTab extends PluginSettingTab {
.setDesc(`Typecho${i18n.t("field.domainName")}`)
.addText((text) =>
text
.setPlaceholder(`${i18n.t("field.domainName")}/index.php/api`)
.setPlaceholder(
`${i18n.t("field.domainName")}/index.php/api`
)
.setValue(this.settings.Host)
.onChange(async (value) => {
this.settings.Host = value;
@ -58,6 +60,31 @@ export class settingTab extends PluginSettingTab {
await this.refreshUserList(containerEl);
});
});
new Setting(containerEl)
.setName(i18n.t("file.enablePasteUpload"))
.setDesc(i18n.t("file.enablePasteUploadDesc"))
.addToggle((toggle) =>
toggle
.setValue(this.settings.enablePasteUpload)
.onChange(async (value) => {
this.settings.enablePasteUpload = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName(i18n.t("file.removeMetadata"))
.setDesc(i18n.t("file.removeMetadataDesc"))
.addToggle((toggle) =>
toggle
.setValue(this.settings.removeMetadata)
.onChange(async (value) => {
this.settings.removeMetadata = value;
await this.plugin.saveSettings();
})
);
}
private async refreshUserList(containerEl: HTMLElement): Promise<void> {

View file

@ -99,6 +99,241 @@
transition: color 0.2s ease-in-out;
}
/* 文件列表样式 */
.file-list-container {
padding: 8px;
}
.file-list {
list-style: none;
margin: 0;
padding: 0;
}
.file-item {
align-items: center;
background-color: #1e1e1e;
border-radius: 6px;
display: flex;
justify-content: space-between;
margin-bottom: 8px;
padding: 10px 12px;
transition: background-color 0.2s ease-in-out;
}
.file-item:hover {
background-color: #2a2a2a;
}
.file-info-container {
display: flex;
align-items: center;
flex: 1;
}
.file-preview-container {
margin-right: 12px;
width: 40px;
height: 40px;
overflow: hidden;
border-radius: 4px;
flex-shrink: 0;
}
.file-preview-img {
width: 100%;
height: 100%;
object-fit: cover;
cursor: pointer;
transition: transform 0.2s ease-in-out;
}
.file-preview-img:hover {
transform: scale(1.1);
}
.file-text-container {
display: flex;
flex-direction: column;
flex: 1;
}
.file-title {
color: #e0e0e0;
font-size: 14px;
margin-bottom: 4px;
}
.file-type {
color: #888;
font-size: 12px;
}
.file-meta-container {
display: flex;
flex-direction: column;
align-items: flex-end;
margin-left: 12px;
}
.file-time {
color: #888;
font-size: 12px;
margin-bottom: 8px;
}
.file-action-container {
display: flex;
gap: 8px;
}
.file-action-button {
background-color: transparent;
border: none;
border-radius: 4px;
color: #888;
cursor: pointer;
font-size: 14px;
padding: 4px;
transition: all 0.2s ease-in-out;
}
.file-action-button:hover {
background-color: rgba(255, 255, 255, 0.1);
color: #e0e0e0;
}
.delete-button:hover {
color: #ff5555;
}
/* 上传按钮和分页设置样式 */
.upload-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 0 8px;
}
.page-size-container {
display: flex;
align-items: center;
gap: 8px;
}
.page-size-label {
color: #888;
font-size: 14px;
}
.page-size-select {
background-color: #2a2a2a;
border: 1px solid #444;
border-radius: 4px;
color: #e0e0e0;
cursor: pointer;
font-size: 14px;
padding: 4px 8px;
transition: all 0.2s ease-in-out;
}
.page-size-select:hover, .page-size-select:focus {
border-color: #666;
outline: none;
}
.upload-button {
align-items: center;
background-color: #2a2a2a;
border: none;
border-radius: 4px;
color: #e0e0e0;
cursor: pointer;
display: flex;
font-size: 14px;
gap: 8px;
padding: 8px 12px;
transition: background-color 0.2s ease-in-out;
}
.upload-button:hover {
background-color: #3a3a3a;
}
/* 图片预览模态框样式 */
.image-preview-modal {
background-color: rgba(0, 0, 0, 0.85);
}
.image-preview-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
max-width: 90vw;
}
.image-preview-title {
color: #fff;
font-size: 18px;
margin-bottom: 12px;
text-align: center;
}
.image-preview-info {
display: flex;
flex-wrap: wrap;
justify-content: center;
margin-bottom: 16px;
width: 100%;
}
.image-info-item {
background-color: rgba(255, 255, 255, 0.1);
border-radius: 4px;
color: #ccc;
font-size: 12px;
margin: 0 6px 6px 0;
padding: 4px 8px;
}
.image-preview-img {
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
max-height: 80vh;
max-width: 100%;
object-fit: contain;
}
/* 分页控件样式 */
.pagination-container {
align-items: center;
display: flex;
justify-content: center;
margin-top: 16px;
}
.pagination-button {
background-color: #2a2a2a;
border: none;
border-radius: 4px;
color: #e0e0e0;
cursor: pointer;
margin: 0 8px;
padding: 6px 12px;
transition: background-color 0.2s ease-in-out;
}
.pagination-button:hover {
background-color: #3a3a3a;
}
.pagination-info {
color: #888;
font-size: 14px;
}
/* 图标样式 */
.tree-link-icon svg,
.tree-article-icon.inline-icon svg {

View file

@ -11,7 +11,9 @@ i18next.init({
fallbackLng: "en",
resources,
interpolation: {
escapeValue: false
escapeValue: false,
prefix: "{",
suffix: "}",
},
keySeparator: "."
});

View file

@ -7,6 +7,7 @@ export class HttpUtils {
return {
"Content-Type": "application/json",
token: getSettings().Token,
Origin: getSettings().Host,
};
}
private static getHost() {
@ -23,13 +24,13 @@ export class HttpUtils {
url: string,
headers?: Record<string, string>
): Promise<ResponseType> {
return this.request("GET", url, null, headers);
return this.request("GET", url, undefined, headers);
}
/**
* POST
* @param url URL
* @param body
* @param body FormData
* @param headers
* @returns
*/
@ -56,15 +57,31 @@ export class HttpUtils {
headers?: Record<string, string>
): Promise<ResponseType> {
headers = {
...headers,
...this.getHeaders(),
...headers,
};
const shouldHaveBody = !["GET", "HEAD", "DELETE"].includes(
method.toUpperCase()
);
const requestOptions = {
method: method,
url: this.getHost() + url,
headers: headers || {},
body: body ? JSON.stringify(body) : undefined,
};
// 只有需要时才添加 body
if (shouldHaveBody && body !== undefined && body !== null) {
(requestOptions as any).body =
typeof body === "string" ? body : JSON.stringify(body);
if (!headers?.["Content-Type"]) {
requestOptions.headers = {
...requestOptions.headers,
"Content-Type": "application/json",
};
}
}
try {
const response: RequestUrlResponse = await requestUrl(
requestOptions

View file

@ -1,3 +1,7 @@
import { getSettings } from "main";
import { Notice, Editor, TFolder, App } from "obsidian";
import { HttpUtils } from "./request";
// 定义树节点接口
export interface TreeNode {
id?: number;
@ -95,4 +99,173 @@ export const Util = {
return (hash >>> 0).toString(16).padStart(8, "0").slice(-6);
},
},
file: {
// 将file转换成Uint8Array
async fileToUint8Array(file: File | Blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const arrayBuffer = reader.result as ArrayBuffer;
resolve(new Uint8Array(arrayBuffer));
};
reader.onerror = reject;
reader.readAsArrayBuffer(file);
});
},
},
url: {
extractDomain(url: string) {
const domain = url.match(/https?:\/\/(www\.)?([^\s/]+)/);
if (domain) {
return domain[0];
}
return "";
},
},
upload: {
async handleImagePaste(
app: App,
editor: Editor,
clipboardData: DataTransfer
): Promise<boolean> {
if (!getSettings().enablePasteUpload) {
return false;
}
const items = Array.from(clipboardData.items || []);
const imageItem = items.find((item) =>
item.type.startsWith("image/")
);
if (!imageItem) return false;
console.log("📥 Detected image paste, handling...", imageItem.type);
try {
const blob = imageItem.getAsFile();
if (!blob) return false;
const ext =
blob.type.split("/")[1]?.replace(/;.*/, "") || "png";
const fileName = `paste-${Date.now()}.${ext}`; // 👈 记住这个 fileName
const notice = new Notice(`📥 正在上传图片:${fileName}`, 0);
const buffer = await Util.file.fileToUint8Array(blob);
const response = await HttpUtils.post(
"/upload?authorId=" + getSettings().User.uid,
{
file: buffer,
fileName: fileName,
}
);
notice.hide();
if (!response?.data?.url) {
new Notice("❌ 图片上传失败", 3000);
return true;
}
// ✅ 新增:删除 Obsidian 原生粘贴产生的 ![[fileName]] 或 ![](fileName)
try {
const cursor = editor.getCursor();
const line = editor.getLine(cursor.line);
// 构造可能的原生粘贴内容Obsidian 偏好双括号)
const nativeEmbed1 = `![[${blob.name}]]`;
const nativeEmbed2 = `![](${blob.name})`;
const nativeEmbed3 = `![image](${blob.name})`; // 少数情况
// 从光标位置往前找(原生粘贴通常在当前光标处插入)
const startPos = Math.max(
0,
cursor.ch - nativeEmbed1.length - 20
);
const segment = line.substring(startPos, cursor.ch + 20);
let toDelete = null;
if (segment.includes(nativeEmbed1)) {
toDelete = nativeEmbed1;
} else if (segment.includes(nativeEmbed2)) {
toDelete = nativeEmbed2;
} else if (segment.includes(nativeEmbed3)) {
toDelete = nativeEmbed3;
}
if (toDelete) {
// 找到匹配项,计算其在行内的位置
const idx = line.indexOf(toDelete, startPos);
if (idx !== -1) {
editor.replaceRange(
"",
{ line: cursor.line, ch: idx },
{ line: cursor.line, ch: idx + toDelete.length }
);
// 修正光标位置(恢复到删除后的位置)
editor.setCursor({ line: cursor.line, ch: idx });
console.log(`🧹 已删除原生粘贴占位符: ${toDelete}`);
}
}
} catch (e) {
console.warn(
"Failed to remove native paste placeholder:",
e
);
// 不中断主流程
}
const domain = Util.url.extractDomain(getSettings().Host);
const imageUrl =
(domain.endsWith("/") ? domain : domain + "/") +
response.data.url;
const markdownImage = `![](${imageUrl})`;
editor.replaceSelection(markdownImage);
// 上传成功后,检查并删除当前目录下的同名本地文件
try {
const activeFile = app.workspace.getActiveFile();
if (activeFile) {
const parentDir = app.vault.getAbstractFileByPath(
activeFile.parent?.path || "/"
);
if (parentDir && parentDir instanceof TFolder) {
const attachmentPath =
(app.vault as any).config
?.attachmentFolderPath || "";
let delFilepath = blob.name;
if (attachmentPath) {
delFilepath = attachmentPath + "/" + blob.name;
delFilepath = delFilepath.substring(1);
}
console.log(delFilepath);
let existingFile =
app.vault.getAbstractFileByPath(delFilepath);
console.log(existingFile);
if (existingFile) {
await app.vault.delete(existingFile);
console.log(
`🗑️ 已删除本地重复文件: ${delFilepath}`
);
}
}
}
} catch (delErr) {
console.warn(
"Cleanup local duplicate file failed:",
delErr
);
// 不中断主流程,静默失败
}
new Notice("✅ 图片已上传并插入", 2000);
return true;
} catch (err) {
console.error("Paste image upload failed:", err);
new Notice("❌ 粘贴上传图片失败,请重试", 3000);
return true;
}
},
},
};

View file

@ -1,4 +1,8 @@
{
"0.1.0": "0.15.0",
"0.1.1": "0.15.0"
"0.1.1": "0.15.0",
"0.1.2": "0.15.0",
"0.1.3": "0.15.0",
"0.1.4": "0.15.0",
"0.1.5": "0.15.0"
}

View file

@ -1,14 +1,82 @@
// main.ts
import { ItemView, setIcon } from "obsidian";
import { ItemView, setIcon, Modal, App, Notice } from "obsidian";
import i18n from "../utils/i18n";
import { getSettings } from "../main";
import { HttpUtils } from "../utils/request";
import { Util } from "../utils/util";
import { BrowseArticles } from "./browse_articles";
// 图片预览模态框
class ImageModal extends Modal {
file: any;
constructor(app: App, file: any) {
super(app);
this.file = file;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("image-preview-modal");
// 创建图片容器
const imageContainer = contentEl.createDiv({
cls: "image-preview-container",
});
// 创建图片标题
imageContainer.createEl("h2", {
text: this.file.title,
cls: "image-preview-title",
});
// 创建图片信息
const infoContainer = imageContainer.createDiv({
cls: "image-preview-info",
});
infoContainer.createEl("span", {
text: `类型: ${this.file.type.toUpperCase()}`,
cls: "image-info-item",
});
infoContainer.createEl("span", {
text: `大小: ${this.formatFileSize(this.file.size)}`,
cls: "image-info-item",
});
infoContainer.createEl("span", {
text: `时间: ${this.file.createdAt}`,
cls: "image-info-item",
});
// 创建图片元素
const img = imageContainer.createEl("img", {
cls: "image-preview-img",
attr: { src: this.file.url },
});
}
// 格式化文件大小
formatFileSize(size: number): string {
if (size < 1024) {
return size + " B";
} else if (size < 1024 * 1024) {
return (size / 1024).toFixed(2) + " KB";
} else {
return (size / (1024 * 1024)).toFixed(2) + " MB";
}
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}
const VIEW_TYPE = "article-view";
class CategoryView extends ItemView {
pageSize = 15; // 默认每页显示15条
getViewType(): string {
return VIEW_TYPE;
}
@ -31,6 +99,7 @@ class CategoryView extends ItemView {
{ key: "all", label: i18n.t("field.allArticle") },
{ key: "category", label: i18n.t("field.category") },
{ key: "tag", label: i18n.t("field.tag") },
{ key: "file", label: i18n.t("field.file") },
];
// 初始化加载第一个 tab
this.currentTab = "category";
@ -42,12 +111,12 @@ class CategoryView extends ItemView {
"tab-span " +
(tab.key === this.currentTab ? "selected" : ""),
});
tabEl.addEventListener("click", () => {
tabEl.addEventListener("click", async () => {
if (tab.key == "all") {
new BrowseArticles(
this.app,
{ name: i18n.t("field.allArticle") },
'all'
"all"
).open();
return;
}
@ -104,6 +173,10 @@ class CategoryView extends ItemView {
"mid",
"parent"
);
const treeContainer = container.createDiv({
cls: "tree-container",
});
this.renderTree(treeContainer, data);
} else if (tabKey === "tag") {
const response = await HttpUtils.get("/tags", {});
data = Util.treeUtil.generateTreeData(
@ -111,10 +184,219 @@ class CategoryView extends ItemView {
"mid",
"parent"
);
const treeContainer = container.createDiv({
cls: "tree-container",
});
this.renderTree(treeContainer, data);
} else if (tabKey === "file") {
await this.loadFileList(container, 1);
}
}
async loadFileList(container: Element, page = 1) {
container.empty();
// 创建上传按钮和分页设置区域
const uploadContainer = container.createDiv({
cls: "upload-container",
});
// 创建分页设置区域
const pageSizeContainer = uploadContainer.createDiv({
cls: "page-size-container",
});
// 添加分页大小标签
pageSizeContainer.createEl("span", {
text: i18n.t("file.itemsPerPage") + ": ",
cls: "page-size-label",
});
// 创建分页大小选择器
const pageSizeSelect = pageSizeContainer.createEl("select", {
cls: "page-size-select",
});
// 添加选项
[10, 15, 20, 30, 50].forEach((size) => {
const option = pageSizeSelect.createEl("option", {
text: size.toString(),
value: size.toString(),
});
if (size === this.pageSize) {
option.selected = true;
}
});
// 添加选择事件
pageSizeSelect.addEventListener("change", () => {
this.pageSize = parseInt(pageSizeSelect.value);
this.loadFileList(container, 1); // 重新加载第一页
});
// 上传按钮
const uploadButton = uploadContainer.createEl("button", {
text: i18n.t("file.uploadFile"),
cls: "upload-button",
});
setIcon(uploadButton, "upload");
// 上传按钮点击事件
uploadButton.addEventListener("click", () => {
this.uploadFile(container, page);
});
const response = await HttpUtils.get(
`/fileList?page=${page}&pageSize=${this.pageSize}`,
{}
);
const fileData = response.data;
if (!fileData || !fileData.dataSet || fileData.dataSet.length === 0) {
container.createEl("p", {
text: i18n.t("error.noFiles"),
});
return;
}
const treeContainer = container.createDiv({ cls: "tree-container" });
this.renderTree(treeContainer, data);
// 处理url
fileData.dataSet.forEach((file: any) => {
file.url = Util.url.extractDomain(getSettings().Host) + file.path;
});
// 创建文件列表容器
const fileListContainer = container.createDiv({
cls: "file-list-container",
});
// 创建文件列表
const fileList = fileListContainer.createEl("ul", { cls: "file-list" });
// 添加文件项
fileData.dataSet.forEach((file: any) => {
const fileItem = fileList.createEl("li", { cls: "file-item" });
// 创建左侧信息区域
const infoContainer = fileItem.createDiv({
cls: "file-info-container",
});
// 如果是图片类型,添加缩略图预览
if (file.mime && file.mime.startsWith("image/")) {
const previewContainer = infoContainer.createDiv({
cls: "file-preview-container",
});
const previewImg = previewContainer.createEl("img", {
cls: "file-preview-img",
attr: { src: file.url },
});
// 点击图片弹出大屏预览
previewImg.addEventListener("click", (e) => {
e.stopPropagation();
this.showImageModal(file);
});
}
// 创建文件信息区域
const textContainer = infoContainer.createDiv({
cls: "file-text-container",
});
// 文件标题
textContainer.createEl("div", {
text: file.title,
cls: "file-title",
});
// 文件类型
textContainer.createEl("div", {
text: file.type ? file.type.toUpperCase() : "",
cls: "file-type",
});
// 创建右侧信息区域
const metaContainer = fileItem.createDiv({
cls: "file-meta-container",
});
// 文件时间
metaContainer.createEl("div", {
text: file.createdAt || "",
cls: "file-time",
});
// 操作按钮容器
const actionContainer = metaContainer.createDiv({
cls: "file-action-container",
});
// 复制URL按钮
const copyButton = actionContainer.createEl("button", {
cls: "file-action-button copy-button",
attr: { title: i18n.t("file.copyUrl") },
});
setIcon(copyButton, "link");
// 复制URL按钮点击事件
copyButton.addEventListener("click", (e) => {
e.stopPropagation();
this.copyToClipboard(file.url, file.title);
});
// 删除按钮
const deleteButton = actionContainer.createEl("button", {
cls: "file-action-button delete-button",
attr: { title: i18n.t("file.deleteFile") },
});
setIcon(deleteButton, "trash");
// 删除按钮点击事件
deleteButton.addEventListener("click", async (e) => {
e.stopPropagation();
if (confirm(`确定要删除文件 "${file.title}" 吗?`)) {
await this.deleteFile(file.cid);
this.loadFileList(container, page);
}
});
});
// 添加分页控件
if (fileData.pages > 1) {
const paginationContainer = container.createDiv({
cls: "pagination-container",
});
// 上一页按钮
if (fileData.page > 1) {
const prevButton = paginationContainer.createEl("button", {
text: "上一页",
cls: "pagination-button",
});
prevButton.addEventListener("click", () => {
this.loadFileList(container, fileData.page - 1);
});
}
// 页码信息
paginationContainer.createEl("span", {
text: `${fileData.page}/${fileData.pages}`,
cls: "pagination-info",
});
// 下一页按钮
if (fileData.page < fileData.pages) {
const nextButton = paginationContainer.createEl("button", {
text: "下一页",
cls: "pagination-button",
});
nextButton.addEventListener("click", () => {
this.loadFileList(container, fileData.page + 1);
});
}
}
}
renderTree(container: Element, nodes: any[], depth = 0) {
@ -182,6 +464,305 @@ class CategoryView extends ItemView {
}
}
// 显示图片大屏预览
showImageModal(file: any) {
const modal = new ImageModal(this.app, file);
modal.open();
}
// 删除文件
async deleteFile(cid: number) {
try {
const response = await HttpUtils.post("/deleteFile", { cid });
if (response) {
new Notice(i18n.t("file.deleteSuccess"));
return true;
}
return false;
} catch (error) {
console.error(i18n.t("file.deleteError") + ":", error);
new Notice(i18n.t("file.deleteError"));
return false;
}
}
// 复制URL到剪贴板
copyToClipboard(url: string, fileName: string) {
if (!url) {
new Notice(i18n.t("file.copyFailed"));
return;
}
// 创建临时文本区域
const textarea = document.createElement("textarea");
textarea.value = url;
textarea.style.position = "absolute";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
// 选择并复制
textarea.select();
document.execCommand("copy");
// 移除临时元素
document.body.removeChild(textarea);
// 显示成功提示
new Notice(i18n.t("file.copySuccess", { fileName: fileName }));
}
// 上传文件(单/多文件,带进度条 + 限速延迟)
uploadFile(container: Element, page: number) {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.multiple = true;
fileInput.style.display = "none";
document.body.appendChild(fileInput);
fileInput.addEventListener("change", async () => {
const files = Array.from(fileInput.files || []);
if (files.length === 0) {
document.body.removeChild(fileInput);
return;
}
// 创建上传进度面板
const progressPanel = this.createUploadProgressPanel(files.length);
document.body.appendChild(progressPanel.element);
let isCancelled = false;
// 取消按钮逻辑
if (progressPanel.cancelButton) {
progressPanel.cancelButton.onclick = () => {
isCancelled = true;
progressPanel.updateStatus("已取消上传");
progressPanel.setProgress(0);
setTimeout(() => progressPanel.hide(), 1000);
};
}
try {
progressPanel.updateStatus("准备上传...");
for (let i = 0; i < files.length; i++) {
if (isCancelled) break;
const file = files[i];
const current = i + 1;
const total = files.length;
progressPanel.updateFile(file.name);
progressPanel.updateStatus(
`正在上传 (${current}/${total})`
);
progressPanel.setProgress(((current - 1) / total) * 100); // 上传前进度
try {
const buffer = await Util.file.fileToUint8Array(file);
const response = await HttpUtils.post(
"/upload?authorId=" + getSettings().User.uid,
{
file: buffer,
fileName: file.name,
}
);
if (!response) {
throw new Error("Upload response is empty");
}
// ✅ 单文件上传成功,更新进度为当前文件完成
progressPanel.setProgress((current / total) * 100);
// ⏱️ 限流延迟
if (i < files.length - 1) {
const delayMs =
300 + Math.floor(Math.random() * 300); // 300~600ms
await new Promise((resolve) =>
setTimeout(resolve, delayMs)
);
}
} catch (err) {
console.error(`Failed to upload ${file.name}:`, err);
progressPanel.updateStatus(
`⚠️ ${file.name} 失败,跳过`
);
progressPanel.setProgress((current / total) * 100);
await new Promise((resolve) =>
setTimeout(resolve, 500)
);
}
}
if (isCancelled) {
progressPanel.updateStatus("上传已取消");
} else {
progressPanel.updateStatus("✅ 全部上传完成!");
progressPanel.setProgress(100);
await new Promise((resolve) => setTimeout(resolve, 1000));
this.loadFileList(container, page); // 刷新列表
}
} catch (error) {
console.error("Upload process error:", error);
progressPanel.updateStatus("❌ 上传过程异常");
} finally {
// 自动隐藏
setTimeout(
() => progressPanel.hide(),
isCancelled ? 1500 : 2000
);
document.body.removeChild(fileInput);
}
});
fileInput.click();
}
// 辅助方法:创建上传进度面板
private createUploadProgressPanel(totalFiles: number) {
const panel = document.createElement("div");
panel.className = "obsidian-upload-progress-panel";
// CSS 注入(一次性,防止重复)
if (!document.getElementById("upload-progress-style")) {
const style = document.createElement("style");
style.id = "upload-progress-style";
style.textContent = `
.obsidian-upload-progress-panel {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 9999;
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: 8px;
padding: 12px 16px;
min-width: 300px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
font-size: var(--font-ui-smaller);
transition: opacity 0.3s, transform 0.3s;
opacity: 1;
transform: translateY(0);
}
.obsidian-upload-progress-panel.fade-out {
opacity: 0;
transform: translateY(20px);
}
.upload-progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.upload-progress-title {
font-weight: 600;
color: var(--text-normal);
}
.upload-progress-cancel {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 16px;
padding: 2px;
border-radius: 4px;
}
.upload-progress-cancel:hover {
color: var(--text-accent);
background: var(--background-modifier-hover);
}
.upload-progress-filename {
color: var(--text-faint);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
.upload-progress-bar-container {
width: 100%;
height: 6px;
background: var(--background-modifier-border);
border-radius: 3px;
overflow: hidden;
margin: 6px 0;
}
.upload-progress-bar {
height: 100%;
background: var(--interactive-accent);
border-radius: 3px;
width: 0%;
transition: width 0.3s ease;
}
.upload-progress-status {
font-size: var(--font-ui-smaller);
color: var(--text-muted);
display: flex;
justify-content: space-between;
}
`;
document.head.appendChild(style);
}
panel.innerHTML = `
<div class="upload-progress-header">
<span class="upload-progress-title">📎 </span>
<button class="upload-progress-cancel" title="取消上传">×</button>
</div>
<div class="upload-progress-filename">-</div>
<div class="upload-progress-bar-container">
<div class="upload-progress-bar"></div>
</div>
<div class="upload-progress-status">
<span class="status-text">...</span>
<span class="progress-percent">0%</span>
</div>
`;
const filenameEl = panel.querySelector(
".upload-progress-filename"
) as HTMLElement;
const barEl = panel.querySelector(
".upload-progress-bar"
) as HTMLElement;
const statusEl = panel.querySelector(".status-text") as HTMLElement;
const percentEl = panel.querySelector(
".progress-percent"
) as HTMLElement;
const cancelButton = panel.querySelector(
".upload-progress-cancel"
) as HTMLElement;
const api = {
element: panel,
cancelButton,
updateFile(filename: string) {
filenameEl.textContent = filename;
},
updateStatus(text: string) {
statusEl.textContent = text;
},
setProgress(percent: number) {
const p = Math.max(0, Math.min(100, Math.round(percent)));
barEl.style.width = `${p}%`;
percentEl.textContent = `${p}%`;
},
hide() {
panel.classList.add("fade-out");
setTimeout(() => {
if (panel.parentNode) panel.parentNode.removeChild(panel);
}, 300);
},
};
return api;
}
async onClose() {
// 清理资源
}

View file

@ -1,5 +1,5 @@
import { App, Modal, Notice } from "obsidian";
import { getSettings } from "../main";
import { App, Modal, Notice, Setting } from "obsidian";
import { getSettings, saveSettings } from "../main";
import { addMetas } from "./add_metasl";
import { HttpUtils } from "../utils/request";
import i18n from "../utils/i18n";
@ -11,6 +11,8 @@ export class PushModal extends Modal {
notice: Notice;
baseFileName = "";
title = "";
slug = "";
slugInput: HTMLInputElement;
content = "";
selectedTags: string[] = [];
selectedCategories: string[] = [];
@ -50,6 +52,7 @@ export class PushModal extends Modal {
},
});
for (const file of files) {
if (!file.path.endsWith(".md")) continue;
const option = selectEl.createEl("option", {
text: file.path,
value: file.path,
@ -78,6 +81,41 @@ export class PushModal extends Modal {
}
});
// 高级选项部分 (Slug)
const advancedDetails = contentEl.createEl("details", {
attr: {
style: "margin-bottom: 15px; border: 1px solid var(--background-modifier-border); border-radius: 4px; padding: 10px;",
},
});
advancedDetails.createEl("summary", {
text: i18n.t("sync.advancedOptions"),
attr: {
style: "cursor: pointer; color: var(--text-muted); font-size: 0.9em;",
},
});
const slugContainer = advancedDetails.createDiv({
attr: { style: "margin-top: 10px;" },
});
slugContainer.createEl("label", {
text: i18n.t("sync.alias"),
attr: {
style: "display: block; font-size: 0.8em; color: var(--text-muted); margin-bottom: 5px;",
},
});
this.slugInput = slugContainer.createEl("input", {
attr: {
type: "text",
placeholder: "Slug",
style: "width: 100%; border: 1px solid var(--background-modifier-border); border-radius: 4px; color: var(--text-normal);",
},
});
this.slugInput.value = this.slug; // 使用 setTitleFromFileName 中已经设置好的值
this.slugInput.addEventListener("input", (event) => {
this.slug = (event.target as HTMLInputElement).value;
});
// 发布按钮
const button = contentEl.createEl("button", {
text: i18n.t("sync.publish"),
@ -94,15 +132,29 @@ export class PushModal extends Modal {
try {
const mid = [...this.selectedCategories, ...this.selectedTags];
let finalContent = this.content;
// 如果开启了去除元数据
if (getSettings().removeMetadata) {
// 匹配开头的 --- ... ---
const yamlRegex = /^---\s*[\s\S]*?---\s*/;
finalContent = finalContent.replace(yamlRegex, "");
}
const data = {
title: this.title,
text: "<!--markdown-->" + this.content,
text: "<!--markdown-->" + finalContent,
authorId: getSettings().User?.uid,
mid: mid.join(","),
slug: "obsidian-" + Util.hash.simpleHash(this.baseFileName),
slug: this.slug,
};
const response = await HttpUtils.post("/postArticle", data);
if (response.status === "success") {
// 保存 slug 映射
if (this.baseFileName) {
getSettings().slugMapping[this.baseFileName] =
this.slug;
await saveSettings();
}
new Notice(
`${i18n.t("sync.publish")} ${i18n.t("common.success")}`
);
@ -148,6 +200,17 @@ export class PushModal extends Modal {
this.baseFileName = file.basename;
this.title = file.basename;
titleInput.value = this.title;
// 设置 Slug
const savedSlug = getSettings().slugMapping[this.baseFileName];
if (savedSlug) {
this.slug = savedSlug;
} else {
this.slug = "obsidian-" + Util.hash.simpleHash(this.baseFileName);
}
if (this.slugInput) {
this.slugInput.value = this.slug;
}
}
/**