Complete the basic functions of the plug-in

This commit is contained in:
2025-09-01 17:07:20 +08:00
parent 7daee1aa86
commit fe0e85b2c8
7 changed files with 3280 additions and 0 deletions

View File

@@ -1,2 +1,11 @@
# obsidian-tos-picbed # obsidian-tos-picbed
Used to automatically upload pictures to TOS and delete remote files when pictures are deleted Used to automatically upload pictures to TOS and delete remote files when pictures are deleted
Key Features
1. Paste images and automatically upload them to object storage, eliminating the need to install software like PicGo locally.
2. Delete images and delete the files in object storage simultaneously, preventing unwanted images from being left in the cloud and causing unnecessary overhead.
3. Delete all images in the current document with one click, eliminating the need to manually delete each image individually.

49
esbuild.config.mjs Normal file
View File

@@ -0,0 +1,49 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
const banner =
`/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = (process.argv[2] === "production");
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins],
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
minify: prod,
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}

267
main.ts Normal file
View File

@@ -0,0 +1,267 @@
import TosClient from "@volcengine/tos-sdk";
import { App, Editor, MarkdownView, Notice, Plugin, PluginSettingTab, Setting, TFile } from "obsidian";
interface TosPicbedPluginSettings {
secretId: string;
secretKey: string;
bucket: string;
region: string;
prefix: string;
usePublicUrl: boolean; // 桶公有读 => 稳定直链;私有读 => 预签名
}
const DEFAULT_SETTINGS: TosPicbedPluginSettings = {
secretId: "",
secretKey: "",
bucket: "",
region: "",
prefix: "",
usePublicUrl: true,
};
class TosUploader {
private client: TosClient;
constructor(private settings: TosPicbedPluginSettings) {
const s = settings;
if (!s.secretId || !s.secretKey) throw new Error("SecretId/SecretKey 为空");
if (!s.bucket || !s.region) throw new Error("Bucket/Region 为空");
this.client = new TosClient({
accessKeyId: s.secretId,
accessKeySecret: s.secretKey,
region: s.region,
});
}
private normPrefix() {
const p = this.settings.prefix?.replace(/^\/+|\/+$/g, "");
return p ? `${p}/` : "";
}
async uploadFile(file: File): Promise<{ url: string; key: string }> {
const ext = file.name.includes(".") ? file.name.split(".").pop() : "";
const name = ext ? `${Date.now()}.${ext}` : `${Date.now()}`;
const key = `${this.normPrefix()}${name}`;
await this.client.putObject({
bucket: this.settings.bucket,
key,
body: file,
contentType: file.type || undefined,
});
const url = await this.getUrl(key);
return { url, key };
}
async deleteByKey(key: string): Promise<void> {
await this.client.deleteObject({ bucket: this.settings.bucket, key });
}
private async getUrl(key: string): Promise<string> {
if (this.settings.usePublicUrl) {
const safeKey = key
.split("/")
.map(encodeURIComponent)
.join("/");
return `https://${this.settings.bucket}.tos-${this.settings.region}.volces.com/${safeKey}`;
}
return this.client.getPreSignedUrl({ bucket: this.settings.bucket, key });
}
static parseKeyFromUrlOrKey(input: string): string {
try {
const u = new URL(input);
return decodeURIComponent(u.pathname.replace(/^\/+/, ""));
} catch {
return input.replace(/^\/+/, "");
}
}
}
class TosPicbedSettingTab extends PluginSettingTab {
constructor(app: App, private plugin: TosPicbedPlugin) { super(app, plugin); }
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl).setName("访问令牌Secret Id").addText(t =>
t.setPlaceholder("AK...").setValue(this.plugin.settings.secretId).onChange(async v => {
this.plugin.settings.secretId = v.trim(); await this.plugin.saveSettings(); this.plugin.reinitUploaderIfReady();
})
);
new Setting(containerEl).setName("访问密钥Secret Key").addText(t =>
t.setPlaceholder("SK...").setValue(this.plugin.settings.secretKey).onChange(async v => {
this.plugin.settings.secretKey = v.trim(); await this.plugin.saveSettings(); this.plugin.reinitUploaderIfReady();
})
);
new Setting(containerEl).setName("存储桶Bucket").addText(t =>
t.setPlaceholder("example-bucket").setValue(this.plugin.settings.bucket).onChange(async v => {
this.plugin.settings.bucket = v.trim(); await this.plugin.saveSettings(); this.plugin.reinitUploaderIfReady();
})
);
new Setting(containerEl).setName("地域Region").addText(t =>
t.setPlaceholder("cn-beijing").setValue(this.plugin.settings.region).onChange(async v => {
this.plugin.settings.region = v.trim(); await this.plugin.saveSettings(); this.plugin.reinitUploaderIfReady();
})
);
new Setting(containerEl).setName("前缀Prefix").addText(t =>
t.setPlaceholder("/").setValue(this.plugin.settings.prefix).onChange(async v => {
this.plugin.settings.prefix = v.trim().replace(/^\/+|\/+$/g, ""); await this.plugin.saveSettings();
})
);
new Setting(containerEl).setName("是否使用公共链接(存储桶公有读)").addToggle(t =>
t.setValue(this.plugin.settings.usePublicUrl).onChange(async v => {
this.plugin.settings.usePublicUrl = v; await this.plugin.saveSettings();
})
);
}
}
export default class TosPicbedPlugin extends Plugin {
settings: TosPicbedPluginSettings;
private uploader: TosUploader | null = null;
async onload() {
await this.loadSettings();
this.reinitUploaderIfReady();
// 粘贴:占位令牌 -> 上传 -> 替换为图片 Markdown
this.registerEvent(
this.app.workspace.on("editor-paste", async (evt: ClipboardEvent, editor: Editor, mdView: MarkdownView) => {
if (!this.uploader) return;
const items = Array.from(evt.clipboardData?.items || []);
const imgs = items.filter(i => i.kind === "file" && i.type.startsWith("image/"));
if (imgs.length === 0) return;
evt.preventDefault(); // 避免 Obsidian 生成本地 ![[...]]
const activeFile = mdView.file;
if (!activeFile) return;
for (const it of imgs) {
const f = it.getAsFile();
if (!f) continue;
// —— 使用不会被渲染为图片的占位令牌 ——
const token = `{{TOS_UPLOADING:${Date.now()}-${Math.random().toString(36).slice(2, 8)}}}`;
const pos = editor.getCursor();
editor.replaceRange(token, pos);
try {
const { url } = await this.uploader.uploadFile(f);
let doc = editor.getValue();
const idx = doc.indexOf(token);
if (idx !== -1) {
const from = editor.offsetToPos(idx);
const to = editor.offsetToPos(idx + token.length);
const finalMd = `![](${url})`;
// 如果此时光标恰好在占位符末尾,让它在替换后仍然保持在图片 Markdown 末尾
const cur = editor.getCursor();
const wasAfterToken = (cur.line === to.line && cur.ch === to.ch);
editor.replaceRange(finalMd, from, to);
if (wasAfterToken) {
const afterPos = editor.offsetToPos(idx + finalMd.length);
editor.setCursor(afterPos);
}
}
// 清理“Pasted image ...”残留:逐个正则命中做区间删除,避免 setValue
let searchDoc = editor.getValue();
const imgRe = /!\[\[Pasted image.*?\.(?:png|jpe?g|gif|webp|svg)\]\]/gi;
let m: RegExpExecArray | null;
while ((m = imgRe.exec(searchDoc)) !== null) {
const start = editor.offsetToPos(m.index);
const end = editor.offsetToPos(m.index + m[0].length);
editor.replaceRange("", start, end);
// 文本已变更,重新获取并重置游标位置
searchDoc = editor.getValue();
imgRe.lastIndex = 0;
}
new Notice("图片上传成功");
} catch (e: any) {
let doc = editor.getValue();
if (doc.includes(token)) {
const after = doc.replace(token, "");
editor.setValue(after);
editor.setCursor(editor.offsetToPos(after.length));
}
new Notice("图片上传失败: " + (e?.message || e));
}
}
})
);
// 右键菜单:删除当前行图片
this.registerEvent(
this.app.workspace.on("editor-menu", (menu, editor) => {
const cur = editor.getCursor();
const line = editor.getLine(cur.line);
const m = line.match(/!\[.*?\]\((.*?)\)/);
if (!m || !this.uploader) return;
menu.addItem(item => {
item.setTitle("删除此图片").setIcon("trash").onClick(async () => {
try {
const key = TosUploader.parseKeyFromUrlOrKey(m[1]);
await this.uploader!.deleteByKey(key);
editor.setLine(cur.line, line.replace(m[0], ""));
new Notice("图片删除成功");
} catch (e: any) {
new Notice("图片删除失败: " + (e?.message || e));
}
});
});
})
);
// 右键菜单:删除全部图片
this.registerEvent(
this.app.workspace.on("editor-menu", (menu, editor) => {
menu.addItem(item => {
item.setTitle("删除全部图片").setIcon("trash").onClick(async () => {
const all = editor.getValue();
const matches = [...all.matchAll(/!\[.*?\]\((.*?)\)/g)];
if (matches.length === 0) return new Notice("未发现图片");
const keys = matches.map(m => TosUploader.parseKeyFromUrlOrKey(m[1]));
if (this.uploader) {
await Promise.all(keys.map(k => this.uploader!.deleteByKey(k).catch(e =>
new Notice("删除失败: " + (e?.message || e))
)));
}
editor.setValue(all.replace(/!\[.*?\]\((.*?)\)/g, ""));
new Notice(`已删除 ${keys.length} 张图片`);
});
});
})
);
this.addSettingTab(new TosPicbedSettingTab(this.app, this));
}
onunload() { this.uploader = null; }
async loadSettings() { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); }
async saveSettings() { await this.saveData(this.settings); }
reinitUploaderIfReady() {
const s = this.settings;
if (s.secretId && s.secretKey && s.bucket && s.region) {
try { this.uploader = new TosUploader(s); } catch { this.uploader = null; }
}
}
private findImageFile(imagePath: string, currentFile: TFile): TFile | null {
let imageFile = this.app.vault.getAbstractFileByPath(imagePath);
if (imageFile instanceof TFile && this.isImageFile(imageFile)) return imageFile;
if (currentFile.parent) {
const relativePath = `${currentFile.parent.path}/${imagePath}`;
imageFile = this.app.vault.getAbstractFileByPath(relativePath);
if (imageFile instanceof TFile && this.isImageFile(imageFile)) return imageFile;
}
imageFile = this.app.vault.getAbstractFileByPath(`/${imagePath}`);
if (imageFile instanceof TFile && this.isImageFile(imageFile)) return imageFile;
const files = this.app.vault.getFiles();
return files.find(f => f.name === imagePath && this.isImageFile(f)) || null;
}
private isImageFile(file: TFile): boolean {
return /png|jpg|jpeg|gif|svg|webp/i.test(file.extension.toLowerCase());
}
}

10
manifest.json Normal file
View File

@@ -0,0 +1,10 @@
{
"id": "tos-picbed",
"name": "TOS Picbed",
"version": "1.0.0",
"minAppVersion": "1.5.7",
"description": "Used to automatically upload pictures to TOS and delete remote files when pictures are deleted",
"author": "yv1ing",
"authorUrl": "https://github.com/yv1ing",
"isDesktopOnly": false
}

2891
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "obsidian-tos-picbed",
"version": "1.0.0",
"description": "Used to automatically upload pictures to TOS and delete remote files when pictures are deleted",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production"
},
"keywords": [
"obsidian",
"picbed"
],
"author": "yv1ing",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "^5.29.0",
"@typescript-eslint/parser": "^5.29.0",
"builtin-modules": "^3.3.0",
"esbuild": "^0.17.3",
"obsidian": "^1.8.7",
"tslib": "^2.4.0",
"typescript": "^4.7.4"
},
"dependencies": {
"@volcengine/tos-sdk": "^2.7.5"
}
}

25
tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"allowSyntheticDefaultImports": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
]
},
"include": [
"**/*.ts"
]
}