dsh-obsidian-memo 0.16.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +270 -0
  3. package/README.md +272 -0
  4. package/cordis.patch.yml +10 -0
  5. package/docs/ARCHITECTURE.md +88 -0
  6. package/docs/AUDIT-0.16.md +74 -0
  7. package/docs/CHANGELOG.md +120 -0
  8. package/docs/CONTRACT.md +544 -0
  9. package/docs/GUIDE.md +177 -0
  10. package/docs/KNOWN_ISSUES.md +113 -0
  11. package/icon.svg +7 -0
  12. package/lib/ai.js +161 -0
  13. package/lib/capture.js +175 -0
  14. package/lib/client.js +6994 -0
  15. package/lib/config-picker.js +29 -0
  16. package/lib/filename.js +33 -0
  17. package/lib/files.js +88 -0
  18. package/lib/host/ai-jobs.js +118 -0
  19. package/lib/host/configuration.js +151 -0
  20. package/lib/host/dispatch.js +32 -0
  21. package/lib/host/environment.js +24 -0
  22. package/lib/host/folders.js +77 -0
  23. package/lib/host/images.js +116 -0
  24. package/lib/host/mutations.js +123 -0
  25. package/lib/host/note-content.js +44 -0
  26. package/lib/host/notes.js +148 -0
  27. package/lib/host/rpc.js +82 -0
  28. package/lib/host/scanning.js +67 -0
  29. package/lib/host/session.js +67 -0
  30. package/lib/host/state.js +68 -0
  31. package/lib/host/vault.js +38 -0
  32. package/lib/host/writes.js +21 -0
  33. package/lib/index.js +88 -0
  34. package/lib/notes.js +454 -0
  35. package/lib/obsidian.js +93 -0
  36. package/lib/scan.js +269 -0
  37. package/lib/settings.js +228 -0
  38. package/lib/shared.js +309 -0
  39. package/lib/trash.js +205 -0
  40. package/locale/en.json +6 -0
  41. package/locale/zh.json +6 -0
  42. package/package.json +76 -0
  43. package/scripts/benchmark.mjs +58 -0
  44. package/scripts/build-client.mjs +39 -0
  45. package/src/client/ai/use-comments.js +75 -0
  46. package/src/client/ai/use-job-notifications.js +53 -0
  47. package/src/client/ai/use-project-dialog.js +58 -0
  48. package/src/client/capture/composer.js +266 -0
  49. package/src/client/capture/images.js +64 -0
  50. package/src/client/capture/keyboard.js +34 -0
  51. package/src/client/components/dialogs.js +67 -0
  52. package/src/client/components/feedback.js +50 -0
  53. package/src/client/components/icons.js +160 -0
  54. package/src/client/components/use-toasts.js +58 -0
  55. package/src/client/constants.js +127 -0
  56. package/src/client/data/state.js +116 -0
  57. package/src/client/data/use-notes-data.js +137 -0
  58. package/src/client/data/visibility.js +14 -0
  59. package/src/client/drag/drop-actions.js +47 -0
  60. package/src/client/drag/motion.js +84 -0
  61. package/src/client/drag/overlay.js +33 -0
  62. package/src/client/drag/use-card-drag.js +149 -0
  63. package/src/client/i18n/en.js +219 -0
  64. package/src/client/i18n/index.js +11 -0
  65. package/src/client/i18n/zh.js +219 -0
  66. package/src/client/images/cache.js +55 -0
  67. package/src/client/images/components.js +121 -0
  68. package/src/client/images/queue.js +106 -0
  69. package/src/client/index.js +40 -0
  70. package/src/client/panel/header.js +146 -0
  71. package/src/client/panel/use-shortcuts.js +79 -0
  72. package/src/client/panel.js +424 -0
  73. package/src/client/preferences.js +198 -0
  74. package/src/client/react.js +8 -0
  75. package/src/client/reader/blocks.js +51 -0
  76. package/src/client/reader/note-drawer.js +270 -0
  77. package/src/client/reader/use-dock-offset.js +23 -0
  78. package/src/client/reader/use-drawer-layout.js +96 -0
  79. package/src/client/reader/use-note-editor.js +230 -0
  80. package/src/client/reader/use-pinned-note.js +41 -0
  81. package/src/client/runtime.js +30 -0
  82. package/src/client/settings/ai-budget-row.js +65 -0
  83. package/src/client/settings/ai-budget.js +42 -0
  84. package/src/client/settings/callouts.js +21 -0
  85. package/src/client/settings/config-file.js +74 -0
  86. package/src/client/settings/image-processing.js +31 -0
  87. package/src/client/settings/official-page.js +32 -0
  88. package/src/client/settings/paths.js +131 -0
  89. package/src/client/settings/settings-drawer.js +244 -0
  90. package/src/client/settings/style-select.js +58 -0
  91. package/src/client/settings/trash.js +30 -0
  92. package/src/client/settings/use-settings-draft.js +207 -0
  93. package/src/client/styles/base.js +63 -0
  94. package/src/client/styles/capture.js +94 -0
  95. package/src/client/styles/index.js +26 -0
  96. package/src/client/styles/overrides.js +116 -0
  97. package/src/client/styles/reader.js +66 -0
  98. package/src/client/styles/settings.js +118 -0
  99. package/src/client/styles/tokens.js +29 -0
  100. package/src/client/styles/wall.js +83 -0
  101. package/src/client/testing.js +139 -0
  102. package/src/client/utils.js +66 -0
  103. package/src/client/wall/model.js +158 -0
  104. package/src/client/wall/note-card.js +201 -0
  105. package/src/client/wall/use-note-actions.js +107 -0
  106. package/src/client/wall/use-subfolders.js +42 -0
  107. package/src/client/wall/use-wall-content.js +135 -0
  108. package/src/client/wall/use-wall-filters.js +92 -0
  109. package/src/client/wall/use-wall-paging.js +55 -0
package/lib/shared.js ADDED
@@ -0,0 +1,309 @@
1
+ /**
2
+ * dsh-obsidian-memo —— Host/Client 共享常量。
3
+ *
4
+ * 这里只放纯数据(无 Node 依赖),方便单独测试,也方便与浏览器半对齐。
5
+ * @module dsh-obsidian-memo/shared
6
+ */
7
+
8
+ /** 包内 RPC 通道名(/^\/[A-Za-z0-9._~-]+$/)。 */
9
+ export const CHANNEL = '/sticky-notes'
10
+
11
+ /** 客户端 locale 命名空间。 */
12
+ export const NS = 'sticky-notes'
13
+
14
+ export const DEFAULT_IMAGE_COMPRESSION = { enabled: true, quality: 70, maxDimension: 2048 }
15
+
16
+ export function normalizeImageCompression(raw) {
17
+ const source = raw && typeof raw === 'object' ? raw : {}
18
+ const number = (value, fallback, min, max) => typeof value !== 'number' || !Number.isFinite(value)
19
+ ? fallback : Math.min(max, Math.max(min, Math.round(value)))
20
+ return {
21
+ enabled: source.enabled !== false,
22
+ quality: number(source.quality, 70, 1, 100),
23
+ maxDimension: number(source.maxDimension, 2048, 256, 8192),
24
+ }
25
+ }
26
+
27
+ /**
28
+ * 卡片淡色板:索引 0 是"无 tag"的纸白,其余按 tag 稳定分配。
29
+ * 0.13:日间纸色整体往白里调(用户反馈"日间卡片颜色更淡/更偏白");深色纸不变。
30
+ */
31
+ export const CARD_PALETTE = [
32
+ { name: '纸白', color: '#FDFAF4', dark: '#2C2721' },
33
+ { name: '苔绿', color: '#F5F9F4', dark: '#232B24' },
34
+ { name: '杏', color: '#FDF7F3', dark: '#2E2622' },
35
+ { name: '雾蓝', color: '#F4F8FC', dark: '#222A33' },
36
+ { name: '藕荷', color: '#FAF5FA', dark: '#2A242C' },
37
+ { name: '米', color: '#FAF8F2', dark: '#2B2822' },
38
+ { name: '胭脂', color: '#FDF5F6', dark: '#2E2426' },
39
+ { name: '青瓷', color: '#F5F9F7', dark: '#222C29' },
40
+ ]
41
+
42
+ /** 允许的 callout 类型(Obsidian 内置)。 */
43
+ export const CALLOUT_TYPES = [
44
+ 'note', 'abstract', 'info', 'todo', 'tip', 'success', 'question',
45
+ 'warning', 'failure', 'danger', 'bug', 'example', 'quote',
46
+ ]
47
+
48
+ /**
49
+ * 0.13 的四个默认 AI 评论人格(设置里可改、可增删)。
50
+ * 设计目标:四枚要有鲜明差异,而不是四种"正确答案"。
51
+ * - 😈 毒舌:尖锐黑色幽默,好笑、像金句,但不为刻薄而刻薄;
52
+ * - ✨ 另一面:从没想过的角度切入 + 人文关怀,让人被看见;
53
+ * - 🧭 落地:务实判断,证据链 + 最省力下一步,惜字如金;
54
+ * - 😇 真心话:贴心伙伴,给回信心与一个轻起手。
55
+ */
56
+ export const DEFAULT_EMOJIS = [
57
+ {
58
+ id: 'devil',
59
+ emoji: '😈',
60
+ label: '毒舌',
61
+ callout: 'quote',
62
+ prompt: `你是一个嘴毒但心热的老朋友。读这张便签,先挑出它最荒诞、最值得被调侃的那一点,用一句真正好笑的黑色幽默点破——要妙、要像金句,不要为了刻薄而硬造段子。笑完,再用一两句说出真正该看清的事。
63
+
64
+ 规则:
65
+ - 如果这是专业问题(医疗、法律、财务、工程、安全等),玩笑之后必须提醒对方去找专业人士,不要给结论性建议;
66
+ - 如果这是写作或创作,收起毒舌,换成"大哥"的口吻:直接讲一句对他写作真正有用的话;
67
+ - 不堆表情、不人身攻击、不说教。中文,120 字以内。`,
68
+ },
69
+ {
70
+ id: 'another',
71
+ emoji: '✨',
72
+ label: '另一面',
73
+ callout: 'abstract',
74
+ prompt: `先放下便签作者自己的视角,从一件他完全没提过的东西切入——另一个人的处境、十年后的自己、一个看似无关的领域——给出一个他大概率没想过的角度。
75
+
76
+ 然后回到人本身:这句话背后可能藏着的情绪、在意或疲惫,用一两句轻轻说中,让他觉得被看见、被理解。
77
+
78
+ 要求:
79
+ - 角度必须具体、能接着往下想,不要说"换个角度想"这种空话;
80
+ - 不空洞共情(不要"我理解你""抱抱"),不喊口号,不强行升华;
81
+ - 中文,130 字以内。`,
82
+ },
83
+ {
84
+ id: 'grounded',
85
+ emoji: '🧭',
86
+ label: '落地',
87
+ callout: 'info',
88
+ prompt: `你是一个干练、务实的落地顾问,惜字如金。读这张便签,只给能直接影响行动的判断:
89
+
90
+ 1)一句话结论:这事该不该做、值不值得推进;
91
+ 2)证据链:2–3 条最有分量的事实或高置信推测,明确标出哪些是推测、以及推断依据;
92
+ 3)最省力的下一步:一个具体动作,加一个能判断是否走通的验证信号。
93
+
94
+ 禁止:罗列"我分析了/我查了"式的工作量,把常识当洞察,说"要重视/要规划"这类空话,列超过 3 个并列点。信息不足就直说缺哪一条关键信息,不要猜。中文,120 字以内。`,
95
+ },
96
+ {
97
+ id: 'truth',
98
+ emoji: '😇',
99
+ label: '真心话',
100
+ callout: 'success',
101
+ prompt: `你是那个会说真话、也永远站在他这边的伙伴。读这张便签:
102
+ - 先接住他此刻的状态,要具体,不要套话;
103
+ - 再指出这件事里他已经做对的一个小地方,要真、要具体;
104
+ - 最后给一句不带压力的鼓励,和一个几乎不费力的起手动作,让他愿意继续。
105
+
106
+ 语气舒服、松弛,像深夜聊天;可以幽默,但不要鸡汤,不夸张,不替他承诺结果。中文,100 字以内。`,
107
+ },
108
+ ]
109
+
110
+ /**
111
+ * 0.11 及更早的默认四枚评论 emoji(原样保留,供设置迁移判断)。
112
+ * 只有当用户的 emojis 与这份默认值**完全一致**时才自动换成 0.13 默认值,
113
+ * 用户改过的预设一律不碰。
114
+ */
115
+ export const LEGACY_EMOJIS = [
116
+ {
117
+ id: 'insight',
118
+ emoji: '🔍',
119
+ label: '洞察',
120
+ callout: 'quote',
121
+ prompt: '你在读一张 Obsidian 便签。找出这条想法里 2–3 个最值得继续追的具体线索、隐含前提或盲点,不要复述原文,不要客套。中文,120 字以内。',
122
+ },
123
+ {
124
+ id: 'extend',
125
+ emoji: '💡',
126
+ label: '延伸',
127
+ callout: 'tip',
128
+ prompt: '给一个能把这想法往前推一步的具体做法、变体或例子,必须具体到能动手,不要泛泛而谈。中文,120 字以内。',
129
+ },
130
+ {
131
+ id: 'risk',
132
+ emoji: '⚠️',
133
+ label: '风险',
134
+ callout: 'warning',
135
+ prompt: '指出这条想法最大的风险、隐藏假设或最容易失败的地方,并给一句判断依据。中文,120 字以内。',
136
+ },
137
+ {
138
+ id: 'next',
139
+ emoji: '✅',
140
+ label: '下一步',
141
+ callout: 'success',
142
+ prompt: '给出一个今天就能动手的最小下一步,必须是具体动作。中文,80 字以内。',
143
+ },
144
+ ]
145
+
146
+ /** 旧默认预设是否为原样未改(id / emoji / label / callout / prompt 全一致)。 */
147
+ export function isLegacyDefaultEmojis(emojis) {
148
+ if (!Array.isArray(emojis) || emojis.length !== LEGACY_EMOJIS.length) return false
149
+ return emojis.every((item, index) => {
150
+ if (!isPlainObject(item)) return false
151
+ const legacy = LEGACY_EMOJIS[index]
152
+ return item.id === legacy.id
153
+ && item.emoji === legacy.emoji
154
+ && item.label === legacy.label
155
+ && item.callout === legacy.callout
156
+ && item.prompt === legacy.prompt
157
+ })
158
+ }
159
+
160
+ /** 默认"开档"建档提示词:只输出 Markdown 正文。 */
161
+ export const DEFAULT_PROJECT_PROMPT = `你在帮我把一张「进行时」便签开档成可持续推进的项目文档。
162
+ 只输出 Markdown 正文,不要输出 YAML frontmatter,不要包在代码围栏里。结构固定:
163
+
164
+ # 项目名(从便签里提炼,不超过 20 字)
165
+ > [!abstract] 一句话定位
166
+
167
+ ## 诉求
168
+ ## 现状与已知
169
+ ## 调查与线索
170
+ ## 下一步(3 条以内)
171
+ ## 待确认的问题
172
+
173
+ 要求:信息只能来自便签内容;不确定的东西写进「待确认的问题」,不要编造;用简洁中文;每个小节 1–3 句或 1–3 条,不要空话。`
174
+
175
+ const MAX_FILE_BYTES = 2 * 1024 * 1024
176
+ const CAPTURE_TOTAL_BYTES = 16 * 1024 * 1024
177
+ // JSON 的控制字符最坏扩展为六字节(\u0001);另留路径与信封空间。
178
+ const NOTE_REQUEST_BYTES = MAX_FILE_BYTES * 6 + 64 * 1024
179
+
180
+ /** Host 侧读取/写入上限。 */
181
+ export const LIMITS = {
182
+ maxFileBytes: MAX_FILE_BYTES,
183
+ noteNameBytes: 240, // 新建 / 编辑改名共用的文件名上限(UTF-8 字节)
184
+ captureImages: 8,
185
+ captureImageBytes: 8 * 1024 * 1024,
186
+ captureTotalBytes: CAPTURE_TOTAL_BYTES,
187
+ // 压缩开启时允许的原始图片上限(客户端在压缩前校验,避免超大图解码爆内存)。
188
+ captureSourceImageBytes: 32 * 1024 * 1024,
189
+ captureSourceTotalBytes: 64 * 1024 * 1024,
190
+ noteRequestBytes: NOTE_REQUEST_BYTES,
191
+ captureRequestBytes: Math.ceil(CAPTURE_TOTAL_BYTES / 3) * 4 + NOTE_REQUEST_BYTES,
192
+ maxDepth: 5,
193
+ summaryComments: 6,
194
+ fullComments: 30,
195
+ scanComments: 200, // 扫描时为 commentKinds 解析的评论条数上限(覆盖"全部评论"的典型场景)
196
+ commentText: 2000,
197
+ excerpt: 360,
198
+ blocks: 200,
199
+ blockText: 2000,
200
+ llmCommentChars: 4000,
201
+ llmProjectChars: 24_000,
202
+ llmInputChars: 12_000,
203
+ projectNameChars: 60,
204
+ emojis: 12,
205
+ cardImages: 3, // 卡片缩略图最多下发几张(第 4 张起只给数量)
206
+ cardImageMaxBytes: 1_500_000, // 卡片缩略图读取上限,超过就不加载(阅读模式仍可看原图)
207
+ jobsTtlMs: 2 * 60 * 1000,
208
+ undoTtlMs: 10 * 60 * 1000,
209
+ }
210
+
211
+ /** 把字节数渲染成人类可读的上限文案;Host 的 readImage / capture 错误共用。 */
212
+ export function formatBytes(bytes) {
213
+ if (bytes >= 1024 * 1024) return `${Math.round((bytes / (1024 * 1024)) * 10) / 10} MB`
214
+ if (bytes >= 1024) return `${Math.round((bytes / 1024) * 10) / 10} KB`
215
+ return `${bytes} 字节`
216
+ }
217
+
218
+ /**
219
+ * AI 预算(0.13 新增):每枚评论 emoji 一份,开档另有一份。
220
+ *
221
+ * DeepSeek 的 `max_tokens` 是「总输出预算」,思考 token 也算在里面;所以 high 档要给足,
222
+ * 否则会出现「思考把预算用完、正文一个字没写」的失败(见 `lib/ai.js` 的 llm-token-budget)。
223
+ *
224
+ * - `preset`:低 / 中 / 高 / 自定义;前 3 档固定映射到 `AI_PRESETS` 的 effort + maxTokens;
225
+ * - 自定义档才使用 `reasoningEffort` / `maxTokens` 两个自定义字段。
226
+ */
227
+ export const REASONING_EFFORTS = ['off', 'low', 'high', 'max']
228
+
229
+ export const AI_PRESETS = {
230
+ low: { reasoningEffort: 'off', maxTokens: 2048 },
231
+ medium: { reasoningEffort: 'low', maxTokens: 4096 },
232
+ high: { reasoningEffort: 'high', maxTokens: 8192 },
233
+ }
234
+
235
+ export const AI_PRESET_IDS = ['low', 'medium', 'high', 'custom']
236
+
237
+ export const AI_MAX_TOKENS_MIN = 256
238
+ export const AI_MAX_TOKENS_MAX = 65536
239
+
240
+ /** 默认档:高(用户要求默认一直用 high)。 */
241
+ export const DEFAULT_AI = { preset: 'high', ...AI_PRESETS.high }
242
+
243
+ /**
244
+ * 把任意来源的 AI 预算规范化成 `{ preset, reasoningEffort, maxTokens }`。
245
+ * 非自定义档一律以预设映射为准(界面上选哪档,实际请求就是哪档);
246
+ * 自定义档的 effort 非法时回退 high,maxTokens 夹在 [min, max] 并取整。
247
+ * @param {unknown} raw
248
+ * @returns {{ preset: string, reasoningEffort: string, maxTokens: number }}
249
+ */
250
+ export function normalizeAi(raw) {
251
+ const source = isPlainObject(raw) ? raw : {}
252
+ const preset = AI_PRESET_IDS.includes(source.preset) ? source.preset : DEFAULT_AI.preset
253
+ const base = preset === 'custom' ? source : AI_PRESETS[preset]
254
+ const fallback = preset === 'custom' ? DEFAULT_AI : AI_PRESETS[preset]
255
+ const reasoningEffort = REASONING_EFFORTS.includes(base.reasoningEffort)
256
+ ? base.reasoningEffort
257
+ : fallback.reasoningEffort
258
+ const rawTokens = Number(base.maxTokens)
259
+ const maxTokens = Number.isFinite(rawTokens)
260
+ ? Math.min(AI_MAX_TOKENS_MAX, Math.max(AI_MAX_TOKENS_MIN, Math.round(rawTokens)))
261
+ : fallback.maxTokens
262
+ return { preset, reasoningEffort, maxTokens }
263
+ }
264
+
265
+ /** 允许的评论 callout 兜底。 */
266
+ export const FALLBACK_CALLOUT = 'note'
267
+
268
+ /** 判断一个值是否是普通对象。 */
269
+ export function isPlainObject(value) {
270
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
271
+ }
272
+
273
+ /** 把 tag 稳定映射到淡色板(空 tag = 纸白)。 */
274
+ export function colorForTag(tag) {
275
+ return paletteForTag(tag).color
276
+ }
277
+
278
+ /** 同一 tag 的暗色模式卡片底色(深色底、同一色相)。 */
279
+ export function darkColorForTag(tag) {
280
+ return paletteForTag(tag).dark
281
+ }
282
+
283
+ function paletteForTag(tag) {
284
+ if (!tag) return CARD_PALETTE[0]
285
+ let hash = 0
286
+ for (let i = 0; i < tag.length; i += 1) hash = (hash * 31 + tag.codePointAt(i)) >>> 0
287
+ return CARD_PALETTE[1 + (hash % (CARD_PALETTE.length - 1))]
288
+ }
289
+
290
+ /**
291
+ * 0.13.8:把便签绝对路径转成 Obsidian 打开 URI。
292
+ *
293
+ * 用 `path` 参数(绝对路径)而不是 `vault` + `file`:Obsidian 的 `path` 直接接受文件
294
+ * 绝对路径,不需要猜仓库的显示名,也不会因为仓库改名/同名仓库而打不开。
295
+ * Host 把这条 URI 交给系统打开方式(macOS `open` / Windows `start` / Linux `xdg-open`)。
296
+ * @param {string} absPath 便签的绝对路径(建议先 resolve)。
297
+ * @returns {string} `obsidian://open?path=...`;空路径返回 ''。
298
+ */
299
+ export function buildObsidianUri(absPath) {
300
+ const path = typeof absPath === 'string' ? absPath.trim() : ''
301
+ if (path === '') return ''
302
+ return `obsidian://open?path=${encodeURIComponent(path)}`
303
+ }
304
+
305
+ /** 本地时间戳:`YYYY-MM-DD HH:mm`。 */
306
+ export function localStamp(date = new Date()) {
307
+ const pad = (n) => String(n).padStart(2, '0')
308
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
309
+ }
package/lib/trash.js ADDED
@@ -0,0 +1,205 @@
1
+ /** 库内 .trash 与可撤回的文件组;不依赖系统垃圾桶权限,不自动清空。 */
2
+ import { randomUUID } from 'node:crypto'
3
+ import { execFile } from 'node:child_process'
4
+ import { lstat, mkdir, readFile, readdir, rename, rmdir, unlink, writeFile } from 'node:fs/promises'
5
+ import { basename, dirname, extname, join, resolve } from 'node:path'
6
+ import { assertPathWithin, failure, isHiddenVaultPath, isInside, moveFileExclusive as restoreFile } from './files.js'
7
+
8
+ export const DELETE_UNDO_MS = 5000
9
+ let systemTrashOverride = null
10
+ export function setSystemTrashForTests(fn) { systemTrashOverride = fn }
11
+
12
+ export function systemTrashCommand(path, platform = process.platform) {
13
+ if (platform === 'darwin') return { command: '/usr/bin/osascript', args: ['-l', 'JavaScript', '-e',
14
+ "ObjC.import('Foundation'); function run(argv) { const error = Ref(); if (!$.NSFileManager.defaultManager.trashItemAtURLResultingItemURLError($.NSURL.fileURLWithPath(argv[0]), null, error)) throw Error(ObjC.unwrap(error[0].localizedDescription)); }", '--', path] }
15
+ if (platform === 'win32') {
16
+ const encodedPath = Buffer.from(path, 'utf8').toString('base64')
17
+ const script = `Add-Type -AssemblyName Microsoft.VisualBasic; $target=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedPath}')); [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory($target, [Microsoft.VisualBasic.FileIO.UIOption]::OnlyErrorDialogs, [Microsoft.VisualBasic.FileIO.RecycleOption]::SendToRecycleBin, [Microsoft.VisualBasic.FileIO.UICancelOption]::ThrowException)`
18
+ return { command: 'powershell.exe', args: ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')] }
19
+ }
20
+ return { command: 'gio', args: ['trash', '--', path] }
21
+ }
22
+
23
+ export async function vaultTrashInfo(vault) {
24
+ const path = await assertTrashRoot(vault, true)
25
+ const items = await readdir(path).catch((error) => { if (error.code === 'ENOENT') return null; throw error })
26
+ return { path, exists: items !== null, count: items?.length || 0 }
27
+ }
28
+
29
+ /** 整个仓库回收站交给系统回收 API,不用 unlink/rm 模拟清空,也不碰系统原有垃圾。 */
30
+ export async function moveVaultTrashToSystem(vault) {
31
+ const info = await vaultTrashInfo(vault)
32
+ if (!info.count) return { moved: 0 }
33
+ try {
34
+ if (systemTrashOverride) await systemTrashOverride(info.path)
35
+ else {
36
+ const { command, args } = systemTrashCommand(info.path)
37
+ await new Promise((resolve, reject) => execFile(command, args, { timeout: 60000, windowsHide: true }, (error) => error ? reject(error) : resolve()))
38
+ }
39
+ } catch (error) { throw failure('trash-failed', `移至系统垃圾桶失败,仓库回收文件仍保留在 ${info.path}:${error.message}`, error) }
40
+ return { moved: info.count }
41
+ }
42
+
43
+ const IMAGE_EXT = /\.(?:png|jpe?g|gif|webp|svg|avif|bmp|heic|tiff?)$/i
44
+ /** 包含评论与 frontmatter 内的图片;代码块内的图片示例不作为附件。 */
45
+ export function imageSources(text) {
46
+ const source = String(text).replace(/^\s*(`{3,}|~{3,})[^\n]*\n[\s\S]*?^\s*\1[^\n]*$/gm, '').replace(/`[^`\n]*`/g, '')
47
+ const images = []
48
+ const pattern = /!\[\[([^\]|]+)(?:\|[^\]]*)?\]\]|!\[[^\]]*\]\(\s*(<[^>]+>|(?:\\.|[^()\s]|\([^()]*\))+)(?:\s+["'][^"']*["'])?\s*\)/g
49
+ for (const match of source.matchAll(pattern)) {
50
+ let path = (match[1] || match[2]).replace(/^<|>$/g, '').trim()
51
+ try { path = decodeURIComponent(path) } catch { continue }
52
+ path = path.replace(/\\([ ()])/g, '$1')
53
+ if (path.startsWith('//') || /^[a-z][a-z\d+.-]*:/i.test(path) || !IMAGE_EXT.test(path)) continue
54
+ images.push({ source: path, wiki: Boolean(match[1]) })
55
+ }
56
+ return images
57
+ }
58
+
59
+ /** 全库索引用于裸附件名解析与共享图片保护;超限中止删除,避免遗漏引用。 */
60
+ export async function collectNoteFiles(vault) {
61
+ const files = [], notes = []
62
+ let count = 0
63
+ async function walk(dir) {
64
+ for (const item of await readdir(dir, { withFileTypes: true })) {
65
+ if (++count > 100000) throw failure('bad-request', '仓库过大,无法安全确认图片引用')
66
+ if (item.name.startsWith('.') || item.name === 'node_modules' || item.isSymbolicLink()) continue
67
+ const path = join(dir, item.name)
68
+ if (item.isDirectory()) await walk(path)
69
+ else if (item.isFile()) { files.push(path); if (extname(path).toLowerCase() === '.md') notes.push(path) }
70
+ }
71
+ }
72
+ await walk(vault)
73
+ return { files, notes }
74
+ }
75
+
76
+ export async function resolveImage(vault, note, image, files) {
77
+ const candidates = [resolve(dirname(note), image.source), resolve(vault, image.source)]
78
+ if (image.wiki && !image.source.includes('/')) candidates.push(...files.filter((path) => basename(path) === image.source))
79
+ for (const path of [...new Set(candidates)]) {
80
+ if (!isInside(vault, path) || isHiddenVaultPath(vault, path)) continue
81
+ const info = await lstat(path).catch((error) => { if (error.code !== 'ENOENT') throw error; return null })
82
+ if (!info?.isFile() || info.isSymbolicLink()) continue
83
+ const actual = await assertPathWithin(vault, path)
84
+ // 同名裸附件无法唯一定位时保留,不删除猜测的文件。
85
+ if (image.wiki && !image.source.includes('/') && files.filter((file) => basename(file) === image.source).length > 1) return null
86
+ return actual
87
+ }
88
+ return null
89
+ }
90
+
91
+ export async function noteAttachments(vault, note, text) {
92
+ const sources = imageSources(text)
93
+ // 纯文字便签没有待回收图片,不必扫描全库和解析其他笔记。
94
+ if (!sources.length) return { images: [], shared: 0 }
95
+ const index = await collectNoteFiles(vault), images = new Set()
96
+ for (const image of sources) {
97
+ const path = await resolveImage(vault, note, image, index.files)
98
+ if (path) images.add(path)
99
+ }
100
+ const shared = new Set()
101
+ if (images.size) for (const other of index.notes) {
102
+ if (other === note) continue
103
+ const content = await readFile(other, 'utf8')
104
+ // 普通链接和嵌入都算引用;共享附件仍留在仓库中。
105
+ for (const image of imageSources(content.replace(/(?<!!)\[\[/g, '![[').replace(/(?<!!)\[([^\]]*)\]\(/g, '![$1]('))) {
106
+ const path = await resolveImage(vault, other, image, index.files)
107
+ if (images.has(path)) shared.add(path)
108
+ }
109
+ }
110
+ return { images: [...images].filter((path) => !shared.has(path)), shared: shared.size }
111
+ }
112
+
113
+ /** 回收入口只使用库内真实的 .trash 目录;不接受指向其他位置的链接。 */
114
+ async function assertTrashRoot(vault, allowMissing = false) {
115
+ const root = join(vault, '.trash')
116
+ await assertPathWithin(vault, root, { allowMissing })
117
+ const info = await lstat(root).catch((error) => { if (error.code !== 'ENOENT') throw error; return null })
118
+ if (info && (!info.isDirectory() || info.isSymbolicLink())) throw failure('bad-request', '仓库 .trash 必须是真实文件夹,不能是符号链接')
119
+ return root
120
+ }
121
+
122
+ async function assertTrashFolder(vault, folder) {
123
+ const root = await assertTrashRoot(vault)
124
+ if (dirname(resolve(folder)) !== resolve(root)) throw failure('bad-request', '回收文件必须在仓库 .trash 内')
125
+ await assertPathWithin(root, folder)
126
+ if (!(await lstat(folder)).isDirectory()) throw failure('bad-request', '回收文件夹不能是符号链接')
127
+ }
128
+
129
+ /** 仅清理本次创建的空目录及清单,保留回收目录中后来添加的文件。 */
130
+ async function cleanTrashFolder(folder, entries) {
131
+ for (const item of entries) await rmdir(dirname(join(folder, item.name))).catch(() => {})
132
+ await unlink(join(folder, 'restore.json')).catch(() => {})
133
+ await rmdir(folder).catch(() => {})
134
+ }
135
+
136
+ /** 每组文件保存在 .trash/MEMO-…,清单记录原路径;失败时原路回滚。 */
137
+ export async function trashNote(vault, note, images) {
138
+ const root = join(vault, '.trash')
139
+ const folder = join(root, `MEMO-${basename(note, extname(note)).slice(0, 40)}-${randomUUID()}`)
140
+ const entries = [...new Set([note, ...images])].map((from, i) => ({ from, name: join(String(i), basename(from)) }))
141
+ const moved = []
142
+ let created = false
143
+ try {
144
+ // 全组预检在移动前完成;隐藏目录仅作为回收位置,不可作为删除来源。
145
+ for (const entry of entries) {
146
+ await assertPathWithin(vault, entry.from)
147
+ if (isHiddenVaultPath(vault, entry.from) || !(await lstat(entry.from)).isFile()) throw failure('bad-request', '只能回收仓库内的普通便签和图片文件')
148
+ }
149
+ await assertTrashRoot(vault, true)
150
+ await mkdir(root, { recursive: true, mode: 0o700 })
151
+ await assertTrashRoot(vault)
152
+ await mkdir(folder, { mode: 0o700 })
153
+ created = true
154
+ await writeFile(join(folder, 'restore.json'), JSON.stringify(entries, null, 2), { flag: 'wx', mode: 0o600 })
155
+ for (const entry of entries) {
156
+ await assertPathWithin(vault, entry.from)
157
+ await assertTrashFolder(vault, folder)
158
+ await mkdir(dirname(join(folder, entry.name)))
159
+ await rename(entry.from, join(folder, entry.name)); moved.push(entry)
160
+ }
161
+ return { path: folder, entries, vault }
162
+ } catch (error) {
163
+ const rollbackErrors = []
164
+ for (const entry of moved.reverse()) {
165
+ try {
166
+ await assertTrashFolder(vault, folder)
167
+ await assertPathWithin(vault, entry.from, { allowMissing: true })
168
+ await restoreFile(join(folder, entry.name), entry.from)
169
+ } catch (cause) { rollbackErrors.push(cause) }
170
+ }
171
+ if (rollbackErrors.length) throw failure('trash-failed', `删除中断,部分文件保留在 ${folder},请按 restore.json 找回;${error.message}`, error)
172
+ if (created) await cleanTrashFolder(folder, entries)
173
+ if (error.code === 'bad-request') throw error
174
+ throw failure('trash-failed', `移到仓库 .trash 失败,请检查目录读写权限:${error.message}`, error)
175
+ }
176
+ }
177
+
178
+ export async function restoreTrashedNote(entry) {
179
+ await assertTrashFolder(entry.vault, entry.path)
180
+ for (const item of entry.entries) {
181
+ await assertPathWithin(entry.vault, item.from, { allowMissing: true })
182
+ if (isHiddenVaultPath(entry.vault, item.from)) throw failure('bad-request', '不能恢复到仓库隐藏目录')
183
+ if (await lstat(item.from).catch((error) => { if (error.code !== 'ENOENT') throw error; return null })) throw failure('exists', '原位置已有同名文件,无法恢复')
184
+ const source = join(entry.path, item.name)
185
+ await assertPathWithin(entry.path, source)
186
+ if (!(await lstat(source)).isFile()) throw failure('bad-request', '回收文件不能是符号链接')
187
+ }
188
+ const restored = []
189
+ try {
190
+ for (const item of entry.entries) {
191
+ await mkdir(dirname(item.from), { recursive: true })
192
+ await assertPathWithin(entry.vault, item.from, { allowMissing: true })
193
+ await restoreFile(join(entry.path, item.name), item.from)
194
+ restored.push(item)
195
+ }
196
+ } catch (error) {
197
+ const rollbackErrors = []
198
+ for (const item of restored.reverse()) {
199
+ try { await restoreFile(item.from, join(entry.path, item.name)) } catch (cause) { rollbackErrors.push(cause) }
200
+ }
201
+ if (rollbackErrors.length) throw failure('restore-failed', `撤回中断,部分文件已回到原位,其余保留在 ${entry.path};请按 restore.json 找回`, error)
202
+ throw error
203
+ }
204
+ await cleanTrashFolder(entry.path, entry.entries)
205
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Obsidian MEMO",
4
+ "description": "Obsidian MEMO: a pastel note wall for DSH with quick capture, images and WebP compression, reading/editing, AI comments, drag-to-trash with undo, copying, archiving, project documents and opening in Obsidian."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Obsidian MEMO",
4
+ "description": "Obsidian MEMO:把「进行时」文件夹变成 DSH 淡色便签墙,支持快速新建、多图粘贴与 WebP 压缩、阅读/编辑、AI 评论、拖拽删除与撤回、复制、归档、到项目与在 Obsidian 打开。"
5
+ }
6
+ }
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "dsh-obsidian-memo",
3
+ "version": "0.16.3",
4
+ "type": "module",
5
+ "description": "dsh-obsidian-memo:DeepSeek Harness 的 Obsidian 便签插件。把 Obsidian 进行时文件夹变成淡色便签墙,支持快速新建、多图与 WebP 压缩、阅读/编辑、AI 评论、归档、开档与在 Obsidian 打开。",
6
+ "main": "lib/index.js",
7
+ "icon": "icon.svg",
8
+ "exports": {
9
+ ".": "./lib/index.js",
10
+ "./client": "./lib/client.js",
11
+ "./locale/*.json": "./locale/*.json",
12
+ "./icon": "./icon.svg",
13
+ "./package.json": "./package.json"
14
+ },
15
+ "files": [
16
+ "lib",
17
+ "src",
18
+ "scripts",
19
+ "AGENTS.md",
20
+ "docs/*.md",
21
+ "locale",
22
+ "cordis.patch.yml",
23
+ "icon.svg",
24
+ "README.md",
25
+ "README.en.md",
26
+ "LICENSE"
27
+ ],
28
+ "dsh": {
29
+ "manifestVersion": 1,
30
+ "bundle": {
31
+ "patch": "./cordis.patch.yml"
32
+ },
33
+ "client": {
34
+ "platform": "web",
35
+ "immediately": true,
36
+ "inject": [
37
+ "@deepseek-ai/dsh-client-locale",
38
+ "@deepseek-ai/dsh-client-ui-sidebar"
39
+ ]
40
+ }
41
+ },
42
+ "engines": {
43
+ "dsh": ">=0.2.2-rc.1 <0.3.0"
44
+ },
45
+ "scripts": {
46
+ "build": "node scripts/build-client.mjs",
47
+ "build:check": "node scripts/build-client.mjs --check",
48
+ "dev": "node scripts/build-client.mjs --watch",
49
+ "test": "pnpm build:check && node --test",
50
+ "prepack": "pnpm build",
51
+ "bench": "node --expose-gc scripts/benchmark.mjs"
52
+ },
53
+ "keywords": [
54
+ "dsh",
55
+ "deepseek-harness",
56
+ "dsh-plugin",
57
+ "obsidian",
58
+ "markdown",
59
+ "notes",
60
+ "sticky-notes",
61
+ "productivity"
62
+ ],
63
+ "author": "cavenzhong",
64
+ "license": "MIT",
65
+ "repository": {
66
+ "type": "git",
67
+ "url": "git+https://github.com/cavenzhong/dsh-obsidian-memo.git"
68
+ },
69
+ "homepage": "https://github.com/cavenzhong/dsh-obsidian-memo#readme",
70
+ "bugs": {
71
+ "url": "https://github.com/cavenzhong/dsh-obsidian-memo/issues"
72
+ },
73
+ "devDependencies": {
74
+ "esbuild": "0.25.12"
75
+ }
76
+ }
@@ -0,0 +1,58 @@
1
+ /** 合成临时仓库;只测 Host 扫描/缓存和排序计算,不写真实配置或仓库。 */
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { dirname, join, resolve } from 'node:path'
5
+ import { fileURLToPath, pathToFileURL } from 'node:url'
6
+
7
+ const source = resolve(process.argv[2] || join(dirname(fileURLToPath(import.meta.url)), '..'))
8
+ const { NotesScanner } = await import(pathToFileURL(join(source, 'lib/scan.js')))
9
+ if (!global.gc) throw new Error('请使用 pnpm bench,或 node --expose-gc scripts/benchmark.mjs')
10
+
11
+ const root = await mkdtemp(join(tmpdir(), 'memo-benchmark-'))
12
+ try {
13
+ const notesDir = join(root, 'notes'), paragraph = '便签性能测试 abcdefghijklmnop\n\n'.repeat(2000)
14
+ await mkdir(notesDir)
15
+ for (let i = 0; i < 1000; i++) await writeFile(join(notesDir, `${i}.md`), `${i}\n\n${paragraph}`)
16
+ global.gc()
17
+ const beforeHeap = process.memoryUsage().heapUsed
18
+ const scanner = new NotesScanner(), settings = { vaultPath: root, notesDir }
19
+ let started = performance.now()
20
+ const first = await scanner.scan(settings)
21
+ const coldMs = performance.now() - started
22
+ global.gc()
23
+ const retainedHeapMiB = (process.memoryUsage().heapUsed - beforeHeap) / 1024 / 1024
24
+ started = performance.now()
25
+ await scanner.scan(settings)
26
+ const warmMs = performance.now() - started
27
+ const edited = join(notesDir, '0.md')
28
+ await writeFile(edited, '一个已更新的便签\n')
29
+ scanner.invalidate(edited)
30
+ started = performance.now()
31
+ const next = await scanner.scan(settings)
32
+ const editMs = performance.now() - started
33
+ const originals = new Set(first.notes)
34
+ const reusedSummariesAfterEdit = next.notes.filter((note) => originals.has(note)).length
35
+
36
+ // 使用与行为测试相同的 bundle 加载方式;无需浏览器或 React 依赖。
37
+ let registration
38
+ const window = { __ModuleLoader__: { load(value) { registration = value } } }
39
+ new Function('window', await readFile(join(source, 'lib/client.js'), 'utf8'))(window)
40
+ const react = { createElement() {}, memo(fn) { return fn }, Fragment: Symbol() }
41
+ const api = registration.factory((name) => name === 'react' ? react : {}).__test
42
+ const names = Array.from({ length: 5000 }, (_, i) => ({ name: `便签 ${(i * 3271) % 5000}`, created: 1, mtime: 1 }))
43
+ started = performance.now()
44
+ api.sortNotes(names, 'name-asc')
45
+ const nameSortMs = performance.now() - started
46
+
47
+ const round = (value) => Math.round(value * 100) / 100
48
+ console.log(JSON.stringify({
49
+ node: process.version,
50
+ notes: first.notes.length,
51
+ approximateNoteKiB: round(Buffer.byteLength(paragraph) / 1024),
52
+ coldMs: round(coldMs), warmMs: round(warmMs), editMs: round(editMs),
53
+ retainedHeapMiB: round(retainedHeapMiB), reusedSummariesAfterEdit,
54
+ sortNotes: names.length, nameSortMs: round(nameSortMs),
55
+ }, null, 2))
56
+ } finally {
57
+ await rm(root, { recursive: true, force: true })
58
+ }