| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855 |
- import { App, Modal, Notice, Plugin, TFile } from "obsidian";
- const 插件前缀 = "[image-stripper]";
- const 图片扩展名集合 = new Set([
- "png",
- "jpg",
- "jpeg",
- "gif",
- "webp",
- "svg",
- "bmp",
- "tif",
- "tiff",
- "avif"
- ]);
- function 是否外链目标(target: string): boolean {
- const t = target.trim().toLowerCase();
- return (
- t.startsWith("http://") ||
- t.startsWith("https://") ||
- t.startsWith("data:") ||
- t.startsWith("ftp://")
- );
- }
- function 提取扩展名(pathOrName: string): string | null {
- const cleaned = pathOrName.trim().split(/[?#]/, 1)[0];
- const idx = cleaned.lastIndexOf(".");
- if (idx === -1) return null;
- const ext = cleaned.slice(idx + 1).toLowerCase();
- return ext || null;
- }
- function 标准化Markdown图片目标(raw: string): string {
- // Markdown 允许形如: 或 
- let t = raw.trim();
- if (t.startsWith("<") && t.endsWith(">")) {
- t = t.slice(1, -1).trim();
- }
- // 取第一个空白前的部分,忽略 title
- const firstPart = t.split(/\s+/, 1)[0] ?? "";
- // 去掉可能的引号
- return firstPart.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1").trim();
- }
- function 收敛空行(content: string): string {
- return content.replace(/\n{3,}/g, "\n\n");
- }
- function 解析Markdown链接目标(inner: string): string {
- // 形如:path "title" 或 <path with spaces> "title"
- let t = inner.trim();
- if (!t) return "";
- if (t.startsWith("<")) {
- const endIdx = t.indexOf(">");
- if (endIdx > 1) {
- return t.slice(1, endIdx).trim();
- }
- }
- // 非尖括号形式:取第一个空白前的部分(忽略 title)
- const firstPart = t.split(/\s+/, 1)[0] ?? "";
- return firstPart.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1").trim();
- }
- type Markdown图片清理结果 = {
- 新内容: string;
- 移除引用次数: number;
- 候选目标列表: string[];
- };
- type Markdown图片过滤结果 = {
- 新内容: string;
- 移除引用次数: number;
- 候选目标列表: string[];
- };
- type 清理选项 = {
- shouldRemoveTarget: (target: string) => boolean;
- };
- function 清理Markdown图片引用(content: string): Markdown图片清理结果 {
- const 候选目标列表: string[] = [];
- let 移除引用次数 = 0;
- let i = 0;
- let out = "";
- while (i < content.length) {
- const bangIdx = content.indexOf("![", i);
- if (bangIdx === -1) {
- out += content.slice(i);
- break;
- }
- out += content.slice(i, bangIdx);
- // 找到 alt 结束的 ']'
- const altEndIdx = content.indexOf("]", bangIdx + 2);
- if (altEndIdx === -1) {
- out += content.slice(bangIdx);
- break;
- }
- // 必须紧跟 '(' 才算 Markdown 图片链接
- const openParenIdx = altEndIdx + 1;
- if (content[openParenIdx] !== "(") {
- out += content.slice(bangIdx, openParenIdx);
- i = openParenIdx;
- continue;
- }
- // 扫描匹配成对括号,支持 URL / title 中出现括号
- let depth = 0;
- let j = openParenIdx;
- let isEscaped = false;
- for (; j < content.length; j += 1) {
- const ch = content[j];
- if (isEscaped) {
- isEscaped = false;
- continue;
- }
- if (ch === "\\") {
- isEscaped = true;
- continue;
- }
- if (ch === "(") {
- depth += 1;
- continue;
- }
- if (ch === ")") {
- depth -= 1;
- if (depth === 0) {
- break;
- }
- }
- }
- if (depth !== 0 || j >= content.length) {
- // 括号不匹配,保守:不删除
- console.debug(`${插件前缀} 发现疑似 Markdown 图片,但括号不匹配,已跳过:pos=${bangIdx}`);
- out += content.slice(bangIdx, openParenIdx + 1);
- i = openParenIdx + 1;
- continue;
- }
- const inner = content.slice(openParenIdx + 1, j);
- const target = 解析Markdown链接目标(inner);
- if (target) 候选目标列表.push(target);
- 移除引用次数 += 1;
- // 跳过整个  片段
- i = j + 1;
- }
- return { 新内容: out, 移除引用次数, 候选目标列表 };
- }
- function 过滤Markdown图片引用(content: string, options: 清理选项): Markdown图片过滤结果 {
- const 候选目标列表: string[] = [];
- let 移除引用次数 = 0;
- let i = 0;
- let out = "";
- while (i < content.length) {
- const bangIdx = content.indexOf("![", i);
- if (bangIdx === -1) {
- out += content.slice(i);
- break;
- }
- out += content.slice(i, bangIdx);
- const altEndIdx = content.indexOf("]", bangIdx + 2);
- if (altEndIdx === -1) {
- out += content.slice(bangIdx);
- break;
- }
- const openParenIdx = altEndIdx + 1;
- if (content[openParenIdx] !== "(") {
- out += content.slice(bangIdx, openParenIdx);
- i = openParenIdx;
- continue;
- }
- let depth = 0;
- let j = openParenIdx;
- let isEscaped = false;
- for (; j < content.length; j += 1) {
- const ch = content[j];
- if (isEscaped) {
- isEscaped = false;
- continue;
- }
- if (ch === "\\") {
- isEscaped = true;
- continue;
- }
- if (ch === "(") {
- depth += 1;
- continue;
- }
- if (ch === ")") {
- depth -= 1;
- if (depth === 0) {
- break;
- }
- }
- }
- if (depth !== 0 || j >= content.length) {
- console.debug(`${插件前缀} 发现疑似 Markdown 图片,但括号不匹配,已跳过:pos=${bangIdx}`);
- out += content.slice(bangIdx, openParenIdx + 1);
- i = openParenIdx + 1;
- continue;
- }
- const inner = content.slice(openParenIdx + 1, j);
- const target = 解析Markdown链接目标(inner);
- if (target) 候选目标列表.push(target);
- if (target && options.shouldRemoveTarget(target)) {
- 移除引用次数 += 1;
- i = j + 1;
- continue;
- }
- out += content.slice(bangIdx, j + 1);
- i = j + 1;
- }
- return { 新内容: out, 移除引用次数, 候选目标列表 };
- }
- type 历史残留清理结果 = {
- 新内容: string;
- 移除残留次数: number;
- };
- function 清理历史残留Svg尾巴(content: string): 历史残留清理结果 {
- // 针对旧实现可能留下的:以单引号开头、包含 URL 编码 SVG、并以 ") 结束" 的残留片段
- // 示例:'%20fill='%23FFFFFF'...%3C/svg%3E)
- const svgTailRe = /(^|\n)\s*'[^ \n]*%3C\/svg%3E\)\s*(?=\n|$)/gi;
- let 移除残留次数 = 0;
- const 新内容 = content.replace(svgTailRe, (m: string, leadingNewline: string) => {
- 移除残留次数 += 1;
- return leadingNewline ? "\n" : "";
- });
- return { 新内容, 移除残留次数 };
- }
- type 清理结果 = {
- 新内容: string;
- 移除引用次数: number;
- 候选目标列表: string[];
- 移除残留次数: number;
- };
- function 清理图片引用(content: string): 清理结果 {
- const mdResult = 清理Markdown图片引用(content);
- let 新内容 = mdResult.新内容;
- let 移除引用次数 = mdResult.移除引用次数;
- const 候选目标列表: string[] = [...mdResult.候选目标列表];
- // Obsidian 内嵌:![[xxx.png]] / ![[xxx.png|100]]
- const wikiEmbedRe = /!\[\[([^\]|#]+?)(?:\|[^\]]+)?\]\]/g;
- 新内容 = 新内容.replace(wikiEmbedRe, (fullMatch, linkGroup: string) => {
- const link = String(linkGroup ?? "").trim();
- if (link) {
- 候选目标列表.push(link);
- }
- 移除引用次数 += 1;
- return "";
- });
- const tailResult = 清理历史残留Svg尾巴(新内容);
- 新内容 = tailResult.新内容;
- 新内容 = 收敛空行(新内容);
- return {
- 新内容,
- 移除引用次数,
- 候选目标列表,
- 移除残留次数: tailResult.移除残留次数
- };
- }
- type 选择性清理结果 = {
- 新内容: string;
- 移除引用次数: number;
- 候选目标列表: string[];
- 移除残留次数: number;
- };
- function 提取图片引用目标列表(content: string): string[] {
- const mdResult = 过滤Markdown图片引用(content, { shouldRemoveTarget: () => false });
- const 目标列表: string[] = [...mdResult.候选目标列表];
- const wikiEmbedRe = /!\[\[([^\]|#]+?)(?:\|[^\]]+)?\]\]/g;
- let match: RegExpExecArray | null = null;
- while ((match = wikiEmbedRe.exec(content)) !== null) {
- const link = String(match[1] ?? "").trim();
- if (link) 目标列表.push(link);
- }
- return 目标列表;
- }
- function 清理图片引用_按目标过滤(content: string, options: 清理选项): 选择性清理结果 {
- const mdResult = 过滤Markdown图片引用(content, options);
- let 新内容 = mdResult.新内容;
- let 移除引用次数 = mdResult.移除引用次数;
- const 候选目标列表: string[] = [...mdResult.候选目标列表];
- const wikiEmbedRe = /!\[\[([^\]|#]+?)(?:\|[^\]]+)?\]\]/g;
- 新内容 = 新内容.replace(wikiEmbedRe, (fullMatch, linkGroup: string) => {
- const link = String(linkGroup ?? "").trim();
- if (link) {
- 候选目标列表.push(link);
- }
- if (link && options.shouldRemoveTarget(link)) {
- 移除引用次数 += 1;
- return "";
- }
- return fullMatch;
- });
- const tailResult = 清理历史残留Svg尾巴(新内容);
- 新内容 = tailResult.新内容;
- 新内容 = 收敛空行(新内容);
- return {
- 新内容,
- 移除引用次数,
- 候选目标列表,
- 移除残留次数: tailResult.移除残留次数
- };
- }
- function 从反链对象提取引用路径列表(backlinks: unknown): string[] {
- // 兼容不同 Obsidian 版本/类型:可能是 { data: { [path]: ... } } 或直接 { [path]: ... }
- if (!backlinks || typeof backlinks !== "object") return [];
- const asAny = backlinks as any;
- const data = asAny.data && typeof asAny.data === "object" ? asAny.data : asAny;
- if (!data || typeof data !== "object") return [];
- return Object.keys(data);
- }
- function 获取文件反链对象(app: App, file: TFile): unknown {
- const metadataCache = app.metadataCache as unknown as {
- getBacklinksForFile?: (targetFile: TFile) => unknown;
- };
- if (typeof metadataCache.getBacklinksForFile === "function") {
- return metadataCache.getBacklinksForFile(file);
- }
- console.debug(
- `${插件前缀} 当前 Obsidian 版本未提供 getBacklinksForFile,跳过反链检查:file=${file.path}`
- );
- return null;
- }
- async function 获取图片文件(app: App, activeFile: TFile, rawTarget: string): Promise<TFile | null> {
- const target = rawTarget.trim();
- if (!target) return null;
- if (是否外链目标(target)) return null;
- const ext = 提取扩展名(target);
- if (!ext || !图片扩展名集合.has(ext)) return null;
- let linkpath = target;
- try {
- linkpath = decodeURI(target);
- } catch {
- // ignore
- }
- const file = app.metadataCache.getFirstLinkpathDest(linkpath, activeFile.path);
- if (!file) return null;
- if (!(file instanceof TFile)) return null;
- const fileExt = (file.extension || "").toLowerCase();
- if (!图片扩展名集合.has(fileExt)) return null;
- return file;
- }
- type 缩略图缓存项 = {
- mtime: number;
- dataUrl: string;
- };
- type 插件缓存数据 = {
- thumbnailCache?: Record<string, 缩略图缓存项>;
- };
- type 缩略图选择项 = {
- file: TFile;
- dataUrl: string | null;
- isSelected: boolean;
- };
- type 缩略图弹窗参数 = {
- items: 缩略图选择项[];
- onConfirm: (selected: 缩略图选择项[]) => void;
- onCancel: () => void;
- };
- function 获取图片MimeType(extension: string): string {
- const ext = extension.toLowerCase();
- if (ext === "svg") return "image/svg+xml";
- if (ext === "jpg" || ext === "jpeg") return "image/jpeg";
- if (ext === "png") return "image/png";
- if (ext === "gif") return "image/gif";
- if (ext === "webp") return "image/webp";
- if (ext === "bmp") return "image/bmp";
- if (ext === "tif" || ext === "tiff") return "image/tiff";
- if (ext === "avif") return "image/avif";
- return "image/*";
- }
- async function 生成缩略图DataUrl(
- app: App,
- file: TFile,
- maxSize: number
- ): Promise<string | null> {
- try {
- const data = await app.vault.readBinary(file);
- const mime = 获取图片MimeType(file.extension || "");
- const blob = new Blob([data], { type: mime });
- const objectUrl = URL.createObjectURL(blob);
- const dataUrl = await new Promise<string | null>((resolve) => {
- const img = new Image();
- img.onload = () => {
- try {
- const width = img.naturalWidth || img.width;
- const height = img.naturalHeight || img.height;
- const scale = Math.min(maxSize / width, maxSize / height, 1);
- const targetW = Math.max(1, Math.round(width * scale));
- const targetH = Math.max(1, Math.round(height * scale));
- const canvas = document.createElement("canvas");
- canvas.width = targetW;
- canvas.height = targetH;
- const ctx = canvas.getContext("2d");
- if (!ctx) {
- resolve(null);
- return;
- }
- ctx.drawImage(img, 0, 0, targetW, targetH);
- resolve(canvas.toDataURL("image/png", 0.85));
- } catch (err) {
- console.debug(`${插件前缀} 生成缩略图失败:${file.path}`, err);
- resolve(null);
- } finally {
- URL.revokeObjectURL(objectUrl);
- }
- };
- img.onerror = () => {
- URL.revokeObjectURL(objectUrl);
- resolve(null);
- };
- img.src = objectUrl;
- });
- return dataUrl;
- } catch (err) {
- console.debug(`${插件前缀} 读取图片失败,无法生成缩略图:${file.path}`, err);
- return null;
- }
- }
- class 缩略图选择弹窗 extends Modal {
- private items: 缩略图选择项[];
- private onConfirm: (selected: 缩略图选择项[]) => void;
- private onCancel: () => void;
- private 已确认 = false;
- constructor(app: App, options: 缩略图弹窗参数) {
- super(app);
- this.items = options.items;
- this.onConfirm = options.onConfirm;
- this.onCancel = options.onCancel;
- }
- onOpen(): void {
- const { contentEl } = this;
- contentEl.empty();
- contentEl.createEl("h2", { text: "选择要清理的图片" });
- const actions = contentEl.createDiv();
- actions.style.display = "flex";
- actions.style.gap = "8px";
- actions.style.marginBottom = "12px";
- const 全选按钮 = actions.createEl("button", { text: "全选" });
- const 全不选按钮 = actions.createEl("button", { text: "全不选" });
- const grid = contentEl.createDiv();
- grid.style.display = "grid";
- grid.style.gridTemplateColumns = "repeat(auto-fill, minmax(140px, 1fr))";
- grid.style.gap = "12px";
- const renderItems = () => {
- grid.empty();
- for (const item of this.items) {
- const card = grid.createDiv();
- card.style.border = "1px solid var(--background-modifier-border)";
- card.style.borderRadius = "8px";
- card.style.padding = "8px";
- card.style.display = "flex";
- card.style.flexDirection = "column";
- card.style.gap = "6px";
- const checkbox = card.createEl("input", { type: "checkbox" });
- checkbox.checked = item.isSelected;
- checkbox.onchange = () => {
- item.isSelected = checkbox.checked;
- };
- if (item.dataUrl) {
- const img = card.createEl("img");
- img.src = item.dataUrl;
- img.style.width = "100%";
- img.style.height = "110px";
- img.style.objectFit = "contain";
- img.style.background = "var(--background-secondary)";
- img.style.borderRadius = "6px";
- } else {
- const placeholder = card.createDiv({ text: "无法生成缩略图" });
- placeholder.style.height = "110px";
- placeholder.style.display = "flex";
- placeholder.style.alignItems = "center";
- placeholder.style.justifyContent = "center";
- placeholder.style.fontSize = "12px";
- placeholder.style.color = "var(--text-muted)";
- placeholder.style.background = "var(--background-secondary)";
- placeholder.style.borderRadius = "6px";
- }
- const nameEl = card.createDiv({ text: item.file.name });
- nameEl.style.fontSize = "12px";
- nameEl.style.wordBreak = "break-all";
- const pathEl = card.createDiv({ text: item.file.path });
- pathEl.style.fontSize = "10px";
- pathEl.style.color = "var(--text-muted)";
- pathEl.style.wordBreak = "break-all";
- }
- };
- renderItems();
- 全选按钮.onclick = () => {
- for (const item of this.items) item.isSelected = true;
- renderItems();
- };
- 全不选按钮.onclick = () => {
- for (const item of this.items) item.isSelected = false;
- renderItems();
- };
- const footer = contentEl.createDiv();
- footer.style.display = "flex";
- footer.style.justifyContent = "flex-end";
- footer.style.gap = "8px";
- footer.style.marginTop = "16px";
- const 取消按钮 = footer.createEl("button", { text: "取消" });
- const 确认按钮 = footer.createEl("button", { text: "确认清理" });
- 确认按钮.style.fontWeight = "bold";
- 取消按钮.onclick = () => {
- this.close();
- };
- 确认按钮.onclick = () => {
- this.已确认 = true;
- const selected = this.items.filter((item) => item.isSelected);
- this.onConfirm(selected);
- this.close();
- };
- }
- onClose(): void {
- if (!this.已确认) {
- this.onCancel();
- }
- this.contentEl.empty();
- }
- }
- export default class ImageStripperPlugin extends Plugin {
- private thumbnailCache: Record<string, 缩略图缓存项> = {};
- private thumbnailCacheDirty = false;
- async onload(): Promise<void> {
- await this.加载缩略图缓存();
- this.addCommand({
- id: "strip-images-in-current-note",
- name: "清理当前笔记图片",
- callback: async () => {
- await this.执行清理当前笔记图片();
- }
- });
- this.addCommand({
- id: "strip-images-in-current-note-select",
- name: "清理当前笔记图片(可选择)",
- callback: async () => {
- await this.执行清理当前笔记图片_可选择();
- }
- });
- }
- onunload(): void {
- console.info(`${插件前缀} 插件已卸载`);
- }
- private async 执行清理当前笔记图片(): Promise<void> {
- const activeFile = this.app.workspace.getActiveFile();
- if (!activeFile) {
- new Notice("未找到当前笔记:请先打开一个笔记再执行图片清理。");
- console.warn(`${插件前缀} 未找到 active file,已终止`);
- return;
- }
- console.info(`${插件前缀} 开始清理图片:file=${activeFile.path}`);
- const 原内容 = await this.app.vault.read(activeFile);
- const { 新内容, 移除引用次数, 候选目标列表, 移除残留次数 } = 清理图片引用(原内容);
- console.debug(
- `${插件前缀} 解析完成:文本长度=${原内容.length} -> ${新内容.length},移除引用次数=${移除引用次数},候选目标数=${候选目标列表.length},移除残留次数=${移除残留次数}`
- );
- if (移除引用次数 === 0 && 移除残留次数 === 0) {
- new Notice("当前笔记未发现图片引用或可清理残留,无需清理。");
- console.info(`${插件前缀} 未发现图片引用或残留,已结束`);
- return;
- }
- // 写回笔记内容
- await this.app.vault.modify(activeFile, 新内容);
- console.info(`${插件前缀} 已写回清理后的笔记内容:file=${activeFile.path}`);
- const 图片文件列表 = await this.解析图片文件列表(activeFile, 候选目标列表);
- console.info(`${插件前缀} 可评估删除的本地图片文件数=${图片文件列表.length}`);
- let 删除数量 = 0;
- for (const imageFile of 图片文件列表) {
- try {
- const backlinks = 获取文件反链对象(this.app, imageFile);
- const 引用路径列表 = 从反链对象提取引用路径列表(backlinks);
- const 其他引用数 = 引用路径列表.filter((p) => p !== activeFile.path).length;
- if (其他引用数 === 0) {
- console.info(`${插件前缀} 将删除图片(无其他引用):${imageFile.path}`);
- await this.app.vault.delete(imageFile);
- 删除数量 += 1;
- } else {
- console.info(`${插件前缀} 保留图片(仍被引用 ${其他引用数} 次):${imageFile.path}`);
- }
- } catch (err) {
- console.error(`${插件前缀} 删除图片失败:${imageFile.path}`, err);
- }
- }
- const 提示 =
- 移除残留次数 > 0
- ? `已移除 ${移除引用次数} 处图片引用;额外清理 ${移除残留次数} 处残留片段;删除 ${删除数量} 个无人引用的图片附件。`
- : `已移除 ${移除引用次数} 处图片引用;删除 ${删除数量} 个无人引用的图片附件。`;
- new Notice(提示);
- console.info(`${插件前缀} 清理完成:${提示} file=${activeFile.path}`);
- }
- private async 执行清理当前笔记图片_可选择(): Promise<void> {
- const activeFile = this.app.workspace.getActiveFile();
- if (!activeFile) {
- new Notice("未找到当前笔记:请先打开一个笔记再执行图片清理。");
- console.warn(`${插件前缀} 未找到 active file,已终止(可选择模式)`);
- return;
- }
- console.info(`${插件前缀} 开始清理图片(可选择):file=${activeFile.path}`);
- const 原内容 = await this.app.vault.read(activeFile);
- const 候选目标列表 = 提取图片引用目标列表(原内容);
- console.debug(
- `${插件前缀} 解析完成(可选择):文本长度=${原内容.length},候选目标数=${候选目标列表.length}`
- );
- if (候选目标列表.length === 0) {
- new Notice("当前笔记未发现图片引用,无需清理。");
- console.info(`${插件前缀} 未发现图片引用,已结束(可选择模式)`);
- return;
- }
- const 图片文件列表 = await this.解析图片文件列表(activeFile, 候选目标列表);
- if (图片文件列表.length === 0) {
- new Notice("未解析到可用的本地图片文件。");
- console.info(`${插件前缀} 未解析到可用图片文件,已结束(可选择模式)`);
- return;
- }
- console.info(`${插件前缀} 进入选择弹窗,图片数=${图片文件列表.length}`);
- const items = await this.构建缩略图选择项(图片文件列表);
- const selectedItems = await this.打开缩略图选择弹窗(items);
- await this.保存缩略图缓存();
- if (!selectedItems) {
- console.info(`${插件前缀} 用户取消选择,未做任何改动`);
- return;
- }
- if (selectedItems.length === 0) {
- new Notice("未选择任何图片,已取消清理。");
- console.info(`${插件前缀} 未选择图片,已结束(可选择模式)`);
- return;
- }
- const selectedPathSet = new Set(selectedItems.map((item) => item.file.path));
- const targetToFilePathMap = await this.解析目标到文件路径Map(activeFile, 候选目标列表);
- const { 新内容, 移除引用次数, 移除残留次数 } = 清理图片引用_按目标过滤(原内容, {
- shouldRemoveTarget: (target: string) => {
- const resolvedPath = targetToFilePathMap.get(target);
- return Boolean(resolvedPath && selectedPathSet.has(resolvedPath));
- }
- });
- if (移除引用次数 === 0 && 移除残留次数 === 0) {
- new Notice("未发现可清理的目标(可能未被引用或已被保留)。");
- console.info(`${插件前缀} 未发生清理变更,已结束(可选择模式)`);
- return;
- }
- await this.app.vault.modify(activeFile, 新内容);
- console.info(`${插件前缀} 已写回清理后的笔记内容(可选择):file=${activeFile.path}`);
- let 删除数量 = 0;
- for (const item of selectedItems) {
- const imageFile = item.file;
- try {
- const backlinks = 获取文件反链对象(this.app, imageFile);
- const 引用路径列表 = 从反链对象提取引用路径列表(backlinks);
- const 其他引用数 = 引用路径列表.filter((p) => p !== activeFile.path).length;
- if (其他引用数 === 0) {
- console.info(`${插件前缀} 将删除图片(无其他引用):${imageFile.path}`);
- await this.app.vault.delete(imageFile);
- 删除数量 += 1;
- } else {
- console.info(`${插件前缀} 保留图片(仍被引用 ${其他引用数} 次):${imageFile.path}`);
- }
- } catch (err) {
- console.error(`${插件前缀} 删除图片失败:${imageFile.path}`, err);
- }
- }
- await this.保存缩略图缓存();
- const 提示 =
- 移除残留次数 > 0
- ? `已移除 ${移除引用次数} 处图片引用;额外清理 ${移除残留次数} 处残留片段;删除 ${删除数量} 个无人引用的图片附件。`
- : `已移除 ${移除引用次数} 处图片引用;删除 ${删除数量} 个无人引用的图片附件。`;
- new Notice(提示);
- console.info(`${插件前缀} 清理完成(可选择):${提示} file=${activeFile.path}`);
- }
- private async 解析图片文件列表(activeFile: TFile, 候选目标列表: string[]): Promise<TFile[]> {
- const 图片文件Map = new Map<string, TFile>();
- for (const rawTarget of 候选目标列表) {
- const file = await 获取图片文件(this.app, activeFile, rawTarget);
- if (file) {
- 图片文件Map.set(file.path, file);
- } else {
- console.debug(`${插件前缀} 跳过删除候选(外链/不可解析/非图片):target=${rawTarget}`);
- }
- }
- return Array.from(图片文件Map.values());
- }
- private async 解析目标到文件路径Map(
- activeFile: TFile,
- 候选目标列表: string[]
- ): Promise<Map<string, string>> {
- const map = new Map<string, string>();
- for (const rawTarget of 候选目标列表) {
- const file = await 获取图片文件(this.app, activeFile, rawTarget);
- if (file) {
- map.set(rawTarget, file.path);
- }
- }
- return map;
- }
- private async 构建缩略图选择项(图片文件列表: TFile[]): Promise<缩略图选择项[]> {
- const items: 缩略图选择项[] = [];
- for (const file of 图片文件列表) {
- const dataUrl = await this.获取缩略图DataUrl(file, 160);
- items.push({ file, dataUrl, isSelected: true });
- }
- return items;
- }
- private async 打开缩略图选择弹窗(
- items: 缩略图选择项[]
- ): Promise<缩略图选择项[] | null> {
- return await new Promise((resolve) => {
- const modal = new 缩略图选择弹窗(this.app, {
- items,
- onConfirm: (selected) => resolve(selected),
- onCancel: () => resolve(null)
- });
- modal.open();
- });
- }
- private async 加载缩略图缓存(): Promise<void> {
- const data = (await this.loadData()) as 插件缓存数据 | null;
- this.thumbnailCache = data?.thumbnailCache ?? {};
- console.info(`${插件前缀} 已加载缩略图缓存:count=${Object.keys(this.thumbnailCache).length}`);
- }
- private async 保存缩略图缓存(): Promise<void> {
- if (!this.thumbnailCacheDirty) return;
- await this.saveData({ thumbnailCache: this.thumbnailCache });
- this.thumbnailCacheDirty = false;
- console.info(`${插件前缀} 已保存缩略图缓存:count=${Object.keys(this.thumbnailCache).length}`);
- }
- private async 获取缩略图DataUrl(file: TFile, maxSize: number): Promise<string | null> {
- const cached = this.thumbnailCache[file.path];
- if (cached && cached.mtime === file.stat.mtime) {
- return cached.dataUrl;
- }
- const dataUrl = await 生成缩略图DataUrl(this.app, file, maxSize);
- if (dataUrl) {
- this.thumbnailCache[file.path] = {
- mtime: file.stat.mtime,
- dataUrl
- };
- this.thumbnailCacheDirty = true;
- }
- return dataUrl;
- }
- }
|