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.
- package/LICENSE +21 -0
- package/README.en.md +270 -0
- package/README.md +272 -0
- package/cordis.patch.yml +10 -0
- package/docs/ARCHITECTURE.md +88 -0
- package/docs/AUDIT-0.16.md +74 -0
- package/docs/CHANGELOG.md +120 -0
- package/docs/CONTRACT.md +544 -0
- package/docs/GUIDE.md +177 -0
- package/docs/KNOWN_ISSUES.md +113 -0
- package/icon.svg +7 -0
- package/lib/ai.js +161 -0
- package/lib/capture.js +175 -0
- package/lib/client.js +6994 -0
- package/lib/config-picker.js +29 -0
- package/lib/filename.js +33 -0
- package/lib/files.js +88 -0
- package/lib/host/ai-jobs.js +118 -0
- package/lib/host/configuration.js +151 -0
- package/lib/host/dispatch.js +32 -0
- package/lib/host/environment.js +24 -0
- package/lib/host/folders.js +77 -0
- package/lib/host/images.js +116 -0
- package/lib/host/mutations.js +123 -0
- package/lib/host/note-content.js +44 -0
- package/lib/host/notes.js +148 -0
- package/lib/host/rpc.js +82 -0
- package/lib/host/scanning.js +67 -0
- package/lib/host/session.js +67 -0
- package/lib/host/state.js +68 -0
- package/lib/host/vault.js +38 -0
- package/lib/host/writes.js +21 -0
- package/lib/index.js +88 -0
- package/lib/notes.js +454 -0
- package/lib/obsidian.js +93 -0
- package/lib/scan.js +269 -0
- package/lib/settings.js +228 -0
- package/lib/shared.js +309 -0
- package/lib/trash.js +205 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +76 -0
- package/scripts/benchmark.mjs +58 -0
- package/scripts/build-client.mjs +39 -0
- package/src/client/ai/use-comments.js +75 -0
- package/src/client/ai/use-job-notifications.js +53 -0
- package/src/client/ai/use-project-dialog.js +58 -0
- package/src/client/capture/composer.js +266 -0
- package/src/client/capture/images.js +64 -0
- package/src/client/capture/keyboard.js +34 -0
- package/src/client/components/dialogs.js +67 -0
- package/src/client/components/feedback.js +50 -0
- package/src/client/components/icons.js +160 -0
- package/src/client/components/use-toasts.js +58 -0
- package/src/client/constants.js +127 -0
- package/src/client/data/state.js +116 -0
- package/src/client/data/use-notes-data.js +137 -0
- package/src/client/data/visibility.js +14 -0
- package/src/client/drag/drop-actions.js +47 -0
- package/src/client/drag/motion.js +84 -0
- package/src/client/drag/overlay.js +33 -0
- package/src/client/drag/use-card-drag.js +149 -0
- package/src/client/i18n/en.js +219 -0
- package/src/client/i18n/index.js +11 -0
- package/src/client/i18n/zh.js +219 -0
- package/src/client/images/cache.js +55 -0
- package/src/client/images/components.js +121 -0
- package/src/client/images/queue.js +106 -0
- package/src/client/index.js +40 -0
- package/src/client/panel/header.js +146 -0
- package/src/client/panel/use-shortcuts.js +79 -0
- package/src/client/panel.js +424 -0
- package/src/client/preferences.js +198 -0
- package/src/client/react.js +8 -0
- package/src/client/reader/blocks.js +51 -0
- package/src/client/reader/note-drawer.js +270 -0
- package/src/client/reader/use-dock-offset.js +23 -0
- package/src/client/reader/use-drawer-layout.js +96 -0
- package/src/client/reader/use-note-editor.js +230 -0
- package/src/client/reader/use-pinned-note.js +41 -0
- package/src/client/runtime.js +30 -0
- package/src/client/settings/ai-budget-row.js +65 -0
- package/src/client/settings/ai-budget.js +42 -0
- package/src/client/settings/callouts.js +21 -0
- package/src/client/settings/config-file.js +74 -0
- package/src/client/settings/image-processing.js +31 -0
- package/src/client/settings/official-page.js +32 -0
- package/src/client/settings/paths.js +131 -0
- package/src/client/settings/settings-drawer.js +244 -0
- package/src/client/settings/style-select.js +58 -0
- package/src/client/settings/trash.js +30 -0
- package/src/client/settings/use-settings-draft.js +207 -0
- package/src/client/styles/base.js +63 -0
- package/src/client/styles/capture.js +94 -0
- package/src/client/styles/index.js +26 -0
- package/src/client/styles/overrides.js +116 -0
- package/src/client/styles/reader.js +66 -0
- package/src/client/styles/settings.js +118 -0
- package/src/client/styles/tokens.js +29 -0
- package/src/client/styles/wall.js +83 -0
- package/src/client/testing.js +139 -0
- package/src/client/utils.js +66 -0
- package/src/client/wall/model.js +158 -0
- package/src/client/wall/note-card.js +201 -0
- package/src/client/wall/use-note-actions.js +107 -0
- package/src/client/wall/use-subfolders.js +42 -0
- package/src/client/wall/use-wall-content.js +135 -0
- package/src/client/wall/use-wall-filters.js +92 -0
- package/src/client/wall/use-wall-paging.js +55 -0
package/lib/obsidian.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-obsidian-memo —— 0.13.8「在 Obsidian 打开」的系统打开器(Host 半)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么必须由 Host 打开:DSH 的外壳只把 http(s) 外链交给系统浏览器,
|
|
5
|
+
* `obsidian://` 这类自定义协议不会触发外部应用(浏览器半与 Electron 半都一样)。
|
|
6
|
+
* 所以 Client 只负责把便签路径发到 Host,Host 用系统命令打开 `obsidian://open?path=…`。
|
|
7
|
+
*
|
|
8
|
+
* 只依赖 node:child_process,零运行时依赖;测试可以用
|
|
9
|
+
* `setOpenExternalUrlForTests()` 覆盖真正的系统调用。
|
|
10
|
+
* @module dsh-obsidian-memo/obsidian
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { execFile } from 'node:child_process'
|
|
14
|
+
import { buildObsidianUri } from './shared.js'
|
|
15
|
+
|
|
16
|
+
/** 系统打开器的等待上限(毫秒);超时视为失败,避免 RPC 一直挂着。 */
|
|
17
|
+
const OPEN_TIMEOUT_MS = 5000
|
|
18
|
+
|
|
19
|
+
/** 测试注入点:每个测试文件是独立进程,模块级覆盖是安全的。 */
|
|
20
|
+
let override = null
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 仅测试用:替换真正的系统打开调用;传 null 恢复。
|
|
24
|
+
* @param {((url: string) => Promise<{ ok: boolean, message?: string }>) | null} fn
|
|
25
|
+
*/
|
|
26
|
+
export function setOpenExternalUrlForTests(fn) {
|
|
27
|
+
override = typeof fn === 'function' ? fn : null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 各平台的「打开 URL」命令。argv 形式,不经过 shell,避免 URL 里的字符被解释。
|
|
32
|
+
* @param {string} url
|
|
33
|
+
* @param {string} [platform]
|
|
34
|
+
* @returns {{ command: string, args: string[] }}
|
|
35
|
+
*/
|
|
36
|
+
export function openUrlCommand(url, platform = process.platform) {
|
|
37
|
+
const target = String(url ?? '')
|
|
38
|
+
if (platform === 'darwin') return { command: 'open', args: [target] }
|
|
39
|
+
if (platform === 'win32') return { command: 'cmd', args: ['/c', 'start', '""', target] }
|
|
40
|
+
return { command: 'xdg-open', args: [target] }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 真正调用系统打开器;永远 resolve `{ ok, message? }`,不抛异常。
|
|
45
|
+
* @param {string} url
|
|
46
|
+
* @param {{ exec?: Function, platform?: string, timeoutMs?: number }} [options]
|
|
47
|
+
*/
|
|
48
|
+
export function systemOpenExternalUrl(url, options = {}) {
|
|
49
|
+
const exec = typeof options.exec === 'function' ? options.exec : execFile
|
|
50
|
+
const platform = typeof options.platform === 'string' && options.platform !== '' ? options.platform : process.platform
|
|
51
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : OPEN_TIMEOUT_MS
|
|
52
|
+
const { command, args } = openUrlCommand(url, platform)
|
|
53
|
+
return new Promise((resolve) => {
|
|
54
|
+
let settled = false
|
|
55
|
+
const finish = (result) => {
|
|
56
|
+
if (settled) return
|
|
57
|
+
settled = true
|
|
58
|
+
resolve(result)
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
exec(command, args, { timeout: timeoutMs, windowsHide: true }, (error) => {
|
|
62
|
+
if (error) finish({ ok: false, message: error?.message ?? String(error) })
|
|
63
|
+
else finish({ ok: true })
|
|
64
|
+
})
|
|
65
|
+
} catch (error) {
|
|
66
|
+
finish({ ok: false, message: error?.message ?? String(error) })
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 打开一条外部 URL(当前只用于 obsidian://)。测试可覆盖。
|
|
73
|
+
* @param {string} url
|
|
74
|
+
* @returns {Promise<{ ok: boolean, message?: string }>}
|
|
75
|
+
*/
|
|
76
|
+
export async function openExternalUrl(url) {
|
|
77
|
+
const target = typeof url === 'string' ? url.trim() : ''
|
|
78
|
+
if (target === '') return { ok: false, message: '缺少要打开的链接' }
|
|
79
|
+
if (override) return override(target)
|
|
80
|
+
return systemOpenExternalUrl(target)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 打开一篇便签:把绝对路径转成 obsidian:// URI 后交给系统。
|
|
85
|
+
* @param {string} absPath
|
|
86
|
+
* @returns {Promise<{ ok: boolean, message?: string, url?: string }>}
|
|
87
|
+
*/
|
|
88
|
+
export async function openNoteInObsidian(absPath) {
|
|
89
|
+
const url = buildObsidianUri(absPath)
|
|
90
|
+
if (url === '') return { ok: false, message: '缺少便签路径' }
|
|
91
|
+
const opened = await openExternalUrl(url)
|
|
92
|
+
return Object.assign({ url }, opened)
|
|
93
|
+
}
|
package/lib/scan.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-obsidian-memo —— 便签目录扫描与缓存。
|
|
3
|
+
*
|
|
4
|
+
* 轮询式刷新(Obsidian 保存后数秒内反映),不注册文件监听、不占用文件锁。
|
|
5
|
+
* 每次 scan 只 stat 文件;内容仅在 mtime/size 变化时重读重解析。
|
|
6
|
+
* @module dsh-obsidian-memo/scan
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createHash } from 'node:crypto'
|
|
10
|
+
import { readdir, readFile, stat } from 'node:fs/promises'
|
|
11
|
+
import { join, relative, sep } from 'node:path'
|
|
12
|
+
import { LIMITS, colorForTag, darkColorForTag } from './shared.js'
|
|
13
|
+
import { assertPathWithin, contentRevision, readNoteFile } from './files.js'
|
|
14
|
+
import {
|
|
15
|
+
clip, commentKindsFrom, getBody, parseNote, summarizeImages, tagForRelativePath,
|
|
16
|
+
} from './notes.js'
|
|
17
|
+
|
|
18
|
+
const SKIP_DIRS = new Set(['node_modules', '__MACOSX', '.trash', '.obsidian'])
|
|
19
|
+
const UNCONFIGURED = 'unconfigured'
|
|
20
|
+
// 全文只为当前阅读服务;不要为整库长期保留正文、blocks 和全部评论。
|
|
21
|
+
const FULL_CACHE_LIMIT = 4
|
|
22
|
+
|
|
23
|
+
function fileSignature(info) {
|
|
24
|
+
return `${info.size}:${info.mtimeMs}:${info.ctimeMs}:${info.ino}:${info.birthtimeMs}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 是否是可以进入的目录项。 */
|
|
28
|
+
function walkable(dirent) {
|
|
29
|
+
if (!dirent.isDirectory()) return false
|
|
30
|
+
if (dirent.name.startsWith('.')) return false
|
|
31
|
+
if (SKIP_DIRS.has(dirent.name)) return false
|
|
32
|
+
return true
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 收集 md 文件(跳过符号链接、隐藏目录)。
|
|
37
|
+
* @param {string} root - 当前目录。
|
|
38
|
+
* @param {number} depth - 当前深度(notesDir 顶层为 0)。
|
|
39
|
+
* @param {string[]} output - 收集结果。
|
|
40
|
+
* @param {boolean} [recursive] - false 时只看顶层,不进入子文件夹。
|
|
41
|
+
*/
|
|
42
|
+
async function collectMarkdown(root, depth, output, recursive = true) {
|
|
43
|
+
let entries
|
|
44
|
+
try {
|
|
45
|
+
entries = await readdir(root, { withFileTypes: true })
|
|
46
|
+
} catch {
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
for (const dirent of entries) {
|
|
50
|
+
if (dirent.isSymbolicLink()) continue
|
|
51
|
+
const absolute = join(root, dirent.name)
|
|
52
|
+
if (dirent.isDirectory()) {
|
|
53
|
+
if (!recursive || depth >= LIMITS.maxDepth || !walkable(dirent)) continue
|
|
54
|
+
await collectMarkdown(absolute, depth + 1, output, recursive)
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
if (!dirent.isFile()) continue
|
|
58
|
+
if (dirent.name.startsWith('.')) continue
|
|
59
|
+
if (!dirent.name.toLowerCase().endsWith('.md')) continue
|
|
60
|
+
output.push(absolute)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 一条便签的缓存记录。 */
|
|
65
|
+
function buildSummary(entry, parsed) {
|
|
66
|
+
return {
|
|
67
|
+
path: entry.path,
|
|
68
|
+
relPath: entry.relPath,
|
|
69
|
+
name: entry.name,
|
|
70
|
+
title: entry.name,
|
|
71
|
+
excerpt: parsed.excerpt,
|
|
72
|
+
tag: entry.tag,
|
|
73
|
+
color: entry.color,
|
|
74
|
+
darkColor: entry.darkColor,
|
|
75
|
+
comments: parsed.comments.slice(0, LIMITS.summaryComments).map((comment) => ({
|
|
76
|
+
type: comment.type,
|
|
77
|
+
emoji: comment.emoji,
|
|
78
|
+
label: comment.label,
|
|
79
|
+
title: comment.title,
|
|
80
|
+
time: comment.time,
|
|
81
|
+
text: clip(comment.text, 500),
|
|
82
|
+
})),
|
|
83
|
+
commentCount: parsed.comments.length,
|
|
84
|
+
...summarizeImages(parsed.blocks),
|
|
85
|
+
commentKinds: commentKindsFrom(parsed.comments),
|
|
86
|
+
created: entry.created,
|
|
87
|
+
mtime: entry.mtime,
|
|
88
|
+
size: entry.size,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export class NotesScanner {
|
|
93
|
+
constructor() {
|
|
94
|
+
/** @type {Map<string, object>} */
|
|
95
|
+
this.entries = new Map()
|
|
96
|
+
this.fullNotes = new Map()
|
|
97
|
+
this.revision = UNCONFIGURED
|
|
98
|
+
this.scanKey = ''
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 清空全部缓存(设置变化时)。 */
|
|
102
|
+
reset() {
|
|
103
|
+
this.entries.clear()
|
|
104
|
+
this.fullNotes.clear()
|
|
105
|
+
this.revision = UNCONFIGURED
|
|
106
|
+
this.scanKey = ''
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 丢弃单个文件的缓存(写入后)。 */
|
|
110
|
+
invalidate(filePath) {
|
|
111
|
+
this.entries.delete(filePath)
|
|
112
|
+
this.fullNotes.delete(filePath)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 扫描一次。
|
|
117
|
+
* @param {{ vaultPath: string, notesDir: string }} settings
|
|
118
|
+
* @returns {Promise<{ configured: boolean, error: string | null, revision: string, notes: object[] }>}
|
|
119
|
+
*/
|
|
120
|
+
async scan(settings) {
|
|
121
|
+
const notesDir = settings?.notesDir ?? ''
|
|
122
|
+
const vaultPath = settings?.vaultPath ?? ''
|
|
123
|
+
const includeSubfolders = settings?.includeSubfolders !== false
|
|
124
|
+
if (!notesDir || !vaultPath) {
|
|
125
|
+
this.reset()
|
|
126
|
+
return { configured: false, error: null, revision: UNCONFIGURED, notes: [] }
|
|
127
|
+
}
|
|
128
|
+
// 扫描参数(仓库 / 目录 / 是否递归)任一变化都要清缓存,避免沿用旧的 tag 与摘要。
|
|
129
|
+
const scanKey = `${vaultPath}\u0000${notesDir}\u0000${includeSubfolders ? '1' : '0'}`
|
|
130
|
+
if (this.scanKey !== scanKey) {
|
|
131
|
+
this.entries.clear()
|
|
132
|
+
this.fullNotes.clear()
|
|
133
|
+
this.scanKey = scanKey
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
await assertPathWithin(vaultPath, notesDir)
|
|
137
|
+
const info = await stat(notesDir)
|
|
138
|
+
if (!info.isDirectory()) {
|
|
139
|
+
return { configured: false, error: `便签目录不是文件夹:${notesDir}`, revision: UNCONFIGURED, notes: [] }
|
|
140
|
+
}
|
|
141
|
+
} catch (error) {
|
|
142
|
+
return {
|
|
143
|
+
configured: false,
|
|
144
|
+
error: `读不到便签目录:${notesDir}(${error?.cause?.code ?? error?.code ?? error?.message ?? '未知错误'})`,
|
|
145
|
+
revision: UNCONFIGURED,
|
|
146
|
+
notes: [],
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const files = []
|
|
151
|
+
await collectMarkdown(notesDir, 0, files, includeSubfolders)
|
|
152
|
+
const seen = new Set()
|
|
153
|
+
const signature = []
|
|
154
|
+
const notes = []
|
|
155
|
+
|
|
156
|
+
for (const absolute of files) {
|
|
157
|
+
let info
|
|
158
|
+
try {
|
|
159
|
+
info = await stat(absolute)
|
|
160
|
+
} catch {
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
if (!info.isFile() || info.size > LIMITS.maxFileBytes) continue
|
|
164
|
+
const relPath = relative(vaultPath, absolute).split(sep).join('/')
|
|
165
|
+
const relativeToNotes = relative(notesDir, absolute).split(sep).join('/')
|
|
166
|
+
const fingerprint = fileSignature(info)
|
|
167
|
+
|
|
168
|
+
let entry = this.entries.get(absolute)
|
|
169
|
+
if (
|
|
170
|
+
entry !== undefined
|
|
171
|
+
&& entry.fingerprint === fingerprint
|
|
172
|
+
&& entry.scanKey === this.scanKey
|
|
173
|
+
) {
|
|
174
|
+
seen.add(absolute)
|
|
175
|
+
notes.push(entry.summary)
|
|
176
|
+
signature.push(`${relativeToNotes}:${fingerprint}`)
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
this.fullNotes.delete(absolute)
|
|
180
|
+
let text
|
|
181
|
+
try {
|
|
182
|
+
text = await readFile(absolute, 'utf8')
|
|
183
|
+
} catch {
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
// 解析到 scanComments 条,保证 commentKinds 覆盖「全部评论」;summary 只取前 6 条。
|
|
187
|
+
const parsed = parseNote(text, { maxComments: LIMITS.scanComments, maxBlocks: LIMITS.blocks })
|
|
188
|
+
const tag = tagForRelativePath(relativeToNotes)
|
|
189
|
+
entry = {
|
|
190
|
+
path: absolute,
|
|
191
|
+
relPath,
|
|
192
|
+
name: relativeToNotes.split('/').pop().replace(/\.md$/i, ''),
|
|
193
|
+
tag,
|
|
194
|
+
color: colorForTag(tag),
|
|
195
|
+
darkColor: darkColorForTag(tag),
|
|
196
|
+
created: Math.round(info.birthtimeMs || info.ctimeMs || info.mtimeMs),
|
|
197
|
+
mtime: info.mtimeMs,
|
|
198
|
+
size: info.size,
|
|
199
|
+
scanKey: this.scanKey,
|
|
200
|
+
fingerprint,
|
|
201
|
+
summary: null,
|
|
202
|
+
}
|
|
203
|
+
entry.summary = buildSummary(entry, parsed)
|
|
204
|
+
seen.add(absolute)
|
|
205
|
+
this.entries.set(absolute, entry)
|
|
206
|
+
notes.push(entry.summary)
|
|
207
|
+
signature.push(`${relativeToNotes}:${fingerprint}`)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
for (const key of [...this.entries.keys()]) {
|
|
211
|
+
if (!seen.has(key)) {
|
|
212
|
+
this.entries.delete(key)
|
|
213
|
+
this.fullNotes.delete(key)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 默认按创建时间(新 → 旧);其余排序由面板切换。
|
|
218
|
+
notes.sort((a, b) => (b.created - a.created) || a.relPath.localeCompare(b.relPath))
|
|
219
|
+
signature.sort()
|
|
220
|
+
this.revision = createHash('sha1')
|
|
221
|
+
.update(`${notesDir}\u0000${signature.join('\u0001')}`)
|
|
222
|
+
.digest('hex')
|
|
223
|
+
.slice(0, 16)
|
|
224
|
+
|
|
225
|
+
return { configured: true, error: null, revision: this.revision, notes }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 读取一张便签的全文(阅读视图用)。
|
|
230
|
+
* @param {string} filePath - md 绝对路径(调用方负责校验在 notesDir 内)。
|
|
231
|
+
* @returns {Promise<object | null>}
|
|
232
|
+
*/
|
|
233
|
+
async readFull(filePath) {
|
|
234
|
+
const entry = this.entries.get(filePath)
|
|
235
|
+
if (entry === undefined) return null
|
|
236
|
+
const cached = this.fullNotes.get(filePath)
|
|
237
|
+
if (cached) {
|
|
238
|
+
this.fullNotes.delete(filePath)
|
|
239
|
+
this.fullNotes.set(filePath, cached)
|
|
240
|
+
return cached
|
|
241
|
+
}
|
|
242
|
+
const text = await readNoteFile(filePath)
|
|
243
|
+
const parsed = parseNote(text, { maxComments: LIMITS.scanComments, maxBlocks: LIMITS.blocks })
|
|
244
|
+
const full = {
|
|
245
|
+
...entry.summary,
|
|
246
|
+
body: getBody(text),
|
|
247
|
+
contentRevision: contentRevision(text),
|
|
248
|
+
blocks: parsed.blocks,
|
|
249
|
+
comments: parsed.comments.slice(0, LIMITS.fullComments),
|
|
250
|
+
projects: parsed.projects,
|
|
251
|
+
}
|
|
252
|
+
// 读取期间可能发生 invalidate/reset;旧快照不重新进入缓存。
|
|
253
|
+
if (this.entries.get(filePath) === entry) {
|
|
254
|
+
this.fullNotes.set(filePath, full)
|
|
255
|
+
if (this.fullNotes.size > FULL_CACHE_LIMIT) this.fullNotes.delete(this.fullNotes.keys().next().value)
|
|
256
|
+
}
|
|
257
|
+
return full
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** 单个文件的当前缓存摘要(写入后使用)。 */
|
|
261
|
+
summary(filePath) {
|
|
262
|
+
return this.entries.get(filePath)?.summary ?? null
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** 一段 JSON 指纹,用于 jobs 参与 revision。 */
|
|
266
|
+
static hash(value) {
|
|
267
|
+
return createHash('sha1').update(String(value)).digest('hex').slice(0, 16)
|
|
268
|
+
}
|
|
269
|
+
}
|
package/lib/settings.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-obsidian-memo —— 设置:默认值、规范化、读写。
|
|
3
|
+
*
|
|
4
|
+
* 设置迁移自插件旧的 `data/settings.json`,运行时存于独立用户配置目录。
|
|
5
|
+
* `Config` 只为首启提供默认值;面板与官方设置页共用 JSON 文件。
|
|
6
|
+
* @module dsh-obsidian-memo/settings
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
10
|
+
import { homedir } from 'node:os'
|
|
11
|
+
import { dirname, basename, isAbsolute, normalize, resolve, join, relative } from 'node:path'
|
|
12
|
+
import {
|
|
13
|
+
CALLOUT_TYPES, DEFAULT_AI, DEFAULT_EMOJIS, DEFAULT_PROJECT_PROMPT, FALLBACK_CALLOUT, LIMITS,
|
|
14
|
+
isLegacyDefaultEmojis, isPlainObject, normalizeAi, normalizeImageCompression, DEFAULT_IMAGE_COMPRESSION,
|
|
15
|
+
} from './shared.js'
|
|
16
|
+
import { atomicWrite } from './files.js'
|
|
17
|
+
|
|
18
|
+
/** 首次运行时的设置。 */
|
|
19
|
+
export const DEFAULT_SETTINGS = {
|
|
20
|
+
vaultPath: '',
|
|
21
|
+
notesDir: '',
|
|
22
|
+
projectDir: '',
|
|
23
|
+
attachmentDir: '',
|
|
24
|
+
archiveTargets: [],
|
|
25
|
+
folderHistory: [],
|
|
26
|
+
// 0.13:每枚 emoji 带一份 AI 预算(默认高:high / 8192 tokens)。
|
|
27
|
+
emojis: DEFAULT_EMOJIS.map((item) => ({ ...item, ai: { ...DEFAULT_AI } })),
|
|
28
|
+
projectPrompt: DEFAULT_PROJECT_PROMPT,
|
|
29
|
+
// 0.13:开档(结构化项目文档)自己的一份 AI 预算,默认同为高。
|
|
30
|
+
projectAi: { ...DEFAULT_AI },
|
|
31
|
+
// 0.13:是否扫描便签目录的子文件夹;缺省/非 false 一律 true。
|
|
32
|
+
includeSubfolders: true,
|
|
33
|
+
imageCompression: { ...DEFAULT_IMAGE_COMPRESSION },
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 生成稳定 id(用于 emoji / 归档目录)。 */
|
|
37
|
+
export function makeId(prefix = 'id') {
|
|
38
|
+
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 单行字符串:去空白、去 NUL。 */
|
|
42
|
+
function text(value, fallback = '') {
|
|
43
|
+
if (typeof value !== 'string') return fallback
|
|
44
|
+
return value.replace(/\u0000/g, '').trim() || fallback
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 路径规范化:绝对路径原样归一;相对路径相对 vault 解析;空串保留。 */
|
|
48
|
+
export function resolveUserPath(value, vaultPath) {
|
|
49
|
+
let raw = text(value)
|
|
50
|
+
if (raw === '') return ''
|
|
51
|
+
if (raw === '~') raw = homedir()
|
|
52
|
+
else if (raw.startsWith('~/')) raw = resolve(homedir(), raw.slice(2))
|
|
53
|
+
if (isAbsolute(raw)) return normalize(raw)
|
|
54
|
+
if (vaultPath) return resolve(vaultPath, raw)
|
|
55
|
+
return raw
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 一个归档目标。 */
|
|
59
|
+
function normalizeTarget(raw, index) {
|
|
60
|
+
if (!isPlainObject(raw)) return null
|
|
61
|
+
const path = text(raw.path)
|
|
62
|
+
if (!path) return null
|
|
63
|
+
const label = text(raw.label) || basename(path) || `归档 ${index + 1}`
|
|
64
|
+
const id = text(raw.id) || makeId('archive')
|
|
65
|
+
return { id, label, path }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 一条 emoji 定义。 */
|
|
69
|
+
function normalizeEmoji(raw, index) {
|
|
70
|
+
if (!isPlainObject(raw)) return null
|
|
71
|
+
const emoji = text(raw.emoji)
|
|
72
|
+
if (!emoji) return null
|
|
73
|
+
const label = text(raw.label) || `评论 ${index + 1}`
|
|
74
|
+
const callout = CALLOUT_TYPES.includes(raw.callout) ? raw.callout : FALLBACK_CALLOUT
|
|
75
|
+
const prompt = text(raw.prompt) || DEFAULT_EMOJIS[0].prompt
|
|
76
|
+
return {
|
|
77
|
+
id: text(raw.id) || makeId('emoji'),
|
|
78
|
+
emoji: emoji.slice(0, 16),
|
|
79
|
+
label: label.slice(0, 24),
|
|
80
|
+
callout,
|
|
81
|
+
prompt,
|
|
82
|
+
// 0.13:这枚 emoji 生成评论时的 token 预算;旧设置文件没有该字段时补默认高。
|
|
83
|
+
ai: normalizeAi(raw.ai),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 把任意来源的设置规范化为安全、完整的形状。
|
|
89
|
+
* 非法字段回退默认值;路径解析为绝对路径(相对 vault);去重。
|
|
90
|
+
* @param {unknown} raw - data/settings.json 或 RPC 传入的设置。
|
|
91
|
+
* @param {{ vaultPath?: string }} [options] - 已知仓库路径(normalize 时作为相对基准)。
|
|
92
|
+
* @returns {typeof DEFAULT_SETTINGS}
|
|
93
|
+
*/
|
|
94
|
+
export function normalizeSettings(raw, options = {}) {
|
|
95
|
+
const source = isPlainObject(raw) ? raw : {}
|
|
96
|
+
const vaultPath = resolveUserPath(source.vaultPath ?? DEFAULT_SETTINGS.vaultPath, options.vaultPath ?? '')
|
|
97
|
+
const notesDir = resolveUserPath(source.notesDir ?? '', vaultPath)
|
|
98
|
+
const projectDir = resolveUserPath(source.projectDir ?? '', vaultPath)
|
|
99
|
+
const attachmentDir = resolveUserPath(source.attachmentDir ?? '', vaultPath)
|
|
100
|
+
const projectPrompt = text(source.projectPrompt, '') || DEFAULT_PROJECT_PROMPT
|
|
101
|
+
|
|
102
|
+
const archiveTargets = []
|
|
103
|
+
const seenPaths = new Set()
|
|
104
|
+
const rawTargets = Array.isArray(source.archiveTargets) ? source.archiveTargets : []
|
|
105
|
+
for (const item of rawTargets.slice(0, 24)) {
|
|
106
|
+
const target = normalizeTarget(item, archiveTargets.length)
|
|
107
|
+
if (!target) continue
|
|
108
|
+
target.path = resolveUserPath(target.path, vaultPath)
|
|
109
|
+
if (seenPaths.has(target.path)) continue
|
|
110
|
+
seenPaths.add(target.path)
|
|
111
|
+
archiveTargets.push(target)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const emojis = []
|
|
115
|
+
const seenIds = new Set()
|
|
116
|
+
const rawEmojis = Array.isArray(source.emojis) ? source.emojis : DEFAULT_EMOJIS
|
|
117
|
+
for (const item of rawEmojis.slice(0, LIMITS.emojis)) {
|
|
118
|
+
const emoji = normalizeEmoji(item, emojis.length)
|
|
119
|
+
if (!emoji) continue
|
|
120
|
+
if (seenIds.has(emoji.id)) emoji.id = makeId('emoji')
|
|
121
|
+
seenIds.add(emoji.id)
|
|
122
|
+
emojis.push(emoji)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
vaultPath,
|
|
127
|
+
notesDir,
|
|
128
|
+
projectDir,
|
|
129
|
+
attachmentDir,
|
|
130
|
+
archiveTargets,
|
|
131
|
+
folderHistory: [...new Set([
|
|
132
|
+
notesDir, projectDir, attachmentDir, ...archiveTargets.map((item) => item.path),
|
|
133
|
+
...(Array.isArray(source.folderHistory) ? source.folderHistory.filter((item) => typeof item === 'string').map((item) => resolveUserPath(item, vaultPath)) : []),
|
|
134
|
+
].filter((item) => item && vaultPath && !relative(vaultPath, item).split(/[\\/]/).some((part) => part === '..' || part.startsWith('.')) && !isAbsolute(relative(vaultPath, item))))].slice(0, 40),
|
|
135
|
+
emojis: emojis.length > 0 ? emojis : DEFAULT_EMOJIS.map((item) => ({ ...item, ai: { ...DEFAULT_AI } })),
|
|
136
|
+
projectPrompt,
|
|
137
|
+
projectAi: normalizeAi(source.projectAi),
|
|
138
|
+
// 只有显式 false 才关闭;缺省、null、旧文件里没有这个字段都按 true 处理。
|
|
139
|
+
includeSubfolders: source.includeSubfolders !== false,
|
|
140
|
+
imageCompression: normalizeImageCompression(source.imageCompression),
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 读取设置文件;不存在或损坏时返回默认值。 */
|
|
145
|
+
export async function readSettingsFile(filePath, fallback = DEFAULT_SETTINGS) {
|
|
146
|
+
try {
|
|
147
|
+
const raw = await readFile(filePath, 'utf8')
|
|
148
|
+
const parsed = JSON.parse(raw)
|
|
149
|
+
// 0.13 预设迁移:旧版默认四枚(与 LEGACY_EMOJIS 逐字段一致)整组换成新默认值。
|
|
150
|
+
// 用户改过任何一项就原样保留;迁移只发生在内存里,下次 saveSettings 才落盘。
|
|
151
|
+
if (isPlainObject(parsed) && isLegacyDefaultEmojis(parsed.emojis)) {
|
|
152
|
+
parsed.emojis = DEFAULT_EMOJIS.map((item) => ({ ...item }))
|
|
153
|
+
}
|
|
154
|
+
return normalizeSettings(parsed, { vaultPath: fallback.vaultPath })
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (error?.code === 'ENOENT' || error instanceof SyntaxError) return normalizeSettings(fallback)
|
|
157
|
+
throw error
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 原子写入设置文件。 */
|
|
162
|
+
export async function writeSettingsFile(filePath, settings) {
|
|
163
|
+
const normalized = normalizeSettings(settings)
|
|
164
|
+
await mkdir(dirname(filePath), { recursive: true })
|
|
165
|
+
await atomicWrite(filePath, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 })
|
|
166
|
+
return normalized
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** 两个设置是否等价(用于决定是否重扫)。 */
|
|
170
|
+
export function settingsEqual(a, b) {
|
|
171
|
+
return JSON.stringify(normalizeSettings(a)) === JSON.stringify(normalizeSettings(b))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** npm 安装目录之外的用户配置;环境变量仍可显式覆盖。 */
|
|
175
|
+
export function configHome(env = process.env, platform = process.platform, home = homedir()) {
|
|
176
|
+
const root = platform === 'win32' ? (env.APPDATA || join(home, 'AppData', 'Roaming'))
|
|
177
|
+
: (env.XDG_CONFIG_HOME || join(home, '.config'))
|
|
178
|
+
return join(root, 'dsh-obsidian-memo')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 文件内保存仓库相对路径,运行时继续使用绝对路径。 */
|
|
182
|
+
export function portableSettings(settings) {
|
|
183
|
+
const value = normalizeSettings(settings)
|
|
184
|
+
const path = (item) => item && value.vaultPath && !isAbsolute(relative(value.vaultPath, item))
|
|
185
|
+
&& !relative(value.vaultPath, item).split(/[\\/]/).includes('..') ? relative(value.vaultPath, item) || '.' : item
|
|
186
|
+
return { ...value, notesDir: path(value.notesDir), projectDir: path(value.projectDir), attachmentDir: path(value.attachmentDir),
|
|
187
|
+
archiveTargets: value.archiveTargets.map((item) => ({ ...item, path: path(item.path) })),
|
|
188
|
+
folderHistory: value.folderHistory.map(path) }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function readConfigFile(filePath) {
|
|
192
|
+
const parsed = JSON.parse(await readFile(filePath, 'utf8'))
|
|
193
|
+
if (!isPlainObject(parsed)) throw new Error('配置文件必须是 JSON 对象')
|
|
194
|
+
if (isLegacyDefaultEmojis(parsed.emojis)) parsed.emojis = DEFAULT_EMOJIS.map((item) => ({ ...item }))
|
|
195
|
+
return normalizeSettings(parsed)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function writeConfigFile(filePath, settings, { exclusive = false } = {}) {
|
|
199
|
+
await mkdir(dirname(filePath), { recursive: true })
|
|
200
|
+
const body = `${JSON.stringify(portableSettings(settings), null, 2)}\n`
|
|
201
|
+
if (exclusive) await writeFile(filePath, body, { flag: 'wx', mode: 0o600 })
|
|
202
|
+
else await atomicWrite(filePath, body, { mode: 0o600 })
|
|
203
|
+
return normalizeSettings(settings)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function locateConfig({ legacyPath, home = configHome(), override = process.env.DSH_STICKY_NOTES_SETTINGS } = {}) {
|
|
207
|
+
if (override) return { path: resolveUserPath(override), home, pinned: true }
|
|
208
|
+
try {
|
|
209
|
+
const pointer = JSON.parse(await readFile(join(home, 'location.json'), 'utf8'))
|
|
210
|
+
if (typeof pointer.path === 'string' && isAbsolute(pointer.path)) return { path: pointer.path, home, pinned: false }
|
|
211
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
212
|
+
const path = join(home, 'config.json')
|
|
213
|
+
try { await readFile(path) } catch (error) {
|
|
214
|
+
if (error.code !== 'ENOENT') throw error
|
|
215
|
+
if (legacyPath) {
|
|
216
|
+
try { await writeConfigFile(path, await readConfigFile(legacyPath), { exclusive: true }) }
|
|
217
|
+
catch (cause) { if (cause.code !== 'ENOENT' && cause.code !== 'EEXIST') throw cause }
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return { path, home, pinned: false }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function rememberConfig(location, path) {
|
|
224
|
+
if (location.pinned) return
|
|
225
|
+
await mkdir(location.home, { recursive: true })
|
|
226
|
+
const target = join(location.home, 'location.json')
|
|
227
|
+
await atomicWrite(target, `${JSON.stringify({ path }, null, 2)}\n`, { mode: 0o600 })
|
|
228
|
+
}
|