@tnotesjs/core 0.7.0 → 0.8.0
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/commands/BaseCommand.ts +58 -0
- package/commands/build/BuildCommand.ts +25 -0
- package/commands/build/PreviewCommand.ts +29 -0
- package/commands/build/index.ts +8 -0
- package/commands/dev/DevCommand.ts +75 -0
- package/commands/dev/index.ts +7 -0
- package/commands/git/PullCommand.ts +25 -0
- package/commands/git/PushCommand.ts +64 -0
- package/commands/git/index.ts +8 -0
- package/commands/index.ts +11 -0
- package/commands/init-sub-repo/InitSubRepoCommand.ts +206 -0
- package/commands/init-sub-repo/index.ts +1 -0
- package/commands/misc/HelpCommand.ts +104 -0
- package/commands/misc/index.ts +7 -0
- package/commands/models.ts +87 -0
- package/commands/note/CreateNoteCommand.ts +160 -0
- package/commands/note/RenameNoteCommand.ts +147 -0
- package/commands/note/UpdateNoteConfigCommand.ts +78 -0
- package/commands/note/index.ts +9 -0
- package/commands/registry.ts +47 -0
- package/commands/update/UpdateCommand.ts +219 -0
- package/commands/update/index.ts +7 -0
- package/commands/update-completed-count/UpdateCompletedCountCommand.ts +208 -0
- package/commands/update-completed-count/index.ts +5 -0
- package/dist/markdown/index.cjs +6 -9
- package/dist/markdown/index.js +6 -9
- package/dist/vitepress/config/index.cjs +214 -58
- package/dist/vitepress/config/index.js +208 -52
- package/markdown/components.ts +86 -0
- package/markdown/index.ts +17 -0
- package/markdown/noteFormatter.test.ts +44 -0
- package/markdown/noteFormatter.ts +237 -0
- package/package.json +7 -3
- package/vitepress/components/BilibiliOutsidePlayer/BilibiliOutsidePlayer.vue +9 -18
- package/vitepress/components/EnWordList/EnWordList.vue +16 -662
- package/vitepress/components/Footprints/Footprints.vue +15 -537
- package/vitepress/components/Mermaid/Mermaid.vue +13 -588
- package/vitepress/components/MindmapPreview/MindmapPreview.vue +12 -434
- package/vitepress/components/MindmapPreview/markdown.ts +1 -1
- package/vitepress/components/NotesTable/NotesTable.vue +11 -130
- package/vitepress/configs/markdown.config.ts +170 -26
- package/vitepress/theme/index.ts +9 -13
- package/vitepress/theme/styles/base.scss +15 -0
- package/workspace/atomic.ts +113 -0
- package/workspace/errors.ts +27 -0
- package/workspace/index.ts +40 -0
- package/workspace/mutationQueue.ts +28 -0
- package/workspace/paths.ts +64 -0
- package/workspace/reconcile.test.ts +300 -0
- package/workspace/reconcile.ts +95 -0
- package/workspace/scanner.ts +292 -0
- package/workspace/types.ts +224 -0
- package/workspace/workspace.test.ts +333 -0
- package/workspace/workspace.ts +1020 -0
- package/vitepress/components/EnWordList/RightClickMenu.vue +0 -93
|
@@ -4,18 +4,21 @@
|
|
|
4
4
|
* Markdown 配置
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import fs from 'fs'
|
|
8
7
|
import markdownItContainer from 'markdown-it-container'
|
|
9
8
|
import mila from 'markdown-it-link-attributes'
|
|
10
9
|
import markdownItTaskLists from 'markdown-it-task-lists'
|
|
11
|
-
import path from 'path'
|
|
12
10
|
|
|
13
11
|
import { generateAnchor } from '../../utils'
|
|
14
12
|
import {
|
|
15
13
|
normalizeMindmapMarkdown,
|
|
16
14
|
parseMindmapFence,
|
|
17
|
-
parseMindmapReference,
|
|
18
15
|
} from '../components/MindmapPreview/markdown'
|
|
16
|
+
// Pure helper only — do not import `@tnotesjs/ui` root from Node config
|
|
17
|
+
// (package entry is .ts; Node cannot strip types under node_modules).
|
|
18
|
+
import {
|
|
19
|
+
parseFootprintsDatetime,
|
|
20
|
+
parseFootprintsSource,
|
|
21
|
+
} from '@tnotesjs/ui/footprints-parse'
|
|
19
22
|
|
|
20
23
|
import type MarkdownIt from 'markdown-it'
|
|
21
24
|
import type { MarkdownOptions } from 'vitepress'
|
|
@@ -47,15 +50,18 @@ const simpleMermaidMarkdown = (md: MarkdownIt) => {
|
|
|
47
50
|
|
|
48
51
|
md.renderer.rules.fence = (tokens, index, options, env, slf) => {
|
|
49
52
|
const token = tokens[index]
|
|
53
|
+
const parts = token.info.trim().split(/\s+/).filter(Boolean)
|
|
50
54
|
|
|
51
|
-
//
|
|
52
|
-
if (
|
|
55
|
+
// `mermaid` or `mermaid center`
|
|
56
|
+
if (parts[0] === 'mermaid') {
|
|
53
57
|
try {
|
|
58
|
+
const centered = parts.slice(1).some((part) => part.toLowerCase() === 'center')
|
|
54
59
|
const key = `mermaid-${Date.now()}-${Math.random()
|
|
55
60
|
.toString(36)
|
|
56
61
|
.substr(2, 9)}`
|
|
57
62
|
const content = token.content
|
|
58
|
-
|
|
63
|
+
const centerAttr = centered ? ' :center="true"' : ''
|
|
64
|
+
return `<Mermaid id="${key}" graph="${encodeURIComponent(content)}"${centerAttr} />`
|
|
59
65
|
} catch (err) {
|
|
60
66
|
return `<pre>${err}</pre>`
|
|
61
67
|
}
|
|
@@ -81,25 +87,10 @@ function configureMindmapFence(md: MarkdownIt) {
|
|
|
81
87
|
const info = token.info.trim()
|
|
82
88
|
const fenceOptions = parseMindmapFence(info)
|
|
83
89
|
if (!fenceOptions) return fence(tokens, index, options, env, slf)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
if (reference) {
|
|
89
|
-
const possibleRel = env?.relativePath || env?.path || env?.filePath || env?.file || ''
|
|
90
|
-
const refFullPath = path.isAbsolute(reference.path)
|
|
91
|
-
? reference.path
|
|
92
|
-
: path.resolve(process.cwd(), possibleRel ? path.dirname(possibleRel) : '', reference.path)
|
|
93
|
-
try {
|
|
94
|
-
content = fs.readFileSync(refFullPath, 'utf8')
|
|
95
|
-
} catch (error) {
|
|
96
|
-
const message = error instanceof Error ? error.message : String(error)
|
|
97
|
-
content = `- Failed to load referenced file: ${reference.path}\n - Error: ${message}`
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
content = normalizeMindmapMarkdown(content, {
|
|
102
|
-
title: fenceOptions.title || reference?.title,
|
|
90
|
+
// Mindmap nodes must live in the fence body. External `<<<` includes are
|
|
91
|
+
// no longer resolved here (body-level VitePress snippets remain separate).
|
|
92
|
+
const content = normalizeMindmapMarkdown(token.content, {
|
|
93
|
+
title: fenceOptions.title,
|
|
103
94
|
})
|
|
104
95
|
const props = [
|
|
105
96
|
`content="${encodeURIComponent(content.trim())}"`,
|
|
@@ -107,7 +98,7 @@ function configureMindmapFence(md: MarkdownIt) {
|
|
|
107
98
|
? ''
|
|
108
99
|
: `:initialExpandLevel="${fenceOptions.initialExpandLevel}"`,
|
|
109
100
|
].filter(Boolean).join(' ')
|
|
110
|
-
return `<
|
|
101
|
+
return `<Mindmap ${props}></Mindmap>\n`
|
|
111
102
|
}
|
|
112
103
|
}
|
|
113
104
|
|
|
@@ -187,6 +178,156 @@ function configureSwiperContainer(md: MarkdownIt) {
|
|
|
187
178
|
})
|
|
188
179
|
}
|
|
189
180
|
|
|
181
|
+
/** Escape text for HTML element bodies (not attributes). */
|
|
182
|
+
function escapeHtmlText(s: string) {
|
|
183
|
+
return s.replace(
|
|
184
|
+
/[&<>"']/g,
|
|
185
|
+
(ch) =>
|
|
186
|
+
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch] as string
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* `::: footprints 2025-01-22 23:47` → Footprints Vue block.
|
|
192
|
+
* Prefer token-based extraction (source line maps are unreliable in VitePress).
|
|
193
|
+
* Text goes through encoded props; images use a slot so Vite rewrites asset URLs.
|
|
194
|
+
*/
|
|
195
|
+
function extractFootprintsPayloadFromTokens(tokens: any[], idx: number) {
|
|
196
|
+
const meta = String(tokens[idx].info || '')
|
|
197
|
+
.trim()
|
|
198
|
+
.replace(/^footprints\s*/i, '')
|
|
199
|
+
const times = parseFootprintsDatetime(meta)
|
|
200
|
+
const paragraphs: string[] = []
|
|
201
|
+
const images: string[] = []
|
|
202
|
+
let otherInfo = ''
|
|
203
|
+
let inOther = false
|
|
204
|
+
|
|
205
|
+
for (let i = idx + 1; i < tokens.length; i++) {
|
|
206
|
+
const t = tokens[i]
|
|
207
|
+
if (t.type === 'container_footprints_close') break
|
|
208
|
+
if (t.type !== 'inline') continue
|
|
209
|
+
|
|
210
|
+
const childImgs: string[] = []
|
|
211
|
+
if (Array.isArray(t.children)) {
|
|
212
|
+
for (const c of t.children) {
|
|
213
|
+
if (c.type === 'image') {
|
|
214
|
+
const src = c.attrGet?.('src') || c.attrs?.find((a: string[]) => a[0] === 'src')?.[1]
|
|
215
|
+
if (src) childImgs.push(src)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const content = String(t.content || '').trim()
|
|
221
|
+
if (content === '---') {
|
|
222
|
+
inOther = true
|
|
223
|
+
continue
|
|
224
|
+
}
|
|
225
|
+
if (childImgs.length) {
|
|
226
|
+
// Treat as image block when the inline is image-only (ignore bare alt text).
|
|
227
|
+
const withoutImgs = content.replace(/!\[[^\]]*\]\([^)]+\)/g, '').trim()
|
|
228
|
+
if (!withoutImgs) {
|
|
229
|
+
images.push(...childImgs)
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const imgOnly = content.match(/^!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)$/)
|
|
234
|
+
if (imgOnly) {
|
|
235
|
+
images.push(imgOnly[2])
|
|
236
|
+
continue
|
|
237
|
+
}
|
|
238
|
+
if (!content) continue
|
|
239
|
+
if (inOther) otherInfo = otherInfo ? `${otherInfo}\n${content}` : content
|
|
240
|
+
else paragraphs.push(content)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { times, paragraphs, images, otherInfo }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function configureFootprintsContainer(md: MarkdownIt) {
|
|
247
|
+
md.use(markdownItContainer, 'footprints', {
|
|
248
|
+
validate: (params: string) => /^footprints(\s|$)/i.test(params.trim()),
|
|
249
|
+
render: (tokens: any[], idx: number, _opts: unknown, env: any) => {
|
|
250
|
+
if (tokens[idx].nesting !== 1) return ''
|
|
251
|
+
|
|
252
|
+
// Extract BEFORE hiding/clearing tokens.
|
|
253
|
+
let payload = extractFootprintsPayloadFromTokens(tokens, idx)
|
|
254
|
+
|
|
255
|
+
// Optional enrichment from source slice when tokens missed content.
|
|
256
|
+
const startLine = tokens[idx].map?.[0] ?? 0
|
|
257
|
+
let endLine = startLine
|
|
258
|
+
for (let i = idx + 1; i < tokens.length; i++) {
|
|
259
|
+
if (tokens[i].type === 'container_footprints_close') {
|
|
260
|
+
endLine = tokens[i].map?.[0] ?? endLine
|
|
261
|
+
break
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const raw = String(env?.src ?? env?.source ?? '')
|
|
265
|
+
if (raw && endLine > startLine) {
|
|
266
|
+
const slice = raw.split(/\r?\n/).slice(startLine, endLine + 1).join('\n')
|
|
267
|
+
const fromSource = parseFootprintsSource(
|
|
268
|
+
slice.includes(':::') ? slice : `::: ${tokens[idx].info}\n${slice}\n:::`
|
|
269
|
+
)
|
|
270
|
+
if (!payload.paragraphs.length && fromSource.paragraphs.length) {
|
|
271
|
+
payload = { ...payload, paragraphs: fromSource.paragraphs }
|
|
272
|
+
}
|
|
273
|
+
if (!payload.images.length && fromSource.images.length) {
|
|
274
|
+
payload = { ...payload, images: fromSource.images }
|
|
275
|
+
}
|
|
276
|
+
if (!payload.otherInfo && fromSource.otherInfo) {
|
|
277
|
+
payload = { ...payload, otherInfo: fromSource.otherInfo }
|
|
278
|
+
}
|
|
279
|
+
if (!payload.times.length && fromSource.times.length) {
|
|
280
|
+
payload = { ...payload, times: fromSource.times }
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
for (let i = idx + 1; i < tokens.length; i++) {
|
|
285
|
+
if (tokens[i].type === 'container_footprints_close') break
|
|
286
|
+
tokens[i].hidden = true
|
|
287
|
+
tokens[i].content = ''
|
|
288
|
+
if (Array.isArray(tokens[i].children)) {
|
|
289
|
+
for (const child of tokens[i].children) {
|
|
290
|
+
child.hidden = true
|
|
291
|
+
child.content = ''
|
|
292
|
+
}
|
|
293
|
+
tokens[i].children = []
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const enc = (value: unknown) =>
|
|
298
|
+
encodeURIComponent(JSON.stringify(value)).replace(/'/g, '%27')
|
|
299
|
+
const bindExpr = (value: unknown) =>
|
|
300
|
+
"JSON.parse(decodeURIComponent('" + enc(value) + "'))"
|
|
301
|
+
const imageSlot = payload.images
|
|
302
|
+
.map((src, i) => {
|
|
303
|
+
return (
|
|
304
|
+
'<img src="' +
|
|
305
|
+
escapeHtmlText(src) +
|
|
306
|
+
'" @click="openModal(' +
|
|
307
|
+
String(i) +
|
|
308
|
+
')" />'
|
|
309
|
+
)
|
|
310
|
+
})
|
|
311
|
+
.join('\n')
|
|
312
|
+
const openTag =
|
|
313
|
+
'<Footprints :times="' +
|
|
314
|
+
bindExpr(payload.times) +
|
|
315
|
+
'" :paragraphs="' +
|
|
316
|
+
bindExpr(payload.paragraphs) +
|
|
317
|
+
'" :other-info="' +
|
|
318
|
+
bindExpr(payload.otherInfo) +
|
|
319
|
+
'">'
|
|
320
|
+
if (!payload.images.length) return openTag + '</Footprints>\n'
|
|
321
|
+
return (
|
|
322
|
+
openTag +
|
|
323
|
+
'\n<template #image-list="{ openModal }">\n' +
|
|
324
|
+
imageSlot +
|
|
325
|
+
'\n</template>\n</Footprints>\n'
|
|
326
|
+
)
|
|
327
|
+
},
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
|
|
190
331
|
/**
|
|
191
332
|
* Markdown 配置
|
|
192
333
|
*/
|
|
@@ -220,6 +361,9 @@ export function getMarkdownConfig(): MarkdownOptions {
|
|
|
220
361
|
|
|
221
362
|
// 添加 Swiper 支持
|
|
222
363
|
configureSwiperContainer(md)
|
|
364
|
+
|
|
365
|
+
// Footprints 容器(Type A)
|
|
366
|
+
configureFootprintsContainer(md)
|
|
223
367
|
},
|
|
224
368
|
anchor: {
|
|
225
369
|
slugify: generateAnchor,
|
package/vitepress/theme/index.ts
CHANGED
|
@@ -13,23 +13,19 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import DefaultTheme from 'vitepress/theme'
|
|
16
|
-
|
|
16
|
+
import { BilibiliVideo, WordList, Mermaid, Mindmap, Footprints } from '@tnotesjs/ui'
|
|
17
17
|
import { initTnotesSearchIndexHmr } from '../client/localSearchIndexBridge'
|
|
18
|
-
import BilibiliOutsidePlayer from '../components/BilibiliOutsidePlayer/BilibiliOutsidePlayer.vue'
|
|
19
18
|
import Discussions from '../components/Discussions/Discussions.vue'
|
|
20
|
-
import EnWordList from '../components/EnWordList/EnWordList.vue'
|
|
21
|
-
import Footprints from '../components/Footprints/Footprints.vue'
|
|
22
19
|
import { useRenameOverlay } from '../components/Layout/composables/useRenameOverlay'
|
|
23
20
|
import { redirectAfterRename } from '../components/Layout/composables/useRenameRedirect'
|
|
24
21
|
import Layout from '../components/Layout/Layout.vue'
|
|
25
|
-
import Mermaid from '../components/Mermaid/Mermaid.vue'
|
|
26
|
-
import MindmapPreview from '../components/MindmapPreview/MindmapPreview.vue'
|
|
27
22
|
import NotesTable from '../components/NotesTable/NotesTable.vue'
|
|
28
23
|
import SidebarCard from '../components/SidebarCard/SidebarCard.vue'
|
|
29
24
|
import Tooltip from '../components/Tooltip/Tooltip.vue'
|
|
30
25
|
|
|
31
26
|
|
|
32
27
|
import type { Theme, EnhanceAppContext } from 'vitepress'
|
|
28
|
+
import '@tnotesjs/ui/styles/tokens.css'
|
|
33
29
|
import './styles/index.scss'
|
|
34
30
|
|
|
35
31
|
/**
|
|
@@ -37,18 +33,18 @@ import './styles/index.scss'
|
|
|
37
33
|
*/
|
|
38
34
|
function registerCoreComponents(ctx: EnhanceAppContext) {
|
|
39
35
|
const { app } = ctx
|
|
40
|
-
app.component('
|
|
41
|
-
|
|
36
|
+
app.component('BilibiliVideo', BilibiliVideo)
|
|
37
|
+
// Legacy full tag until knowledge-base migration; short aliases B/E/N/F are removed.
|
|
38
|
+
app.component('BilibiliOutsidePlayer', BilibiliVideo)
|
|
39
|
+
app.component('WordList', WordList)
|
|
40
|
+
app.component('EnWordList', WordList)
|
|
42
41
|
app.component('Discussions', Discussions)
|
|
43
|
-
app.component('EnWordList', EnWordList)
|
|
44
|
-
app.component('E', EnWordList)
|
|
45
42
|
app.component('Footprints', Footprints)
|
|
46
|
-
app.component('F', Footprints)
|
|
47
43
|
app.component('SidebarCard', SidebarCard)
|
|
48
|
-
app.component('
|
|
44
|
+
app.component('Mindmap', Mindmap)
|
|
45
|
+
app.component('MindmapPreview', Mindmap)
|
|
49
46
|
app.component('Mermaid', Mermaid)
|
|
50
47
|
app.component('NotesTable', NotesTable)
|
|
51
|
-
app.component('N', NotesTable)
|
|
52
48
|
app.component('Tooltip', Tooltip)
|
|
53
49
|
}
|
|
54
50
|
|
|
@@ -13,6 +13,21 @@
|
|
|
13
13
|
/* 品牌色 */
|
|
14
14
|
--vp-c-brand-1: #646cff;
|
|
15
15
|
--vp-c-brand-2: #747bff;
|
|
16
|
+
|
|
17
|
+
/* Map VitePress tokens onto @tnotesjs/ui --tn-* */
|
|
18
|
+
--tn-c-brand: var(--vp-c-brand-1);
|
|
19
|
+
--tn-c-text: var(--vp-c-text-1);
|
|
20
|
+
--tn-c-text-2: var(--vp-c-text-2);
|
|
21
|
+
--tn-c-bg: var(--vp-c-bg);
|
|
22
|
+
--tn-c-bg-soft: var(--vp-c-bg-soft);
|
|
23
|
+
--tn-c-bg-elv: var(--vp-c-bg-elv);
|
|
24
|
+
--tn-c-divider: var(--vp-c-divider);
|
|
25
|
+
--tn-c-danger: var(--vp-c-danger-1);
|
|
26
|
+
--tn-c-success-soft: var(--vp-c-green-soft);
|
|
27
|
+
--tn-c-danger-soft: var(--vp-c-danger-soft);
|
|
28
|
+
--tn-c-default-soft: var(--vp-c-default-soft);
|
|
29
|
+
--tn-shadow-2: var(--vp-shadow-2);
|
|
30
|
+
--tn-font-mono: var(--vp-font-family-mono);
|
|
16
31
|
}
|
|
17
32
|
|
|
18
33
|
/* #endregion */
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { constants as fsConstants } from 'node:fs'
|
|
3
|
+
import fs from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { WorkspaceError } from './errors'
|
|
7
|
+
|
|
8
|
+
export interface AtomicWrite {
|
|
9
|
+
path: string
|
|
10
|
+
data: string | Uint8Array
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface OriginalFile {
|
|
14
|
+
path: string
|
|
15
|
+
existed: boolean
|
|
16
|
+
data?: Buffer
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function pathExists(filePath: string): Promise<boolean> {
|
|
20
|
+
try {
|
|
21
|
+
await fs.access(filePath, fsConstants.F_OK)
|
|
22
|
+
return true
|
|
23
|
+
} catch {
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function stageWrite(write: AtomicWrite): Promise<string> {
|
|
29
|
+
await fs.mkdir(path.dirname(write.path), { recursive: true })
|
|
30
|
+
const temporaryPath = path.join(
|
|
31
|
+
path.dirname(write.path),
|
|
32
|
+
`.${path.basename(write.path)}.${randomUUID()}.tmp`,
|
|
33
|
+
)
|
|
34
|
+
const handle = await fs.open(temporaryPath, 'wx')
|
|
35
|
+
try {
|
|
36
|
+
await handle.writeFile(write.data)
|
|
37
|
+
await handle.sync()
|
|
38
|
+
} finally {
|
|
39
|
+
await handle.close()
|
|
40
|
+
}
|
|
41
|
+
return temporaryPath
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function restoreOriginals(originals: OriginalFile[]): Promise<void> {
|
|
45
|
+
for (const original of originals) {
|
|
46
|
+
if (original.existed && original.data) {
|
|
47
|
+
const restorePath = await stageWrite({
|
|
48
|
+
path: original.path,
|
|
49
|
+
data: original.data,
|
|
50
|
+
})
|
|
51
|
+
await fs.rename(restorePath, original.path)
|
|
52
|
+
} else {
|
|
53
|
+
await fs.rm(original.path, { force: true })
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Stage every file before replacing any target. If a replacement fails, the
|
|
60
|
+
* captured originals are restored. Directory mutations are deliberately kept
|
|
61
|
+
* outside this helper and validated before they run.
|
|
62
|
+
*/
|
|
63
|
+
export async function writeFilesAtomically(
|
|
64
|
+
writes: AtomicWrite[],
|
|
65
|
+
): Promise<void> {
|
|
66
|
+
const uniqueWrites = new Map<string, AtomicWrite>()
|
|
67
|
+
for (const write of writes) uniqueWrites.set(write.path, write)
|
|
68
|
+
const normalizedWrites = [...uniqueWrites.values()]
|
|
69
|
+
|
|
70
|
+
const originals: OriginalFile[] = []
|
|
71
|
+
const staged: Array<{ target: string; temporary: string }> = []
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
for (const write of normalizedWrites) {
|
|
75
|
+
const existed = await pathExists(write.path)
|
|
76
|
+
originals.push({
|
|
77
|
+
path: write.path,
|
|
78
|
+
existed,
|
|
79
|
+
data: existed ? await fs.readFile(write.path) : undefined,
|
|
80
|
+
})
|
|
81
|
+
staged.push({
|
|
82
|
+
target: write.path,
|
|
83
|
+
temporary: await stageWrite(write),
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const item of staged) {
|
|
88
|
+
await fs.rename(item.temporary, item.target)
|
|
89
|
+
}
|
|
90
|
+
} catch (error) {
|
|
91
|
+
await Promise.allSettled(
|
|
92
|
+
staged.map((item) => fs.rm(item.temporary, { force: true })),
|
|
93
|
+
)
|
|
94
|
+
try {
|
|
95
|
+
await restoreOriginals(originals)
|
|
96
|
+
} catch (restoreError) {
|
|
97
|
+
throw new WorkspaceError(
|
|
98
|
+
'FILESYSTEM_ERROR',
|
|
99
|
+
'写入失败,并且无法完整恢复原文件',
|
|
100
|
+
{
|
|
101
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
102
|
+
restoreCause:
|
|
103
|
+
restoreError instanceof Error
|
|
104
|
+
? restoreError.message
|
|
105
|
+
: String(restoreError),
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
throw new WorkspaceError('FILESYSTEM_ERROR', '无法原子写入知识库文件', {
|
|
110
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type WorkspaceErrorCode =
|
|
2
|
+
| 'WORKSPACE_DISPOSED'
|
|
3
|
+
| 'WORKSPACE_INVALID'
|
|
4
|
+
| 'WORKSPACE_READ_ONLY'
|
|
5
|
+
| 'NOTE_NOT_FOUND'
|
|
6
|
+
| 'NOTE_INDEX_EXHAUSTED'
|
|
7
|
+
| 'REVISION_CONFLICT'
|
|
8
|
+
| 'INVALID_TITLE'
|
|
9
|
+
| 'INVALID_TOC_ENTRY'
|
|
10
|
+
| 'INVALID_PATH'
|
|
11
|
+
| 'FILESYSTEM_ERROR'
|
|
12
|
+
|
|
13
|
+
export class WorkspaceError extends Error {
|
|
14
|
+
readonly code: WorkspaceErrorCode
|
|
15
|
+
readonly details?: Record<string, unknown>
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
code: WorkspaceErrorCode,
|
|
19
|
+
message: string,
|
|
20
|
+
details?: Record<string, unknown>,
|
|
21
|
+
) {
|
|
22
|
+
super(message)
|
|
23
|
+
this.name = 'WorkspaceError'
|
|
24
|
+
this.code = code
|
|
25
|
+
this.details = details
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Workspace } from './workspace'
|
|
2
|
+
|
|
3
|
+
import type { CreateWorkspaceOptions, TNotesWorkspace } from './types'
|
|
4
|
+
|
|
5
|
+
export function createWorkspace(
|
|
6
|
+
options: CreateWorkspaceOptions,
|
|
7
|
+
): TNotesWorkspace {
|
|
8
|
+
return new Workspace(options)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export { WorkspaceError } from './errors'
|
|
12
|
+
export type { WorkspaceErrorCode } from './errors'
|
|
13
|
+
export type {
|
|
14
|
+
AttachmentResult,
|
|
15
|
+
ChangedFile,
|
|
16
|
+
CreateNoteInput,
|
|
17
|
+
CreateTocGroupInput,
|
|
18
|
+
CreateWorkspaceOptions,
|
|
19
|
+
DeletePreviewItem,
|
|
20
|
+
DeleteTocEntryInput,
|
|
21
|
+
DeleteTocEntryPreview,
|
|
22
|
+
KnowledgeBaseSnapshot,
|
|
23
|
+
MoveTocEntryInput,
|
|
24
|
+
MutationResult,
|
|
25
|
+
NoteDocument,
|
|
26
|
+
NotePlacement,
|
|
27
|
+
RenameNoteInput,
|
|
28
|
+
RenameTocGroupInput,
|
|
29
|
+
SaveNoteInput,
|
|
30
|
+
TNotesWorkspace,
|
|
31
|
+
TocEntryRef,
|
|
32
|
+
UpdateNoteConfigInput,
|
|
33
|
+
WorkspaceDiagnostic,
|
|
34
|
+
WorkspaceHealth,
|
|
35
|
+
WorkspaceKnowledgeBaseConfig,
|
|
36
|
+
WorkspaceLogger,
|
|
37
|
+
WorkspaceNoteConfig,
|
|
38
|
+
WorkspaceNoteSummary,
|
|
39
|
+
WriteAttachmentInput,
|
|
40
|
+
} from './types'
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export class MutationQueue {
|
|
2
|
+
private tail: Promise<void> = Promise.resolve()
|
|
3
|
+
private disposed = false
|
|
4
|
+
|
|
5
|
+
async run<T>(operation: () => Promise<T>): Promise<T> {
|
|
6
|
+
if (this.disposed) {
|
|
7
|
+
throw new Error('Mutation queue has been disposed')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const previous = this.tail
|
|
11
|
+
let release!: () => void
|
|
12
|
+
this.tail = new Promise<void>((resolve) => {
|
|
13
|
+
release = resolve
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
await previous
|
|
17
|
+
try {
|
|
18
|
+
return await operation()
|
|
19
|
+
} finally {
|
|
20
|
+
release()
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async dispose(): Promise<void> {
|
|
25
|
+
this.disposed = true
|
|
26
|
+
await this.tail
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { WorkspaceError } from './errors'
|
|
4
|
+
|
|
5
|
+
export interface WorkspacePaths {
|
|
6
|
+
root: string
|
|
7
|
+
config: string
|
|
8
|
+
toc: string
|
|
9
|
+
notes: string
|
|
10
|
+
sidebar: string
|
|
11
|
+
packageJson: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createWorkspacePaths(rootPath: string): WorkspacePaths {
|
|
15
|
+
if (!rootPath || !path.isAbsolute(rootPath)) {
|
|
16
|
+
throw new WorkspaceError(
|
|
17
|
+
'INVALID_PATH',
|
|
18
|
+
'知识库路径必须是非空的绝对路径',
|
|
19
|
+
{ rootPath },
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const root = path.normalize(path.resolve(rootPath))
|
|
24
|
+
return {
|
|
25
|
+
root,
|
|
26
|
+
config: path.join(root, '.tnotes.json'),
|
|
27
|
+
toc: path.join(root, 'TOC.md'),
|
|
28
|
+
notes: path.join(root, 'notes'),
|
|
29
|
+
sidebar: path.join(root, 'sidebar.json'),
|
|
30
|
+
packageJson: path.join(root, 'package.json'),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function assertPathInside(parent: string, target: string): void {
|
|
35
|
+
const relative = path.relative(parent, target)
|
|
36
|
+
if (
|
|
37
|
+
relative === '' ||
|
|
38
|
+
(!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))
|
|
39
|
+
) {
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
throw new WorkspaceError('INVALID_PATH', '目标路径超出允许范围', {
|
|
44
|
+
parent,
|
|
45
|
+
target,
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function sanitizeFileName(fileName: string): string {
|
|
50
|
+
const value = fileName.trim()
|
|
51
|
+
if (
|
|
52
|
+
!value ||
|
|
53
|
+
value === '.' ||
|
|
54
|
+
value === '..' ||
|
|
55
|
+
value.includes('/') ||
|
|
56
|
+
value.includes('\\') ||
|
|
57
|
+
value.includes('\0')
|
|
58
|
+
) {
|
|
59
|
+
throw new WorkspaceError('INVALID_PATH', '附件文件名不合法', {
|
|
60
|
+
fileName,
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
return value
|
|
64
|
+
}
|