Quellcode durchsuchen

为 Obsidian 添加 Image Stripper 插件的初始实现
- 创建了核心插件文件,包括 `main.ts`、`package.json` 和 `manifest.json`。
- 添加了包含 `esbuild.config.mjs` 的构建配置。
- 包含了用于排除不必要文件的 `.gitignore`。
- 实现了从笔记中清除图片嵌入并删除未被引用图片的功能。
- 添加了包含安装和使用说明的 README。
- 在 `tsconfig.json` 中配置了 TypeScript 设置。

betterMax vor 3 Wochen
Commit
0fa8cffc21
8 geänderte Dateien mit 1042 neuen und 0 gelöschten Zeilen
  1. 10 0
      .gitignore
  2. 43 0
      README.md
  3. 32 0
      esbuild.config.mjs
  4. 11 0
      manifest.json
  5. 588 0
      package-lock.json
  6. 19 0
      package.json
  7. 323 0
      src/main.ts
  8. 16 0
      tsconfig.json

+ 10 - 0
.gitignore

@@ -0,0 +1,10 @@
+node_modules/
+dist/
+.obsidian/
+main.js
+main.js.map
+.DS_Store
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+

+ 43 - 0
README.md

@@ -0,0 +1,43 @@
+# Image Stripper(Obsidian 插件)
+
+一个简单的 Obsidian 插件:**清理当前笔记中的所有图片引用**,并且**仅在图片在整个 Vault 中没有其他引用时才删除该图片附件文件**(更安全)。
+
+## 功能说明
+
+- 支持清理的图片引用形式:
+  - Markdown 图片:`![](...)`
+  - Obsidian 内嵌:`![[xxx.png]]`、`![[xxx.png|100]]`
+- 删除策略:
+  - 总是先从当前笔记中移除图片引用
+  - 仅当该图片文件在全 Vault 没有其他反链引用时才删除图片文件
+  - 外链图片(如 `https://...`)只会移除引用,不会删除任何文件
+
+## 安装(本地)
+
+1. 构建插件(生成 `main.js`):
+
+```powershell
+npm run build
+```
+
+2. 将以下文件复制到你的 Vault 插件目录:
+   - 目标目录:`你的Vault/.obsidian/plugins/image-stripper/`
+   - 需要的文件:
+     - `manifest.json`
+     - `main.js`
+
+3. 在 Obsidian 中启用插件:
+   - 设置 → 第三方插件 → 允许第三方插件
+   - 设置 → 第三方插件 → 找到 `Image Stripper` → 启用
+
+## 使用方法
+
+在任意笔记中打开该笔记,然后:
+- 打开命令面板(默认 `Ctrl+P`)
+- 执行命令:`清理当前笔记图片`
+
+执行后会弹出通知,包含:移除的图片引用数量、删除的无人引用图片附件数量。
+
+## 排障与注意事项
+
+- 建议先在测试 Vault 验证,再在重要 Vault 中使用(必要时先备份)。\n+- 反链判断依赖 Obsidian 的元数据缓存;如果你刚批量导入/修改了大量文件,建议等索引稳定后再执行。\n+- 如果某些图片引用无法解析到 Vault 内的真实文件(例如路径不标准),插件仍会移除引用,但会跳过“删除附件”。\n+- 调试日志:打开开发者工具 Console,可搜索前缀 `"[image-stripper]"`。\n+\n+## 开发\n+\n+```powershell\n+npm run dev\n+```\n+\n+`dev` 会开启 esbuild watch,源码变更会自动重建生成 `main.js`。\n+

+ 32 - 0
esbuild.config.mjs

@@ -0,0 +1,32 @@
+import esbuild from "esbuild";
+import process from "node:process";
+
+const isProduction = process.argv.includes("production");
+const isDev = process.argv.includes("dev");
+
+const context = await esbuild.context({
+  entryPoints: ["src/main.ts"],
+  bundle: true,
+  external: ["obsidian"],
+  format: "cjs",
+  target: "es2018",
+  logLevel: "info",
+  sourcemap: !isProduction,
+  outfile: "main.js"
+});
+
+if (isProduction) {
+  await context.rebuild();
+  await context.dispose();
+  process.exit(0);
+}
+
+if (isDev) {
+  await context.watch();
+  console.log("[image-stripper] 开发模式:正在监听构建变更(esbuild watch)");
+} else {
+  await context.rebuild();
+  await context.dispose();
+  process.exit(0);
+}
+

+ 11 - 0
manifest.json

@@ -0,0 +1,11 @@
+{
+  "id": "image-stripper",
+  "name": "Image Stripper",
+  "version": "0.1.0",
+  "minAppVersion": "1.0.0",
+  "description": "Strip image embeds from the current note and optionally delete unreferenced image files in the vault.",
+  "author": "Image Stripper Contributors",
+  "authorUrl": "",
+  "isDesktopOnly": false
+}
+

+ 588 - 0
package-lock.json

@@ -0,0 +1,588 @@
+{
+  "name": "image-stripper",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "image-stripper",
+      "version": "0.1.0",
+      "devDependencies": {
+        "@types/node": "^20.11.30",
+        "esbuild": "^0.20.2",
+        "obsidian": "^1.5.12",
+        "typescript": "^5.4.5"
+      }
+    },
+    "node_modules/@codemirror/state": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-6.5.0.tgz",
+      "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "@marijn/find-cluster-break": "^1.0.0"
+      }
+    },
+    "node_modules/@codemirror/view": {
+      "version": "6.38.6",
+      "resolved": "https://registry.npmmirror.com/@codemirror/view/-/view-6.38.6.tgz",
+      "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "@codemirror/state": "^6.5.0",
+        "crelt": "^1.0.6",
+        "style-mod": "^4.1.0",
+        "w3c-keyname": "^2.2.4"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz",
+      "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.20.2.tgz",
+      "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz",
+      "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.20.2.tgz",
+      "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz",
+      "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz",
+      "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz",
+      "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz",
+      "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz",
+      "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz",
+      "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz",
+      "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz",
+      "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz",
+      "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz",
+      "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz",
+      "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz",
+      "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz",
+      "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz",
+      "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz",
+      "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz",
+      "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz",
+      "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz",
+      "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz",
+      "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@marijn/find-cluster-break": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
+      "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true
+    },
+    "node_modules/@types/codemirror": {
+      "version": "5.60.8",
+      "resolved": "https://registry.npmmirror.com/@types/codemirror/-/codemirror-5.60.8.tgz",
+      "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/tern": "*"
+      }
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz",
+      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/node": {
+      "version": "20.19.28",
+      "resolved": "https://registry.npmmirror.com/@types/node/-/node-20.19.28.tgz",
+      "integrity": "sha512-VyKBr25BuFDzBFCK5sUM6ZXiWfqgCTwTAOK8qzGV/m9FCirXYDlmczJ+d5dXBAQALGCdRRdbteKYfJ84NGEusw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~6.21.0"
+      }
+    },
+    "node_modules/@types/tern": {
+      "version": "0.23.9",
+      "resolved": "https://registry.npmmirror.com/@types/tern/-/tern-0.23.9.tgz",
+      "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "*"
+      }
+    },
+    "node_modules/crelt": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmmirror.com/crelt/-/crelt-1.0.6.tgz",
+      "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true
+    },
+    "node_modules/esbuild": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.20.2.tgz",
+      "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.20.2",
+        "@esbuild/android-arm": "0.20.2",
+        "@esbuild/android-arm64": "0.20.2",
+        "@esbuild/android-x64": "0.20.2",
+        "@esbuild/darwin-arm64": "0.20.2",
+        "@esbuild/darwin-x64": "0.20.2",
+        "@esbuild/freebsd-arm64": "0.20.2",
+        "@esbuild/freebsd-x64": "0.20.2",
+        "@esbuild/linux-arm": "0.20.2",
+        "@esbuild/linux-arm64": "0.20.2",
+        "@esbuild/linux-ia32": "0.20.2",
+        "@esbuild/linux-loong64": "0.20.2",
+        "@esbuild/linux-mips64el": "0.20.2",
+        "@esbuild/linux-ppc64": "0.20.2",
+        "@esbuild/linux-riscv64": "0.20.2",
+        "@esbuild/linux-s390x": "0.20.2",
+        "@esbuild/linux-x64": "0.20.2",
+        "@esbuild/netbsd-x64": "0.20.2",
+        "@esbuild/openbsd-x64": "0.20.2",
+        "@esbuild/sunos-x64": "0.20.2",
+        "@esbuild/win32-arm64": "0.20.2",
+        "@esbuild/win32-ia32": "0.20.2",
+        "@esbuild/win32-x64": "0.20.2"
+      }
+    },
+    "node_modules/moment": {
+      "version": "2.29.4",
+      "resolved": "https://registry.npmmirror.com/moment/-/moment-2.29.4.tgz",
+      "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/obsidian": {
+      "version": "1.11.4",
+      "resolved": "https://registry.npmmirror.com/obsidian/-/obsidian-1.11.4.tgz",
+      "integrity": "sha512-n0KD3S+VndgaByrEtEe8NELy0ya6/s+KZ7OcxA6xOm5NN4thxKpQjo6eqEudHEvfGCeT/TYToAKJzitQ1I3XTg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/codemirror": "5.60.8",
+        "moment": "2.29.4"
+      },
+      "peerDependencies": {
+        "@codemirror/state": "6.5.0",
+        "@codemirror/view": "6.38.6"
+      }
+    },
+    "node_modules/style-mod": {
+      "version": "4.1.3",
+      "resolved": "https://registry.npmmirror.com/style-mod/-/style-mod-4.1.3.tgz",
+      "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "6.21.0",
+      "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
+      "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/w3c-keyname": {
+      "version": "2.2.8",
+      "resolved": "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+      "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true
+    }
+  }
+}

+ 19 - 0
package.json

@@ -0,0 +1,19 @@
+{
+  "name": "image-stripper",
+  "version": "0.1.0",
+  "private": true,
+  "description": "Obsidian plugin: strip image embeds from the current note, optionally delete unreferenced image files.",
+  "main": "main.js",
+  "scripts": {
+    "build": "node esbuild.config.mjs production",
+    "dev": "node esbuild.config.mjs dev",
+    "version": "node -e \"console.log('version script placeholder')\""
+  },
+  "devDependencies": {
+    "@types/node": "^20.11.30",
+    "esbuild": "^0.20.2",
+    "obsidian": "^1.5.12",
+    "typescript": "^5.4.5"
+  }
+}
+

+ 323 - 0
src/main.ts

@@ -0,0 +1,323 @@
+import { App, 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 允许形如:![](path "title") 或 ![](<path with spaces>)
+  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[];
+};
+
+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, 移除引用次数, 候选目标列表 };
+}
+
+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.移除残留次数
+  };
+}
+
+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);
+}
+
+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;
+}
+
+export default class ImageStripperPlugin extends Plugin {
+  async onload(): Promise<void> {
+    this.addCommand({
+      id: "strip-images-in-current-note",
+      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 图片文件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());
+    console.info(`${插件前缀} 可评估删除的本地图片文件数=${图片文件列表.length}`);
+
+    let 删除数量 = 0;
+    for (const imageFile of 图片文件列表) {
+      try {
+        const backlinks = this.app.metadataCache.getBacklinksForFile(imageFile as any);
+        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}`);
+  }
+}
+

+ 16 - 0
tsconfig.json

@@ -0,0 +1,16 @@
+{
+  "compilerOptions": {
+    "target": "ES2020",
+    "useDefineForClassFields": true,
+    "module": "ESNext",
+    "lib": ["ES2020", "DOM"],
+    "moduleResolution": "Bundler",
+    "resolveJsonModule": true,
+    "noEmit": true,
+    "strict": true,
+    "skipLibCheck": true,
+    "types": ["node"]
+  },
+  "include": ["src/**/*.ts"]
+}
+