|
|
@@ -1,4 +1,4 @@
|
|
|
-import { App, Notice, Plugin, TFile } from "obsidian";
|
|
|
+import { App, Modal, Notice, Plugin, TFile } from "obsidian";
|
|
|
|
|
|
const 插件前缀 = "[image-stripper]";
|
|
|
|
|
|
@@ -72,6 +72,16 @@ type Markdown图片清理结果 = {
|
|
|
候选目标列表: string[];
|
|
|
};
|
|
|
|
|
|
+type Markdown图片过滤结果 = {
|
|
|
+ 新内容: string;
|
|
|
+ 移除引用次数: number;
|
|
|
+ 候选目标列表: string[];
|
|
|
+};
|
|
|
+
|
|
|
+type 清理选项 = {
|
|
|
+ shouldRemoveTarget: (target: string) => boolean;
|
|
|
+};
|
|
|
+
|
|
|
function 清理Markdown图片引用(content: string): Markdown图片清理结果 {
|
|
|
const 候选目标列表: string[] = [];
|
|
|
let 移除引用次数 = 0;
|
|
|
@@ -149,6 +159,84 @@ function 清理Markdown图片引用(content: string): Markdown图片清理结果
|
|
|
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;
|
|
|
@@ -203,6 +291,58 @@ function 清理图片引用(content: string): 清理结果 {
|
|
|
};
|
|
|
}
|
|
|
|
|
|
+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 [];
|
|
|
@@ -212,6 +352,19 @@ function 从反链对象提取引用路径列表(backlinks: unknown): string[] {
|
|
|
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;
|
|
|
@@ -237,8 +390,218 @@ async function 获取图片文件(app: App, activeFile: TFile, rawTarget: string
|
|
|
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: "清理当前笔记图片",
|
|
|
@@ -246,6 +609,13 @@ export default class ImageStripperPlugin extends Plugin {
|
|
|
await this.执行清理当前笔记图片();
|
|
|
}
|
|
|
});
|
|
|
+ this.addCommand({
|
|
|
+ id: "strip-images-in-current-note-select",
|
|
|
+ name: "清理当前笔记图片(可选择)",
|
|
|
+ callback: async () => {
|
|
|
+ await this.执行清理当前笔记图片_可选择();
|
|
|
+ }
|
|
|
+ });
|
|
|
}
|
|
|
|
|
|
onunload(): void {
|
|
|
@@ -279,24 +649,13 @@ export default class ImageStripperPlugin extends Plugin {
|
|
|
await this.app.vault.modify(activeFile, 新内容);
|
|
|
console.info(`${插件前缀} 已写回清理后的笔记内容:file=${activeFile.path}`);
|
|
|
|
|
|
- // 解析候选图片文件(去重)
|
|
|
- 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}`);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- const 图片文件列表 = Array.from(图片文件Map.values());
|
|
|
+ const 图片文件列表 = await this.解析图片文件列表(activeFile, 候选目标列表);
|
|
|
console.info(`${插件前缀} 可评估删除的本地图片文件数=${图片文件列表.length}`);
|
|
|
|
|
|
let 删除数量 = 0;
|
|
|
for (const imageFile of 图片文件列表) {
|
|
|
try {
|
|
|
- const backlinks = this.app.metadataCache.getBacklinksForFile(imageFile as any);
|
|
|
+ const backlinks = 获取文件反链对象(this.app, imageFile);
|
|
|
const 引用路径列表 = 从反链对象提取引用路径列表(backlinks);
|
|
|
const 其他引用数 = 引用路径列表.filter((p) => p !== activeFile.path).length;
|
|
|
|
|
|
@@ -319,5 +678,178 @@ export default class ImageStripperPlugin extends Plugin {
|
|
|
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;
|
|
|
+ }
|
|
|
}
|
|
|
|