@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
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import type { TNotesConfig } from '../types/config'
|
|
2
|
+
import type { NoteConfig } from '../types/note'
|
|
3
|
+
import type { TocTreeNode, TocSidebarItem } from '../utils/tocHelpers'
|
|
4
|
+
|
|
5
|
+
export type WorkspaceDiagnosticSeverity = 'error' | 'warning' | 'info'
|
|
6
|
+
|
|
7
|
+
export interface WorkspaceDiagnostic {
|
|
8
|
+
code: string
|
|
9
|
+
message: string
|
|
10
|
+
severity: WorkspaceDiagnosticSeverity
|
|
11
|
+
path?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type WorkspaceHealth =
|
|
15
|
+
| { status: 'ready'; diagnostics: WorkspaceDiagnostic[] }
|
|
16
|
+
| { status: 'invalid'; diagnostics: WorkspaceDiagnostic[] }
|
|
17
|
+
| { status: 'future-schema'; diagnostics: WorkspaceDiagnostic[] }
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Core only edits known fields, but it must retain fields introduced by a
|
|
21
|
+
* knowledge base or by a newer Core release.
|
|
22
|
+
*/
|
|
23
|
+
export type WorkspaceNoteConfig = NoteConfig & Record<string, unknown>
|
|
24
|
+
export type WorkspaceKnowledgeBaseConfig = TNotesConfig &
|
|
25
|
+
Record<string, unknown>
|
|
26
|
+
|
|
27
|
+
export interface WorkspaceNoteSummary {
|
|
28
|
+
uuid: string
|
|
29
|
+
index: string
|
|
30
|
+
title: string
|
|
31
|
+
dirName: string
|
|
32
|
+
directoryPath: string
|
|
33
|
+
readmePath: string
|
|
34
|
+
configPath: string
|
|
35
|
+
config: WorkspaceNoteConfig
|
|
36
|
+
revision: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface KnowledgeBaseSnapshot {
|
|
40
|
+
id: string
|
|
41
|
+
rootPath: string
|
|
42
|
+
config: WorkspaceKnowledgeBaseConfig | null
|
|
43
|
+
health: WorkspaceHealth
|
|
44
|
+
toc: TocTreeNode[]
|
|
45
|
+
sidebar: TocSidebarItem[]
|
|
46
|
+
notes: WorkspaceNoteSummary[]
|
|
47
|
+
revision: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface NoteDocument extends WorkspaceNoteSummary {
|
|
51
|
+
content: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ChangedFile {
|
|
55
|
+
path: string
|
|
56
|
+
kind: 'created' | 'updated' | 'deleted' | 'renamed' | 'trashed'
|
|
57
|
+
previousPath?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface MutationResult<T> {
|
|
61
|
+
value: T
|
|
62
|
+
changedFiles: ChangedFile[]
|
|
63
|
+
snapshotRevision: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface WorkspaceLogger {
|
|
67
|
+
debug?(message: string, details?: unknown): void
|
|
68
|
+
info?(message: string, details?: unknown): void
|
|
69
|
+
warn?(message: string, details?: unknown): void
|
|
70
|
+
error?(message: string, details?: unknown): void
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface CreateWorkspaceOptions {
|
|
74
|
+
rootPath: string
|
|
75
|
+
logger?: WorkspaceLogger
|
|
76
|
+
format?: {
|
|
77
|
+
prettier?: boolean
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface SaveNoteInput {
|
|
82
|
+
noteUuid: string
|
|
83
|
+
content: string
|
|
84
|
+
expectedRevision: string
|
|
85
|
+
prettier?: boolean
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type NotePlacement =
|
|
89
|
+
| { type: 'root'; placement?: 'start' | 'end' }
|
|
90
|
+
| {
|
|
91
|
+
type: 'note'
|
|
92
|
+
targetNoteUuid: string
|
|
93
|
+
placement: 'before' | 'after' | 'inside'
|
|
94
|
+
}
|
|
95
|
+
| {
|
|
96
|
+
type: 'folder'
|
|
97
|
+
folderPath: string[]
|
|
98
|
+
placement: 'before' | 'after' | 'inside'
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface CreateNoteInput {
|
|
102
|
+
title: string
|
|
103
|
+
placement?: NotePlacement
|
|
104
|
+
config?: Partial<Pick<WorkspaceNoteConfig, 'description' | 'enableDiscussions'>>
|
|
105
|
+
expectedSnapshotRevision?: string
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface RenameNoteInput {
|
|
109
|
+
noteUuid: string
|
|
110
|
+
title: string
|
|
111
|
+
expectedRevision: string
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface UpdateNoteConfigInput {
|
|
115
|
+
noteUuid: string
|
|
116
|
+
updates: Partial<
|
|
117
|
+
Pick<
|
|
118
|
+
WorkspaceNoteConfig,
|
|
119
|
+
'done' | 'description' | 'enableDiscussions'
|
|
120
|
+
>
|
|
121
|
+
>
|
|
122
|
+
expectedRevision: string
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export type TocEntryRef =
|
|
126
|
+
| { type: 'note'; noteUuid: string }
|
|
127
|
+
| { type: 'folder'; folderPath: string[] }
|
|
128
|
+
| { type: 'line'; tocLineIndex: number }
|
|
129
|
+
|
|
130
|
+
export interface MoveTocEntryInput {
|
|
131
|
+
source: TocEntryRef
|
|
132
|
+
target: TocEntryRef
|
|
133
|
+
placement: 'before' | 'after' | 'inside'
|
|
134
|
+
expectedSnapshotRevision: string
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface CreateTocGroupInput {
|
|
138
|
+
title: string
|
|
139
|
+
placement?: NotePlacement
|
|
140
|
+
expectedSnapshotRevision: string
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface RenameTocGroupInput {
|
|
144
|
+
folderPath: string[]
|
|
145
|
+
title: string
|
|
146
|
+
expectedSnapshotRevision: string
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface DeletePreviewItem {
|
|
150
|
+
noteUuid: string
|
|
151
|
+
index: string
|
|
152
|
+
title: string
|
|
153
|
+
directoryPath: string
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface DeleteTocEntryPreview {
|
|
157
|
+
entry: TocEntryRef
|
|
158
|
+
notes: DeletePreviewItem[]
|
|
159
|
+
filePaths: string[]
|
|
160
|
+
directoryPaths: string[]
|
|
161
|
+
snapshotRevision: string
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface DeleteTocEntryInput {
|
|
165
|
+
entry: TocEntryRef
|
|
166
|
+
expectedSnapshotRevision: string
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface WriteAttachmentInput {
|
|
170
|
+
noteUuid: string
|
|
171
|
+
fileName: string
|
|
172
|
+
data: Uint8Array
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface AttachmentResult {
|
|
176
|
+
absolutePath: string
|
|
177
|
+
markdownPath: string
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface TNotesWorkspace {
|
|
181
|
+
inspect(): Promise<KnowledgeBaseSnapshot>
|
|
182
|
+
refresh(): Promise<KnowledgeBaseSnapshot>
|
|
183
|
+
reconcileTocCompletion(): Promise<MutationResult<KnowledgeBaseSnapshot>>
|
|
184
|
+
|
|
185
|
+
notes: {
|
|
186
|
+
read(noteUuid: string): Promise<NoteDocument>
|
|
187
|
+
save(input: SaveNoteInput): Promise<MutationResult<NoteDocument>>
|
|
188
|
+
create(input: CreateNoteInput): Promise<MutationResult<NoteDocument>>
|
|
189
|
+
rename(input: RenameNoteInput): Promise<MutationResult<NoteDocument>>
|
|
190
|
+
updateConfig(
|
|
191
|
+
input: UpdateNoteConfigInput,
|
|
192
|
+
): Promise<MutationResult<NoteDocument>>
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
toc: {
|
|
196
|
+
move(input: MoveTocEntryInput): Promise<MutationResult<KnowledgeBaseSnapshot>>
|
|
197
|
+
createGroup(
|
|
198
|
+
input: CreateTocGroupInput,
|
|
199
|
+
): Promise<MutationResult<KnowledgeBaseSnapshot>>
|
|
200
|
+
renameGroup(
|
|
201
|
+
input: RenameTocGroupInput,
|
|
202
|
+
): Promise<MutationResult<KnowledgeBaseSnapshot>>
|
|
203
|
+
previewDelete(entry: TocEntryRef): Promise<DeleteTocEntryPreview>
|
|
204
|
+
deleteEntry(
|
|
205
|
+
input: DeleteTocEntryInput,
|
|
206
|
+
): Promise<MutationResult<KnowledgeBaseSnapshot>>
|
|
207
|
+
setDone(input: UpdateNoteConfigInput): Promise<MutationResult<NoteDocument>>
|
|
208
|
+
/**
|
|
209
|
+
* Align TOC.md + sidebar.json with the filesystem truth (files-first).
|
|
210
|
+
* Valid notes missing from the TOC are appended (root level, by index);
|
|
211
|
+
* note dirs with a missing/invalid config are soft-deleted to
|
|
212
|
+
* notes/.trash/. Idempotent: no changes -> changedFiles = [].
|
|
213
|
+
*/
|
|
214
|
+
reconcileFromFiles(): Promise<MutationResult<KnowledgeBaseSnapshot>>
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
attachments: {
|
|
218
|
+
writeLocal(
|
|
219
|
+
input: WriteAttachmentInput,
|
|
220
|
+
): Promise<MutationResult<AttachmentResult>>
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
dispose(): Promise<void>
|
|
224
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { createWorkspace, WorkspaceError } from './index'
|
|
7
|
+
|
|
8
|
+
const temporaryDirectories: string[] = []
|
|
9
|
+
|
|
10
|
+
afterEach(async () => {
|
|
11
|
+
await Promise.all(
|
|
12
|
+
temporaryDirectories.splice(0).map((directory) =>
|
|
13
|
+
fs.rm(directory, { recursive: true, force: true }),
|
|
14
|
+
),
|
|
15
|
+
)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
function knowledgeBaseConfig(id: string, repoName: string) {
|
|
19
|
+
return {
|
|
20
|
+
id,
|
|
21
|
+
author: 'tnotesjs',
|
|
22
|
+
repoName,
|
|
23
|
+
keywords: [repoName],
|
|
24
|
+
sidebarShowNoteId: false,
|
|
25
|
+
ignore_dirs: [],
|
|
26
|
+
socialLinks: [],
|
|
27
|
+
menuItems: [],
|
|
28
|
+
root_item: {
|
|
29
|
+
title: repoName,
|
|
30
|
+
completed_notes_count: {},
|
|
31
|
+
details: repoName,
|
|
32
|
+
link: '/',
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function noteConfig(id: string, done = false) {
|
|
38
|
+
return {
|
|
39
|
+
bilibili: [],
|
|
40
|
+
tnotes: [],
|
|
41
|
+
yuque: [],
|
|
42
|
+
done,
|
|
43
|
+
enableDiscussions: false,
|
|
44
|
+
description: '',
|
|
45
|
+
id,
|
|
46
|
+
customField: 'preserved',
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function createFixture(input?: {
|
|
51
|
+
repoName?: string
|
|
52
|
+
knowledgeBaseId?: string
|
|
53
|
+
notes?: Array<{
|
|
54
|
+
index: string
|
|
55
|
+
title: string
|
|
56
|
+
id: string
|
|
57
|
+
done?: boolean
|
|
58
|
+
content?: string
|
|
59
|
+
}>
|
|
60
|
+
toc?: string
|
|
61
|
+
}) {
|
|
62
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'tnotes-core-workspace-'))
|
|
63
|
+
temporaryDirectories.push(root)
|
|
64
|
+
const repoName = input?.repoName ?? 'TNotes.fixture'
|
|
65
|
+
const notes =
|
|
66
|
+
input?.notes ??
|
|
67
|
+
[
|
|
68
|
+
{
|
|
69
|
+
index: '0001',
|
|
70
|
+
title: 'Alpha',
|
|
71
|
+
id: 'note-alpha',
|
|
72
|
+
done: false,
|
|
73
|
+
},
|
|
74
|
+
]
|
|
75
|
+
await fs.mkdir(path.join(root, 'notes'))
|
|
76
|
+
await fs.writeFile(
|
|
77
|
+
path.join(root, '.tnotes.json'),
|
|
78
|
+
JSON.stringify(
|
|
79
|
+
knowledgeBaseConfig(input?.knowledgeBaseId ?? 'kb-fixture', repoName),
|
|
80
|
+
null,
|
|
81
|
+
2,
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
await fs.writeFile(path.join(root, 'README.md'), 'legacy root readme\n')
|
|
85
|
+
|
|
86
|
+
for (const note of notes) {
|
|
87
|
+
const noteDirectory = path.join(root, 'notes', `${note.index}. ${note.title}`)
|
|
88
|
+
await fs.mkdir(noteDirectory)
|
|
89
|
+
await fs.writeFile(
|
|
90
|
+
path.join(noteDirectory, '.tnotes.json'),
|
|
91
|
+
`${JSON.stringify(noteConfig(note.id, note.done), null, 2)}\n`,
|
|
92
|
+
)
|
|
93
|
+
await fs.writeFile(
|
|
94
|
+
path.join(noteDirectory, 'README.md'),
|
|
95
|
+
note.content ??
|
|
96
|
+
`# stale title\n\n<!-- region:toc -->\n\n<!-- endregion:toc -->\n\n## Intro\n`,
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const toc =
|
|
101
|
+
input?.toc ??
|
|
102
|
+
`${notes.map((note) => `- [${note.done ? 'x' : ' '}] ${note.index}. ${note.title}`).join('\n')}\n`
|
|
103
|
+
await fs.writeFile(path.join(root, 'TOC.md'), toc)
|
|
104
|
+
await fs.writeFile(path.join(root, 'sidebar.json'), '[]')
|
|
105
|
+
return root
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
describe('path-injected workspace', () => {
|
|
109
|
+
it('keeps two knowledge bases isolated in the same process', async () => {
|
|
110
|
+
const firstRoot = await createFixture({
|
|
111
|
+
repoName: 'TNotes.first',
|
|
112
|
+
knowledgeBaseId: 'kb-first',
|
|
113
|
+
notes: [{ index: '0001', title: 'First', id: 'first-note' }],
|
|
114
|
+
})
|
|
115
|
+
const secondRoot = await createFixture({
|
|
116
|
+
repoName: 'TNotes.second',
|
|
117
|
+
knowledgeBaseId: 'kb-second',
|
|
118
|
+
notes: [{ index: '0001', title: 'Second', id: 'second-note' }],
|
|
119
|
+
})
|
|
120
|
+
const first = createWorkspace({ rootPath: firstRoot })
|
|
121
|
+
const second = createWorkspace({ rootPath: secondRoot })
|
|
122
|
+
|
|
123
|
+
const [firstSnapshot, secondSnapshot] = await Promise.all([
|
|
124
|
+
first.inspect(),
|
|
125
|
+
second.inspect(),
|
|
126
|
+
])
|
|
127
|
+
expect(firstSnapshot.id).toBe('kb-first')
|
|
128
|
+
expect(secondSnapshot.id).toBe('kb-second')
|
|
129
|
+
expect(firstSnapshot.notes[0].title).toBe('First')
|
|
130
|
+
expect(secondSnapshot.notes[0].title).toBe('Second')
|
|
131
|
+
|
|
132
|
+
const firstDocument = await first.notes.read('first-note')
|
|
133
|
+
const secondDocument = await second.notes.read('second-note')
|
|
134
|
+
expect(firstDocument.readmePath.startsWith(firstRoot)).toBe(true)
|
|
135
|
+
expect(secondDocument.readmePath.startsWith(secondRoot)).toBe(true)
|
|
136
|
+
|
|
137
|
+
await first.dispose()
|
|
138
|
+
await second.dispose()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('reports an invalid workspace without exiting the process', async () => {
|
|
142
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'tnotes-invalid-'))
|
|
143
|
+
temporaryDirectories.push(root)
|
|
144
|
+
const workspace = createWorkspace({ rootPath: root })
|
|
145
|
+
const snapshot = await workspace.inspect()
|
|
146
|
+
|
|
147
|
+
expect(snapshot.health.status).toBe('invalid')
|
|
148
|
+
expect(snapshot.health.diagnostics.map((item) => item.code)).toEqual(
|
|
149
|
+
expect.arrayContaining([
|
|
150
|
+
'CONFIG_MISSING',
|
|
151
|
+
'TOC_MISSING',
|
|
152
|
+
'NOTES_DIRECTORY_MISSING',
|
|
153
|
+
]),
|
|
154
|
+
)
|
|
155
|
+
await expect(
|
|
156
|
+
workspace.notes.create({ title: 'Blocked' }),
|
|
157
|
+
).rejects.toMatchObject({ code: 'WORKSPACE_INVALID' })
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('formats and saves a note without changing the root README', async () => {
|
|
161
|
+
const root = await createFixture()
|
|
162
|
+
const workspace = createWorkspace({ rootPath: root })
|
|
163
|
+
const before = await workspace.notes.read('note-alpha')
|
|
164
|
+
const rootReadmeBefore = await fs.readFile(path.join(root, 'README.md'), 'utf8')
|
|
165
|
+
|
|
166
|
+
const result = await workspace.notes.save({
|
|
167
|
+
noteUuid: 'note-alpha',
|
|
168
|
+
expectedRevision: before.revision,
|
|
169
|
+
content: `# user title\n\n## Hello\n\n### Child\n\n#### 9.8. Plain\n\n\`\`\`md\n## Not a heading\n\`\`\`\n`,
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
expect(result.value.content).toContain(
|
|
173
|
+
'# [0001. Alpha](https://github.com/tnotesjs/TNotes.fixture/tree/main/notes/0001.%20Alpha)',
|
|
174
|
+
)
|
|
175
|
+
expect(result.value.content).toContain('## 1. Hello')
|
|
176
|
+
expect(result.value.content).toContain('### 1.1. Child')
|
|
177
|
+
expect(result.value.content).toContain('#### Plain')
|
|
178
|
+
expect(result.value.content).toContain('```md\n## Not a heading\n```')
|
|
179
|
+
expect(result.value.content).toContain('<!-- region:toc -->')
|
|
180
|
+
expect(result.value.content).toContain('- [1. Hello](#1-hello)')
|
|
181
|
+
expect(await fs.readFile(path.join(root, 'README.md'), 'utf8')).toBe(
|
|
182
|
+
rootReadmeBefore,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
await expect(
|
|
186
|
+
workspace.notes.save({
|
|
187
|
+
noteUuid: 'note-alpha',
|
|
188
|
+
expectedRevision: before.revision,
|
|
189
|
+
content: result.value.content,
|
|
190
|
+
}),
|
|
191
|
+
).rejects.toMatchObject({ code: 'REVISION_CONFLICT' })
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('uses the smallest free index and updates only TOC/sidebar', async () => {
|
|
195
|
+
const root = await createFixture({
|
|
196
|
+
notes: [
|
|
197
|
+
{ index: '0001', title: 'One', id: 'one' },
|
|
198
|
+
{ index: '0003', title: 'Three', id: 'three' },
|
|
199
|
+
],
|
|
200
|
+
})
|
|
201
|
+
const workspace = createWorkspace({ rootPath: root })
|
|
202
|
+
const rootReadmeBefore = await fs.readFile(path.join(root, 'README.md'), 'utf8')
|
|
203
|
+
const created = await workspace.notes.create({
|
|
204
|
+
title: 'Two',
|
|
205
|
+
placement: {
|
|
206
|
+
type: 'note',
|
|
207
|
+
targetNoteUuid: 'three',
|
|
208
|
+
placement: 'before',
|
|
209
|
+
},
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
expect(created.value.index).toBe('0002')
|
|
213
|
+
expect(created.value.config).not.toHaveProperty('category')
|
|
214
|
+
const toc = await fs.readFile(path.join(root, 'TOC.md'), 'utf8')
|
|
215
|
+
expect(toc.indexOf('0002. Two')).toBeLessThan(toc.indexOf('0003. Three'))
|
|
216
|
+
expect(await fs.readFile(path.join(root, 'README.md'), 'utf8')).toBe(
|
|
217
|
+
rootReadmeBefore,
|
|
218
|
+
)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('renames a note while preserving its UUID and unknown config fields', async () => {
|
|
222
|
+
const root = await createFixture()
|
|
223
|
+
const workspace = createWorkspace({ rootPath: root })
|
|
224
|
+
const before = await workspace.notes.read('note-alpha')
|
|
225
|
+
const rootReadmeBefore = await fs.readFile(path.join(root, 'README.md'), 'utf8')
|
|
226
|
+
const renamed = await workspace.notes.rename({
|
|
227
|
+
noteUuid: before.uuid,
|
|
228
|
+
title: 'Renamed',
|
|
229
|
+
expectedRevision: before.revision,
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
expect(renamed.value.uuid).toBe('note-alpha')
|
|
233
|
+
expect(renamed.value.dirName).toBe('0001. Renamed')
|
|
234
|
+
expect(renamed.value.config.customField).toBe('preserved')
|
|
235
|
+
await expect(fs.access(path.join(root, 'notes', '0001. Alpha'))).rejects.toThrow()
|
|
236
|
+
expect(await fs.readFile(path.join(root, 'TOC.md'), 'utf8')).toContain(
|
|
237
|
+
'0001. Renamed',
|
|
238
|
+
)
|
|
239
|
+
expect(await fs.readFile(path.join(root, 'README.md'), 'utf8')).toBe(
|
|
240
|
+
rootReadmeBefore,
|
|
241
|
+
)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('syncs an externally changed TOC checkbox back to note config', async () => {
|
|
245
|
+
const root = await createFixture()
|
|
246
|
+
const workspace = createWorkspace({ rootPath: root })
|
|
247
|
+
await fs.writeFile(path.join(root, 'TOC.md'), '- [x] 0001. Alpha\n')
|
|
248
|
+
const result = await workspace.reconcileTocCompletion()
|
|
249
|
+
|
|
250
|
+
expect(result.value.notes[0].config.done).toBe(true)
|
|
251
|
+
const config = JSON.parse(
|
|
252
|
+
await fs.readFile(
|
|
253
|
+
path.join(root, 'notes', '0001. Alpha', '.tnotes.json'),
|
|
254
|
+
'utf8',
|
|
255
|
+
),
|
|
256
|
+
)
|
|
257
|
+
expect(config.done).toBe(true)
|
|
258
|
+
expect(config.customField).toBe('preserved')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('previews and permanently deletes a TOC subtree', async () => {
|
|
262
|
+
const root = await createFixture({
|
|
263
|
+
notes: [
|
|
264
|
+
{ index: '0001', title: 'Parent', id: 'parent' },
|
|
265
|
+
{ index: '0002', title: 'Child', id: 'child' },
|
|
266
|
+
{ index: '0003', title: 'Keep', id: 'keep' },
|
|
267
|
+
],
|
|
268
|
+
toc: '- Group\n - [ ] 0001. Parent\n - [ ] 0002. Child\n- [ ] 0003. Keep\n',
|
|
269
|
+
})
|
|
270
|
+
const workspace = createWorkspace({ rootPath: root })
|
|
271
|
+
const preview = await workspace.toc.previewDelete({
|
|
272
|
+
type: 'folder',
|
|
273
|
+
folderPath: ['Group'],
|
|
274
|
+
})
|
|
275
|
+
expect(preview.notes.map((note) => note.noteUuid)).toEqual([
|
|
276
|
+
'parent',
|
|
277
|
+
'child',
|
|
278
|
+
])
|
|
279
|
+
expect(preview.filePaths).toHaveLength(4)
|
|
280
|
+
|
|
281
|
+
const deleted = await workspace.toc.deleteEntry({
|
|
282
|
+
entry: { type: 'folder', folderPath: ['Group'] },
|
|
283
|
+
expectedSnapshotRevision: preview.snapshotRevision,
|
|
284
|
+
})
|
|
285
|
+
expect(deleted.value.notes.map((note) => note.uuid)).toEqual(['keep'])
|
|
286
|
+
expect(await fs.readFile(path.join(root, 'TOC.md'), 'utf8')).toBe(
|
|
287
|
+
'- [ ] 0003. Keep\n',
|
|
288
|
+
)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it('writes local attachments with collision-safe names', async () => {
|
|
292
|
+
const root = await createFixture()
|
|
293
|
+
const workspace = createWorkspace({ rootPath: root })
|
|
294
|
+
const first = await workspace.attachments.writeLocal({
|
|
295
|
+
noteUuid: 'note-alpha',
|
|
296
|
+
fileName: 'image.png',
|
|
297
|
+
data: new Uint8Array([1, 2, 3]),
|
|
298
|
+
})
|
|
299
|
+
const second = await workspace.attachments.writeLocal({
|
|
300
|
+
noteUuid: 'note-alpha',
|
|
301
|
+
fileName: 'image.png',
|
|
302
|
+
data: new Uint8Array([4, 5, 6]),
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
expect(first.value.markdownPath).toBe('./assets/image.png')
|
|
306
|
+
expect(second.value.markdownPath).toBe('./assets/image-1.png')
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('becomes read-only for a future schema', async () => {
|
|
310
|
+
const root = await createFixture()
|
|
311
|
+
const configPath = path.join(root, '.tnotes.json')
|
|
312
|
+
const config = JSON.parse(await fs.readFile(configPath, 'utf8'))
|
|
313
|
+
config.schemaVersion = 999
|
|
314
|
+
await fs.writeFile(configPath, JSON.stringify(config, null, 2))
|
|
315
|
+
const workspace = createWorkspace({ rootPath: root })
|
|
316
|
+
|
|
317
|
+
expect((await workspace.inspect()).health.status).toBe('future-schema')
|
|
318
|
+
await expect(workspace.notes.read('note-alpha')).resolves.toMatchObject({
|
|
319
|
+
uuid: 'note-alpha',
|
|
320
|
+
})
|
|
321
|
+
const document = await workspace.notes.read('note-alpha')
|
|
322
|
+
await expect(
|
|
323
|
+
workspace.notes.save({
|
|
324
|
+
noteUuid: document.uuid,
|
|
325
|
+
expectedRevision: document.revision,
|
|
326
|
+
content: document.content,
|
|
327
|
+
}),
|
|
328
|
+
).rejects.toMatchObject({
|
|
329
|
+
code: 'WORKSPACE_READ_ONLY',
|
|
330
|
+
})
|
|
331
|
+
expect(WorkspaceError).toBeTypeOf('function')
|
|
332
|
+
})
|
|
333
|
+
})
|