Explorar o código

feat: 添加可选择清理模式与缩略图预览功能

- 新增"清理当前笔记图片(可选择)"命令,支持可视化选择要删除的图片
- 实现缩略图生成与缓存机制,提升用户体验
- 添加图片选择弹窗,支持网格预览、全选/全不选操作
- 重构图片清理逻辑,提取可复用的过滤函数
- 改进反链获取逻辑,增强版本兼容性
maxfeng hai 2 días
pai
achega
843d0301f4
Modificáronse 1 ficheiros con 546 adicións e 14 borrados
  1. 546 14
      src/main.ts

+ 546 - 14
src/main.ts

@@ -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;
+  }
 }