mirror of
https://github.com/yv1ing/obsidian-tos-picbed.git
synced 2025-09-30 11:55:34 +08:00
Complete the basic functions of the plug-in
This commit is contained in:
@@ -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
49
esbuild.config.mjs
Normal 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
267
main.ts
Normal 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 = ``;
|
||||||
|
|
||||||
|
// 如果此时光标恰好在占位符末尾,让它在替换后仍然保持在图片 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
10
manifest.json
Normal 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
2891
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal 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
25
tsconfig.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user