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,29 @@
1
+ /** 原生 JSON 文件选择;目录选择仍使用 DSH 的 directoryPickerController。 */
2
+ import { execFile } from 'node:child_process'
3
+ import { failure } from './files.js'
4
+
5
+ let pickerOverride = null
6
+ export function setConfigFilePickerForTests(picker) { pickerOverride = picker }
7
+
8
+ export function configFilePickerCommand(platform = process.platform) {
9
+ if (platform === 'darwin') return { command: '/usr/bin/osascript', args: ['-e',
10
+ 'try\nreturn POSIX path of (choose file with prompt "选择 JSON 配置文件" of type {"json"})\non error number -128\nreturn ""\nend try'] }
11
+ if (platform === 'win32') {
12
+ const script = '[Console]::OutputEncoding=[Text.UTF8Encoding]::new(); Add-Type -AssemblyName System.Windows.Forms; $picker=New-Object System.Windows.Forms.OpenFileDialog; $picker.Title="Select JSON configuration"; $picker.Filter="JSON (*.json)|*.json"; $picker.Multiselect=$false; try { if ($picker.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Write($picker.FileName) } } finally { $picker.Dispose() }'
13
+ return { command: 'powershell.exe', args: ['-NoProfile', '-STA', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')] }
14
+ }
15
+ return { command: 'zenity', args: ['--file-selection', '--title=Select JSON configuration', '--file-filter=JSON | *.json'] }
16
+ }
17
+
18
+ export function pickConfigFile({ platform = process.platform, exec = execFile } = {}) {
19
+ if (pickerOverride) return Promise.resolve().then(pickerOverride)
20
+ const { command, args } = configFilePickerCommand(platform)
21
+ return new Promise((resolve, reject) => {
22
+ exec(command, args, { timeout: 10 * 60 * 1000, windowsHide: true, encoding: 'utf8' }, (error, stdout) => {
23
+ // Zenity 用 exit 1 表示取消;macOS / Windows 的取消返回空输出。
24
+ if (error && platform !== 'darwin' && platform !== 'win32' && error.code === 1) return resolve(null)
25
+ if (error) return reject(failure('picker-unavailable', '文件选择失败:' + error.message, error))
26
+ resolve(String(stdout || '').trim() || null)
27
+ })
28
+ })
29
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * 便签文件名的唯一规范化入口:新建(capture)与编辑改名(saveNote)共用同一套规则。
3
+ * 只处理文件名本体(不含 .md),不负责路径边界与重名检查。
4
+ * @module dsh-obsidian-memo/filename
5
+ */
6
+
7
+ import { LIMITS } from './shared.js'
8
+
9
+ // 路径分隔符 / Windows 保留字符 / Markdown 链接特殊字符 / C0 控制符 / 零宽与双向控制符 / BOM。
10
+ const INVALID_NAME_CHARS = /[\\/:*?"<>|#^[\]\x00-\x1f\x7f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/g
11
+ // Windows 会忽略基础名尾随空格并截断扩展名;这里按第一个点前的基础名判断。
12
+ const WINDOWS_RESERVED = /^(?:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])$/
13
+
14
+ /**
15
+ * 规范化便签文件名。
16
+ * @param {unknown} raw - 用户输入(可含扩展名、空白、控制字符)。
17
+ * @param {number} [maxBytes] - UTF-8 字节上限,默认 `LIMITS.noteNameBytes`。
18
+ * @returns {{ name: string, error: 'empty' | 'reserved' | 'too-long' | null }}
19
+ */
20
+ export function sanitizeNoteFileName(raw, maxBytes = LIMITS.noteNameBytes) {
21
+ let name = String(raw ?? '')
22
+ .normalize('NFC')
23
+ .replace(INVALID_NAME_CHARS, ' ')
24
+ .replace(/\s+/g, ' ')
25
+ .trim()
26
+ // 先 trim 再删首尾点/空格,避免 " .foo" 变成隐藏文件 " .foo"。
27
+ name = name.replace(/^\.+/, '').replace(/[. ]+$/, '').trim()
28
+ if (name === '') return { name, error: 'empty' }
29
+ const base = name.split('.')[0].trim().toUpperCase()
30
+ if (WINDOWS_RESERVED.test(base)) return { name, error: 'reserved' }
31
+ if (Buffer.byteLength(name, 'utf8') > maxBytes) return { name, error: 'too-long' }
32
+ return { name, error: null }
33
+ }
package/lib/files.js ADDED
@@ -0,0 +1,88 @@
1
+ /** 文件边界与写入原语;所有 Host 入口共用同一套真实路径规则。 */
2
+ import { createHash, randomUUID } from 'node:crypto'
3
+ import { constants } from 'node:fs'
4
+ import { copyFile, link, lstat, readFile, realpath, rename, stat, unlink, writeFile } from 'node:fs/promises'
5
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
6
+ import { LIMITS } from './shared.js'
7
+
8
+ export function failure(code, message, cause) {
9
+ return Object.assign(new Error(message), { code, ...(cause === undefined ? {} : { cause }) })
10
+ }
11
+
12
+ export function isInside(root, target) {
13
+ const rel = relative(root, target)
14
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
15
+ }
16
+
17
+ /** 是否落在仓库内的隐藏路径(Obsidian 不索引以 . 开头的目录/文件,如 .obsidian / .trash)。 */
18
+ export function isHiddenVaultPath(root, target) {
19
+ const rel = relative(resolve(root), resolve(target))
20
+ if (rel === '' || isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`)) return false
21
+ return rel.split(sep).some((segment) => segment.startsWith('.'))
22
+ }
23
+
24
+ /** 新建路径按最近的已有父目录解析;悬空符号链接不能当作尚未创建的目录。 */
25
+ async function realLocation(target, allowMissing) {
26
+ try {
27
+ return await realpath(target)
28
+ } catch (error) {
29
+ if (!allowMissing || error.code !== 'ENOENT') throw error
30
+ const entry = await lstat(target).catch((cause) => {
31
+ if (cause.code !== 'ENOENT') throw cause
32
+ return null
33
+ })
34
+ if (entry !== null) throw failure('bad-request', `路径无法解析:${target}`)
35
+ const parent = dirname(target)
36
+ if (parent === target) throw error
37
+ return join(await realLocation(parent, true), basename(target))
38
+ }
39
+ }
40
+
41
+ /** 同时限制用户路径与真实位置,返回真实位置供读取/扩展名检查使用。 */
42
+ export async function assertPathWithin(root, target, { allowMissing = false } = {}) {
43
+ const absolute = resolve(target)
44
+ if (!isInside(resolve(root), absolute)) throw failure('bad-request', `路径必须在配置目录内:${target}`)
45
+ let rootReal, targetReal
46
+ try {
47
+ ;[rootReal, targetReal] = await Promise.all([realpath(root), realLocation(absolute, allowMissing)])
48
+ } catch (error) {
49
+ if (error.code === 'bad-request') throw error
50
+ throw failure('not-found', `路径不存在或无法读取:${target}`, error)
51
+ }
52
+ if (!isInside(rootReal, targetReal)) throw failure('bad-request', `路径符号链接指向了配置目录之外:${target}`)
53
+ return targetReal
54
+ }
55
+
56
+ export function contentRevision(text) {
57
+ return createHash('sha256').update(text).digest('hex')
58
+ }
59
+
60
+ export async function readNoteFile(filePath) {
61
+ const info = await stat(filePath)
62
+ if (!info.isFile()) throw failure('not-found', '便签不是文件')
63
+ if (info.size > LIMITS.maxFileBytes) throw failure('bad-request', '便签文件过大')
64
+ return readFile(filePath, 'utf8')
65
+ }
66
+
67
+ /** 独占发布并移走源文件;不支持硬链接时使用独占复制,目标存在则绝不覆盖。 */
68
+ export async function moveFileExclusive(from, to) {
69
+ try { await link(from, to) } catch (error) {
70
+ if (!['EXDEV', 'EPERM', 'EACCES', 'ENOTSUP', 'EOPNOTSUPP', 'ENOSYS'].includes(error.code)) throw error
71
+ await copyFile(from, to, constants.COPYFILE_EXCL)
72
+ }
73
+ try { await unlink(from) } catch (error) {
74
+ await unlink(to)
75
+ throw error
76
+ }
77
+ }
78
+
79
+ /** 替换已有文件;项目创建策略仍由原来的开档流程决定。 */
80
+ export async function atomicWrite(filePath, content, { mode } = {}) {
81
+ const tmp = `${filePath}.${randomUUID()}.tmp`
82
+ try {
83
+ await writeFile(tmp, content, { encoding: 'utf8', flag: 'wx', mode })
84
+ await rename(tmp, filePath)
85
+ } finally {
86
+ await unlink(tmp).catch((error) => { if (error.code !== 'ENOENT') throw error })
87
+ }
88
+ }
@@ -0,0 +1,118 @@
1
+ /** 评论与开档任务实现;复用实例任务表、默认模型和逐文件写锁。 */
2
+ import { buildCommentRequest, buildProjectDocument, buildProjectRequest, generateText, sourceWikilink } from '../ai.js'
3
+ import { assertPathWithin, atomicWrite, failure, isInside, readNoteFile } from '../files.js'
4
+ import { appendComment, setProjectsLink } from '../notes.js'
5
+ import { LIMITS, localStamp } from '../shared.js'
6
+ import { ensureDir, fileExists, readModelSource } from './note-content.js'
7
+ import { join, relative, sep } from 'node:path'
8
+
9
+ function createAiEndpoints({
10
+ assertNotePath, ctx, finishJob, getSettings, invalidateAndScan, invalidateScan, jobs, requireNotes,
11
+ runningJob, startJob, withNoteWrite,
12
+ }) {
13
+ async function runCommentJob(job, emoji) {
14
+ try {
15
+ const note = await readModelSource(job.path)
16
+ const generated = await generateText(ctx, buildCommentRequest(note, emoji))
17
+ await withNoteWrite(job.path, async () => {
18
+ await assertNotePath(job.path)
19
+ const original = await readNoteFile(job.path)
20
+ const next = appendComment(original, {
21
+ type: emoji.callout,
22
+ title: `${emoji.emoji} ${emoji.label} · ${localStamp()}`,
23
+ text: generated.text,
24
+ })
25
+ await atomicWrite(job.path, next)
26
+ invalidateScan(job.path)
27
+ })
28
+ // 撞上 token 上限但有正文:内容已写入,Client 用 job.truncated 提示「可能被截断」。
29
+ if (generated.truncated) job.truncated = true
30
+ finishJob(job)
31
+ } catch (error) {
32
+ finishJob(job, error)
33
+ }
34
+ }
35
+
36
+ async function endpointComment(payload) {
37
+ const filePath = await assertNotePath(payload?.path)
38
+ const emojiId = typeof payload?.emojiId === 'string' ? payload.emojiId : ''
39
+ const emoji = getSettings().emojis.find((item) => item.id === emojiId)
40
+ if (!emoji) throw failure('bad-request', '找不到这个 emoji 的配置')
41
+ await readNoteFile(filePath)
42
+ const running = runningJob('comment', filePath, emojiId)
43
+ if (running !== undefined) return { jobId: running.id }
44
+ const runningCount = [...jobs.values()].filter((job) => job.status === 'running').length
45
+ if (runningCount >= 4) throw failure('busy', '同时运行的 AI 任务太多了,稍等一下')
46
+ const job = startJob('comment', filePath, { emojiId })
47
+ void runCommentJob(job, emoji)
48
+ return { jobId: job.id }
49
+ }
50
+
51
+ function sanitizeProjectName(input) {
52
+ let name = String(input ?? '').trim().replace(/\u0000/g, '')
53
+ name = name.replace(/[\\/:*?"<>|#^[\]]/g, ' ').replace(/\s+/g, ' ').trim()
54
+ name = name.replace(/^\.+/, '').slice(0, LIMITS.projectNameChars).trim()
55
+ return name
56
+ }
57
+
58
+ async function runProjectJob(job, name, sourcePath) {
59
+ try {
60
+ const paths = requireNotes()
61
+ const note = await readModelSource(sourcePath)
62
+ await assertPathWithin(paths.vault, job.projectFolder, { allowMissing: true })
63
+ await ensureDir(job.projectFolder)
64
+ const request = buildProjectRequest(note, getSettings().projectPrompt, name, getSettings().projectAi)
65
+ const generated = await generateText(ctx, request)
66
+ const sourceRel = relative(paths.vault, sourcePath).split(sep).join('/')
67
+ const document = buildProjectDocument({
68
+ name,
69
+ generated: generated.text,
70
+ sourceRelPath: sourceRel,
71
+ sourceTitle: note.title || name,
72
+ })
73
+ await assertPathWithin(paths.vault, job.projectPath, { allowMissing: true })
74
+ await atomicWrite(job.projectPath, document)
75
+ const projectRel = relative(paths.vault, job.projectPath).split(sep).join('/')
76
+ const link = sourceWikilink(projectRel, name)
77
+ await withNoteWrite(sourcePath, async () => {
78
+ await assertNotePath(sourcePath)
79
+ const original = await readNoteFile(sourcePath)
80
+ const linked = setProjectsLink(original, link)
81
+ if (linked.changed) {
82
+ await atomicWrite(sourcePath, linked.text)
83
+ job.link = 'linked'
84
+ } else {
85
+ job.link = linked.reason
86
+ }
87
+ })
88
+ await invalidateAndScan(sourcePath)
89
+ // 撞上 token 上限但有正文:文档已写入,Client 用 job.truncated 提示「可能被截断」。
90
+ if (generated.truncated) job.truncated = true
91
+ finishJob(job)
92
+ } catch (error) {
93
+ finishJob(job, error)
94
+ }
95
+ }
96
+
97
+ async function endpointOpenProject(payload) {
98
+ const paths = requireNotes()
99
+ if (!paths.project) throw failure('not-configured', '还没有配置开档目录')
100
+ if (!isInside(paths.vault, paths.project)) throw failure('bad-request', '开档目录不在仓库内')
101
+ const sourcePath = await assertNotePath(payload?.path)
102
+ const name = sanitizeProjectName(payload?.name)
103
+ if (name === '') throw failure('bad-request', '项目名不能为空')
104
+ const projectFolder = join(paths.project, name)
105
+ const projectPath = join(projectFolder, `${name}.md`)
106
+ await assertPathWithin(paths.vault, projectPath, { allowMissing: true })
107
+ if (await fileExists(projectPath)) throw failure('exists', `开档目录里已有「${name}」`)
108
+ const running = runningJob('project', sourcePath)
109
+ if (running !== undefined) return { jobId: running.id, projectPath: running.projectPath }
110
+ const job = startJob('project', sourcePath, { name, projectFolder, projectPath })
111
+ void runProjectJob(job, name, sourcePath)
112
+ return { jobId: job.id, projectPath }
113
+ }
114
+
115
+ return { endpointComment, endpointOpenProject }
116
+ }
117
+
118
+ export { createAiEndpoints }
@@ -0,0 +1,151 @@
1
+ /** 持有当前配置、文件位置和配置队列;接受新设置后通知组合入口。 */
2
+ import { pickConfigFile } from '../config-picker.js'
3
+ import { assertPathWithin, failure, isHiddenVaultPath, isInside } from '../files.js'
4
+ import { openExternalUrl } from '../obsidian.js'
5
+ import { NotesScanner } from '../scan.js'
6
+ import {
7
+ DEFAULT_SETTINGS, locateConfig, normalizeSettings, readConfigFile, rememberConfig, resolveUserPath,
8
+ writeConfigFile,
9
+ } from '../settings.js'
10
+ import { LEGACY_SETTINGS_FILE, PACKAGE_DIR } from './environment.js'
11
+ import { stat } from 'node:fs/promises'
12
+ import { dirname, isAbsolute, resolve } from 'node:path'
13
+
14
+ async function createConfiguration({ config, onSettingsChange }) {
15
+ const fallback = config && typeof config === 'object' ? { ...DEFAULT_SETTINGS, ...config } : DEFAULT_SETTINGS
16
+
17
+ const location = await locateConfig({ legacyPath: LEGACY_SETTINGS_FILE })
18
+
19
+ let settingsFile = location.path
20
+
21
+ let settings
22
+
23
+ let configError = ''
24
+
25
+ try { settings = await readConfigFile(settingsFile) } catch (error) {
26
+ settings = normalizeSettings(fallback)
27
+ if (error.code === 'ENOENT') await writeConfigFile(settingsFile, settings, { exclusive: true })
28
+ else configError = '配置未加载:' + error.message
29
+ }
30
+
31
+ let configSignature = ''
32
+
33
+ let settingsQueue = Promise.resolve()
34
+
35
+ function queueSettings(action) {
36
+ const pending = settingsQueue.then(action)
37
+ settingsQueue = pending.catch(() => {})
38
+ return pending
39
+ }
40
+
41
+ function publicSettings() {
42
+ return { ...JSON.parse(JSON.stringify(settings)), configFile: { path: settingsFile, pinned: location.pinned, error: configError, revision: configRevision() } }
43
+ }
44
+
45
+ function configRevision() { return NotesScanner.hash(settingsFile + '\u0000' + JSON.stringify(settings)) }
46
+
47
+ function settingsFingerprint() {
48
+ return JSON.stringify(publicSettings())
49
+ }
50
+
51
+ async function validateSettings(raw) {
52
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw failure('bad-request', '设置格式不对')
53
+ const normalized = normalizeSettings(raw)
54
+ const vault = normalized.vaultPath ? resolve(normalized.vaultPath) : ''
55
+ if (vault) {
56
+ if (!(await stat(vault)).isDirectory()) throw failure('bad-request', '仓库必须是文件夹')
57
+ const checks = [
58
+ ['便签目录', normalized.notesDir],
59
+ ['开档目录', normalized.projectDir],
60
+ ['图片文件夹', normalized.attachmentDir],
61
+ ...normalized.archiveTargets.map((target) => ['归档目录', target.path]),
62
+ ]
63
+ for (const [label, value] of checks) {
64
+ if (!value) continue
65
+ if (!isInside(vault, resolve(value))) throw failure('bad-request', `${label}必须在仓库内:${value}`)
66
+ await assertPathWithin(vault, value, { allowMissing: true })
67
+ }
68
+ if (normalized.attachmentDir && isHiddenVaultPath(vault, normalized.attachmentDir)) {
69
+ throw failure('bad-request', `图片文件夹不能位于隐藏目录(Obsidian 不索引以 . 开头的目录):${normalized.attachmentDir}`)
70
+ }
71
+ const notesInfo = normalized.notesDir ? await stat(resolve(normalized.notesDir)).catch(() => null) : null
72
+ if (normalized.notesDir && (notesInfo === null || !notesInfo.isDirectory())) {
73
+ throw failure('bad-request', `便签目录不存在:${normalized.notesDir}`)
74
+ }
75
+ if (normalized.attachmentDir) {
76
+ const info = await stat(normalized.attachmentDir).catch((error) => {
77
+ if (error.code === 'ENOENT') return null
78
+ throw error
79
+ })
80
+ if (info && !info.isDirectory()) throw failure('bad-request', `图片文件夹不是目录:${normalized.attachmentDir}`)
81
+ }
82
+ }
83
+ return normalized
84
+ }
85
+
86
+ function acceptSettings(next) {
87
+ const previous = settings
88
+ settings = next
89
+ configError = ''
90
+ onSettingsChange(next, previous)
91
+ }
92
+
93
+ async function refreshConfig() {
94
+ try {
95
+ const info = await stat(settingsFile)
96
+ const signature = `${info.mtimeMs}:${info.ctimeMs}:${info.size}:${info.ino}`
97
+ if (signature === configSignature) return
98
+ const next = await validateSettings(await readConfigFile(settingsFile))
99
+ if (JSON.stringify(next) !== JSON.stringify(settings)) acceptSettings(next)
100
+ configSignature = signature
101
+ configError = ''
102
+ } catch (error) { configError = `配置未加载,继续使用上次有效设置:${error.message}` }
103
+ }
104
+
105
+ async function endpointSaveSettings(payload) {
106
+ const normalized = await validateSettings(payload?.settings)
107
+ await refreshConfig()
108
+ if (configError) throw failure('conflict', configError)
109
+ if (payload?.expected && payload.expected !== configRevision()) throw failure('conflict', '配置已在其他位置更新,请重新打开设置后保存')
110
+ acceptSettings(await writeConfigFile(settingsFile, normalized))
111
+ return { settings: publicSettings() }
112
+ }
113
+
114
+ async function endpointConfigFile(payload) {
115
+ const action = payload?.action
116
+ if (action === 'open' || action === 'reveal') {
117
+ const opened = action === 'open' ? await openExternalUrl(settingsFile) : await openExternalUrl(dirname(settingsFile))
118
+ if (!opened.ok) throw failure('open-failed', opened.message)
119
+ } else if (action === 'load' || action === 'relocate') {
120
+ if (location.pinned) throw failure('bad-request', 'DSH_STICKY_NOTES_SETTINGS 已固定配置位置,请先移除环境变量')
121
+ const target = resolveUserPath(payload?.path)
122
+ if (!target || !target.toLowerCase().endsWith('.json') || !isAbsolute(target)) throw failure('bad-request', '请选择绝对路径的 JSON 文件')
123
+ if (isInside(PACKAGE_DIR, target)) throw failure('bad-request', '配置应保存在插件安装目录以外')
124
+ if (action === 'relocate') {
125
+ await refreshConfig()
126
+ if (configError) throw failure('conflict', configError)
127
+ }
128
+ const next = action === 'load' ? await validateSettings(await readConfigFile(target)) : settings
129
+ if (action === 'relocate') await writeConfigFile(target, next, { exclusive: true })
130
+ await rememberConfig(location, target)
131
+ settingsFile = target
132
+ configSignature = ''
133
+ acceptSettings(next)
134
+ } else if (action === 'reload') {
135
+ acceptSettings(await validateSettings(await readConfigFile(settingsFile)))
136
+ configSignature = ''
137
+ } else throw failure('bad-request', '未知配置操作')
138
+ return { settings: publicSettings() }
139
+ }
140
+
141
+ async function endpointChooseConfigFile() {
142
+ if (location.pinned) throw failure('bad-request', 'DSH_STICKY_NOTES_SETTINGS 已固定配置位置,请先移除环境变量')
143
+ return { path: await pickConfigFile() }
144
+ }
145
+
146
+ function getSettings() { return settings }
147
+
148
+ return { queueSettings, publicSettings, settingsFingerprint, refreshConfig, endpointSaveSettings, endpointConfigFile, endpointChooseConfigFile, getSettings }
149
+ }
150
+
151
+ export { createConfiguration }
@@ -0,0 +1,32 @@
1
+ /** endpoint 分发、错误信封与写入/配置/删除操作的串行化规则。 */
2
+ import { resolve } from 'node:path'
3
+
4
+ function createDispatcher({ endpoints, queueSettings, withNoteWrite }) {
5
+ let deleteQueue = Promise.resolve()
6
+
7
+ async function dispatch(endpoint, payload) {
8
+ // DSH 的 client-connection 解析器(0.2.x)要求失败信封带 details 对象;缺了会把业务错误当成 transport 错误。
9
+ if (typeof endpoint !== 'string' || !Object.hasOwn(endpoints, endpoint)) {
10
+ return { ok: false, error: { code: 'bad-request', message: `未知 endpoint:${endpoint}`, details: {} } }
11
+ }
12
+ const handler = endpoints[endpoint]
13
+ try {
14
+ const mutating = ['saveNote', 'toggleTask', 'archive', 'deleteNote'].includes(endpoint)
15
+ const run = () => mutating && typeof payload?.path === 'string'
16
+ ? withNoteWrite(resolve(payload.path), () => handler(payload)) : handler(payload)
17
+ const deleting = endpoint === 'deleteNote' || endpoint === 'undoDelete' || endpoint === 'trash'
18
+ const configuring = ['saveSettings', 'configFile', 'settings'].includes(endpoint)
19
+ const pending = deleting ? deleteQueue.catch(() => {}).then(run) : configuring ? queueSettings(run) : run()
20
+ if (deleting) deleteQueue = pending.catch(() => {})
21
+ const value = await pending
22
+ return { ok: true, value }
23
+ } catch (error) {
24
+ const code = typeof error?.code === 'string' ? error.code : 'fs-failed'
25
+ return { ok: false, error: { code, message: error?.message ?? String(error), details: {} } }
26
+ }
27
+ }
28
+
29
+ return dispatch
30
+ }
31
+
32
+ export { createDispatcher }
@@ -0,0 +1,24 @@
1
+ /** 包根目录、版本和调试日志;安装位置变化不影响旧配置迁移。 */
2
+ import { createRequire } from 'node:module'
3
+ import { join } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ const PACKAGE_DIR = fileURLToPath(new URL('../..', import.meta.url))
7
+
8
+ const LEGACY_SETTINGS_FILE = join(PACKAGE_DIR, 'data', 'settings.json')
9
+
10
+ const PACKAGE_JSON = createRequire(import.meta.url)('../../package.json')
11
+
12
+ const VERSION = typeof PACKAGE_JSON?.version === 'string' && PACKAGE_JSON.version !== '' ? PACKAGE_JSON.version : '0.0.0'
13
+
14
+ const DEBUG = process.env.DSH_STICKY_NOTES_DEBUG === '1'
15
+
16
+ function debugLog(...args) {
17
+ if (DEBUG) console.info('[sticky-notes]', ...args)
18
+ }
19
+
20
+ function now() {
21
+ return Date.now()
22
+ }
23
+
24
+ export { PACKAGE_DIR, LEGACY_SETTINGS_FILE, VERSION, debugLog, now }
@@ -0,0 +1,77 @@
1
+ /** 仓库内目录浏览、新建与系统选择器。 */
2
+ import { assertPathWithin, failure, isHiddenVaultPath } from '../files.js'
3
+ import { resolveUserPath } from '../settings.js'
4
+ import { mkdir, readdir, stat } from 'node:fs/promises'
5
+ import { isAbsolute, join, relative } from 'node:path'
6
+
7
+ function createFolderEndpoints({ ctx, getSettings }) {
8
+ async function folderInVault(payload, allowMissing = false) {
9
+ const vault = resolveUserPath(payload?.vaultPath || getSettings().vaultPath)
10
+ if (!vault || !isAbsolute(vault)) throw failure('not-configured', '请先选择仓库文件夹')
11
+ if (!(await stat(vault)).isDirectory()) throw failure('bad-request', '仓库必须是文件夹')
12
+ const path = resolveUserPath(payload?.path || '.', vault)
13
+ await assertPathWithin(vault, path, { allowMissing })
14
+ if (isHiddenVaultPath(vault, path)) throw failure('bad-request', '请选择仓库内非隐藏文件夹')
15
+ return { vault, path }
16
+ }
17
+
18
+ async function endpointListFolders(payload) {
19
+ const { vault } = await folderInVault(payload)
20
+ const folders = ['.']
21
+ let truncated = false
22
+ async function walk(dir) {
23
+ for (const item of await readdir(dir, { withFileTypes: true })) {
24
+ if (item.name.startsWith('.') || item.name === 'node_modules' || !item.isDirectory() || item.isSymbolicLink()) continue
25
+ if (folders.length >= 1500) { truncated = true; return }
26
+ const path = join(dir, item.name)
27
+ await assertPathWithin(vault, path)
28
+ folders.push(relative(vault, path))
29
+ await walk(path)
30
+ if (truncated) return
31
+ }
32
+ }
33
+ await walk(vault)
34
+ return { folders: folders.sort((a, b) => a.localeCompare(b)), truncated }
35
+ }
36
+
37
+ async function endpointCreateFolder(payload) {
38
+ if (typeof payload?.path !== 'string' || !payload.path.trim() || payload.path.split(/[\\/]/).includes('..')) throw failure('bad-request', '请填写仓库内的文件夹名称')
39
+ const { vault, path } = await folderInVault(payload, true)
40
+ await mkdir(path, { recursive: true })
41
+ await assertPathWithin(vault, path)
42
+ return { path }
43
+ }
44
+
45
+ async function endpointChooseConfigDirectory() {
46
+ const result = await endpointPickDirectory()
47
+ if (!result.path) return { path: null, files: [] }
48
+ const files = (await readdir(result.path, { withFileTypes: true }))
49
+ .filter((item) => item.isFile() && item.name.toLowerCase().endsWith('.json'))
50
+ .map((item) => join(result.path, item.name)).sort()
51
+ return { path: result.path, files }
52
+ }
53
+
54
+ async function endpointPickDirectory(payload) {
55
+ const controller = ctx.get('directoryPickerController')
56
+ if (controller === undefined) throw failure('picker-unavailable', '当前环境没有目录选择器')
57
+ const abort = new AbortController()
58
+ const timeout = setTimeout(() => abort.abort(new Error('timeout')), 10 * 60 * 1000)
59
+ try {
60
+ const path = await controller.pick(abort.signal)
61
+ if (path && payload?.vaultPath) {
62
+ const checked = await folderInVault({ vaultPath: payload.vaultPath, path })
63
+ if (!(await stat(checked.path)).isDirectory()) throw failure('bad-request', '请选择文件夹')
64
+ }
65
+ return { path: typeof path === 'string' && path !== '' ? path : null }
66
+ } catch (error) {
67
+ if (error?.code === 'bad-request' || error?.code === 'not-configured') throw error
68
+ throw failure('picker-unavailable', `目录选择失败:${error?.message ?? error}`, error)
69
+ } finally {
70
+ clearTimeout(timeout)
71
+ }
72
+ }
73
+
74
+ return { endpointListFolders, endpointCreateFolder, endpointChooseConfigDirectory, endpointPickDirectory }
75
+ }
76
+
77
+ export { createFolderEndpoints }