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,116 @@
1
+ /** 本地图片读取;持有附件路径缓存和限时负缓存。 */
2
+ import { assertPathWithin, failure, isInside } from '../files.js'
3
+ import { formatBytes } from '../shared.js'
4
+ import { debugLog, now } from './environment.js'
5
+ import { readFile, readdir, realpath, stat } from 'node:fs/promises'
6
+ import { dirname, extname, join, relative, resolve, sep } from 'node:path'
7
+
8
+ function createImageReader({ assertNotePath, requireNotes }) {
9
+ const DEFAULT_IMAGE_MAX_BYTES = 8 * 1024 * 1024
10
+
11
+ const IMAGE_PATH_CACHE_LIMIT = 500
12
+
13
+ const IMAGE_MISS_TTL_MS = 60 * 1000
14
+
15
+ function imageMaxBytes(value) {
16
+ const requested = Number(value)
17
+ if (!Number.isFinite(requested) || requested <= 0) return null
18
+ return Math.floor(requested)
19
+ }
20
+
21
+ const imagePaths = new Map()
22
+
23
+ const imageMisses = new Map()
24
+
25
+ function rememberImagePath(key, actual) {
26
+ if (imagePaths.size >= IMAGE_PATH_CACHE_LIMIT) {
27
+ const oldest = imagePaths.keys().next().value
28
+ if (oldest !== undefined) imagePaths.delete(oldest)
29
+ }
30
+ imagePaths.set(key, actual)
31
+ }
32
+
33
+ function rememberImageMiss(key) {
34
+ if (imageMisses.size >= IMAGE_PATH_CACHE_LIMIT) {
35
+ const oldest = imageMisses.keys().next().value
36
+ if (oldest !== undefined) imageMisses.delete(oldest)
37
+ }
38
+ imageMisses.set(key, now() + IMAGE_MISS_TTL_MS)
39
+ }
40
+
41
+ async function endpointReadImage(payload) {
42
+ const startedAt = performance.now()
43
+ const notePath = await assertNotePath(payload?.path)
44
+ const wiki = payload?.wiki === true
45
+ let source = typeof payload?.source === 'string' ? payload.source.trim() : ''
46
+ try { source = decodeURIComponent(source) } catch { throw failure('bad-request', '图片路径编码无效') }
47
+ if (!source || source.startsWith('//') || /^[a-z][a-z\d+.-]*:/i.test(source)) throw failure('bad-request', '本地图片路径无效')
48
+ const paths = requireNotes()
49
+ const imagePath = resolve(dirname(notePath), source)
50
+ if (!isInside(paths.vault, imagePath)) throw failure('bad-request', '图片不在 Obsidian 仓库内')
51
+
52
+ // 附件名解析(尤其 wiki 裸文件名)要遍历全库;命中与落空都缓存,避免反复 walk。
53
+ // 命中缓存用 realpath 复核,文件被移动/删除就失效重算。
54
+ const cacheKey = `${notePath}\u0000${source}\u0000${wiki ? '1' : '0'}`
55
+ const cached = imagePaths.get(cacheKey)
56
+ let actual = cached === undefined ? null : await realpath(cached).catch(() => null)
57
+ if (cached !== undefined && actual === null) imagePaths.delete(cacheKey)
58
+ if (actual === null) {
59
+ // 负缓存:60s 内同一附件名不再全库查找(wiki 裸附件名找到不存在的图时最贵)。
60
+ const missExpires = imageMisses.get(cacheKey)
61
+ if (missExpires !== undefined) {
62
+ if (missExpires > now()) {
63
+ debugLog(`readImage · not-found · negative-cache · ${(performance.now() - startedAt).toFixed(1)}ms`)
64
+ throw failure('not-found', '找不到本地图片')
65
+ }
66
+ imageMisses.delete(cacheKey)
67
+ }
68
+ }
69
+ if (actual === null) {
70
+ try { actual = await realpath(imagePath) } catch {
71
+ try { actual = await realpath(resolve(paths.vault, source)) } catch { /* 继续查找 Obsidian 附件 */ }
72
+ }
73
+ }
74
+ if (actual === null && wiki) {
75
+ debugLog('readImage · walk vault attachments')
76
+ const matches = []
77
+ let visited = 0
78
+ async function walk(dir) {
79
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
80
+ if (++visited > 20000) throw failure('bad-request', '附件查找范围过大,请使用仓库相对路径')
81
+ if (entry.name.startsWith('.') || entry.isSymbolicLink() || entry.name === 'node_modules') continue
82
+ const candidate = join(dir, entry.name)
83
+ if (entry.isDirectory()) await walk(candidate)
84
+ else if (entry.isFile() && (relative(paths.vault, candidate).split(sep).join('/') === source || (source.indexOf('/') < 0 && entry.name === source))) matches.push(candidate)
85
+ }
86
+ }
87
+ await walk(paths.vault)
88
+ if (matches.length > 1) throw failure('bad-request', '有多个同名附件,请使用仓库相对路径')
89
+ if (matches.length === 1) actual = await realpath(matches[0]).catch(() => null)
90
+ }
91
+ if (actual === null) {
92
+ if (wiki) rememberImageMiss(cacheKey)
93
+ debugLog(`readImage · not-found · ${(performance.now() - startedAt).toFixed(1)}ms`)
94
+ throw failure('not-found', '找不到本地图片')
95
+ }
96
+ await assertPathWithin(await realpath(paths.vault), actual)
97
+ const mime = ({ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.avif': 'image/avif' })[extname(actual).toLowerCase()]
98
+ if (!mime) throw failure('bad-request', '不支持这种图片格式')
99
+ const info = await stat(actual)
100
+ if (!info.isFile()) throw failure('bad-request', '本地图片不是文件')
101
+ // 默认上限 8 MB;卡片缩略图显式传 maxBytes 时按传入值判定。超过统一报 too-large。
102
+ const maxBytes = imageMaxBytes(payload?.maxBytes) ?? DEFAULT_IMAGE_MAX_BYTES
103
+ if (info.size > maxBytes) throw failure('too-large', `本地图片超过读取上限(${formatBytes(maxBytes)})`)
104
+ const data = await readFile(actual)
105
+ imageMisses.delete(cacheKey)
106
+ rememberImagePath(cacheKey, actual)
107
+ debugLog(`readImage ${(performance.now() - startedAt).toFixed(1)}ms · ${data.length} bytes · ${cached !== undefined ? 'path-cache' : 'resolve'}`)
108
+ return { dataUrl: `data:${mime};base64,${data.toString('base64')}` }
109
+ }
110
+
111
+ function clearImagePaths() { imagePaths.clear(); imageMisses.clear() }
112
+
113
+ return { endpointReadImage, clearImagePaths }
114
+ }
115
+
116
+ export { createImageReader }
@@ -0,0 +1,123 @@
1
+ /** 归档、删除、撤回与系统回收站操作;保留独占移动和整组回滚。 */
2
+ import { assertPathWithin, failure, isInside, moveFileExclusive, readNoteFile } from '../files.js'
3
+ import { openExternalUrl } from '../obsidian.js'
4
+ import { makeId } from '../settings.js'
5
+ import { LIMITS } from '../shared.js'
6
+ import { DELETE_UNDO_MS, moveVaultTrashToSystem, noteAttachments, restoreTrashedNote, trashNote, vaultTrashInfo } from '../trash.js'
7
+ import { now } from './environment.js'
8
+ import { ensureDir, fileExists } from './note-content.js'
9
+ import { lstat, realpath } from 'node:fs/promises'
10
+ import { basename, dirname, join } from 'node:path'
11
+
12
+ function createMutationEndpoints({
13
+ assertNotePath, clearImagePaths, deleteUndos, invalidateAndScan, invalidateScan, jobs, purgeExpiredState,
14
+ requireNotes, resolved, undos, withNoteWrite,
15
+ }) {
16
+ async function endpointArchive(payload) {
17
+ const paths = requireNotes()
18
+ const filePath = await assertNotePath(payload?.path)
19
+ const targetId = typeof payload?.targetId === 'string' ? payload.targetId : ''
20
+ const target = paths.archive.find((item) => item.id === targetId)
21
+ if (!target) throw failure('bad-request', '还没有配置这个归档目录')
22
+ if (!isInside(paths.vault, target.path)) throw failure('bad-request', '归档目录不在仓库内')
23
+ if (target.path === paths.notes || isInside(target.path, paths.notes)) throw failure('bad-request', '归档目录不能是便签目录或其父目录')
24
+ const archiveReal = await assertPathWithin(paths.vault, target.path, { allowMissing: true })
25
+ const notesReal = await assertPathWithin(paths.vault, paths.notes)
26
+ if (isInside(archiveReal, notesReal)) throw failure('bad-request', '归档目录不能是便签目录或其父目录')
27
+ await readNoteFile(filePath)
28
+ await ensureDir(target.path)
29
+ await assertPathWithin(paths.vault, target.path)
30
+ const sourceName = basename(filePath)
31
+ let candidate = join(target.path, sourceName)
32
+ if (await fileExists(candidate)) {
33
+ const stem = sourceName.replace(/\.md$/i, '')
34
+ let index = 2
35
+ for (; index < 100; index += 1) {
36
+ candidate = join(target.path, `${stem} (${index}).md`)
37
+ if (!(await fileExists(candidate))) break
38
+ }
39
+ if (index >= 100) throw failure('exists', '目标目录里同名文件太多')
40
+ }
41
+ await assertPathWithin(paths.vault, candidate, { allowMissing: true })
42
+ await moveFileExclusive(filePath, candidate)
43
+ await invalidateAndScan(filePath)
44
+ const undoToken = makeId('undo')
45
+ purgeExpiredState()
46
+ undos.set(undoToken, { from: filePath, to: candidate, expires: now() + LIMITS.undoTtlMs })
47
+ return { undoToken, to: candidate, targetId }
48
+ }
49
+
50
+ async function endpointUndoArchive(payload) {
51
+ const token = typeof payload?.undoToken === 'string' ? payload.undoToken : ''
52
+ const entry = undos.get(token)
53
+ if (!entry || entry.expires < now()) {
54
+ undos.delete(token)
55
+ throw failure('not-found', '撤销窗口已过期')
56
+ }
57
+ const paths = requireNotes()
58
+ await assertPathWithin(paths.vault, entry.to)
59
+ await assertPathWithin(paths.vault, paths.notes)
60
+ await assertPathWithin(paths.notes, entry.from, { allowMissing: true })
61
+ if (await fileExists(entry.from)) throw failure('exists', '原位置已有同名文件,无法恢复')
62
+ await ensureDir(dirname(entry.from))
63
+ await moveFileExclusive(entry.to, entry.from)
64
+ undos.delete(token)
65
+ await invalidateAndScan(entry.from)
66
+ return { restoredTo: entry.from }
67
+ }
68
+
69
+ async function endpointDeleteNote(payload) {
70
+ const paths = requireNotes()
71
+ const filePath = await assertNotePath(payload?.path)
72
+ if ((await lstat(filePath)).isSymbolicLink()) throw failure('bad-request', '请在原文件位置删除便签,不能删除符号链接')
73
+ if ([...jobs.values()].some((job) => job.path === filePath && job.status === 'running')) throw failure('busy', '请等这张便签的 AI 任务完成后再删除')
74
+ const original = await readNoteFile(filePath)
75
+ const vault = await realpath(paths.vault), notePath = await realpath(filePath)
76
+ const attachments = await noteAttachments(vault, notePath, original)
77
+ const entry = await trashNote(vault, notePath, attachments.images)
78
+ purgeExpiredState()
79
+ const undoToken = makeId('delete')
80
+ const expiresAt = now() + DELETE_UNDO_MS
81
+ deleteUndos.set(undoToken, { ...entry, expires: expiresAt })
82
+ clearImagePaths()
83
+ invalidateScan(filePath)
84
+ return { undoToken, expiresAt, imageCount: attachments.images.length, sharedImageCount: attachments.shared }
85
+ }
86
+
87
+ async function endpointUndoDelete(payload) {
88
+ const token = typeof payload?.undoToken === 'string' ? payload.undoToken : ''
89
+ const entry = deleteUndos.get(token)
90
+ if (!entry || entry.expires <= now()) {
91
+ deleteUndos.delete(token)
92
+ throw failure('not-found', '撤回窗口已过期,可在仓库 .trash 文件夹中找回文件')
93
+ }
94
+ if (!resolved().vault || await realpath(resolved().vault) !== entry.vault) throw failure('conflict', '仓库设置已更改,请在原仓库 .trash 文件夹中恢复文件')
95
+ await withNoteWrite(entry.entries[0].from, () => restoreTrashedNote(entry))
96
+ deleteUndos.delete(token)
97
+ invalidateScan(entry.entries[0].from)
98
+ clearImagePaths()
99
+ return { restoredTo: entry.entries[0].from }
100
+ }
101
+
102
+ async function endpointTrash(payload) {
103
+ const vault = resolved().vault
104
+ if (!vault) throw failure('not-configured', '请先选择仓库')
105
+ const info = await vaultTrashInfo(vault)
106
+ if (payload?.action === 'reveal') {
107
+ if (!info.exists) throw failure('not-found', '仓库回收站为空')
108
+ const opened = await openExternalUrl(info.path)
109
+ if (!opened.ok) throw failure('open-failed', opened.message)
110
+ } else if (payload?.action === 'empty') {
111
+ if (payload.confirm !== true) throw failure('bad-request', '请先确认移至系统垃圾桶')
112
+ if ([...deleteUndos.values()].some((item) => item.expires > now())) throw failure('busy', '请等待 5 秒撤回窗口结束后再清理')
113
+ const result = await moveVaultTrashToSystem(vault)
114
+ deleteUndos.clear()
115
+ return result
116
+ } else if (payload?.action !== 'info') throw failure('bad-request', '未知回收站操作')
117
+ return info
118
+ }
119
+
120
+ return { endpointArchive, endpointUndoArchive, endpointDeleteNote, endpointUndoDelete, endpointTrash }
121
+ }
122
+
123
+ export { createMutationEndpoints }
@@ -0,0 +1,44 @@
1
+ /** 读取完整便签和模型原文,以及共享文件/目录辅助函数。 */
2
+ import { contentRevision, failure, readNoteFile } from '../files.js'
3
+ import { commentKindsFrom, getBody, parseNote } from '../notes.js'
4
+ import { LIMITS } from '../shared.js'
5
+ import { mkdir, stat } from 'node:fs/promises'
6
+ import { basename, extname } from 'node:path'
7
+
8
+ async function fileExists(filePath) {
9
+ try {
10
+ await stat(filePath)
11
+ return true
12
+ } catch {
13
+ return false
14
+ }
15
+ }
16
+
17
+ async function ensureDir(dirPath) {
18
+ const info = await stat(dirPath).catch(() => null)
19
+ if (info !== null && !info.isDirectory()) throw failure('fs-failed', `不是文件夹:${dirPath}`)
20
+ if (info === null) await mkdir(dirPath, { recursive: true })
21
+ }
22
+
23
+ async function readFullNote(filePath) {
24
+ const text = await readNoteFile(filePath)
25
+ // 为让 commentKinds 覆盖「全部评论」解析到 scanComments;对外 comments 仍只给 fullComments 条。
26
+ const parsed = parseNote(text, { maxComments: LIMITS.scanComments, maxBlocks: LIMITS.blocks })
27
+ return {
28
+ path: filePath,
29
+ title: basename(filePath, '.md'),
30
+ body: getBody(text),
31
+ contentRevision: contentRevision(text),
32
+ blocks: parsed.blocks,
33
+ comments: parsed.comments.slice(0, LIMITS.fullComments),
34
+ commentCount: parsed.comments.length,
35
+ commentKinds: commentKindsFrom(parsed.comments),
36
+ projects: parsed.projects,
37
+ }
38
+ }
39
+
40
+ async function readModelSource(filePath) {
41
+ return { title: basename(filePath, extname(filePath)), body: getBody(await readNoteFile(filePath)) }
42
+ }
43
+
44
+ export { fileExists, ensureDir, readFullNote, readModelSource }
@@ -0,0 +1,148 @@
1
+ /** 便签读取、新建、版本化编辑、改名、任务切换和 Obsidian 打开。 */
2
+ import { createCapturedNote } from '../capture.js'
3
+ import { sanitizeNoteFileName } from '../filename.js'
4
+ import { assertPathWithin, atomicWrite, contentRevision, failure, isInside, moveFileExclusive, readNoteFile } from '../files.js'
5
+ import { getBody, parseNote, splitFrontmatter, summarizeImages } from '../notes.js'
6
+ import { openNoteInObsidian } from '../obsidian.js'
7
+ import { LIMITS, colorForTag, darkColorForTag } from '../shared.js'
8
+ import { debugLog, now } from './environment.js'
9
+ import { fileExists, readFullNote } from './note-content.js'
10
+ import { realpath, rename, stat } from 'node:fs/promises'
11
+ import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path'
12
+
13
+ function createNoteEndpoints({ assertNotePath, getSettings, invalidateAndScan, requireNotes, resolved, scanOnce, scanner }) {
14
+ async function endpointNote(payload) {
15
+ const filePath = await assertNotePath(payload?.path)
16
+ await scanOnce()
17
+ let note = await scanner.readFull(filePath)
18
+ if (note === null) {
19
+ await scanOnce()
20
+ note = await scanner.readFull(filePath)
21
+ }
22
+ if (note === null) {
23
+ const full = await readFullNote(filePath)
24
+ return {
25
+ ...full,
26
+ relPath: relative(resolved().vault, filePath).split(sep).join('/'),
27
+ name: basename(filePath, '.md'),
28
+ excerpt: '',
29
+ tag: null,
30
+ color: colorForTag(null),
31
+ darkColor: darkColorForTag(null),
32
+ comments: full.comments.slice(0, LIMITS.fullComments),
33
+ commentCount: full.commentCount,
34
+ ...summarizeImages(full.blocks),
35
+ commentKinds: full.commentKinds,
36
+ mtime: now(),
37
+ size: 0,
38
+ pending: null,
39
+ }
40
+ }
41
+ return note
42
+ }
43
+
44
+ async function endpointSaveNote(payload) {
45
+ const filePath = await assertNotePath(payload?.path)
46
+ if (typeof payload?.body !== 'string') throw failure('bad-request', '缺少正文')
47
+ const originalName = basename(filePath, extname(filePath))
48
+ const requestedName = payload?.name ?? originalName
49
+ let name = originalName
50
+ if (requestedName !== originalName) {
51
+ const nameCheck = sanitizeNoteFileName(requestedName)
52
+ if (nameCheck.error === 'too-long') throw failure('bad-request', `文件名不能超过 ${LIMITS.noteNameBytes} 字节`)
53
+ if (nameCheck.error === 'reserved') throw failure('bad-request', '文件名不能使用系统保留名')
54
+ if (nameCheck.error === 'empty') throw failure('bad-request', '文件名不能为空')
55
+ name = nameCheck.name
56
+ }
57
+ const target = name === originalName ? filePath : join(dirname(filePath), `${name}.md`)
58
+ let caseOnlyRename = false
59
+ await assertPathWithin(requireNotes().vault, target, { allowMissing: true })
60
+ if (target !== filePath && await fileExists(target)) {
61
+ // 仅大小写不同的改名:macOS/Windows 的大小写不敏感文件系统上 fileExists 会命中自己,
62
+ // 两边 realpath 相同就说明是同一个文件,允许改名,不误报 exists。
63
+ const [sourceReal, targetReal] = await Promise.all([
64
+ realpath(filePath).catch(() => null),
65
+ realpath(target).catch(() => null),
66
+ ])
67
+ if (sourceReal === null || targetReal === null || sourceReal !== targetReal) {
68
+ throw failure('exists', `同名便签已存在:${name}`)
69
+ }
70
+ caseOnlyRename = true
71
+ }
72
+ const original = await readNoteFile(filePath)
73
+ if (typeof payload.expectedRevision !== 'string' || payload.expectedRevision !== contentRevision(original)) {
74
+ throw failure('conflict', '便签已被修改或缺少编辑版本;请先复制保留草稿,再取消编辑并重新打开')
75
+ }
76
+ const { frontmatter, bom } = splitFrontmatter(original)
77
+ const bare = payload.body.replace(/\r\n?/g, '\n').replace(/^\n+/u, '').replace(/\s+$/u, '')
78
+ const bodyText = bare === '' ? '' : `${bare}\n`
79
+ const composed = frontmatter === null
80
+ ? bodyText
81
+ : bodyText === '' ? `${frontmatter}\n` : `${frontmatter}\n\n${bodyText}`
82
+ const content = `${bom ? '\uFEFF' : ''}${composed}`
83
+ if (Buffer.byteLength(content, 'utf8') > LIMITS.maxFileBytes) throw failure('bad-request', '便签文件过大')
84
+ await atomicWrite(filePath, content)
85
+ if (target !== filePath) {
86
+ try {
87
+ if (caseOnlyRename) await rename(filePath, target)
88
+ else await moveFileExclusive(filePath, target)
89
+ } catch (error) {
90
+ if (error.code === 'EEXIST') throw failure('exists', `同名便签已存在:${name};正文已保存在原文件中`)
91
+ throw error
92
+ }
93
+ }
94
+ await invalidateAndScan(filePath)
95
+ const note = scanner.summary(target)
96
+ if (note === null) throw failure('fs-failed', '保存后读不到便签')
97
+ return { note }
98
+ }
99
+
100
+ async function endpointCreateNote(payload) {
101
+ const path = await createCapturedNote(requireNotes(), payload)
102
+ await invalidateAndScan(path).catch((error) => debugLog('新建后刷新失败:', error.message))
103
+ // 目录可在创建后被切换;提交已落盘时依然返回成功,避免重试产生歧义。
104
+ return { note: scanner.summary(path), path }
105
+ }
106
+
107
+ async function endpointToggleTask(payload) {
108
+ const filePath = await assertNotePath(payload?.path)
109
+ const sourceLine = Number(payload?.sourceLine)
110
+ if (!Number.isInteger(sourceLine) || sourceLine < 0 || typeof payload?.checked !== 'boolean') throw failure('bad-request', '任务项参数无效')
111
+ const original = await readNoteFile(filePath)
112
+ const split = splitFrontmatter(original)
113
+ if (getBody(original) !== payload?.expectedBody) throw failure('conflict', '便签已被修改,请刷新后再勾选')
114
+ if (!parseNote(original).blocks.some((block) => block.kind === 'li' && block.sourceLine === sourceLine)) throw failure('bad-request', '只能修改阅读模式中的任务项')
115
+ const lines = split.body.split('\n')
116
+ if (sourceLine >= lines.length) throw failure('bad-request', '任务项已变化,请刷新后重试')
117
+ const line = lines[sourceLine]
118
+ const match = line.match(/^(\s*(?:[-*+]|\d+[.)])\s+\[)([ xX])(\]\s+.*)$/)
119
+ if (!match) throw failure('bad-request', '这行已不是任务项,请刷新后重试')
120
+ // 只替换对应行的任务标记,保留 BOM、frontmatter、空行与原换行格式。
121
+ const raw = original.split(/(\r\n|\r|\n)/)
122
+ const lineIndex = (Math.ceil(raw.length / 2) - lines.length + sourceLine) * 2
123
+ raw[lineIndex] = raw[lineIndex].replace(/(\[)[ xX](\])/, `$1${payload.checked ? 'x' : ' '}$2`)
124
+ await atomicWrite(filePath, raw.join(''))
125
+ await invalidateAndScan(filePath)
126
+ return { note: scanner.summary(filePath) }
127
+ }
128
+
129
+ async function endpointOpenInObsidian(payload) {
130
+ const raw = payload?.path
131
+ if (typeof raw !== 'string' || raw.trim() === '') throw failure('bad-request', '缺少便签路径')
132
+ const target = resolve(raw)
133
+ const vault = getSettings().vaultPath ? resolve(getSettings().vaultPath) : ''
134
+ if (vault === '' || !isInside(vault, target)) throw failure('bad-request', '便签必须在仓库内')
135
+ await assertPathWithin(vault, target)
136
+ const info = await stat(target).catch(() => null)
137
+ if (info === null || !info.isFile()) throw failure('not-found', '便签不存在')
138
+ const opened = await openNoteInObsidian(target)
139
+ if (opened?.ok !== true) {
140
+ throw failure('open-failed', opened?.message ? `打开 Obsidian 失败:${opened.message}` : '打开 Obsidian 失败')
141
+ }
142
+ return { url: opened.url }
143
+ }
144
+
145
+ return { endpointNote, endpointSaveNote, endpointCreateNote, endpointToggleTask, endpointOpenInObsidian }
146
+ }
147
+
148
+ export { createNoteEndpoints }
@@ -0,0 +1,82 @@
1
+ /** 已认证 HTTP RPC 传输:认证、路径、请求预算与信封;不执行业务。 */
2
+ import { CHANNEL, LIMITS } from '../shared.js'
3
+
4
+ function registerRpcRoute(ctx, dispatch) {
5
+ ctx.effect(() => {
6
+ const webServer = ctx.get('webServer')
7
+ const connection = ctx.get('connection')
8
+ if (webServer === undefined) return () => {}
9
+ const route = {
10
+ kind: 'prefix',
11
+ path: CHANNEL,
12
+ handler: async (req, res) => {
13
+ const rejection = connection?.requestRejection?.({ headers: req.headers })
14
+ if (rejection !== undefined) {
15
+ res.writeHead(rejection)
16
+ res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
17
+ return
18
+ }
19
+ if (req.method !== 'POST') {
20
+ res.writeHead(405)
21
+ res.end('method not allowed')
22
+ return
23
+ }
24
+ let pathname
25
+ try { pathname = new URL(req.url ?? CHANNEL, 'http://127.0.0.1').pathname } catch {
26
+ respond(res, 'invalid', { ok: false, error: { code: 'bad-request', message: '请求路径无效', details: {} } })
27
+ return
28
+ }
29
+ const pathEndpoint = pathname.startsWith(`${CHANNEL}/`) ? pathname.slice(CHANNEL.length + 1) : ''
30
+ let raw = ''
31
+ try {
32
+ const limit = pathEndpoint === 'createNote' ? LIMITS.captureRequestBytes
33
+ : ['saveNote', 'toggleTask'].includes(pathEndpoint) ? LIMITS.noteRequestBytes : LIMITS.maxFileBytes
34
+ let bytes = 0
35
+ const chunks = []
36
+ for await (const chunk of req) {
37
+ bytes += Buffer.byteLength(chunk)
38
+ if (bytes > limit) throw new Error('body too large')
39
+ chunks.push(Buffer.from(chunk))
40
+ }
41
+ raw = Buffer.concat(chunks).toString('utf8')
42
+ } catch (error) {
43
+ res.writeHead(400)
44
+ res.end('unreadable body')
45
+ return
46
+ }
47
+ let message
48
+ try {
49
+ message = JSON.parse(raw)
50
+ } catch {
51
+ respond(res, 'invalid', { ok: false, error: { code: 'bad-request', message: 'body is not JSON', details: {} } })
52
+ return
53
+ }
54
+ const rpcId = typeof message?.rpcId === 'string' ? message.rpcId : 'invalid'
55
+ const endpoint = typeof message?.method === 'string' ? message.method : ''
56
+ if (endpoint === '' || endpoint !== pathEndpoint) {
57
+ respond(res, rpcId, {
58
+ ok: false,
59
+ error: { code: 'bad-request', message: `method ${JSON.stringify(endpoint)} 与路径 ${JSON.stringify(pathname)} 不匹配`, details: {} },
60
+ })
61
+ return
62
+ }
63
+ let result
64
+ try {
65
+ result = await dispatch(endpoint, message.payload ?? {})
66
+ } catch (error) {
67
+ result = { ok: false, error: { code: 'fs-failed', message: error?.message ?? String(error), details: {} } }
68
+ }
69
+ respond(res, rpcId, result)
70
+ },
71
+ }
72
+ return webServer.register(route)
73
+ }, 'sticky-notes: rpc route')
74
+ }
75
+
76
+ function respond(res, rpcId, result) {
77
+ const body = { type: 'server-response', rpcId, result }
78
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
79
+ res.end(JSON.stringify(body))
80
+ }
81
+
82
+ export { registerRpcRoute }
@@ -0,0 +1,67 @@
1
+ /** 按实例维护扫描单飞、串行队列和失效代次。 */
2
+ import { NotesScanner } from '../scan.js'
3
+ import { debugLog } from './environment.js'
4
+
5
+ function createScanCoordinator({ getSettings }) {
6
+ const scanner = new NotesScanner()
7
+
8
+ let scanEpoch = 0
9
+
10
+ let scanRecord = null
11
+
12
+ let scanQueue = Promise.resolve()
13
+
14
+ function invalidateScan(filePath) {
15
+ scanEpoch += 1
16
+ scanner.invalidate(filePath)
17
+ }
18
+
19
+ function resetScan() {
20
+ scanEpoch += 1
21
+ scanner.reset()
22
+ return scanEpoch
23
+ }
24
+
25
+ function queueScan() {
26
+ const run = scanQueue.then(() => scanner.scan(getSettings()))
27
+ scanQueue = run.catch(() => {})
28
+ return run
29
+ }
30
+
31
+ function scanOnce(requireEpoch = 0) {
32
+ const current = scanRecord
33
+ // 进行中的扫描会在结束时自检 epoch 并重试,可以安全复用;
34
+ // 已结束的扫描只有代次足够新时才复用,避免把写前结果返回给写操作。
35
+ if (current !== null && (current.epoch >= requireEpoch || !current.settled)) {
36
+ debugLog('scan reuse · in-flight')
37
+ return current.promise
38
+ }
39
+ const record = { epoch: scanEpoch, settled: false, promise: null }
40
+ record.promise = (async () => {
41
+ for (;;) {
42
+ const epoch = scanEpoch
43
+ const startedAt = performance.now()
44
+ const result = await queueScan()
45
+ const elapsed = (performance.now() - startedAt).toFixed(1)
46
+ debugLog(`scan ${elapsed}ms · ${result.notes.length} notes${epoch === scanEpoch ? '' : ' · epoch changed, retry'}`)
47
+ if (epoch === scanEpoch) {
48
+ record.settled = true
49
+ return result
50
+ }
51
+ }
52
+ })().finally(() => {
53
+ if (scanRecord === record) scanRecord = null
54
+ })
55
+ scanRecord = record
56
+ return record.promise
57
+ }
58
+
59
+ async function invalidateAndScan(filePath) {
60
+ invalidateScan(filePath)
61
+ return scanOnce(scanEpoch)
62
+ }
63
+
64
+ return { invalidateScan, resetScan, scanOnce, invalidateAndScan, scanner }
65
+ }
66
+
67
+ export { createScanCoordinator }
@@ -0,0 +1,67 @@
1
+ /** 按实例持有任务与撤回 token,清理过期状态并提供任务快照。 */
2
+ import { makeId } from '../settings.js'
3
+ import { LIMITS } from '../shared.js'
4
+ import { debugLog, now } from './environment.js'
5
+
6
+ function createSessionState() {
7
+ const jobs = new Map()
8
+
9
+ const undos = new Map()
10
+
11
+ const deleteUndos = new Map()
12
+
13
+ function purgeExpiredState() {
14
+ const time = now(), cutoff = time - LIMITS.jobsTtlMs
15
+ for (const [id, job] of jobs) {
16
+ if (job.status !== 'running' && (job.finishedAt ?? 0) < cutoff) jobs.delete(id)
17
+ }
18
+ for (const tokens of [undos, deleteUndos]) {
19
+ for (const [token, entry] of tokens) if (entry.expires <= time) tokens.delete(token)
20
+ }
21
+ }
22
+
23
+ function liveJobs() {
24
+ purgeExpiredState()
25
+ return [...jobs.values()].map((job) => ({ ...job }))
26
+ }
27
+
28
+ function runningJob(kind, filePath, emojiId) {
29
+ for (const job of jobs.values()) {
30
+ if (job.status !== 'running' || job.kind !== kind || job.path !== filePath) continue
31
+ if (kind === 'comment' && job.emojiId !== emojiId) continue
32
+ return job
33
+ }
34
+ return undefined
35
+ }
36
+
37
+ function startJob(kind, filePath, extra = {}) {
38
+ const job = { id: makeId('job'), kind, path: filePath, status: 'running', startedAt: now(), ...extra }
39
+ jobs.set(job.id, job)
40
+ return job
41
+ }
42
+
43
+ function finishJob(job, error) {
44
+ job.finishedAt = now()
45
+ if (error) {
46
+ job.status = 'error'
47
+ job.error = error?.message ?? String(error)
48
+ // 0.13:带错误码,Client 据此给出「去调 AI 预算」这类可操作提示(例如 llm-token-budget)。
49
+ const code = error?.code
50
+ if (typeof code === 'string' && code !== '') job.errorCode = code
51
+ debugLog(`${job.kind} 失败${job.errorCode ? `(${job.errorCode})` : ''}:${job.error}`)
52
+ } else {
53
+ job.status = 'done'
54
+ }
55
+ }
56
+
57
+ function jobsFingerprint() {
58
+ return [...jobs.values()]
59
+ .map((job) => `${job.id}:${job.status}:${job.error ?? ''}:${job.errorCode ?? ''}:${job.truncated === true ? 'cut' : ''}`)
60
+ .sort()
61
+ .join('|')
62
+ }
63
+
64
+ return { purgeExpiredState, liveJobs, runningJob, startJob, finishJob, jobsFingerprint, jobs, undos, deleteUndos }
65
+ }
66
+
67
+ export { createSessionState }