main.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855
  1. import { App, Modal, Notice, Plugin, TFile } from "obsidian";
  2. const 插件前缀 = "[image-stripper]";
  3. const 图片扩展名集合 = new Set([
  4. "png",
  5. "jpg",
  6. "jpeg",
  7. "gif",
  8. "webp",
  9. "svg",
  10. "bmp",
  11. "tif",
  12. "tiff",
  13. "avif"
  14. ]);
  15. function 是否外链目标(target: string): boolean {
  16. const t = target.trim().toLowerCase();
  17. return (
  18. t.startsWith("http://") ||
  19. t.startsWith("https://") ||
  20. t.startsWith("data:") ||
  21. t.startsWith("ftp://")
  22. );
  23. }
  24. function 提取扩展名(pathOrName: string): string | null {
  25. const cleaned = pathOrName.trim().split(/[?#]/, 1)[0];
  26. const idx = cleaned.lastIndexOf(".");
  27. if (idx === -1) return null;
  28. const ext = cleaned.slice(idx + 1).toLowerCase();
  29. return ext || null;
  30. }
  31. function 标准化Markdown图片目标(raw: string): string {
  32. // Markdown 允许形如:![](path "title") 或 ![](<path with spaces>)
  33. let t = raw.trim();
  34. if (t.startsWith("<") && t.endsWith(">")) {
  35. t = t.slice(1, -1).trim();
  36. }
  37. // 取第一个空白前的部分,忽略 title
  38. const firstPart = t.split(/\s+/, 1)[0] ?? "";
  39. // 去掉可能的引号
  40. return firstPart.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1").trim();
  41. }
  42. function 收敛空行(content: string): string {
  43. return content.replace(/\n{3,}/g, "\n\n");
  44. }
  45. function 解析Markdown链接目标(inner: string): string {
  46. // 形如:path "title" 或 <path with spaces> "title"
  47. let t = inner.trim();
  48. if (!t) return "";
  49. if (t.startsWith("<")) {
  50. const endIdx = t.indexOf(">");
  51. if (endIdx > 1) {
  52. return t.slice(1, endIdx).trim();
  53. }
  54. }
  55. // 非尖括号形式:取第一个空白前的部分(忽略 title)
  56. const firstPart = t.split(/\s+/, 1)[0] ?? "";
  57. return firstPart.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1").trim();
  58. }
  59. type Markdown图片清理结果 = {
  60. 新内容: string;
  61. 移除引用次数: number;
  62. 候选目标列表: string[];
  63. };
  64. type Markdown图片过滤结果 = {
  65. 新内容: string;
  66. 移除引用次数: number;
  67. 候选目标列表: string[];
  68. };
  69. type 清理选项 = {
  70. shouldRemoveTarget: (target: string) => boolean;
  71. };
  72. function 清理Markdown图片引用(content: string): Markdown图片清理结果 {
  73. const 候选目标列表: string[] = [];
  74. let 移除引用次数 = 0;
  75. let i = 0;
  76. let out = "";
  77. while (i < content.length) {
  78. const bangIdx = content.indexOf("![", i);
  79. if (bangIdx === -1) {
  80. out += content.slice(i);
  81. break;
  82. }
  83. out += content.slice(i, bangIdx);
  84. // 找到 alt 结束的 ']'
  85. const altEndIdx = content.indexOf("]", bangIdx + 2);
  86. if (altEndIdx === -1) {
  87. out += content.slice(bangIdx);
  88. break;
  89. }
  90. // 必须紧跟 '(' 才算 Markdown 图片链接
  91. const openParenIdx = altEndIdx + 1;
  92. if (content[openParenIdx] !== "(") {
  93. out += content.slice(bangIdx, openParenIdx);
  94. i = openParenIdx;
  95. continue;
  96. }
  97. // 扫描匹配成对括号,支持 URL / title 中出现括号
  98. let depth = 0;
  99. let j = openParenIdx;
  100. let isEscaped = false;
  101. for (; j < content.length; j += 1) {
  102. const ch = content[j];
  103. if (isEscaped) {
  104. isEscaped = false;
  105. continue;
  106. }
  107. if (ch === "\\") {
  108. isEscaped = true;
  109. continue;
  110. }
  111. if (ch === "(") {
  112. depth += 1;
  113. continue;
  114. }
  115. if (ch === ")") {
  116. depth -= 1;
  117. if (depth === 0) {
  118. break;
  119. }
  120. }
  121. }
  122. if (depth !== 0 || j >= content.length) {
  123. // 括号不匹配,保守:不删除
  124. console.debug(`${插件前缀} 发现疑似 Markdown 图片,但括号不匹配,已跳过:pos=${bangIdx}`);
  125. out += content.slice(bangIdx, openParenIdx + 1);
  126. i = openParenIdx + 1;
  127. continue;
  128. }
  129. const inner = content.slice(openParenIdx + 1, j);
  130. const target = 解析Markdown链接目标(inner);
  131. if (target) 候选目标列表.push(target);
  132. 移除引用次数 += 1;
  133. // 跳过整个 ![...](...) 片段
  134. i = j + 1;
  135. }
  136. return { 新内容: out, 移除引用次数, 候选目标列表 };
  137. }
  138. function 过滤Markdown图片引用(content: string, options: 清理选项): Markdown图片过滤结果 {
  139. const 候选目标列表: string[] = [];
  140. let 移除引用次数 = 0;
  141. let i = 0;
  142. let out = "";
  143. while (i < content.length) {
  144. const bangIdx = content.indexOf("![", i);
  145. if (bangIdx === -1) {
  146. out += content.slice(i);
  147. break;
  148. }
  149. out += content.slice(i, bangIdx);
  150. const altEndIdx = content.indexOf("]", bangIdx + 2);
  151. if (altEndIdx === -1) {
  152. out += content.slice(bangIdx);
  153. break;
  154. }
  155. const openParenIdx = altEndIdx + 1;
  156. if (content[openParenIdx] !== "(") {
  157. out += content.slice(bangIdx, openParenIdx);
  158. i = openParenIdx;
  159. continue;
  160. }
  161. let depth = 0;
  162. let j = openParenIdx;
  163. let isEscaped = false;
  164. for (; j < content.length; j += 1) {
  165. const ch = content[j];
  166. if (isEscaped) {
  167. isEscaped = false;
  168. continue;
  169. }
  170. if (ch === "\\") {
  171. isEscaped = true;
  172. continue;
  173. }
  174. if (ch === "(") {
  175. depth += 1;
  176. continue;
  177. }
  178. if (ch === ")") {
  179. depth -= 1;
  180. if (depth === 0) {
  181. break;
  182. }
  183. }
  184. }
  185. if (depth !== 0 || j >= content.length) {
  186. console.debug(`${插件前缀} 发现疑似 Markdown 图片,但括号不匹配,已跳过:pos=${bangIdx}`);
  187. out += content.slice(bangIdx, openParenIdx + 1);
  188. i = openParenIdx + 1;
  189. continue;
  190. }
  191. const inner = content.slice(openParenIdx + 1, j);
  192. const target = 解析Markdown链接目标(inner);
  193. if (target) 候选目标列表.push(target);
  194. if (target && options.shouldRemoveTarget(target)) {
  195. 移除引用次数 += 1;
  196. i = j + 1;
  197. continue;
  198. }
  199. out += content.slice(bangIdx, j + 1);
  200. i = j + 1;
  201. }
  202. return { 新内容: out, 移除引用次数, 候选目标列表 };
  203. }
  204. type 历史残留清理结果 = {
  205. 新内容: string;
  206. 移除残留次数: number;
  207. };
  208. function 清理历史残留Svg尾巴(content: string): 历史残留清理结果 {
  209. // 针对旧实现可能留下的:以单引号开头、包含 URL 编码 SVG、并以 ") 结束" 的残留片段
  210. // 示例:'%20fill='%23FFFFFF'...%3C/svg%3E)
  211. const svgTailRe = /(^|\n)\s*'[^ \n]*%3C\/svg%3E\)\s*(?=\n|$)/gi;
  212. let 移除残留次数 = 0;
  213. const 新内容 = content.replace(svgTailRe, (m: string, leadingNewline: string) => {
  214. 移除残留次数 += 1;
  215. return leadingNewline ? "\n" : "";
  216. });
  217. return { 新内容, 移除残留次数 };
  218. }
  219. type 清理结果 = {
  220. 新内容: string;
  221. 移除引用次数: number;
  222. 候选目标列表: string[];
  223. 移除残留次数: number;
  224. };
  225. function 清理图片引用(content: string): 清理结果 {
  226. const mdResult = 清理Markdown图片引用(content);
  227. let 新内容 = mdResult.新内容;
  228. let 移除引用次数 = mdResult.移除引用次数;
  229. const 候选目标列表: string[] = [...mdResult.候选目标列表];
  230. // Obsidian 内嵌:![[xxx.png]] / ![[xxx.png|100]]
  231. const wikiEmbedRe = /!\[\[([^\]|#]+?)(?:\|[^\]]+)?\]\]/g;
  232. 新内容 = 新内容.replace(wikiEmbedRe, (fullMatch, linkGroup: string) => {
  233. const link = String(linkGroup ?? "").trim();
  234. if (link) {
  235. 候选目标列表.push(link);
  236. }
  237. 移除引用次数 += 1;
  238. return "";
  239. });
  240. const tailResult = 清理历史残留Svg尾巴(新内容);
  241. 新内容 = tailResult.新内容;
  242. 新内容 = 收敛空行(新内容);
  243. return {
  244. 新内容,
  245. 移除引用次数,
  246. 候选目标列表,
  247. 移除残留次数: tailResult.移除残留次数
  248. };
  249. }
  250. type 选择性清理结果 = {
  251. 新内容: string;
  252. 移除引用次数: number;
  253. 候选目标列表: string[];
  254. 移除残留次数: number;
  255. };
  256. function 提取图片引用目标列表(content: string): string[] {
  257. const mdResult = 过滤Markdown图片引用(content, { shouldRemoveTarget: () => false });
  258. const 目标列表: string[] = [...mdResult.候选目标列表];
  259. const wikiEmbedRe = /!\[\[([^\]|#]+?)(?:\|[^\]]+)?\]\]/g;
  260. let match: RegExpExecArray | null = null;
  261. while ((match = wikiEmbedRe.exec(content)) !== null) {
  262. const link = String(match[1] ?? "").trim();
  263. if (link) 目标列表.push(link);
  264. }
  265. return 目标列表;
  266. }
  267. function 清理图片引用_按目标过滤(content: string, options: 清理选项): 选择性清理结果 {
  268. const mdResult = 过滤Markdown图片引用(content, options);
  269. let 新内容 = mdResult.新内容;
  270. let 移除引用次数 = mdResult.移除引用次数;
  271. const 候选目标列表: string[] = [...mdResult.候选目标列表];
  272. const wikiEmbedRe = /!\[\[([^\]|#]+?)(?:\|[^\]]+)?\]\]/g;
  273. 新内容 = 新内容.replace(wikiEmbedRe, (fullMatch, linkGroup: string) => {
  274. const link = String(linkGroup ?? "").trim();
  275. if (link) {
  276. 候选目标列表.push(link);
  277. }
  278. if (link && options.shouldRemoveTarget(link)) {
  279. 移除引用次数 += 1;
  280. return "";
  281. }
  282. return fullMatch;
  283. });
  284. const tailResult = 清理历史残留Svg尾巴(新内容);
  285. 新内容 = tailResult.新内容;
  286. 新内容 = 收敛空行(新内容);
  287. return {
  288. 新内容,
  289. 移除引用次数,
  290. 候选目标列表,
  291. 移除残留次数: tailResult.移除残留次数
  292. };
  293. }
  294. function 从反链对象提取引用路径列表(backlinks: unknown): string[] {
  295. // 兼容不同 Obsidian 版本/类型:可能是 { data: { [path]: ... } } 或直接 { [path]: ... }
  296. if (!backlinks || typeof backlinks !== "object") return [];
  297. const asAny = backlinks as any;
  298. const data = asAny.data && typeof asAny.data === "object" ? asAny.data : asAny;
  299. if (!data || typeof data !== "object") return [];
  300. return Object.keys(data);
  301. }
  302. function 获取文件反链对象(app: App, file: TFile): unknown {
  303. const metadataCache = app.metadataCache as unknown as {
  304. getBacklinksForFile?: (targetFile: TFile) => unknown;
  305. };
  306. if (typeof metadataCache.getBacklinksForFile === "function") {
  307. return metadataCache.getBacklinksForFile(file);
  308. }
  309. console.debug(
  310. `${插件前缀} 当前 Obsidian 版本未提供 getBacklinksForFile,跳过反链检查:file=${file.path}`
  311. );
  312. return null;
  313. }
  314. async function 获取图片文件(app: App, activeFile: TFile, rawTarget: string): Promise<TFile | null> {
  315. const target = rawTarget.trim();
  316. if (!target) return null;
  317. if (是否外链目标(target)) return null;
  318. const ext = 提取扩展名(target);
  319. if (!ext || !图片扩展名集合.has(ext)) return null;
  320. let linkpath = target;
  321. try {
  322. linkpath = decodeURI(target);
  323. } catch {
  324. // ignore
  325. }
  326. const file = app.metadataCache.getFirstLinkpathDest(linkpath, activeFile.path);
  327. if (!file) return null;
  328. if (!(file instanceof TFile)) return null;
  329. const fileExt = (file.extension || "").toLowerCase();
  330. if (!图片扩展名集合.has(fileExt)) return null;
  331. return file;
  332. }
  333. type 缩略图缓存项 = {
  334. mtime: number;
  335. dataUrl: string;
  336. };
  337. type 插件缓存数据 = {
  338. thumbnailCache?: Record<string, 缩略图缓存项>;
  339. };
  340. type 缩略图选择项 = {
  341. file: TFile;
  342. dataUrl: string | null;
  343. isSelected: boolean;
  344. };
  345. type 缩略图弹窗参数 = {
  346. items: 缩略图选择项[];
  347. onConfirm: (selected: 缩略图选择项[]) => void;
  348. onCancel: () => void;
  349. };
  350. function 获取图片MimeType(extension: string): string {
  351. const ext = extension.toLowerCase();
  352. if (ext === "svg") return "image/svg+xml";
  353. if (ext === "jpg" || ext === "jpeg") return "image/jpeg";
  354. if (ext === "png") return "image/png";
  355. if (ext === "gif") return "image/gif";
  356. if (ext === "webp") return "image/webp";
  357. if (ext === "bmp") return "image/bmp";
  358. if (ext === "tif" || ext === "tiff") return "image/tiff";
  359. if (ext === "avif") return "image/avif";
  360. return "image/*";
  361. }
  362. async function 生成缩略图DataUrl(
  363. app: App,
  364. file: TFile,
  365. maxSize: number
  366. ): Promise<string | null> {
  367. try {
  368. const data = await app.vault.readBinary(file);
  369. const mime = 获取图片MimeType(file.extension || "");
  370. const blob = new Blob([data], { type: mime });
  371. const objectUrl = URL.createObjectURL(blob);
  372. const dataUrl = await new Promise<string | null>((resolve) => {
  373. const img = new Image();
  374. img.onload = () => {
  375. try {
  376. const width = img.naturalWidth || img.width;
  377. const height = img.naturalHeight || img.height;
  378. const scale = Math.min(maxSize / width, maxSize / height, 1);
  379. const targetW = Math.max(1, Math.round(width * scale));
  380. const targetH = Math.max(1, Math.round(height * scale));
  381. const canvas = document.createElement("canvas");
  382. canvas.width = targetW;
  383. canvas.height = targetH;
  384. const ctx = canvas.getContext("2d");
  385. if (!ctx) {
  386. resolve(null);
  387. return;
  388. }
  389. ctx.drawImage(img, 0, 0, targetW, targetH);
  390. resolve(canvas.toDataURL("image/png", 0.85));
  391. } catch (err) {
  392. console.debug(`${插件前缀} 生成缩略图失败:${file.path}`, err);
  393. resolve(null);
  394. } finally {
  395. URL.revokeObjectURL(objectUrl);
  396. }
  397. };
  398. img.onerror = () => {
  399. URL.revokeObjectURL(objectUrl);
  400. resolve(null);
  401. };
  402. img.src = objectUrl;
  403. });
  404. return dataUrl;
  405. } catch (err) {
  406. console.debug(`${插件前缀} 读取图片失败,无法生成缩略图:${file.path}`, err);
  407. return null;
  408. }
  409. }
  410. class 缩略图选择弹窗 extends Modal {
  411. private items: 缩略图选择项[];
  412. private onConfirm: (selected: 缩略图选择项[]) => void;
  413. private onCancel: () => void;
  414. private 已确认 = false;
  415. constructor(app: App, options: 缩略图弹窗参数) {
  416. super(app);
  417. this.items = options.items;
  418. this.onConfirm = options.onConfirm;
  419. this.onCancel = options.onCancel;
  420. }
  421. onOpen(): void {
  422. const { contentEl } = this;
  423. contentEl.empty();
  424. contentEl.createEl("h2", { text: "选择要清理的图片" });
  425. const actions = contentEl.createDiv();
  426. actions.style.display = "flex";
  427. actions.style.gap = "8px";
  428. actions.style.marginBottom = "12px";
  429. const 全选按钮 = actions.createEl("button", { text: "全选" });
  430. const 全不选按钮 = actions.createEl("button", { text: "全不选" });
  431. const grid = contentEl.createDiv();
  432. grid.style.display = "grid";
  433. grid.style.gridTemplateColumns = "repeat(auto-fill, minmax(140px, 1fr))";
  434. grid.style.gap = "12px";
  435. const renderItems = () => {
  436. grid.empty();
  437. for (const item of this.items) {
  438. const card = grid.createDiv();
  439. card.style.border = "1px solid var(--background-modifier-border)";
  440. card.style.borderRadius = "8px";
  441. card.style.padding = "8px";
  442. card.style.display = "flex";
  443. card.style.flexDirection = "column";
  444. card.style.gap = "6px";
  445. const checkbox = card.createEl("input", { type: "checkbox" });
  446. checkbox.checked = item.isSelected;
  447. checkbox.onchange = () => {
  448. item.isSelected = checkbox.checked;
  449. };
  450. if (item.dataUrl) {
  451. const img = card.createEl("img");
  452. img.src = item.dataUrl;
  453. img.style.width = "100%";
  454. img.style.height = "110px";
  455. img.style.objectFit = "contain";
  456. img.style.background = "var(--background-secondary)";
  457. img.style.borderRadius = "6px";
  458. } else {
  459. const placeholder = card.createDiv({ text: "无法生成缩略图" });
  460. placeholder.style.height = "110px";
  461. placeholder.style.display = "flex";
  462. placeholder.style.alignItems = "center";
  463. placeholder.style.justifyContent = "center";
  464. placeholder.style.fontSize = "12px";
  465. placeholder.style.color = "var(--text-muted)";
  466. placeholder.style.background = "var(--background-secondary)";
  467. placeholder.style.borderRadius = "6px";
  468. }
  469. const nameEl = card.createDiv({ text: item.file.name });
  470. nameEl.style.fontSize = "12px";
  471. nameEl.style.wordBreak = "break-all";
  472. const pathEl = card.createDiv({ text: item.file.path });
  473. pathEl.style.fontSize = "10px";
  474. pathEl.style.color = "var(--text-muted)";
  475. pathEl.style.wordBreak = "break-all";
  476. }
  477. };
  478. renderItems();
  479. 全选按钮.onclick = () => {
  480. for (const item of this.items) item.isSelected = true;
  481. renderItems();
  482. };
  483. 全不选按钮.onclick = () => {
  484. for (const item of this.items) item.isSelected = false;
  485. renderItems();
  486. };
  487. const footer = contentEl.createDiv();
  488. footer.style.display = "flex";
  489. footer.style.justifyContent = "flex-end";
  490. footer.style.gap = "8px";
  491. footer.style.marginTop = "16px";
  492. const 取消按钮 = footer.createEl("button", { text: "取消" });
  493. const 确认按钮 = footer.createEl("button", { text: "确认清理" });
  494. 确认按钮.style.fontWeight = "bold";
  495. 取消按钮.onclick = () => {
  496. this.close();
  497. };
  498. 确认按钮.onclick = () => {
  499. this.已确认 = true;
  500. const selected = this.items.filter((item) => item.isSelected);
  501. this.onConfirm(selected);
  502. this.close();
  503. };
  504. }
  505. onClose(): void {
  506. if (!this.已确认) {
  507. this.onCancel();
  508. }
  509. this.contentEl.empty();
  510. }
  511. }
  512. export default class ImageStripperPlugin extends Plugin {
  513. private thumbnailCache: Record<string, 缩略图缓存项> = {};
  514. private thumbnailCacheDirty = false;
  515. async onload(): Promise<void> {
  516. await this.加载缩略图缓存();
  517. this.addCommand({
  518. id: "strip-images-in-current-note",
  519. name: "清理当前笔记图片",
  520. callback: async () => {
  521. await this.执行清理当前笔记图片();
  522. }
  523. });
  524. this.addCommand({
  525. id: "strip-images-in-current-note-select",
  526. name: "清理当前笔记图片(可选择)",
  527. callback: async () => {
  528. await this.执行清理当前笔记图片_可选择();
  529. }
  530. });
  531. }
  532. onunload(): void {
  533. console.info(`${插件前缀} 插件已卸载`);
  534. }
  535. private async 执行清理当前笔记图片(): Promise<void> {
  536. const activeFile = this.app.workspace.getActiveFile();
  537. if (!activeFile) {
  538. new Notice("未找到当前笔记:请先打开一个笔记再执行图片清理。");
  539. console.warn(`${插件前缀} 未找到 active file,已终止`);
  540. return;
  541. }
  542. console.info(`${插件前缀} 开始清理图片:file=${activeFile.path}`);
  543. const 原内容 = await this.app.vault.read(activeFile);
  544. const { 新内容, 移除引用次数, 候选目标列表, 移除残留次数 } = 清理图片引用(原内容);
  545. console.debug(
  546. `${插件前缀} 解析完成:文本长度=${原内容.length} -> ${新内容.length},移除引用次数=${移除引用次数},候选目标数=${候选目标列表.length},移除残留次数=${移除残留次数}`
  547. );
  548. if (移除引用次数 === 0 && 移除残留次数 === 0) {
  549. new Notice("当前笔记未发现图片引用或可清理残留,无需清理。");
  550. console.info(`${插件前缀} 未发现图片引用或残留,已结束`);
  551. return;
  552. }
  553. // 写回笔记内容
  554. await this.app.vault.modify(activeFile, 新内容);
  555. console.info(`${插件前缀} 已写回清理后的笔记内容:file=${activeFile.path}`);
  556. const 图片文件列表 = await this.解析图片文件列表(activeFile, 候选目标列表);
  557. console.info(`${插件前缀} 可评估删除的本地图片文件数=${图片文件列表.length}`);
  558. let 删除数量 = 0;
  559. for (const imageFile of 图片文件列表) {
  560. try {
  561. const backlinks = 获取文件反链对象(this.app, imageFile);
  562. const 引用路径列表 = 从反链对象提取引用路径列表(backlinks);
  563. const 其他引用数 = 引用路径列表.filter((p) => p !== activeFile.path).length;
  564. if (其他引用数 === 0) {
  565. console.info(`${插件前缀} 将删除图片(无其他引用):${imageFile.path}`);
  566. await this.app.vault.delete(imageFile);
  567. 删除数量 += 1;
  568. } else {
  569. console.info(`${插件前缀} 保留图片(仍被引用 ${其他引用数} 次):${imageFile.path}`);
  570. }
  571. } catch (err) {
  572. console.error(`${插件前缀} 删除图片失败:${imageFile.path}`, err);
  573. }
  574. }
  575. const 提示 =
  576. 移除残留次数 > 0
  577. ? `已移除 ${移除引用次数} 处图片引用;额外清理 ${移除残留次数} 处残留片段;删除 ${删除数量} 个无人引用的图片附件。`
  578. : `已移除 ${移除引用次数} 处图片引用;删除 ${删除数量} 个无人引用的图片附件。`;
  579. new Notice(提示);
  580. console.info(`${插件前缀} 清理完成:${提示} file=${activeFile.path}`);
  581. }
  582. private async 执行清理当前笔记图片_可选择(): Promise<void> {
  583. const activeFile = this.app.workspace.getActiveFile();
  584. if (!activeFile) {
  585. new Notice("未找到当前笔记:请先打开一个笔记再执行图片清理。");
  586. console.warn(`${插件前缀} 未找到 active file,已终止(可选择模式)`);
  587. return;
  588. }
  589. console.info(`${插件前缀} 开始清理图片(可选择):file=${activeFile.path}`);
  590. const 原内容 = await this.app.vault.read(activeFile);
  591. const 候选目标列表 = 提取图片引用目标列表(原内容);
  592. console.debug(
  593. `${插件前缀} 解析完成(可选择):文本长度=${原内容.length},候选目标数=${候选目标列表.length}`
  594. );
  595. if (候选目标列表.length === 0) {
  596. new Notice("当前笔记未发现图片引用,无需清理。");
  597. console.info(`${插件前缀} 未发现图片引用,已结束(可选择模式)`);
  598. return;
  599. }
  600. const 图片文件列表 = await this.解析图片文件列表(activeFile, 候选目标列表);
  601. if (图片文件列表.length === 0) {
  602. new Notice("未解析到可用的本地图片文件。");
  603. console.info(`${插件前缀} 未解析到可用图片文件,已结束(可选择模式)`);
  604. return;
  605. }
  606. console.info(`${插件前缀} 进入选择弹窗,图片数=${图片文件列表.length}`);
  607. const items = await this.构建缩略图选择项(图片文件列表);
  608. const selectedItems = await this.打开缩略图选择弹窗(items);
  609. await this.保存缩略图缓存();
  610. if (!selectedItems) {
  611. console.info(`${插件前缀} 用户取消选择,未做任何改动`);
  612. return;
  613. }
  614. if (selectedItems.length === 0) {
  615. new Notice("未选择任何图片,已取消清理。");
  616. console.info(`${插件前缀} 未选择图片,已结束(可选择模式)`);
  617. return;
  618. }
  619. const selectedPathSet = new Set(selectedItems.map((item) => item.file.path));
  620. const targetToFilePathMap = await this.解析目标到文件路径Map(activeFile, 候选目标列表);
  621. const { 新内容, 移除引用次数, 移除残留次数 } = 清理图片引用_按目标过滤(原内容, {
  622. shouldRemoveTarget: (target: string) => {
  623. const resolvedPath = targetToFilePathMap.get(target);
  624. return Boolean(resolvedPath && selectedPathSet.has(resolvedPath));
  625. }
  626. });
  627. if (移除引用次数 === 0 && 移除残留次数 === 0) {
  628. new Notice("未发现可清理的目标(可能未被引用或已被保留)。");
  629. console.info(`${插件前缀} 未发生清理变更,已结束(可选择模式)`);
  630. return;
  631. }
  632. await this.app.vault.modify(activeFile, 新内容);
  633. console.info(`${插件前缀} 已写回清理后的笔记内容(可选择):file=${activeFile.path}`);
  634. let 删除数量 = 0;
  635. for (const item of selectedItems) {
  636. const imageFile = item.file;
  637. try {
  638. const backlinks = 获取文件反链对象(this.app, imageFile);
  639. const 引用路径列表 = 从反链对象提取引用路径列表(backlinks);
  640. const 其他引用数 = 引用路径列表.filter((p) => p !== activeFile.path).length;
  641. if (其他引用数 === 0) {
  642. console.info(`${插件前缀} 将删除图片(无其他引用):${imageFile.path}`);
  643. await this.app.vault.delete(imageFile);
  644. 删除数量 += 1;
  645. } else {
  646. console.info(`${插件前缀} 保留图片(仍被引用 ${其他引用数} 次):${imageFile.path}`);
  647. }
  648. } catch (err) {
  649. console.error(`${插件前缀} 删除图片失败:${imageFile.path}`, err);
  650. }
  651. }
  652. await this.保存缩略图缓存();
  653. const 提示 =
  654. 移除残留次数 > 0
  655. ? `已移除 ${移除引用次数} 处图片引用;额外清理 ${移除残留次数} 处残留片段;删除 ${删除数量} 个无人引用的图片附件。`
  656. : `已移除 ${移除引用次数} 处图片引用;删除 ${删除数量} 个无人引用的图片附件。`;
  657. new Notice(提示);
  658. console.info(`${插件前缀} 清理完成(可选择):${提示} file=${activeFile.path}`);
  659. }
  660. private async 解析图片文件列表(activeFile: TFile, 候选目标列表: string[]): Promise<TFile[]> {
  661. const 图片文件Map = new Map<string, TFile>();
  662. for (const rawTarget of 候选目标列表) {
  663. const file = await 获取图片文件(this.app, activeFile, rawTarget);
  664. if (file) {
  665. 图片文件Map.set(file.path, file);
  666. } else {
  667. console.debug(`${插件前缀} 跳过删除候选(外链/不可解析/非图片):target=${rawTarget}`);
  668. }
  669. }
  670. return Array.from(图片文件Map.values());
  671. }
  672. private async 解析目标到文件路径Map(
  673. activeFile: TFile,
  674. 候选目标列表: string[]
  675. ): Promise<Map<string, string>> {
  676. const map = new Map<string, string>();
  677. for (const rawTarget of 候选目标列表) {
  678. const file = await 获取图片文件(this.app, activeFile, rawTarget);
  679. if (file) {
  680. map.set(rawTarget, file.path);
  681. }
  682. }
  683. return map;
  684. }
  685. private async 构建缩略图选择项(图片文件列表: TFile[]): Promise<缩略图选择项[]> {
  686. const items: 缩略图选择项[] = [];
  687. for (const file of 图片文件列表) {
  688. const dataUrl = await this.获取缩略图DataUrl(file, 160);
  689. items.push({ file, dataUrl, isSelected: true });
  690. }
  691. return items;
  692. }
  693. private async 打开缩略图选择弹窗(
  694. items: 缩略图选择项[]
  695. ): Promise<缩略图选择项[] | null> {
  696. return await new Promise((resolve) => {
  697. const modal = new 缩略图选择弹窗(this.app, {
  698. items,
  699. onConfirm: (selected) => resolve(selected),
  700. onCancel: () => resolve(null)
  701. });
  702. modal.open();
  703. });
  704. }
  705. private async 加载缩略图缓存(): Promise<void> {
  706. const data = (await this.loadData()) as 插件缓存数据 | null;
  707. this.thumbnailCache = data?.thumbnailCache ?? {};
  708. console.info(`${插件前缀} 已加载缩略图缓存:count=${Object.keys(this.thumbnailCache).length}`);
  709. }
  710. private async 保存缩略图缓存(): Promise<void> {
  711. if (!this.thumbnailCacheDirty) return;
  712. await this.saveData({ thumbnailCache: this.thumbnailCache });
  713. this.thumbnailCacheDirty = false;
  714. console.info(`${插件前缀} 已保存缩略图缓存:count=${Object.keys(this.thumbnailCache).length}`);
  715. }
  716. private async 获取缩略图DataUrl(file: TFile, maxSize: number): Promise<string | null> {
  717. const cached = this.thumbnailCache[file.path];
  718. if (cached && cached.mtime === file.stat.mtime) {
  719. return cached.dataUrl;
  720. }
  721. const dataUrl = await 生成缩略图DataUrl(this.app, file, maxSize);
  722. if (dataUrl) {
  723. this.thumbnailCache[file.path] = {
  724. mtime: file.stat.mtime,
  725. dataUrl
  726. };
  727. this.thumbnailCacheDirty = true;
  728. }
  729. return dataUrl;
  730. }
  731. }