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
@@ -0,0 +1,68 @@
1
+ /** 组合扫描、配置和任务状态,生成客户端轮询响应与 revision。 */
2
+ import { NotesScanner } from '../scan.js'
3
+ import { VERSION, debugLog } from './environment.js'
4
+
5
+ function createStateEndpoints({
6
+ getSettings, jobsFingerprint, liveJobs, publicSettings, queueSettings, refreshConfig, resetScan,
7
+ scanOnce, settingsFingerprint,
8
+ }) {
9
+ function compositeRevision(scanRevision) {
10
+ return NotesScanner.hash(`${scanRevision}\u0000${settingsFingerprint()}\u0000${jobsFingerprint()}`)
11
+ }
12
+
13
+ async function endpointState(payload) {
14
+ // 只串行配置读取;全库扫描留在单飞队列中,避免不同面板重复扫描。
15
+ await queueSettings(refreshConfig)
16
+ const knownRevision = typeof payload?.knownRevision === 'string' ? payload.knownRevision : null
17
+ const startedAt = performance.now()
18
+ const scan = await scanOnce()
19
+ const currentJobs = liveJobs()
20
+ const pendingByPath = new Map()
21
+ for (const job of currentJobs) {
22
+ if (job.status === 'running' && job.kind === 'comment') {
23
+ const emoji = getSettings().emojis.find((item) => item.id === job.emojiId)
24
+ pendingByPath.set(job.path, { emoji: emoji?.emoji ?? '✍️', label: emoji?.label ?? '评论' })
25
+ }
26
+ }
27
+ const revision = compositeRevision(scan.revision)
28
+ const unchanged = knownRevision !== null && knownRevision === revision
29
+ const notes = unchanged ? null : scan.notes.map((summary) => ({
30
+ ...summary,
31
+ pending: pendingByPath.get(summary.path) ?? null,
32
+ }))
33
+ const value = {
34
+ configured: scan.configured,
35
+ revision,
36
+ // 0.13:插件实际版本(来自 package.json),Client 顶栏 byline 用它自动对齐。
37
+ version: VERSION,
38
+ jobs: currentJobs,
39
+ settings: publicSettings(),
40
+ ...(unchanged ? { unchanged: true } : { notes }),
41
+ }
42
+ if (scan.error) value.error = scan.error
43
+ debugLog(`state ${(performance.now() - startedAt).toFixed(1)}ms · ${scan.notes.length} notes${unchanged ? ' · unchanged' : ''}`)
44
+ return value
45
+ }
46
+
47
+ async function endpointSettings(payload) {
48
+ await refreshConfig()
49
+ const revision = NotesScanner.hash(settingsFingerprint())
50
+ return {
51
+ configured: Boolean(getSettings().vaultPath && getSettings().notesDir),
52
+ version: VERSION,
53
+ revision,
54
+ settings: publicSettings(),
55
+ ...(payload?.knownRevision === revision ? { unchanged: true } : {}),
56
+ }
57
+ }
58
+
59
+ async function endpointRescan() {
60
+ const epoch = resetScan()
61
+ const scan = await scanOnce(epoch)
62
+ return { revision: compositeRevision(scan.revision) }
63
+ }
64
+
65
+ return { endpointState, endpointSettings, endpointRescan }
66
+ }
67
+
68
+ export { createStateEndpoints }
@@ -0,0 +1,38 @@
1
+ /** 按最新配置解析工作目录,并统一校验便签与真实路径。 */
2
+ import { assertPathWithin, failure, isInside } from '../files.js'
3
+ import { extname, resolve } from 'node:path'
4
+
5
+ function createVaultPaths({ getSettings }) {
6
+ function resolved() {
7
+ const vault = getSettings().vaultPath ? resolve(getSettings().vaultPath) : ''
8
+ return {
9
+ vault,
10
+ notes: getSettings().notesDir ? resolve(getSettings().notesDir) : '',
11
+ project: getSettings().projectDir ? resolve(getSettings().projectDir) : '',
12
+ attachmentDir: getSettings().attachmentDir ? resolve(getSettings().attachmentDir) : '',
13
+ archive: getSettings().archiveTargets.map((target) => ({ ...target, path: resolve(target.path) })),
14
+ }
15
+ }
16
+
17
+ function requireNotes() {
18
+ const paths = resolved()
19
+ if (!paths.vault || !paths.notes) throw failure('not-configured', '还没有配置仓库与便签目录')
20
+ return paths
21
+ }
22
+
23
+ async function assertNotePath(filePath) {
24
+ const paths = requireNotes()
25
+ if (typeof filePath !== 'string' || filePath.trim() === '') throw failure('bad-request', '缺少便签路径')
26
+ const absolute = resolve(filePath)
27
+ if (!isInside(paths.notes, absolute)) throw failure('bad-request', '便签不在配置的便签目录内')
28
+ if (extname(absolute).toLowerCase() !== '.md') throw failure('bad-request', '不是 markdown 文件')
29
+ await assertPathWithin(paths.vault, paths.notes)
30
+ const actual = await assertPathWithin(paths.notes, absolute)
31
+ if (extname(actual).toLowerCase() !== '.md') throw failure('bad-request', '便签符号链接目标不是 markdown 文件')
32
+ return absolute
33
+ }
34
+
35
+ return { resolved, requireNotes, assertNotePath }
36
+ }
37
+
38
+ export { createVaultPaths }
@@ -0,0 +1,21 @@
1
+ /** 按真实文件路径串行写操作;失败不会阻塞后续写入。 */
2
+ import { realpath } from 'node:fs/promises'
3
+ import { resolve } from 'node:path'
4
+
5
+ function createWriteCoordinator() {
6
+ const writes = new Map()
7
+
8
+ async function withNoteWrite(filePath, action) {
9
+ filePath = await realpath(filePath).catch(() => resolve(filePath))
10
+ const previous = writes.get(filePath) || Promise.resolve()
11
+ const pending = previous.catch(() => {}).then(action)
12
+ writes.set(filePath, pending)
13
+ try { return await pending } finally {
14
+ if (writes.get(filePath) === pending) writes.delete(filePath)
15
+ }
16
+ }
17
+
18
+ return { withNoteWrite }
19
+ }
20
+
21
+ export { createWriteCoordinator }
package/lib/index.js ADDED
@@ -0,0 +1,88 @@
1
+ /** Cordis Host entry: compose per-plugin services and the authenticated RPC route. */
2
+ import { createConfiguration } from './host/configuration.js'
3
+ import { createScanCoordinator } from './host/scanning.js'
4
+ import { createSessionState } from './host/session.js'
5
+ import { createVaultPaths } from './host/vault.js'
6
+ import { createWriteCoordinator } from './host/writes.js'
7
+ import { createImageReader } from './host/images.js'
8
+ import { createNoteEndpoints } from './host/notes.js'
9
+ import { createAiEndpoints } from './host/ai-jobs.js'
10
+ import { createMutationEndpoints } from './host/mutations.js'
11
+ import { createStateEndpoints } from './host/state.js'
12
+ import { createFolderEndpoints } from './host/folders.js'
13
+ import { createDispatcher } from './host/dispatch.js'
14
+ import { debugLog } from './host/environment.js'
15
+ import { registerRpcRoute } from './host/rpc.js'
16
+
17
+ export const name = 'sticky-notes'
18
+ export const inject = ['connection', 'webServer', 'llm', 'agentDefaultModel', 'directoryPickerController']
19
+
20
+ export async function apply(ctx, config) {
21
+ const {
22
+ queueSettings, publicSettings, settingsFingerprint, refreshConfig, endpointSaveSettings,
23
+ endpointConfigFile, endpointChooseConfigFile, getSettings,
24
+ } = await createConfiguration({ config, onSettingsChange(next, previous) {
25
+ if (['vaultPath', 'notesDir', 'includeSubfolders'].some((key) => previous[key] !== next[key])) resetScan()
26
+ if (previous.vaultPath !== next.vaultPath || previous.notesDir !== next.notesDir) clearImagePaths()
27
+ } })
28
+
29
+ const { invalidateScan, resetScan, scanOnce, invalidateAndScan, scanner } = createScanCoordinator({ getSettings })
30
+
31
+ const { purgeExpiredState, liveJobs, runningJob, startJob, finishJob, jobsFingerprint, jobs, undos, deleteUndos } = createSessionState()
32
+
33
+ const { resolved, requireNotes, assertNotePath } = createVaultPaths({ getSettings })
34
+
35
+ const { withNoteWrite } = createWriteCoordinator()
36
+
37
+ const { endpointReadImage, clearImagePaths } = createImageReader({ assertNotePath, requireNotes })
38
+
39
+ const { endpointNote, endpointSaveNote, endpointCreateNote, endpointToggleTask, endpointOpenInObsidian } = createNoteEndpoints({ assertNotePath, getSettings, invalidateAndScan, requireNotes, resolved, scanOnce, scanner })
40
+
41
+ const { endpointComment, endpointOpenProject } = createAiEndpoints({
42
+ assertNotePath, ctx, finishJob, getSettings, invalidateAndScan, invalidateScan, jobs, requireNotes,
43
+ runningJob, startJob, withNoteWrite,
44
+ })
45
+
46
+ const { endpointArchive, endpointUndoArchive, endpointDeleteNote, endpointUndoDelete, endpointTrash } = createMutationEndpoints({
47
+ assertNotePath, clearImagePaths, deleteUndos, invalidateAndScan, invalidateScan, jobs,
48
+ purgeExpiredState, requireNotes, resolved, undos, withNoteWrite,
49
+ })
50
+
51
+ const { endpointState, endpointSettings, endpointRescan } = createStateEndpoints({
52
+ getSettings, jobsFingerprint, liveJobs, publicSettings, queueSettings, refreshConfig, resetScan,
53
+ scanOnce, settingsFingerprint,
54
+ })
55
+
56
+ const { endpointListFolders, endpointCreateFolder, endpointChooseConfigDirectory, endpointPickDirectory } = createFolderEndpoints({ ctx, getSettings })
57
+
58
+ const endpoints = {
59
+ state: endpointState,
60
+ settings: endpointSettings,
61
+ note: endpointNote,
62
+ saveNote: endpointSaveNote,
63
+ createNote: endpointCreateNote,
64
+ toggleTask: endpointToggleTask,
65
+ readImage: endpointReadImage,
66
+ comment: endpointComment,
67
+ archive: endpointArchive,
68
+ undoArchive: endpointUndoArchive,
69
+ deleteNote: endpointDeleteNote,
70
+ undoDelete: endpointUndoDelete,
71
+ openProject: endpointOpenProject,
72
+ openInObsidian: endpointOpenInObsidian,
73
+ saveSettings: endpointSaveSettings,
74
+ configFile: endpointConfigFile,
75
+ listFolders: endpointListFolders,
76
+ createFolder: endpointCreateFolder,
77
+ chooseConfigDirectory: endpointChooseConfigDirectory,
78
+ chooseConfigFile: endpointChooseConfigFile,
79
+ trash: endpointTrash,
80
+ pickDirectory: endpointPickDirectory,
81
+ rescan: endpointRescan,
82
+ }
83
+
84
+ const dispatch = createDispatcher({ endpoints, queueSettings, withNoteWrite })
85
+
86
+ registerRpcRoute(ctx, dispatch)
87
+ debugLog('通道已注册;便签目录:', getSettings().notesDir || '未配置')
88
+ }
package/lib/notes.js ADDED
@@ -0,0 +1,454 @@
1
+ /**
2
+ * dsh-obsidian-memo —— Markdown 解析与写入(纯函数,可单测)。
3
+ *
4
+ * 只处理 Obsidian 风格的 md:
5
+ * - frontmatter 只用于读/写 PROJECTS,渲染永远丢弃;
6
+ * - 第一个标题或第一段 = 便签标题;
7
+ * - 其余正文块 = 补充;callout = 评论;
8
+ * - 行尾/段中 block id(`^qa-daily-preview-…`)不进入展示,且不影响锚点后面的文字。
9
+ *
10
+ * 写入只做两件事:在末尾评论块之前插入正文段落、在文末追加评论 callout;
11
+ * 两者都返回新文本,由调用方原子写盘。frontmatter 其余内容一律原样保留。
12
+ * @module dsh-obsidian-memo/notes
13
+ */
14
+
15
+ import { LIMITS } from './shared.js'
16
+
17
+ const CALLOUT_HEAD = /^\s*>\s*\[!([A-Za-z0-9_-]+)\]([+-]?)\s*(.*)$/
18
+ const HEADING = /^\s*(#{1,6})\s+(.*)$/
19
+ const LIST_ITEM = /^\s*([-*+]|\d{1,9}[.)])\s+(.*)$/
20
+ const TASK_MARK = /^\[([ xX])\]\s+(.*)$/
21
+ const QUOTE = /^\s*>\s?(.*)$/
22
+ const FENCE = /^\s*(```|~~~)(.*)$/
23
+ const HR = /^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/
24
+ // Obsidian block id(如 `^qa-daily-preview-1e5t8lm`)只出现在**行尾**。0.13.9:段落按行拼接成空格后,
25
+ // block id 会跑到段落中间,所以必须先在原始行上按「行尾锚点」删掉,再拼接——只删锚点本身,
26
+ // 锚点后面的文字(下一行)保留;同时不会误删行内代码 / 行中上标里的 `^`。
27
+ const BLOCK_ID = /(?:^|[ \t])\^[A-Za-z0-9][A-Za-z0-9_-]*(?:[ \t]+\^[A-Za-z0-9][A-Za-z0-9_-]*)*[ \t]*$/
28
+
29
+ /** 保留段落中的 Markdown / Obsidian 图片,图片两侧仍显示文字。 */
30
+ function paragraphBlocks(text) {
31
+ // 0.13.9:先按原始行删掉行尾 block id(换行信息此时还在),再把段落拼成一行。
32
+ const source = stripBlockIds(text)
33
+ const pattern = /!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]|!\[([^\]]*)\]\(\s*(<[^>]+>|(?:\\.|[^()\s]|\([^()]*\))+)(?:\s+["'][^"']*["'])?\s*\)/g
34
+ const blocks = []
35
+ let offset = 0
36
+ for (const match of source.matchAll(pattern)) {
37
+ const before = stripInline(source.slice(offset, match.index).replace(/\n/g, ' '))
38
+ if (before) blocks.push({ kind: 'p', text: before.slice(0, LIMITS.blockText) })
39
+ blocks.push({ kind: 'image', text: '', source: (match[1] || match[4]).replace(/^<|>$/g, '').trim(), alt: match[2] || match[3] || '', wiki: Boolean(match[1]) })
40
+ offset = match.index + match[0].length
41
+ }
42
+ const rest = stripInline(source.slice(offset).replace(/\n/g, ' '))
43
+ if (rest) blocks.push({ kind: 'p', text: rest.slice(0, LIMITS.blockText) })
44
+ return blocks
45
+ }
46
+
47
+ /** 去掉 BOM 并统一换行。 */
48
+ export function normalizeText(text) {
49
+ const withoutBom = typeof text === 'string' && text.startsWith('\uFEFF') ? text.slice(1) : text
50
+ return String(withoutBom ?? '').replace(/\r\n?/g, '\n')
51
+ }
52
+
53
+ /**
54
+ * 拆分 frontmatter 与正文。
55
+ * @param {string} text - md 原文。
56
+ * @returns {{ frontmatter: string | null, body: string, bom: boolean }}
57
+ */
58
+ export function splitFrontmatter(text) {
59
+ const bom = typeof text === 'string' && text.startsWith('\uFEFF')
60
+ const body = normalizeText(text)
61
+ const lines = body.split('\n')
62
+ if (lines[0]?.trim() !== '---') return { frontmatter: null, body, bom }
63
+ let close = -1
64
+ for (let i = 1; i < lines.length; i += 1) {
65
+ const trimmed = lines[i].trim()
66
+ if (trimmed === '---' || trimmed === '...') {
67
+ close = i
68
+ break
69
+ }
70
+ }
71
+ if (close < 0) return { frontmatter: null, body, bom }
72
+ return {
73
+ frontmatter: lines.slice(0, close + 1).join('\n'),
74
+ body: lines.slice(close + 1).join('\n').replace(/^\n+/, ''),
75
+ bom,
76
+ }
77
+ }
78
+
79
+ /** 读 frontmatter 里的 PROJECTS 原始值(没有则 null)。 */
80
+ export function readProjects(text) {
81
+ const { frontmatter } = splitFrontmatter(text)
82
+ if (frontmatter === null) return null
83
+ for (const line of frontmatter.split('\n')) {
84
+ const match = line.match(/^PROJECTS\s*:\s*(.*)$/)
85
+ if (!match) continue
86
+ const value = match[1].trim()
87
+ return value === '' ? null : value
88
+ }
89
+ return null
90
+ }
91
+
92
+ /**
93
+ * 给源便签写回项目链接(只动 PROJECTS 一个键)。
94
+ * 已有非空值时不覆盖,返回 changed:false。
95
+ * @param {string} text - md 原文。
96
+ * @param {string} wikilink - 形如 `[[路径/项目|项目]]`。
97
+ * @returns {{ text: string, changed: boolean, reason: string }}
98
+ */
99
+ export function setProjectsLink(text, wikilink) {
100
+ const bom = typeof text === 'string' && text.startsWith('\uFEFF') ? '\uFEFF' : ''
101
+ const body = normalizeText(text)
102
+ const lines = body.split('\n')
103
+ const value = `"${wikilink.replace(/"/g, '\\"')}"`
104
+ let open = -1
105
+ let close = -1
106
+ if (lines[0]?.trim() === '---') {
107
+ open = 0
108
+ for (let i = 1; i < lines.length; i += 1) {
109
+ const trimmed = lines[i].trim()
110
+ if (trimmed === '---' || trimmed === '...') {
111
+ close = i
112
+ break
113
+ }
114
+ }
115
+ }
116
+ if (open >= 0 && close > 0) {
117
+ for (let i = open + 1; i < close; i += 1) {
118
+ const match = lines[i].match(/^PROJECTS\s*:\s*(.*)$/)
119
+ if (!match) continue
120
+ const rest = match[1].trim()
121
+ if (rest !== '' && rest !== '[]' && rest !== '""' && rest !== "''" && rest !== '~' && rest !== 'null') {
122
+ return { text, changed: false, reason: 'non-empty' }
123
+ }
124
+ for (let j = i + 1; j < close; j += 1) {
125
+ if (!/^\s+-\s+/.test(lines[j])) break
126
+ if (lines[j].trim() === '-' || /^\s+-\s*\S/.test(lines[j])) return { text, changed: false, reason: 'non-empty' }
127
+ }
128
+ lines[i] = `PROJECTS: ${value}`
129
+ return { text: bom + lines.join('\n'), changed: true, reason: 'updated' }
130
+ }
131
+ lines.splice(close, 0, `PROJECTS: ${value}`)
132
+ return { text: bom + lines.join('\n'), changed: true, reason: 'added' }
133
+ }
134
+ const created = ['---', `PROJECTS: ${value}`, '---', '', body.replace(/^\n+/, '')].join('\n')
135
+ return { text: bom + created, changed: true, reason: 'created' }
136
+ }
137
+
138
+ /**
139
+ * 0.13.9:按原始行删除行尾 block id,保留换行与后面的行。
140
+ * Obsidian 的 block id 必须位于行尾(`^abc`),所以这里只匹配每行末尾的锚点;
141
+ * 行中/行内代码里的 `^` 不会被误删。
142
+ * @param {string} input
143
+ * @returns {string}
144
+ */
145
+ export function stripBlockIds(input) {
146
+ return String(input ?? '')
147
+ .split('\n')
148
+ .map((line) => line.replace(BLOCK_ID, ''))
149
+ .join('\n')
150
+ }
151
+
152
+ /** 去掉行内 markdown 记号,得到可展示的纯文本。 */
153
+ export function stripInline(input) {
154
+ // 0.13.9:先处理行尾 block id(多行输入如评论 callout 也适用;单行标题/列表就是行尾)。
155
+ let s = stripBlockIds(String(input ?? ''))
156
+ s = s.replace(/!\[\[[^\]]*\]\]/g, '')
157
+ s = s.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
158
+ s = s.replace(/!\[[^\]]*\]/g, '')
159
+ s = s.replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, (_, _target, alias) => alias)
160
+ s = s.replace(/\[\[([^\]]+)\]\]/g, (_, target) => {
161
+ const withoutHeading = String(target).replace(/#[^|]*$/, '')
162
+ const leaf = withoutHeading.split('/').pop() ?? ''
163
+ return leaf.replace(/\.md$/i, '')
164
+ })
165
+ s = s.replace(/\[([^\]]+)\]\([^)]*\)/g, (_, label) => label)
166
+ s = s.replace(/%%[\s\S]*?%%/g, '')
167
+ s = s.replace(/`([^`]*)`/g, (_, code) => code)
168
+ s = s.replace(/(\*\*|__)(.*?)\1/g, (_, _m, inner) => inner)
169
+ s = s.replace(/(\*|_)(?=\S)([^*_]*?)(?<=\S)\1/g, (_, _m, inner) => inner)
170
+ s = s.replace(/==([\s\S]*?)==/g, (_, inner) => inner)
171
+ s = s.replace(/~~([\s\S]*?)~~/g, (_, inner) => inner)
172
+ s = s.replace(/<[^>]+>/g, '')
173
+ s = s.replace(/[ \t]+/g, ' ')
174
+ return s.trim()
175
+ }
176
+
177
+ /** 从 callout 标题里提取 emoji / label / time。 */
178
+ function splitCalloutTitle(title) {
179
+ const raw = String(title ?? '').trim()
180
+ let emoji = ''
181
+ let rest = raw
182
+ const leading = raw.match(/^([^\p{L}\p{N}\s]{1,8})\s*/u)
183
+ if (leading) {
184
+ emoji = leading[1]
185
+ rest = raw.slice(leading[0].length)
186
+ }
187
+ const timeMatch = rest.match(/(\d{4}-\d{2}-\d{2})(?:\s+(\d{2}:\d{2}))?/)
188
+ const time = timeMatch ? `${timeMatch[1]}${timeMatch[2] ? ` ${timeMatch[2]}` : ''}` : ''
189
+ const beforeTime = time ? rest.slice(0, rest.indexOf(timeMatch[1])).replace(/\s*·\s*$/, '').trim() : rest
190
+ const label = beforeTime.split(/\s*·\s*/)[0].trim() || beforeTime || ''
191
+ return { emoji, label, time }
192
+ }
193
+
194
+ /**
195
+ * 解析一张便签。
196
+ * @param {string} text - md 原文。
197
+ * @param {{ maxComments?: number, maxBlocks?: number }} [options]
198
+ * @returns {{
199
+ * projects: string | null,
200
+ * title: string,
201
+ * blocks: { kind: string, text: string }[],
202
+ * comments: object[],
203
+ * excerpt: string,
204
+ * }}
205
+ */
206
+ export function parseNote(text, options = {}) {
207
+ const maxComments = options.maxComments ?? LIMITS.fullComments
208
+ const maxBlocks = options.maxBlocks ?? LIMITS.blocks
209
+ const { body, frontmatter } = splitFrontmatter(text)
210
+ const lines = body.split('\n')
211
+ const blocks = []
212
+ const comments = []
213
+ let projects = null
214
+ if (frontmatter !== null) {
215
+ for (const line of frontmatter.split('\n')) {
216
+ const match = line.match(/^PROJECTS\s*:\s*(.*)$/)
217
+ if (match) {
218
+ const value = match[1].trim()
219
+ projects = value === '' ? null : value
220
+ break
221
+ }
222
+ }
223
+ }
224
+
225
+ let i = 0
226
+ let renderComplete = false
227
+ while (i < lines.length) {
228
+ const line = lines[i]
229
+ if (line.trim() === '') {
230
+ i += 1
231
+ continue
232
+ }
233
+
234
+ const callout = line.match(CALLOUT_HEAD)
235
+ if (callout) {
236
+ const type = callout[1].toLowerCase()
237
+ const title = callout[3].trim()
238
+ const bodyLines = []
239
+ const keepComment = comments.length < Math.max(1, maxComments)
240
+ i += 1
241
+ while (i < lines.length) {
242
+ const quoted = lines[i].match(QUOTE)
243
+ if (!quoted) break
244
+ if (keepComment) bodyLines.push(quoted[1])
245
+ i += 1
246
+ }
247
+ if (!keepComment) continue
248
+ const rawText = bodyLines.join('\n').replace(/\n{3,}/g, '\n\n').trim()
249
+ const meta = splitCalloutTitle(title)
250
+ comments.push({
251
+ type,
252
+ emoji: meta.emoji,
253
+ label: meta.label || title || type,
254
+ title,
255
+ time: meta.time,
256
+ text: stripInline(rawText).slice(0, LIMITS.commentText),
257
+ })
258
+ continue
259
+ }
260
+
261
+ // 展示块与标题都已齐全后,仅继续找尾部评论;不再解析随后所有段落。
262
+ // fenced code 和普通引用仍须整段跳过,避免把其中的示例当作评论。
263
+ if (!renderComplete && maxBlocks > 0 && blocks.length >= maxBlocks) {
264
+ const titleBlock = blocks[findTitleIndex(blocks)]
265
+ renderComplete = titleBlock?.kind === 'h' || titleBlock?.kind === 'p'
266
+ }
267
+ if (renderComplete) {
268
+ const fence = line.match(FENCE)
269
+ if (fence) {
270
+ i += 1
271
+ while (i < lines.length && !lines[i].trim().startsWith(fence[1])) i += 1
272
+ if (i < lines.length) i += 1
273
+ } else if (QUOTE.test(line)) {
274
+ while (i < lines.length && QUOTE.test(lines[i])) i += 1
275
+ } else i += 1
276
+ continue
277
+ }
278
+
279
+ if (HR.test(line)) {
280
+ blocks.push({ kind: 'hr', text: '' })
281
+ i += 1
282
+ continue
283
+ }
284
+
285
+ const fence = line.match(FENCE)
286
+ if (fence) {
287
+ const marker = fence[1]
288
+ const collected = []
289
+ i += 1
290
+ while (i < lines.length && !lines[i].trim().startsWith(marker)) {
291
+ collected.push(lines[i])
292
+ i += 1
293
+ }
294
+ if (i < lines.length) i += 1
295
+ const codeText = collected.join('\n').trim()
296
+ if (codeText) blocks.push({ kind: 'code', text: codeText.slice(0, LIMITS.blockText) })
297
+ continue
298
+ }
299
+
300
+ const heading = line.match(HEADING)
301
+ if (heading) {
302
+ blocks.push({ kind: 'h', text: stripInline(heading[2]).slice(0, LIMITS.blockText) })
303
+ i += 1
304
+ continue
305
+ }
306
+
307
+ const quote = line.match(QUOTE)
308
+ if (quote) {
309
+ const collected = []
310
+ while (i < lines.length) {
311
+ const quoted = lines[i].match(QUOTE)
312
+ if (!quoted) break
313
+ collected.push(quoted[1])
314
+ i += 1
315
+ }
316
+ const quotedText = stripInline(stripBlockIds(collected.join('\n')).replace(/\n/g, ' '))
317
+ if (quotedText) blocks.push({ kind: 'quote', text: quotedText.slice(0, LIMITS.blockText) })
318
+ continue
319
+ }
320
+
321
+ const item = line.match(LIST_ITEM)
322
+ if (item) {
323
+ const task = item[2].match(TASK_MARK)
324
+ const checked = task === null ? null : task[1].toLowerCase() === 'x'
325
+ const text = stripInline(task === null ? item[2] : task[2]).slice(0, LIMITS.blockText)
326
+ blocks.push({ kind: 'li', text, ...(/^\d/.test(item[1]) ? { marker: item[1] } : {}), ...(checked === null ? {} : { checked, sourceLine: i }) })
327
+ i += 1
328
+ continue
329
+ }
330
+
331
+ const paragraph = []
332
+ while (i < lines.length && lines[i].trim() !== '') {
333
+ const next = lines[i]
334
+ if (HR.test(next) || CALLOUT_HEAD.test(next) || HEADING.test(next) || LIST_ITEM.test(next) || QUOTE.test(next) || FENCE.test(next)) break
335
+ paragraph.push(next)
336
+ i += 1
337
+ }
338
+ blocks.push(...paragraphBlocks(paragraph.join('\n')))
339
+ if (paragraph.length === 0) i += 1
340
+ }
341
+
342
+ // 标题现在取文件名(由 scan 覆写);第一段仍然是正文的一部分,不再从 blocks 里剔除。
343
+ const titleIndex = findTitleIndex(blocks)
344
+ const title = titleIndex >= 0 ? blocks[titleIndex].text : ''
345
+ const bodyBlocks = blocks.slice(0, maxBlocks)
346
+ const excerptParts = []
347
+ for (const block of bodyBlocks) {
348
+ if (block.kind === 'code' || block.kind === 'image' || block.kind === 'hr') continue
349
+ const prefix = block.kind === 'li' && typeof block.checked === 'boolean' ? (block.checked ? '☑ ' : '☐ ') : ''
350
+ excerptParts.push(`${prefix}${block.text}`)
351
+ if (excerptParts.join(' ').length >= LIMITS.excerpt) break
352
+ }
353
+ const excerpt = clip(excerptParts.join(' ').replace(/\s+/g, ' '), LIMITS.excerpt)
354
+
355
+ return {
356
+ projects,
357
+ title,
358
+ blocks: bodyBlocks,
359
+ comments: comments.slice(0, Math.max(1, maxComments)),
360
+ excerpt,
361
+ }
362
+ }
363
+
364
+ /** 标题块:优先第一个 h/p,其次第一个非空块。 */
365
+ function findTitleIndex(blocks) {
366
+ for (let i = 0; i < blocks.length; i += 1) {
367
+ if (blocks[i].kind === 'p' || blocks[i].kind === 'h') return i
368
+ }
369
+ return blocks.length > 0 ? 0 : -1
370
+ }
371
+
372
+ /** 按字符截断,尽量不切 emoji。 */
373
+ export function clip(value, max) {
374
+ const s = String(value ?? '')
375
+ if (s.length <= max) return s
376
+ return `${Array.from(s).slice(0, max).join('')}…`
377
+ }
378
+
379
+ /** 取出正文(不含 frontmatter;用于编辑模式与保存)。 */
380
+ export function getBody(text) {
381
+ const { body } = splitFrontmatter(text)
382
+ return body.replace(/^\n+/, '')
383
+ }
384
+
385
+ /** 构造一个评论 callout 文本块。 */
386
+ export function buildCommentCallout({ type = 'note', title = '', text: body = '' }) {
387
+ const head = `> [!${type}] ${String(title ?? '').trim()}`.trimEnd()
388
+ const quoted = String(body ?? '')
389
+ .replace(/\r\n?/g, '\n')
390
+ .split('\n')
391
+ .map((line) => (line.trim() === '' ? '>' : `> ${line}`))
392
+ return [head, ...quoted].join('\n')
393
+ }
394
+
395
+ /**
396
+ * 在文件末尾追加评论 callout。
397
+ * @param {string} text - md 原文。
398
+ * @param {{ type: string, title: string, text: string }} callout
399
+ * @returns {string}
400
+ */
401
+ export function appendComment(text, callout) {
402
+ const bom = typeof text === 'string' && text.startsWith('\uFEFF') ? '\uFEFF' : ''
403
+ const base = normalizeText(text).replace(/\s+$/u, '')
404
+ const block = buildCommentCallout(callout)
405
+ return `${bom}${base ? `${base}\n\n${block}` : block}\n`
406
+ }
407
+
408
+ /**
409
+ * 从相对路径推导 tag:相对 notesDir 的第一层子文件夹名。
410
+ * @param {string} relativePath - 相对 notesDir 的路径(用 /)。
411
+ * @returns {string | null}
412
+ */
413
+ export function tagForRelativePath(relativePath) {
414
+ const parts = String(relativePath ?? '').split('/').filter(Boolean)
415
+ return parts.length > 1 ? parts[0] : null
416
+ }
417
+
418
+ /**
419
+ * 抽正文里的图片:前 limit 张的来源 + 正文图片总数。
420
+ * 只统计正文块(评论 callout 里的图不会进 blocks),供卡片缩略图与「+N」使用。
421
+ * @param {{ kind: string, source?: string, alt?: string, wiki?: boolean }[]} blocks
422
+ * @param {number} [limit] - 最多下发几张(默认 LIMITS.cardImages)。
423
+ * @returns {{ images: { source: string, alt: string, wiki: boolean }[], imageCount: number }}
424
+ */
425
+ export function summarizeImages(blocks, limit = LIMITS.cardImages) {
426
+ const all = (Array.isArray(blocks) ? blocks : [])
427
+ .filter((block) => block?.kind === 'image' && typeof block.source === 'string' && block.source.trim() !== '')
428
+ return {
429
+ images: all.slice(0, Math.max(0, limit)).map((block) => ({
430
+ source: block.source,
431
+ alt: typeof block.alt === 'string' ? block.alt : '',
432
+ wiki: block.wiki === true,
433
+ })),
434
+ imageCount: all.length,
435
+ }
436
+ }
437
+
438
+ /**
439
+ * 评论里出现过的 emoji(去重、按出现顺序)。空 emoji 不计入。
440
+ * @param {{ emoji?: string }[]} comments
441
+ * @returns {string[]}
442
+ */
443
+ export function commentKindsFrom(comments) {
444
+ const seen = new Set()
445
+ const kinds = []
446
+ for (const comment of Array.isArray(comments) ? comments : []) {
447
+ const emoji = typeof comment?.emoji === 'string' ? comment.emoji : ''
448
+ if (emoji === '' || seen.has(emoji)) continue
449
+ seen.add(emoji)
450
+ kinds.push(emoji)
451
+ }
452
+ return kinds
453
+ }
454
+