dsh-obsidian-memo 0.16.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +270 -0
  3. package/README.md +272 -0
  4. package/cordis.patch.yml +10 -0
  5. package/docs/ARCHITECTURE.md +88 -0
  6. package/docs/AUDIT-0.16.md +74 -0
  7. package/docs/CHANGELOG.md +120 -0
  8. package/docs/CONTRACT.md +544 -0
  9. package/docs/GUIDE.md +177 -0
  10. package/docs/KNOWN_ISSUES.md +113 -0
  11. package/icon.svg +7 -0
  12. package/lib/ai.js +161 -0
  13. package/lib/capture.js +175 -0
  14. package/lib/client.js +6994 -0
  15. package/lib/config-picker.js +29 -0
  16. package/lib/filename.js +33 -0
  17. package/lib/files.js +88 -0
  18. package/lib/host/ai-jobs.js +118 -0
  19. package/lib/host/configuration.js +151 -0
  20. package/lib/host/dispatch.js +32 -0
  21. package/lib/host/environment.js +24 -0
  22. package/lib/host/folders.js +77 -0
  23. package/lib/host/images.js +116 -0
  24. package/lib/host/mutations.js +123 -0
  25. package/lib/host/note-content.js +44 -0
  26. package/lib/host/notes.js +148 -0
  27. package/lib/host/rpc.js +82 -0
  28. package/lib/host/scanning.js +67 -0
  29. package/lib/host/session.js +67 -0
  30. package/lib/host/state.js +68 -0
  31. package/lib/host/vault.js +38 -0
  32. package/lib/host/writes.js +21 -0
  33. package/lib/index.js +88 -0
  34. package/lib/notes.js +454 -0
  35. package/lib/obsidian.js +93 -0
  36. package/lib/scan.js +269 -0
  37. package/lib/settings.js +228 -0
  38. package/lib/shared.js +309 -0
  39. package/lib/trash.js +205 -0
  40. package/locale/en.json +6 -0
  41. package/locale/zh.json +6 -0
  42. package/package.json +76 -0
  43. package/scripts/benchmark.mjs +58 -0
  44. package/scripts/build-client.mjs +39 -0
  45. package/src/client/ai/use-comments.js +75 -0
  46. package/src/client/ai/use-job-notifications.js +53 -0
  47. package/src/client/ai/use-project-dialog.js +58 -0
  48. package/src/client/capture/composer.js +266 -0
  49. package/src/client/capture/images.js +64 -0
  50. package/src/client/capture/keyboard.js +34 -0
  51. package/src/client/components/dialogs.js +67 -0
  52. package/src/client/components/feedback.js +50 -0
  53. package/src/client/components/icons.js +160 -0
  54. package/src/client/components/use-toasts.js +58 -0
  55. package/src/client/constants.js +127 -0
  56. package/src/client/data/state.js +116 -0
  57. package/src/client/data/use-notes-data.js +137 -0
  58. package/src/client/data/visibility.js +14 -0
  59. package/src/client/drag/drop-actions.js +47 -0
  60. package/src/client/drag/motion.js +84 -0
  61. package/src/client/drag/overlay.js +33 -0
  62. package/src/client/drag/use-card-drag.js +149 -0
  63. package/src/client/i18n/en.js +219 -0
  64. package/src/client/i18n/index.js +11 -0
  65. package/src/client/i18n/zh.js +219 -0
  66. package/src/client/images/cache.js +55 -0
  67. package/src/client/images/components.js +121 -0
  68. package/src/client/images/queue.js +106 -0
  69. package/src/client/index.js +40 -0
  70. package/src/client/panel/header.js +146 -0
  71. package/src/client/panel/use-shortcuts.js +79 -0
  72. package/src/client/panel.js +424 -0
  73. package/src/client/preferences.js +198 -0
  74. package/src/client/react.js +8 -0
  75. package/src/client/reader/blocks.js +51 -0
  76. package/src/client/reader/note-drawer.js +270 -0
  77. package/src/client/reader/use-dock-offset.js +23 -0
  78. package/src/client/reader/use-drawer-layout.js +96 -0
  79. package/src/client/reader/use-note-editor.js +230 -0
  80. package/src/client/reader/use-pinned-note.js +41 -0
  81. package/src/client/runtime.js +30 -0
  82. package/src/client/settings/ai-budget-row.js +65 -0
  83. package/src/client/settings/ai-budget.js +42 -0
  84. package/src/client/settings/callouts.js +21 -0
  85. package/src/client/settings/config-file.js +74 -0
  86. package/src/client/settings/image-processing.js +31 -0
  87. package/src/client/settings/official-page.js +32 -0
  88. package/src/client/settings/paths.js +131 -0
  89. package/src/client/settings/settings-drawer.js +244 -0
  90. package/src/client/settings/style-select.js +58 -0
  91. package/src/client/settings/trash.js +30 -0
  92. package/src/client/settings/use-settings-draft.js +207 -0
  93. package/src/client/styles/base.js +63 -0
  94. package/src/client/styles/capture.js +94 -0
  95. package/src/client/styles/index.js +26 -0
  96. package/src/client/styles/overrides.js +116 -0
  97. package/src/client/styles/reader.js +66 -0
  98. package/src/client/styles/settings.js +118 -0
  99. package/src/client/styles/tokens.js +29 -0
  100. package/src/client/styles/wall.js +83 -0
  101. package/src/client/testing.js +139 -0
  102. package/src/client/utils.js +66 -0
  103. package/src/client/wall/model.js +158 -0
  104. package/src/client/wall/note-card.js +201 -0
  105. package/src/client/wall/use-note-actions.js +107 -0
  106. package/src/client/wall/use-subfolders.js +42 -0
  107. package/src/client/wall/use-wall-content.js +135 -0
  108. package/src/client/wall/use-wall-filters.js +92 -0
  109. package/src/client/wall/use-wall-paging.js +55 -0
package/lib/capture.js ADDED
@@ -0,0 +1,175 @@
1
+ /** 快速记录:先保存附件,最后以独占方式发布 Markdown,任何失败都回收本次附件。 */
2
+ import { randomUUID } from 'node:crypto'
3
+ import { mkdir, open, readFile, stat, unlink, writeFile } from 'node:fs/promises'
4
+ import { extname, join, relative, resolve, sep } from 'node:path'
5
+ import { assertPathWithin, failure, isHiddenVaultPath, moveFileExclusive, readNoteFile } from './files.js'
6
+ import { sanitizeNoteFileName } from './filename.js'
7
+ import { formatBytes, LIMITS } from './shared.js'
8
+ import { splitFrontmatter } from './notes.js'
9
+
10
+ const MONTH_NAMES = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
11
+ const WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
12
+ // 与 moment 的默认格式对齐的常用 token 子集;未识别的 token 原样保留(例如模板里的 `[literal]`)。
13
+ const MOMENT_TOKEN = /\[[^\]]*\]|YYYY|YY|MMMM|MMM|MM|M|DD|D|dddd|ddd|HH|H|hh|h|mm|m|ss|s|ZZ|Z|A|a|[\s\S]/g
14
+
15
+ /** 用 Date 复刻 Default Template 依赖的 moment 常用 token;只用于模板,不引入依赖。 */
16
+ export function formatTemplateMoment(date, format) {
17
+ const pad = (value) => String(value).padStart(2, '0')
18
+ const hours = date.getHours()
19
+ const offset = -date.getTimezoneOffset()
20
+ const sign = offset >= 0 ? '+' : '-'
21
+ const absolute = Math.abs(offset)
22
+ const tokens = {
23
+ YYYY: String(date.getFullYear()),
24
+ YY: String(date.getFullYear()).slice(-2),
25
+ MMMM: MONTH_NAMES[date.getMonth()],
26
+ MMM: MONTH_NAMES[date.getMonth()].slice(0, 3),
27
+ MM: pad(date.getMonth() + 1),
28
+ M: String(date.getMonth() + 1),
29
+ DD: pad(date.getDate()),
30
+ D: String(date.getDate()),
31
+ dddd: WEEKDAY_NAMES[date.getDay()],
32
+ ddd: WEEKDAY_NAMES[date.getDay()].slice(0, 3),
33
+ HH: pad(hours),
34
+ H: String(hours),
35
+ hh: pad(hours % 12 || 12),
36
+ h: String(hours % 12 || 12),
37
+ mm: pad(date.getMinutes()),
38
+ m: String(date.getMinutes()),
39
+ ss: pad(date.getSeconds()),
40
+ s: String(date.getSeconds()),
41
+ ZZ: `${sign}${pad(Math.floor(absolute / 60))}${pad(absolute % 60)}`,
42
+ Z: `${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`,
43
+ A: hours < 12 ? 'AM' : 'PM',
44
+ a: hours < 12 ? 'am' : 'pm',
45
+ }
46
+ return String(format).replace(MOMENT_TOKEN, (token) => {
47
+ if (token.startsWith('[') && token.endsWith(']')) return token.slice(1, -1)
48
+ return Object.hasOwn(tokens, token) ? tokens[token] : token
49
+ })
50
+ }
51
+
52
+ /** 替换 Default Template 在整份模板里会替换的三个变量(这里只作用于 frontmatter)。 */
53
+ export function expandTemplateVariables(text, title, now = new Date()) {
54
+ // 与 Default Template 的替换顺序一致:date → time → title(title 里即使出现占位符也不再展开)。
55
+ return text
56
+ .replace(/\{\{date(?::([^}]+))?\}\}/g, (_, format) => formatTemplateMoment(now, format ?? 'YYYY-MM-DD'))
57
+ .replace(/\{\{time(?::([^}]+))?\}\}/g, (_, format) => formatTemplateMoment(now, format ?? 'HH:mm'))
58
+ .replace(/\{\{title\}\}/g, () => title)
59
+ }
60
+
61
+ /** 模板只是增强项:配置损坏、模板不可读或越界都按「无模板」继续,绝不阻塞新建。 */
62
+ async function captureFrontmatter(vault, notes, title) {
63
+ try {
64
+ return await readTemplateFrontmatter(vault, notes, title)
65
+ } catch {
66
+ return null
67
+ }
68
+ }
69
+
70
+ /** 只读 Default Template 的文件夹映射;新建时直接附上模板 meta,不依赖 Obsidian 的空文件事件。 */
71
+ async function readTemplateFrontmatter(vault, notes, title) {
72
+ async function configAt(relativePath) {
73
+ const path = join(vault, relativePath)
74
+ await assertPathWithin(vault, path, { allowMissing: true })
75
+ try { return JSON.parse(await readFile(path, 'utf8')) } catch (error) {
76
+ if (error.code === 'ENOENT') return null
77
+ throw failure('fs-failed', `无法读取新建便签的模板配置:${relativePath}`, error)
78
+ }
79
+ }
80
+ const config = await configAt('.obsidian/plugins/default-template/data.json')
81
+ if (!config || typeof config !== 'object') return null
82
+ // 保守判断:community-plugins.json 缺失/损坏/非数组都视为插件未启用,避免误套模板。
83
+ const enabled = await configAt('.obsidian/community-plugins.json')
84
+ if (!Array.isArray(enabled) || !enabled.includes('default-template')) return null
85
+ const folder = relative(vault, notes).split(sep).join('/')
86
+ const path = `${folder ? `${folder}/` : ''}${title}.md`
87
+ const normalize = (value) => String(value).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')
88
+ const matches = (prefix) => prefix && (path === prefix || path.startsWith(`${prefix}/`))
89
+ if (Array.isArray(config.ignorePaths) && config.ignorePaths.some((item) => typeof item === 'string' && matches(normalize(item)))) return null
90
+ const candidates = Object.entries(config.folderTemplates || {})
91
+ .filter(([key, value]) => typeof value === 'string' && matches(normalize(key)))
92
+ .sort(([a], [b]) => b.length - a.length).map(([, value]) => value)
93
+ if (typeof config.defaultTemplate === 'string' && config.defaultTemplate) candidates.push(config.defaultTemplate)
94
+ for (const candidate of candidates) {
95
+ const template = resolve(vault, candidate)
96
+ await assertPathWithin(vault, template, { allowMissing: true })
97
+ const info = await stat(template).catch((error) => { if (error.code === 'ENOENT') return null; throw error })
98
+ if (!info?.isFile()) continue
99
+ if (extname(template).toLowerCase() !== '.md') throw failure('bad-request', '新建便签模板必须是 Markdown 文件')
100
+ const frontmatter = splitFrontmatter(await readNoteFile(template)).frontmatter
101
+ return frontmatter === null ? null : expandTemplateVariables(frontmatter, title)
102
+ }
103
+ return null
104
+ }
105
+
106
+ function imageBytes(image) {
107
+ const match = typeof image?.dataUrl === 'string' && image.dataUrl.match(/^data:image\/(png|jpeg|gif|webp);base64,([A-Za-z0-9+/]+={0,2})$/)
108
+ if (!match || match[2].length % 4 !== 0) throw failure('bad-request', '请选择 PNG、JPEG、GIF 或 WebP 图片')
109
+ if (match[2].length > Math.ceil(LIMITS.captureImageBytes / 3) * 4) throw failure('too-large', `单张图片不能超过 ${formatBytes(LIMITS.captureImageBytes)}`)
110
+ const bytes = Buffer.from(match[2], 'base64')
111
+ const type = match[1]
112
+ const valid = type === 'png' ? bytes.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))
113
+ : type === 'jpeg' ? bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff
114
+ : type === 'gif' ? /^GIF8[79]a$/.test(bytes.subarray(0, 6).toString())
115
+ : bytes.subarray(0, 4).toString() === 'RIFF' && bytes.subarray(8, 12).toString() === 'WEBP'
116
+ if (!valid) throw failure('bad-request', '图片格式与内容不一致')
117
+ if (bytes.length > LIMITS.captureImageBytes) throw failure('too-large', `单张图片不能超过 ${formatBytes(LIMITS.captureImageBytes)}`)
118
+ return { bytes, ext: type === 'jpeg' ? 'jpg' : type }
119
+ }
120
+
121
+ export async function createCapturedNote({ vault, notes, attachmentDir: configuredAttachmentDir = '' }, payload) {
122
+ if (typeof payload?.title !== 'string' || typeof payload?.body !== 'string') throw failure('bad-request', '缺少想法或正文')
123
+ const titleCheck = sanitizeNoteFileName(payload.title)
124
+ if (titleCheck.error === 'too-long') throw failure('bad-request', '想法过长,请缩短文件名,按 Tab 将细节放入正文')
125
+ if (titleCheck.error !== null) throw failure('bad-request', '请输入可用的便签文件名')
126
+ const title = titleCheck.name
127
+ const images = payload.images === undefined ? [] : payload.images
128
+ if (!Array.isArray(images) || images.length > LIMITS.captureImages) throw failure('bad-request', `每篇便签最多添加 ${LIMITS.captureImages} 张图片`)
129
+ const decoded = images.map(imageBytes)
130
+ if (decoded.reduce((sum, image) => sum + image.bytes.length, 0) > LIMITS.captureTotalBytes) throw failure('too-large', `图片合计不能超过 ${formatBytes(LIMITS.captureTotalBytes)}`)
131
+ const actualVault = await assertPathWithin(vault, vault)
132
+ const actualNotes = await assertPathWithin(vault, notes)
133
+ const target = join(actualNotes, `${title}.md`)
134
+ await assertPathWithin(actualNotes, target, { allowMissing: true })
135
+ const attachmentRoot = configuredAttachmentDir ? vault : actualNotes
136
+ let attachmentDir = configuredAttachmentDir ? resolve(vault, configuredAttachmentDir) : join(actualNotes, '_attachments')
137
+ if (decoded.length) {
138
+ attachmentDir = await assertPathWithin(attachmentRoot, attachmentDir, { allowMissing: true })
139
+ // 只限制用户显式配置的图片文件夹;默认的 notesDir/_attachments 跟随便签目录,不额外加规则。
140
+ if (configuredAttachmentDir && isHiddenVaultPath(actualVault, attachmentDir)) {
141
+ throw failure('bad-request', '图片文件夹不能位于仓库的隐藏目录(Obsidian 不索引以 . 开头的目录)')
142
+ }
143
+ await mkdir(attachmentDir, { recursive: true })
144
+ await assertPathWithin(configuredAttachmentDir ? actualVault : actualNotes, attachmentDir)
145
+ }
146
+ const attachments = decoded.map((image) => ({ ...image, name: `${randomUUID()}.${image.ext}` }))
147
+ const frontmatter = await captureFrontmatter(actualVault, actualNotes, title)
148
+ const body = payload.body.replace(/\r\n?/g, '\n').trim()
149
+ // 使用仓库相对 wikilink,便签归档到其他文件夹后图片仍可解析。
150
+ const embeds = attachments.map((image) => `![[${relative(actualVault, join(attachmentDir, image.name)).split(sep).join('/')}]]`).join('\n\n')
151
+ const content = [frontmatter, body, embeds].filter(Boolean).join('\n\n')
152
+ const text = content ? `${content}\n` : ''
153
+ if (Buffer.byteLength(text, 'utf8') > LIMITS.maxFileBytes) throw failure('too-large', '便签正文过大')
154
+ const written = []
155
+ const temp = join(actualNotes, `.${randomUUID()}.tmp`)
156
+ let published = false
157
+ try {
158
+ for (const image of attachments) {
159
+ const path = join(attachmentDir, image.name)
160
+ const handle = await open(path, 'wx')
161
+ written.push(path)
162
+ try { await handle.writeFile(image.bytes) } finally { await handle.close() }
163
+ }
164
+ await writeFile(temp, text, { encoding: 'utf8', flag: 'wx' })
165
+ await moveFileExclusive(temp, target) // 不覆盖重名文件;并发创建也只允许一个成功。
166
+ published = true
167
+ return join(notes, `${title}.md`)
168
+ } catch (error) {
169
+ if (error.code === 'EEXIST') throw failure('exists', `同名便签已存在:${title}`)
170
+ throw error
171
+ } finally {
172
+ await unlink(temp).catch(() => {})
173
+ if (!published) await Promise.all(written.map((path) => unlink(path)))
174
+ }
175
+ }