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
|
||||
|
||||
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