@khorsheed/dsh-library 0.0.0-stage → 0.1.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.
Files changed (136) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.en.md +49 -0
  3. package/README.i18n.yaml +6 -0
  4. package/README.md +48 -2
  5. package/cordis.patch.yml +17 -0
  6. package/lib/client.js +66276 -0
  7. package/lib/index.js +3318 -0
  8. package/lib/profiles-BXM8xNUf.js +1608 -0
  9. package/lib/profiles-BrbeM-bi.js +1635 -0
  10. package/lib/profiles-CFrPQdfO.js +1692 -0
  11. package/lib/profiles-DxtzIUvd.js +1632 -0
  12. package/lib/profiles-O0ddEit3.js +1631 -0
  13. package/lib/profiles-Oj-zBAY9.js +1692 -0
  14. package/lib/profiles-gIEviEuO.js +1610 -0
  15. package/lib/store-CIcZgTsg.js +1456 -0
  16. package/lib/store-DfYOmS44.js +1418 -0
  17. package/lib/tool.js +1597 -0
  18. package/lib/typert.host.d.ts +3 -0
  19. package/lib/typert.host.js +3811 -0
  20. package/lib/typert.remote-client.d.ts +137 -0
  21. package/lib/typert.remote-client.js +3077 -0
  22. package/lib/types/cards.d.ts +41 -0
  23. package/lib/types/cards.js +111 -0
  24. package/lib/types/client/LibraryPane.d.ts +26 -0
  25. package/lib/types/client/LibraryPane.js +117 -0
  26. package/lib/types/client/bind/BindChip.d.ts +33 -0
  27. package/lib/types/client/bind/BindChip.js +102 -0
  28. package/lib/types/client/contract.d.ts +164 -0
  29. package/lib/types/client/contract.js +1 -0
  30. package/lib/types/client/covers.d.ts +36 -0
  31. package/lib/types/client/covers.js +66 -0
  32. package/lib/types/client/definition.d.ts +27 -0
  33. package/lib/types/client/definition.js +32 -0
  34. package/lib/types/client/icons-local.d.ts +41 -0
  35. package/lib/types/client/icons-local.js +25 -0
  36. package/lib/types/client/icons.d.ts +27 -0
  37. package/lib/types/client/icons.js +11 -0
  38. package/lib/types/client/index.d.ts +35 -0
  39. package/lib/types/client/index.js +219 -0
  40. package/lib/types/client/judge.d.ts +70 -0
  41. package/lib/types/client/judge.js +262 -0
  42. package/lib/types/client/locales.d.ts +553 -0
  43. package/lib/types/client/locales.js +1076 -0
  44. package/lib/types/client/look.d.ts +63 -0
  45. package/lib/types/client/look.js +64 -0
  46. package/lib/types/client/math.d.ts +24 -0
  47. package/lib/types/client/math.js +47 -0
  48. package/lib/types/client/mistakes.d.ts +21 -0
  49. package/lib/types/client/mistakes.js +28 -0
  50. package/lib/types/client/preset-visibility.d.ts +109 -0
  51. package/lib/types/client/preset-visibility.js +123 -0
  52. package/lib/types/client/quiz-drafts.d.ts +29 -0
  53. package/lib/types/client/quiz-drafts.js +50 -0
  54. package/lib/types/client/store.d.ts +109 -0
  55. package/lib/types/client/store.js +44 -0
  56. package/lib/types/client/titles.d.ts +17 -0
  57. package/lib/types/client/titles.js +23 -0
  58. package/lib/types/client/ui.d.ts +96 -0
  59. package/lib/types/client/ui.js +124 -0
  60. package/lib/types/client/views/book.d.ts +57 -0
  61. package/lib/types/client/views/book.js +164 -0
  62. package/lib/types/client/views/cards.d.ts +17 -0
  63. package/lib/types/client/views/cards.js +53 -0
  64. package/lib/types/client/views/home.d.ts +26 -0
  65. package/lib/types/client/views/home.js +263 -0
  66. package/lib/types/client/views/lesson.d.ts +42 -0
  67. package/lib/types/client/views/lesson.js +146 -0
  68. package/lib/types/client/views/path.d.ts +32 -0
  69. package/lib/types/client/views/path.js +64 -0
  70. package/lib/types/client/views/quiz.d.ts +42 -0
  71. package/lib/types/client/views/quiz.js +366 -0
  72. package/lib/types/client/views/station-quiz.d.ts +43 -0
  73. package/lib/types/client/views/station-quiz.js +203 -0
  74. package/lib/types/client/views/station.d.ts +18 -0
  75. package/lib/types/client/views/station.js +142 -0
  76. package/lib/types/image-refs.d.ts +35 -0
  77. package/lib/types/image-refs.js +71 -0
  78. package/lib/types/index.d.ts +51 -0
  79. package/lib/types/index.js +31 -0
  80. package/lib/types/mastery.d.ts +81 -0
  81. package/lib/types/mastery.js +110 -0
  82. package/lib/types/profiles.d.ts +31 -0
  83. package/lib/types/profiles.js +25 -0
  84. package/lib/types/remote.d.ts +355 -0
  85. package/lib/types/remote.js +332 -0
  86. package/lib/types/service/assess.d.ts +144 -0
  87. package/lib/types/service/assess.js +773 -0
  88. package/lib/types/service/bindings.d.ts +92 -0
  89. package/lib/types/service/bindings.js +198 -0
  90. package/lib/types/service/cards.d.ts +85 -0
  91. package/lib/types/service/cards.js +320 -0
  92. package/lib/types/service/core.d.ts +124 -0
  93. package/lib/types/service/core.js +169 -0
  94. package/lib/types/service/pages.d.ts +60 -0
  95. package/lib/types/service/pages.js +125 -0
  96. package/lib/types/service/roadmap.d.ts +99 -0
  97. package/lib/types/service/roadmap.js +284 -0
  98. package/lib/types/service/rules.d.ts +63 -0
  99. package/lib/types/service/rules.js +245 -0
  100. package/lib/types/service/shelf.d.ts +136 -0
  101. package/lib/types/service/shelf.js +422 -0
  102. package/lib/types/service.d.ts +42 -0
  103. package/lib/types/service.js +41 -0
  104. package/lib/types/store.d.ts +257 -0
  105. package/lib/types/store.js +1376 -0
  106. package/lib/types/tool-definition.d.ts +49 -0
  107. package/lib/types/tool-definition.js +49 -0
  108. package/lib/types/tool.d.ts +71 -0
  109. package/lib/types/tool.js +174 -0
  110. package/lib/types/tools/assess.d.ts +11 -0
  111. package/lib/types/tools/assess.js +82 -0
  112. package/lib/types/tools/binding.d.ts +11 -0
  113. package/lib/types/tools/binding.js +46 -0
  114. package/lib/types/tools/cards.d.ts +11 -0
  115. package/lib/types/tools/cards.js +100 -0
  116. package/lib/types/tools/kit.d.ts +215 -0
  117. package/lib/types/tools/kit.js +281 -0
  118. package/lib/types/tools/notes.d.ts +11 -0
  119. package/lib/types/tools/notes.js +40 -0
  120. package/lib/types/tools/page.d.ts +11 -0
  121. package/lib/types/tools/page.js +59 -0
  122. package/lib/types/tools/project.d.ts +11 -0
  123. package/lib/types/tools/project.js +67 -0
  124. package/lib/types/tools/quiz.d.ts +11 -0
  125. package/lib/types/tools/quiz.js +74 -0
  126. package/lib/types/tools/read.d.ts +11 -0
  127. package/lib/types/tools/read.js +127 -0
  128. package/lib/types/tools/resource.d.ts +11 -0
  129. package/lib/types/tools/resource.js +147 -0
  130. package/lib/types/tools/roadmap.d.ts +11 -0
  131. package/lib/types/tools/roadmap.js +102 -0
  132. package/lib/types/types.d.ts +752 -0
  133. package/lib/types/types.js +159 -0
  134. package/locale/en.json +6 -0
  135. package/locale/zh.json +6 -0
  136. package/package.json +135 -3
@@ -0,0 +1,1376 @@
1
+ /**
2
+ * The host-side state store: a version-guarded index (`state.json`), the
3
+ * session bindings (`bindings.json`), and one directory per project under
4
+ * `$DSH_HOME/state/dsh-library/`:
5
+ *
6
+ * ```
7
+ * state.json # version-guarded index (project list)
8
+ * bindings.json # sessionId → { projectId, resourceId? }
9
+ * projects/<projectId>/
10
+ * project.json # { id, name, template, goal?, createdAt }
11
+ * resources/<resourceId>.json # shelf entries, thick (toc) and thin
12
+ * pages/<resourceId>.jsonl # a thick resource's photographed pages, append-only
13
+ * roadmaps/<resourceId>.json # a textbook's adopted path
14
+ * roadmaps/<resourceId>.draft.json # its coach's draft, until 采用
15
+ * assessments/<assessmentId>.json # quizzes and materialized practice rubrics
16
+ * cards.jsonl # knowledge cards (rewritten whole on update)
17
+ * attempts.jsonl # append-only
18
+ * events.jsonl # append-only audit trail
19
+ * ```
20
+ *
21
+ * **Read-old, write-new (v5).** The v3/v4 layout — `books/<bookId>/book.json`
22
+ * + `roadmap.json` + `pages.jsonl`, `resources.jsonl`, `quizzes/<id>.json`,
23
+ * — is never written any more but always READ: every reader merges or
24
+ * falls back to the legacy spot, every
25
+ * normalize* function accepts the legacy field names (`bookId`, node `kind`,
26
+ * cover unions, `quizId`). Legacy files stay in place (a rollback loses
27
+ * nothing); the next write of an entity lands in the new layout. v2 roots
28
+ * (per-book roadmaps with `chapterRanges`) still normalize on the fly, and
29
+ * `migrateState` rewrites them into the covers shape as before.
30
+ *
31
+ * The write discipline is the mission store's (the in-repo precedent for
32
+ * shared JSON state): every mutation funnels through a per-scope lock → read →
33
+ * modify → temp-write → atomic rename; the JSONL ledgers are append-only
34
+ * (an `O_APPEND` write under the same lock); ids are path segments and pass
35
+ * the safe-alphabet check before they join a path. The lock is hand-rolled
36
+ * (exclusive-create of a `.lock` file carrying `{pid, at}`; a dead or ancient
37
+ * owner is reclaimed) — no dependency, replaceable without touching the data
38
+ * model.
39
+ *
40
+ * **Why plain `node:fs` and not `ctx.fs`** — the reader's lesson: `ctx.fs` is
41
+ * the session-fenced sandbox, and a workspace-write session refuses writes to
42
+ * `$DSH_HOME/state`; this state belongs to the DEPLOYMENT.
43
+ *
44
+ * An unwritable root degrades reads to empty and refuses writes with
45
+ * `storage-unavailable` — a composition must never fail for lack of a disk.
46
+ *
47
+ * @module @khorsheed/dsh-library/store
48
+ */
49
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
50
+ import { dirname, join } from 'node:path';
51
+ import { formatLocator, isLibraryId, libraryErrorMessage, LIBRARY_EVENT_TYPES, LIBRARY_COLORS, LIBRARY_ICONS, LIBRARY_RESOURCE_KINDS, LIBRARY_RESOURCE_ORIGINS, LIBRARY_STATE_VERSION, MAX_BOX, MAX_WEAKNESS, STATE_ROOT_SEGMENT, } from "./types.js";
52
+ /** A store failure that the service maps onto a refusal. */
53
+ export class LibraryStoreError extends Error {
54
+ kind;
55
+ constructor(message, kind) {
56
+ super(message);
57
+ this.kind = kind;
58
+ this.name = 'LibraryStoreError';
59
+ }
60
+ }
61
+ /* ------------------------------------------------------------------ ids */
62
+ /** Assert an id is usable as a path segment (no traversal, no separators). */
63
+ export function assertLibraryId(id, what) {
64
+ if (!isLibraryId(id)) {
65
+ throw new LibraryStoreError(`library: ${what} ${JSON.stringify(id)} is not a usable id (allowed: letters, digits, '.', '_', '-')`, 'io');
66
+ }
67
+ }
68
+ /** Mint a store id: a readable prefix plus time-and-random uniqueness. */
69
+ export function mintLibraryId(prefix) {
70
+ const stamp = Date.now().toString(36);
71
+ const rand = Math.random().toString(36).slice(2, 8);
72
+ return `${prefix}_${stamp}${rand}`;
73
+ }
74
+ const LOCK_TIMEOUT_MS = 10_000;
75
+ const LOCK_STALE_MS = 60_000;
76
+ /** A lock file younger than this but not yet parseable is a writer mid-create — wait, don't reclaim. */
77
+ const LOCK_GRACE_MS = 5_000;
78
+ async function sleep(ms) {
79
+ await new Promise(resolvePromise => { setTimeout(resolvePromise, ms); });
80
+ }
81
+ function lockIsStale(lockPath) {
82
+ let raw;
83
+ try {
84
+ raw = readFileSync(lockPath, 'utf8');
85
+ }
86
+ catch {
87
+ return false; // vanished between checks, or unreadable — let the create retry decide
88
+ }
89
+ let pid;
90
+ let at;
91
+ try {
92
+ const info = JSON.parse(raw);
93
+ if (typeof info.pid === 'number')
94
+ pid = info.pid;
95
+ if (typeof info.at === 'number')
96
+ at = info.at;
97
+ }
98
+ catch {
99
+ // Half-written lock (create and write are two syscalls): young → wait.
100
+ try {
101
+ return Date.now() - statSync(lockPath).mtimeMs > LOCK_GRACE_MS;
102
+ }
103
+ catch {
104
+ return false;
105
+ }
106
+ }
107
+ if (at !== undefined && Date.now() - at > LOCK_STALE_MS)
108
+ return true;
109
+ if (pid === undefined)
110
+ return false;
111
+ try {
112
+ process.kill(pid, 0);
113
+ return false; // owner alive
114
+ }
115
+ catch {
116
+ return true; // ESRCH: owner is dead, the lock is orphaned
117
+ }
118
+ }
119
+ /** Acquire the lock file, waiting out live owners and reclaiming stale ones. */
120
+ export async function acquireLibraryLock(lockPath) {
121
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
122
+ for (;;) {
123
+ try {
124
+ const fd = openSync(lockPath, 'wx');
125
+ try {
126
+ writeFileSync(fd, `${JSON.stringify({ pid: process.pid, at: Date.now() })}\n`);
127
+ }
128
+ finally {
129
+ closeSync(fd);
130
+ }
131
+ let released = false;
132
+ return {
133
+ release() {
134
+ if (released)
135
+ return;
136
+ released = true;
137
+ try {
138
+ unlinkSync(lockPath);
139
+ }
140
+ catch {
141
+ // Already reclaimed as stale — nothing to do.
142
+ }
143
+ },
144
+ };
145
+ }
146
+ catch (error) {
147
+ if (error?.code !== 'EEXIST')
148
+ throw error;
149
+ if (lockIsStale(lockPath)) {
150
+ try {
151
+ unlinkSync(lockPath);
152
+ }
153
+ catch {
154
+ // Someone else reclaimed it first — loop around.
155
+ }
156
+ continue;
157
+ }
158
+ if (Date.now() > deadline) {
159
+ throw new LibraryStoreError(`library: timed out waiting for the lock ${lockPath}`, 'io');
160
+ }
161
+ await sleep(15 + Math.random() * 35);
162
+ }
163
+ }
164
+ }
165
+ function asRecord(value) {
166
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : undefined;
167
+ }
168
+ /** A trimmed, length-capped string, or undefined when absent / empty. */
169
+ function text(value, max) {
170
+ if (typeof value !== 'string')
171
+ return undefined;
172
+ const trimmed = value.trim();
173
+ return trimmed === '' ? undefined : trimmed.slice(0, max);
174
+ }
175
+ function id(value) {
176
+ return typeof value === 'string' && isLibraryId(value) ? value : undefined;
177
+ }
178
+ function finite(value) {
179
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
180
+ }
181
+ function stamp(value) {
182
+ return typeof value === 'string' ? value : new Date(0).toISOString();
183
+ }
184
+ function strings(value, max) {
185
+ return Array.isArray(value) ? value.flatMap(item => { const s = text(item, max); return s === undefined ? [] : [s]; }) : [];
186
+ }
187
+ /** Acceptance lines: bare strings pass through; { line, ref? } objects normalize. */
188
+ function acceptanceEntries(value) {
189
+ if (!Array.isArray(value))
190
+ return [];
191
+ return value.flatMap((entry) => {
192
+ if (typeof entry === 'string') {
193
+ const line = text(entry, 300);
194
+ return line === undefined ? [] : [line];
195
+ }
196
+ const record = asRecord(entry);
197
+ const line = text(record?.line, 300);
198
+ if (record === undefined || line === undefined)
199
+ return [];
200
+ return [{ line, ...opt('ref', text(record.ref, 100)) }];
201
+ });
202
+ }
203
+ /** Spread `{ [key]: value }` only when value is defined. */
204
+ function opt(key, value) {
205
+ return (value === undefined ? {} : { [key]: value });
206
+ }
207
+ /** The empty index. */
208
+ export function emptyLibraryIndex() {
209
+ return { version: LIBRARY_STATE_VERSION, projects: [] };
210
+ }
211
+ /**
212
+ * Coerce a parsed value into an index. A version this store does not
213
+ * recognize reads as empty — v2 through v5 all read tolerantly (every entity
214
+ * normalize accepts the older shapes; `migrateState` still rewrites v2
215
+ * chapterRanges into covers).
216
+ */
217
+ export function normalizeLibraryIndex(value) {
218
+ const record = asRecord(value);
219
+ const version = finite(record?.version);
220
+ if (record === undefined || version === undefined || version < 2 || version > LIBRARY_STATE_VERSION)
221
+ return emptyLibraryIndex();
222
+ const projects = Array.isArray(record.projects) ? record.projects : [];
223
+ const seen = new Set();
224
+ return {
225
+ version: LIBRARY_STATE_VERSION,
226
+ projects: projects.flatMap(item => {
227
+ const project = normalizeProject(item);
228
+ if (project === undefined || seen.has(project.id))
229
+ return [];
230
+ seen.add(project.id);
231
+ return [project];
232
+ }),
233
+ };
234
+ }
235
+ /** One project, when it carries the fields that make it one. */
236
+ export function normalizeProject(value) {
237
+ const record = asRecord(value);
238
+ const projectId = id(record?.id);
239
+ const name = text(record?.name, 200);
240
+ if (record === undefined || projectId === undefined || name === undefined)
241
+ return undefined;
242
+ const template = record.template === 'language' || record.template === 'custom' ? record.template : 'subject';
243
+ return {
244
+ id: projectId,
245
+ name,
246
+ template,
247
+ ...opt('color', LIBRARY_COLORS.find(color => color === record.color)),
248
+ ...opt('icon', LIBRARY_ICONS.find(icon => icon === record.icon)),
249
+ ...opt('templateNote', text(record.templateNote, 500)),
250
+ ...opt('goal', text(record.goal, 500)),
251
+ createdAt: stamp(record.createdAt),
252
+ };
253
+ }
254
+ /** One table-of-contents list, re-numbered 1-based, when any row parses. */
255
+ function tocEntries(value) {
256
+ if (!Array.isArray(value))
257
+ return undefined;
258
+ const toc = value.flatMap(item => {
259
+ const entry = asRecord(item);
260
+ const entryTitle = text(entry?.title, 300);
261
+ if (entryTitle === undefined)
262
+ return [];
263
+ return [{ title: entryTitle, ...opt('part', text(entry?.part, 200)) }];
264
+ });
265
+ return toc.map((entry, index) => ({ ...entry, n: index + 1 }));
266
+ }
267
+ /** The extras strip (开始前 / 附录), when any row parses. */
268
+ function extraEntries(value) {
269
+ if (!Array.isArray(value))
270
+ return undefined;
271
+ return value.flatMap(item => {
272
+ const extra = asRecord(item);
273
+ const extraTitle = text(extra?.title, 200);
274
+ if (extra === undefined || extraTitle === undefined)
275
+ return [];
276
+ return [{
277
+ title: extraTitle,
278
+ ...opt('note', text(extra.note, 300)),
279
+ ...opt('where', text(extra.where, 60)),
280
+ when: extra.when === 'reference' ? 'reference' : 'before',
281
+ }];
282
+ });
283
+ }
284
+ /**
285
+ * One shelf entry, when it parses. Reads BOTH layouts: a v5
286
+ * `resources/<id>.json` (kind may be 'book', toc/meta live at the top) and a
287
+ * legacy one — a `books/<id>/book.json` (kind forced to 'book', book-ish
288
+ * fields folded into meta) or a `resources.jsonl` line (a thin link).
289
+ */
290
+ export function normalizeResource(value) {
291
+ const record = asRecord(value);
292
+ const resourceId = id(record?.id);
293
+ const title = text(record?.title, 300);
294
+ if (record === undefined || resourceId === undefined || title === undefined)
295
+ return undefined;
296
+ // Legacy book.json: the book-ish fields sat at the top level.
297
+ const legacyBook = Array.isArray(record.toc) || record.bookKind !== undefined;
298
+ const kind = legacyBook
299
+ ? 'book'
300
+ : LIBRARY_RESOURCE_KINDS.find(kind => kind === record.kind) ?? 'article';
301
+ const toc = tocEntries(record.toc);
302
+ const source = asRecord(record.tocSource);
303
+ const metaRecord = asRecord(record.meta);
304
+ const meta = (() => {
305
+ const bookKind = (metaRecord?.bookKind ?? record.bookKind) === 'material' ? 'material'
306
+ : (metaRecord?.bookKind ?? record.bookKind) === 'knowledge' ? 'knowledge'
307
+ : undefined;
308
+ const extras = extraEntries(metaRecord?.extras ?? record.extras);
309
+ const merged = {
310
+ ...opt('bookKind', bookKind),
311
+ ...opt('short', text(metaRecord?.short ?? record.short, 8)),
312
+ ...(extras !== undefined && extras.length > 0 ? { extras } : {}),
313
+ ...opt('author', text(metaRecord?.author ?? record.author, 200)),
314
+ ...opt('edition', text(metaRecord?.edition ?? record.edition, 100)),
315
+ ...opt('isbn', text(metaRecord?.isbn ?? record.isbn, 40)),
316
+ };
317
+ return Object.keys(merged).length === 0 ? undefined : merged;
318
+ })();
319
+ return {
320
+ id: resourceId,
321
+ title,
322
+ kind,
323
+ ...opt('url', text(record.url, 1000)),
324
+ ...opt('note', text(record.note, 300)),
325
+ // Legacy addedBy: the learner's own stays theirs; the coach's reads as a pick.
326
+ ...opt('origin', LIBRARY_RESOURCE_ORIGINS.find(origin => origin === record.origin)
327
+ ?? (record.addedBy === 'learner' ? 'learner' : record.addedBy === 'coach' || legacyBook ? 'picked' : undefined)),
328
+ ...(toc !== undefined && toc.length > 0 ? { toc } : {}),
329
+ ...(toc !== undefined && toc.length > 0 ? opt('seq', positiveInt(record.seq)) : {}),
330
+ ...(source === undefined ? {} : {
331
+ tocSource: {
332
+ by: source.by === 'web' || source.by === 'photo' ? source.by : 'model',
333
+ ...opt('url', text(source.url, 1000)),
334
+ ...opt('verifiedAt', text(source.verifiedAt, 40)),
335
+ },
336
+ }),
337
+ ...(meta === undefined ? {} : { meta }),
338
+ createdAt: stamp(record.createdAt),
339
+ };
340
+ }
341
+ /**
342
+ * The shelf's reading order: textbooks (thick) before thin links; textbooks
343
+ * by `seq`, unnumbered ones after the numbered, ties by age; links by age.
344
+ */
345
+ export function shelfOrder(a, b) {
346
+ const thickA = a.toc !== undefined && a.toc.length > 0;
347
+ const thickB = b.toc !== undefined && b.toc.length > 0;
348
+ if (thickA !== thickB)
349
+ return thickA ? -1 : 1;
350
+ if (thickA) {
351
+ const seqA = a.seq ?? Number.POSITIVE_INFINITY;
352
+ const seqB = b.seq ?? Number.POSITIVE_INFINITY;
353
+ if (seqA !== seqB)
354
+ return seqA < seqB ? -1 : 1;
355
+ }
356
+ return a.createdAt.localeCompare(b.createdAt);
357
+ }
358
+ /** A positive integer, or undefined. */
359
+ function positiveInt(value) {
360
+ return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined;
361
+ }
362
+ /** A v2/v3 chapter-ranges list, as ranges. */
363
+ function legacyRanges(value) {
364
+ return Array.isArray(value)
365
+ ? value.flatMap(range => {
366
+ const r = asRecord(range);
367
+ const from = finite(r?.from);
368
+ const to = finite(r?.to);
369
+ return from === undefined || to === undefined ? [] : [{ from, to }];
370
+ })
371
+ : [];
372
+ }
373
+ /**
374
+ * One node's cover, when it parses. Reads the v5 shape (`{ ref, locator? }`)
375
+ * and every legacy union member: `{ kind: 'book', bookId, ranges }`,
376
+ * `{ kind: 'resource', resourceId }`, `{ kind: 'url', url, title? }`,
377
+ * `{ kind: 'topic', text }`. Provenance rides along when present.
378
+ */
379
+ export function normalizeNodeCover(value) {
380
+ const record = asRecord(value);
381
+ if (record === undefined)
382
+ return undefined;
383
+ const by = record.by === 'web' || record.by === 'model' || record.by === 'user' ? record.by : undefined;
384
+ const provenance = { ...opt('by', by), ...opt('verifiedAt', text(record.verifiedAt, 40)) };
385
+ // v5: { ref, locator?, title? }.
386
+ const ref = text(record.ref, 1000);
387
+ if (ref !== undefined) {
388
+ return { ref, ...opt('locator', text(record.locator, 200)), ...opt('title', text(record.title, 300)), ...provenance };
389
+ }
390
+ switch (record.kind) {
391
+ case 'book': {
392
+ const bookId = id(record.bookId);
393
+ if (bookId === undefined)
394
+ return undefined;
395
+ const ranges = legacyRanges(record.ranges);
396
+ return { ref: bookId, ...(ranges.length === 0 ? {} : { locator: formatLocator(ranges) }), ...provenance };
397
+ }
398
+ case 'resource': {
399
+ const resourceId = id(record.resourceId);
400
+ return resourceId === undefined ? undefined : { ref: resourceId, ...provenance };
401
+ }
402
+ case 'url': {
403
+ const url = text(record.url, 1000);
404
+ return url === undefined ? undefined : { ref: url, ...opt('title', text(record.title, 300)), ...provenance };
405
+ }
406
+ case 'topic': {
407
+ const topicText = text(record.text, 300);
408
+ return topicText === undefined ? undefined : { ref: `topic:${topicText}`, ...provenance };
409
+ }
410
+ default:
411
+ return undefined;
412
+ }
413
+ }
414
+ /** One roadmap (adopted or draft), when it parses; rule checks live in the service. */
415
+ export function normalizeRoadmap(value) {
416
+ const record = asRecord(value);
417
+ if (record === undefined || !Array.isArray(record.nodes))
418
+ return undefined;
419
+ // v5 names it primaryResourceId; v3/v4 roadmaps carried bookId.
420
+ const primaryResourceId = id(record.primaryResourceId) ?? id(record.bookId);
421
+ const nodes = [];
422
+ for (const item of record.nodes) {
423
+ const node = asRecord(item);
424
+ const nodeId = id(node?.id);
425
+ const title = text(node?.title, 300);
426
+ // A stored 'locked' reads as current: stations no longer lock (D6, 10-26).
427
+ const state = node?.state === 'locked' || node?.state === 'current' ? 'current' : node?.state === 'done' ? 'done' : undefined;
428
+ if (node === undefined || nodeId === undefined || title === undefined || state === undefined)
429
+ return undefined;
430
+ // v5 carries `gate`; v3/v4 carried kind: quiz | read | practice.
431
+ const gate = node.gate === 'read' || node.gate === 'acceptance' || node.gate === 'quiz'
432
+ ? node.gate
433
+ : node.kind === 'read' ? 'read' : node.kind === 'practice' ? 'acceptance' : 'quiz';
434
+ // v3+ nodes carry covers; a v2 node wraps its chapterRanges into the one
435
+ // book cover of its book's path.
436
+ const covers = Array.isArray(node.covers)
437
+ ? node.covers.flatMap(cover => {
438
+ const normalized = normalizeNodeCover(cover);
439
+ return normalized === undefined ? [] : [normalized];
440
+ })
441
+ : primaryResourceId !== undefined && node.chapterRanges !== undefined
442
+ ? [{ ref: primaryResourceId, locator: formatLocator(legacyRanges(node.chapterRanges)) }]
443
+ : [];
444
+ const stats = asRecord(node.quizStats);
445
+ const notesRec = asRecord(node.notes);
446
+ const notesText = text(notesRec?.text, 40000);
447
+ // 考点: quiz-gated nodes only; a malformed entry drops out, the rest survive.
448
+ const points = gate === 'quiz' && Array.isArray(node.points)
449
+ ? node.points.flatMap(entry => {
450
+ const point = asRecord(entry);
451
+ const pointId = point !== undefined ? id(point.id) : undefined;
452
+ const pointTitle = text(point?.title, 100);
453
+ if (pointId === undefined || pointTitle === undefined)
454
+ return [];
455
+ const notes = Array.isArray(point?.notes)
456
+ ? point.notes.flatMap(raw => {
457
+ const note = asRecord(raw);
458
+ const noteText = text(note?.text, 1000);
459
+ return noteText === undefined ? [] : [{ text: noteText, ...opt('term', text(note?.term, 100)), at: stamp(note?.at), ...opt('session', text(note?.session, 200)) }];
460
+ })
461
+ : [];
462
+ return [{ id: pointId, title: pointTitle, ...(notes.length === 0 ? {} : { notes }) }];
463
+ })
464
+ : undefined;
465
+ nodes.push({
466
+ id: nodeId,
467
+ title,
468
+ gate,
469
+ goals: typeof node.goals === 'string' ? strings([node.goals], 300) : strings(node.goals, 300),
470
+ dependsOn: Array.isArray(node.dependsOn) ? node.dependsOn.filter((dep) => typeof dep === 'string') : [],
471
+ covers,
472
+ state,
473
+ ...(stats === undefined || gate !== 'quiz' ? {} : {
474
+ quizStats: {
475
+ attempts: finite(stats.attempts) ?? 0,
476
+ ...opt('lastRatio', finite(stats.lastRatio)),
477
+ ...opt('bestRatio', finite(stats.bestRatio)),
478
+ },
479
+ }),
480
+ ...(points === undefined || points.length === 0 ? {} : { points }),
481
+ ...(gate === 'acceptance' && Array.isArray(node.acceptance) ? { acceptance: acceptanceEntries(node.acceptance) } : {}),
482
+ // 讲义 live in notes/<resource>/<node>.md; the node keeps their stamp
483
+ // (text '' until readRoadmap fills it). A legacy node still carries the
484
+ // text inline — it moves to the file on the next write.
485
+ ...(notesRec === undefined || (notesText === undefined && finite(notesRec.revisions) === undefined) ? {} : {
486
+ notes: { text: notesText ?? '', updatedAt: stamp(notesRec.updatedAt), revisions: finite(notesRec.revisions) ?? 1 },
487
+ }),
488
+ });
489
+ }
490
+ const passRatio = finite(record.passRatio);
491
+ return {
492
+ ...opt('primaryResourceId', primaryResourceId),
493
+ status: record.status === 'adopted' ? 'adopted' : 'draft',
494
+ nodes,
495
+ ...(passRatio !== undefined && passRatio > 0 && passRatio <= 1 ? { passRatio } : {}),
496
+ ...opt('note', text(record.note, 300)),
497
+ generatedAt: stamp(record.generatedAt),
498
+ ...opt('adoptedAt', text(record.adoptedAt, 40)),
499
+ };
500
+ }
501
+ /** An attachment reference, when every durable field is present. */
502
+ export function normalizeAttachmentRef(value) {
503
+ const record = asRecord(value);
504
+ const attachmentId = text(record?.attachmentId, 200);
505
+ const mediaType = text(record?.mediaType, 100);
506
+ const bytes = finite(record?.bytes);
507
+ const width = finite(record?.width);
508
+ const height = finite(record?.height);
509
+ if (attachmentId === undefined || mediaType === undefined || bytes === undefined || width === undefined || height === undefined)
510
+ return undefined;
511
+ return { attachmentId, mediaType, bytes, width, height };
512
+ }
513
+ /** One photographed page (one pages/<resourceId>.jsonl line), when it parses. */
514
+ export function normalizePage(value) {
515
+ const record = asRecord(value);
516
+ const pageId = id(record?.id);
517
+ // v5 names it resourceId; v3/v4 pages carried bookId.
518
+ const resourceId = id(record?.resourceId) ?? id(record?.bookId);
519
+ const attachment = normalizeAttachmentRef(record?.attachment);
520
+ if (record === undefined || pageId === undefined || resourceId === undefined || attachment === undefined)
521
+ return undefined;
522
+ return {
523
+ id: pageId,
524
+ resourceId,
525
+ ...opt('nodeId', id(record.nodeId)),
526
+ ...opt('label', text(record.label, 60)),
527
+ attachment,
528
+ text: typeof record.text === 'string' ? record.text.slice(0, 20_000) : '',
529
+ marks: Array.isArray(record.marks)
530
+ ? record.marks.flatMap(item => {
531
+ const mark = asRecord(item);
532
+ const markText = text(mark?.text, 300);
533
+ if (mark === undefined || markText === undefined)
534
+ return [];
535
+ return [{
536
+ text: markText,
537
+ ...opt('sentence', text(mark.sentence, 1000)),
538
+ ...opt('handNote', text(mark.handNote, 300)),
539
+ ...opt('gloss', text(mark.gloss, 1000)),
540
+ ...(mark.uncertain === true ? { uncertain: true } : {}),
541
+ }];
542
+ })
543
+ : [],
544
+ cardIds: strings(record.cardIds, 100),
545
+ at: stamp(record.at),
546
+ };
547
+ }
548
+ const CARD_SOURCE_TYPES = ['photo', 'chat', 'quiz', 'manual'];
549
+ /** One knowledge card (one cards.jsonl line), when it parses. */
550
+ export function normalizeCard(value) {
551
+ const record = asRecord(value);
552
+ const cardId = id(record?.id);
553
+ const term = text(record?.term, 300);
554
+ const gloss = text(record?.gloss, 2000);
555
+ if (record === undefined || cardId === undefined || term === undefined || gloss === undefined)
556
+ return undefined;
557
+ const mastery = asRecord(record.mastery) ?? {};
558
+ const box = finite(mastery.box);
559
+ const weakness = finite(record.weakness) ?? 0;
560
+ return {
561
+ id: cardId,
562
+ kind: record.kind === 'word' ? 'word' : 'concept',
563
+ term,
564
+ aliases: strings(record.aliases, 300),
565
+ gloss,
566
+ ...opt('userGloss', text(record.userGloss, 1000)),
567
+ contexts: Array.isArray(record.contexts)
568
+ ? record.contexts.flatMap(item => {
569
+ const context = asRecord(item);
570
+ const sentence = text(context?.sentence, 1000);
571
+ if (context === undefined || sentence === undefined)
572
+ return [];
573
+ return [{ sentence, ...opt('resourceId', id(context.resourceId) ?? id(context.bookId)), ...opt('pageId', id(context.pageId)), ...opt('nodeId', id(context.nodeId)) }];
574
+ })
575
+ : [],
576
+ ...opt('resourceId', id(record.resourceId) ?? id(record.bookId)),
577
+ sources: Array.isArray(record.sources)
578
+ ? record.sources.flatMap(item => {
579
+ const source = asRecord(item);
580
+ const type = CARD_SOURCE_TYPES.find(candidate => candidate === source?.type);
581
+ if (source === undefined || type === undefined)
582
+ return [];
583
+ return [{
584
+ type,
585
+ ...opt('ref', text(source.ref, 200)),
586
+ ...opt('note', text(source.note, 300)),
587
+ ...(source.weakened === true ? { weakened: true } : {}),
588
+ at: stamp(source.at),
589
+ }];
590
+ })
591
+ : [],
592
+ weakness: Math.min(Math.max(Math.round(weakness), 0), MAX_WEAKNESS),
593
+ mastery: {
594
+ box: box === undefined ? 0 : Math.min(Math.max(Math.round(box), 0), MAX_BOX),
595
+ dueAt: stamp(mastery.dueAt),
596
+ streak: Math.max(0, Math.round(finite(mastery.streak) ?? 0)),
597
+ },
598
+ status: record.status === 'mastered' || record.status === 'deleted' ? record.status : 'active',
599
+ createdAt: stamp(record.createdAt),
600
+ updatedAt: stamp(record.updatedAt ?? record.createdAt),
601
+ };
602
+ }
603
+ /**
604
+ * Options arrive as plain strings, as { label } objects, or letter-keyed maps —
605
+ * the stored form is always plain strings, letter prefixes ("A) ") stripped
606
+ * (the answering UI badges the letter itself).
607
+ */
608
+ export function normalizeQuizOptions(value) {
609
+ const letter = /^[A-Za-z][.、))::]\s*/;
610
+ const list = Array.isArray(value)
611
+ ? value
612
+ : (typeof value === 'object' && value !== null ? Object.values(value) : []);
613
+ if (list.length === 0)
614
+ return undefined;
615
+ const out = [];
616
+ for (const option of list) {
617
+ if (typeof option === 'string') {
618
+ out.push(option);
619
+ continue;
620
+ }
621
+ const label = asRecord(option)?.label;
622
+ if (typeof label === 'string') {
623
+ out.push(label.replace(letter, ''));
624
+ continue;
625
+ }
626
+ return undefined;
627
+ }
628
+ return out;
629
+ }
630
+ function normalizeQuizQuestions(value) {
631
+ if (!Array.isArray(value))
632
+ return undefined;
633
+ const questions = [];
634
+ for (const item of value) {
635
+ const q = asRecord(item);
636
+ const qid = text(q?.id, 60);
637
+ const kind = q?.kind === 'choice' || q?.kind === 'fill' || q?.kind === 'free' ? q.kind : undefined;
638
+ const target = asRecord(q?.target);
639
+ const evidence = asRecord(q?.evidence);
640
+ if (q === undefined || qid === undefined || kind === undefined || typeof q.prompt !== 'string' || target === undefined || evidence === undefined) {
641
+ return undefined;
642
+ }
643
+ const targetKind = target.kind === 'marked' || target.kind === 'asked' || target.kind === 'missed' ? target.kind : 'topic';
644
+ const evidenceKind = evidence.kind === 'photo' || evidence.kind === 'web' ? evidence.kind : 'knowledge';
645
+ questions.push({
646
+ id: qid,
647
+ kind,
648
+ prompt: q.prompt,
649
+ ...(() => { const options = normalizeQuizOptions(q.options); return options === undefined ? {} : { options }; })(),
650
+ answer: typeof q.answer === 'string' ? q.answer : '',
651
+ ...opt('explanation', text(q.explanation, 2000)),
652
+ ...opt('point', text(q.point, 60)),
653
+ ...(() => {
654
+ const origin = asRecord(q.origin);
655
+ const assessmentId = id(origin?.assessmentId);
656
+ const questionId = text(origin?.questionId, 60);
657
+ return assessmentId === undefined || questionId === undefined ? {} : { origin: { assessmentId, questionId } };
658
+ })(),
659
+ target: { kind: targetKind, ...(Array.isArray(target.cardIds) ? { cardIds: strings(target.cardIds, 100) } : {}) },
660
+ evidence: {
661
+ kind: evidenceKind,
662
+ ...opt('pageId', id(evidence.pageId)),
663
+ ...opt('url', text(evidence.url, 1000)),
664
+ ...opt('quote', text(evidence.quote, 2000)),
665
+ },
666
+ });
667
+ }
668
+ return questions;
669
+ }
670
+ function normalizeRubricLines(value) {
671
+ if (!Array.isArray(value))
672
+ return undefined;
673
+ const lines = [];
674
+ for (const [index, item] of value.entries()) {
675
+ const record = asRecord(item);
676
+ const line = text(record?.line, 300);
677
+ if (line === undefined)
678
+ return undefined;
679
+ lines.push({ id: text(record?.id, 20) ?? `L${index + 1}`, line, ...opt('ref', text(record?.ref, 100)) });
680
+ }
681
+ return lines;
682
+ }
683
+ /**
684
+ * One assessment, when it parses. Reads BOTH shapes: a v5
685
+ * `assessments/<id>.json` (type quiz | practice, scope.resourceId) and a
686
+ * legacy `quizzes/<id>.json` (a quiz — scope.bookId folds into resourceId).
687
+ */
688
+ export function normalizeAssessment(value) {
689
+ const record = asRecord(value);
690
+ const assessmentId = id(record?.id);
691
+ const scope = asRecord(record?.scope);
692
+ if (record === undefined || assessmentId === undefined || scope === undefined)
693
+ return undefined;
694
+ const nodeId = id(scope.nodeId);
695
+ const resourceId = id(scope.resourceId) ?? id(scope.bookId);
696
+ const type = record.type === 'practice' ? 'practice' : record.type === 'retake' ? 'retake' : 'quiz';
697
+ if (type === 'practice') {
698
+ const rubric = normalizeRubricLines(record.rubric);
699
+ if (rubric === undefined)
700
+ return undefined;
701
+ return {
702
+ id: assessmentId,
703
+ type,
704
+ scope: { ...opt('nodeId', nodeId), ...opt('resourceId', resourceId) },
705
+ rubric,
706
+ ...opt('supersededBy', id(record.supersededBy)),
707
+ createdAt: stamp(record.createdAt),
708
+ };
709
+ }
710
+ const questions = normalizeQuizQuestions(record.questions);
711
+ if (questions === undefined)
712
+ return undefined;
713
+ return {
714
+ id: assessmentId,
715
+ type,
716
+ scope: { ...opt('nodeId', nodeId), ...opt('resourceId', resourceId) },
717
+ ...(record.focus === 'mine' || record.focus === 'book' || record.focus === 'mixed' ? { focus: record.focus } : {}),
718
+ questions,
719
+ ...opt('supersededBy', id(record.supersededBy)),
720
+ createdAt: stamp(record.createdAt),
721
+ };
722
+ }
723
+ /** One attempt (one attempts.jsonl line), when it parses. */
724
+ export function normalizeAttempt(value) {
725
+ const record = asRecord(value);
726
+ const attemptId = id(record?.id);
727
+ // v5 names the assessment; a v3/v4 quiz sitting carried quizId (the same
728
+ // entity, the same id) and a v4 acceptance sitting carried nodeId (kept as
729
+ // the legacy key, resolved through the node's practice assessment).
730
+ const assessmentId = id(record?.assessmentId) ?? id(record?.quizId);
731
+ const nodeId = id(record?.nodeId);
732
+ const ratio = finite(record?.ratio);
733
+ if (record === undefined || attemptId === undefined || (assessmentId === undefined && nodeId === undefined) || ratio === undefined || !Array.isArray(record.verdicts))
734
+ return undefined;
735
+ const verdicts = [];
736
+ for (const item of record.verdicts) {
737
+ const v = asRecord(item);
738
+ if (v === undefined || typeof v.task !== 'string' || typeof v.criterion !== 'string' || typeof v.pass !== 'boolean' || typeof v.evidence !== 'string') {
739
+ return undefined;
740
+ }
741
+ verdicts.push({
742
+ task: v.task,
743
+ criterion: v.criterion,
744
+ pass: v.pass,
745
+ ...opt('ratio', finite(v.ratio)),
746
+ evidence: v.evidence,
747
+ by: v.by === 'self' || v.by === 'user' || v.by === 'auto' ? v.by : 'llm',
748
+ ...(v.void === true ? { void: true } : {}),
749
+ });
750
+ }
751
+ return { id: attemptId, ...opt('assessmentId', assessmentId), ...opt('nodeId', nodeId), verdicts, ratio, at: stamp(record.at) };
752
+ }
753
+ /** One audit event (one events.jsonl line), when it parses. */
754
+ export function normalizeEvent(value) {
755
+ const record = asRecord(value);
756
+ // The v3/v4 'book.added' reads as 'resource.added' — the shelf unified.
757
+ const rawType = record?.type === 'book.added' ? 'resource.added' : record?.type;
758
+ const type = typeof rawType === 'string' && LIBRARY_EVENT_TYPES.includes(rawType)
759
+ ? rawType
760
+ : undefined;
761
+ if (record === undefined || type === undefined)
762
+ return undefined;
763
+ const count = finite(record.count);
764
+ const count2 = finite(record.count2);
765
+ return {
766
+ at: stamp(record.at),
767
+ type,
768
+ ...opt('id', text(record.id, 200)),
769
+ ...opt('title', text(record.title, 300)),
770
+ ...opt('resourceId', id(record.resourceId) ?? id(record.bookId)),
771
+ ...opt('source', CARD_SOURCE_TYPES.find(candidate => candidate === record.source)),
772
+ ...opt('session', text(record.session, 200)),
773
+ ...(Array.isArray(record.ids) ? { ids: strings(record.ids, 100) } : {}),
774
+ ...opt('count', count === undefined ? undefined : Math.floor(count)),
775
+ ...opt('count2', count2 === undefined ? undefined : Math.floor(count2)),
776
+ ...opt('ratio', finite(record.ratio)),
777
+ ...opt('nodeId', id(record.nodeId)),
778
+ ...opt('from', POINT_STATES.find(state => state === record.from)),
779
+ ...opt('to', POINT_STATES.find(state => state === record.to)),
780
+ ...opt('detail', text(record.detail, 300)),
781
+ };
782
+ }
783
+ const POINT_STATES = ['untested', 'weak', 'practicing', 'mastered'];
784
+ /** The bindings map, dropping rows that do not parse. */
785
+ export function normalizeBindings(value) {
786
+ const out = new Map();
787
+ const record = asRecord(value);
788
+ if (record === undefined)
789
+ return out;
790
+ for (const [sessionId, raw] of Object.entries(record)) {
791
+ const binding = asRecord(raw);
792
+ const projectId = id(binding?.projectId);
793
+ if (sessionId === '' || projectId === undefined)
794
+ continue;
795
+ out.set(sessionId, { projectId, ...opt('resourceId', id(binding?.resourceId) ?? id(binding?.bookId)), ...opt('previousProjectId', id(binding?.previousProjectId)) });
796
+ }
797
+ return out;
798
+ }
799
+ /* ----------------------------------------------------------------- store */
800
+ /**
801
+ * The native-fs library store. Reads never lock (a read observes one committed
802
+ * revision, never a partial one — writes are atomic renames); writes serialize
803
+ * on a per-project lock (`projects/<id>.lock`), the index and the bindings on
804
+ * their own.
805
+ */
806
+ export class LibraryStore {
807
+ stateRoot;
808
+ /** Set once the root has proven unwritable; reads then degrade to empty. */
809
+ unwritable;
810
+ /**
811
+ * @param config - optional state-root override (tests, deployments that keep
812
+ * state elsewhere).
813
+ */
814
+ constructor(config = {}) {
815
+ this.stateRoot = resolveLibraryStateRoot(config.stateRoot);
816
+ }
817
+ /** The state root (diagnostics and tests). */
818
+ get root() {
819
+ return this.stateRoot;
820
+ }
821
+ /** Why persistence is unavailable, when it is. */
822
+ get unavailableReason() {
823
+ return this.unwritable;
824
+ }
825
+ /**
826
+ * Whether persistence is available — probed by creating the root, because a
827
+ * read-only deployment is only knowable by trying.
828
+ */
829
+ get available() {
830
+ if (this.unwritable !== undefined)
831
+ return false;
832
+ try {
833
+ mkdirSync(this.stateRoot, { recursive: true });
834
+ return true;
835
+ }
836
+ catch (error) {
837
+ this.unwritable = `${this.stateRoot}: ${libraryErrorMessage(error)}`;
838
+ return false;
839
+ }
840
+ }
841
+ get indexFile() {
842
+ return join(this.stateRoot, 'state.json');
843
+ }
844
+ get bindingsFile() {
845
+ return join(this.stateRoot, 'bindings.json');
846
+ }
847
+ get projectsDir() {
848
+ return join(this.stateRoot, 'projects');
849
+ }
850
+ /** One project's directory. */
851
+ projectDir(projectId) {
852
+ assertLibraryId(projectId, 'project id');
853
+ return join(this.projectsDir, projectId);
854
+ }
855
+ /** The v5 resource file. */
856
+ resourceFile(projectId, resourceId) {
857
+ assertLibraryId(resourceId, 'resource id');
858
+ return join(this.projectDir(projectId), 'resources', `${resourceId}.json`);
859
+ }
860
+ /** The legacy per-book directory (v3/v4 layout — read-only now). */
861
+ legacyBookDir(projectId, bookId) {
862
+ assertLibraryId(bookId, 'book id');
863
+ return join(this.projectDir(projectId), 'books', bookId);
864
+ }
865
+ cardsFile(projectId) {
866
+ return join(this.projectDir(projectId), 'cards.jsonl');
867
+ }
868
+ /** The legacy thin-links ledger (v3/v4 layout — read-only now). */
869
+ legacyResourcesFile(projectId) {
870
+ return join(this.projectDir(projectId), 'resources.jsonl');
871
+ }
872
+ attemptsFile(projectId) {
873
+ return join(this.projectDir(projectId), 'attempts.jsonl');
874
+ }
875
+ eventsFile(projectId) {
876
+ return join(this.projectDir(projectId), 'events.jsonl');
877
+ }
878
+ /** The v5 assessment file. */
879
+ assessmentFile(projectId, assessmentId) {
880
+ assertLibraryId(assessmentId, 'assessment id');
881
+ return join(this.projectDir(projectId), 'assessments', `${assessmentId}.json`);
882
+ }
883
+ /** The legacy quiz file (v3/v4 layout — read-only now). */
884
+ legacyQuizFile(projectId, quizId) {
885
+ assertLibraryId(quizId, 'quiz id');
886
+ return join(this.projectDir(projectId), 'quizzes', `${quizId}.json`);
887
+ }
888
+ /** The v5 roadmap file, keyed by its textbook. */
889
+ roadmapFile(projectId, resourceId, which) {
890
+ assertLibraryId(resourceId, 'resource id');
891
+ return join(this.projectDir(projectId), 'roadmaps', which === 'adopted' ? `${resourceId}.json` : `${resourceId}.draft.json`);
892
+ }
893
+ /** The legacy per-book roadmap file (v3/v4 layout — read-only now). */
894
+ legacyRoadmapFile(projectId, resourceId, which) {
895
+ return join(this.legacyBookDir(projectId, resourceId), which === 'adopted' ? 'roadmap.json' : 'roadmap.draft.json');
896
+ }
897
+ /** The v5 pages ledger of one thick resource. */
898
+ /** notes/<resource>/<node>.md — or <node>.<n>.md, the Nth revision kept before a rewrite. */
899
+ notesFile(projectId, resourceId, nodeId, revision) {
900
+ assertLibraryId(resourceId, 'resource id');
901
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(nodeId))
902
+ throw new LibraryStoreError(`not a station id: ${JSON.stringify(nodeId)}`, 'corrupt');
903
+ return join(this.projectDir(projectId), 'notes', resourceId, revision === undefined ? `${nodeId}.md` : `${nodeId}.${revision}.md`);
904
+ }
905
+ pagesFile(projectId, resourceId) {
906
+ assertLibraryId(resourceId, 'resource id');
907
+ return join(this.projectDir(projectId), 'pages', `${resourceId}.jsonl`);
908
+ }
909
+ /** The per-project lock's path. */
910
+ projectLock(projectId) {
911
+ assertLibraryId(projectId, 'project id');
912
+ return join(this.projectsDir, `${projectId}.lock`);
913
+ }
914
+ /** Serialize under the scope's lock: read-modify-write as one critical section. */
915
+ async withProjectLock(projectId, fn) {
916
+ if (!this.available)
917
+ throw new LibraryStoreError('library: no writable state root', 'unavailable');
918
+ mkdirSync(this.projectsDir, { recursive: true });
919
+ const lock = await acquireLibraryLock(this.projectLock(projectId));
920
+ try {
921
+ return await fn();
922
+ }
923
+ finally {
924
+ lock.release();
925
+ }
926
+ }
927
+ /** The index's own lock (project create/delete). */
928
+ async withIndexLock(fn) {
929
+ return await this.withRootLock(this.indexFile, fn);
930
+ }
931
+ /** The bindings' own lock. */
932
+ async withBindingsLock(fn) {
933
+ return await this.withRootLock(this.bindingsFile, fn);
934
+ }
935
+ async withRootLock(file, fn) {
936
+ if (!this.available)
937
+ throw new LibraryStoreError('library: no writable state root', 'unavailable');
938
+ mkdirSync(this.stateRoot, { recursive: true });
939
+ const lock = await acquireLibraryLock(`${file}.lock`);
940
+ try {
941
+ return await fn();
942
+ }
943
+ finally {
944
+ lock.release();
945
+ }
946
+ }
947
+ /* ------------------------------------------------------------ low level */
948
+ /**
949
+ * Read and normalize one JSON document.
950
+ *
951
+ * @returns undefined when the file is absent; throws `corrupt` when it
952
+ * exists but does not parse (a hand-edited file stays visible rather than
953
+ * being silently clobbered).
954
+ */
955
+ readJsonFile(file, normalize) {
956
+ let raw;
957
+ try {
958
+ raw = readFileSync(file, 'utf8');
959
+ }
960
+ catch (error) {
961
+ if (error?.code === 'ENOENT')
962
+ return undefined;
963
+ throw new LibraryStoreError(`library: ${file} could not be read — ${libraryErrorMessage(error)}`, 'io');
964
+ }
965
+ let parsed;
966
+ try {
967
+ parsed = JSON.parse(raw);
968
+ }
969
+ catch {
970
+ throw new LibraryStoreError(`library: ${file} does not parse — fix or remove it by hand`, 'corrupt');
971
+ }
972
+ const doc = normalize(parsed);
973
+ if (doc === undefined) {
974
+ throw new LibraryStoreError(`library: ${file} holds no usable document — fix or remove it by hand`, 'corrupt');
975
+ }
976
+ return doc;
977
+ }
978
+ /** Write one document atomically (temp file + rename). */
979
+ writeJsonFile(file, value) {
980
+ this.writeTextFile(file, `${JSON.stringify(value, null, 2)}\n`);
981
+ }
982
+ /** Write one text file atomically. */
983
+ writeTextFile(file, body) {
984
+ mkdirSync(dirname(file), { recursive: true });
985
+ const temporary = `${file}.${process.pid}.tmp`;
986
+ try {
987
+ writeFileSync(temporary, body);
988
+ renameSync(temporary, file);
989
+ }
990
+ catch (error) {
991
+ rmSync(temporary, { force: true });
992
+ throw new LibraryStoreError(`library: ${file} could not be written — ${libraryErrorMessage(error)}`, 'io');
993
+ }
994
+ }
995
+ /** Append lines to one JSONL ledger (append-only: never a rewrite). */
996
+ appendJsonlFile(file, values) {
997
+ if (values.length === 0)
998
+ return;
999
+ mkdirSync(dirname(file), { recursive: true });
1000
+ const lines = values.map(value => `${JSON.stringify(value)}\n`).join('');
1001
+ try {
1002
+ writeFileSync(file, lines, { flag: 'a' });
1003
+ }
1004
+ catch (error) {
1005
+ throw new LibraryStoreError(`library: ${file} could not be appended — ${libraryErrorMessage(error)}`, 'io');
1006
+ }
1007
+ }
1008
+ /** Read one JSONL ledger tolerantly: unparseable lines drop, never fail the read. */
1009
+ readJsonlFile(file, normalize) {
1010
+ let raw;
1011
+ try {
1012
+ raw = readFileSync(file, 'utf8');
1013
+ }
1014
+ catch (error) {
1015
+ if (error?.code === 'ENOENT')
1016
+ return [];
1017
+ throw new LibraryStoreError(`library: ${file} could not be read — ${libraryErrorMessage(error)}`, 'io');
1018
+ }
1019
+ const out = [];
1020
+ for (const line of raw.split('\n')) {
1021
+ const trimmed = line.trim();
1022
+ if (trimmed === '')
1023
+ continue;
1024
+ try {
1025
+ const value = normalize(JSON.parse(trimmed));
1026
+ if (value !== undefined)
1027
+ out.push(value);
1028
+ }
1029
+ catch {
1030
+ // A torn line (a crash mid-append) is skipped — the ledger is a log.
1031
+ }
1032
+ }
1033
+ return out;
1034
+ }
1035
+ /* --------------------------------------------------------------- reads */
1036
+ /** The index; absent reads as empty, an unavailable root as empty. */
1037
+ readIndex() {
1038
+ if (!this.available)
1039
+ return emptyLibraryIndex();
1040
+ return this.readJsonFile(this.indexFile, value => normalizeLibraryIndex(value)) ?? emptyLibraryIndex();
1041
+ }
1042
+ /** The session bindings; absent or unreadable reads as empty. */
1043
+ readBindings() {
1044
+ if (!this.available)
1045
+ return new Map();
1046
+ try {
1047
+ return normalizeBindings(JSON.parse(readFileSync(this.bindingsFile, 'utf8')));
1048
+ }
1049
+ catch {
1050
+ return new Map();
1051
+ }
1052
+ }
1053
+ /** One project's record; undefined when absent. */
1054
+ readProject(projectId) {
1055
+ return this.readJsonFile(this.projectFile(projectId), normalizeProject);
1056
+ }
1057
+ projectFile(projectId) {
1058
+ return join(this.projectDir(projectId), 'project.json');
1059
+ }
1060
+ /**
1061
+ * One shelf entry; undefined when absent. The v5 file wins; the legacy
1062
+ * spots follow (a book's `books/<id>/book.json`, then a thin link's
1063
+ * resources.jsonl line).
1064
+ */
1065
+ readResource(projectId, resourceId) {
1066
+ const current = this.readJsonFile(this.resourceFile(projectId, resourceId), normalizeResource);
1067
+ if (current !== undefined)
1068
+ return current;
1069
+ const legacyBook = this.readJsonFile(join(this.legacyBookDir(projectId, resourceId), 'book.json'), normalizeResource);
1070
+ if (legacyBook !== undefined)
1071
+ return legacyBook;
1072
+ return this.readJsonlFile(this.legacyResourcesFile(projectId), normalizeResource).find(resource => resource.id === resourceId);
1073
+ }
1074
+ /**
1075
+ * Every shelf entry of one project, both layouts merged (a v5 file shadows
1076
+ * a legacy twin of the same id): textbooks first in study order (`seq`,
1077
+ * then age — an unnumbered legacy textbook queues behind the numbered
1078
+ * ones), then thin links oldest first.
1079
+ */
1080
+ listResources(projectId) {
1081
+ const byId = new Map();
1082
+ const legacyDir = join(this.projectDir(projectId), 'books');
1083
+ if (existsSync(legacyDir)) {
1084
+ for (const entry of readdirSync(legacyDir, { withFileTypes: true })) {
1085
+ if (!entry.isDirectory() || !isLibraryId(entry.name))
1086
+ continue;
1087
+ const book = this.readJsonFile(join(legacyDir, entry.name, 'book.json'), normalizeResource);
1088
+ if (book !== undefined)
1089
+ byId.set(book.id, book);
1090
+ }
1091
+ }
1092
+ for (const link of this.readJsonlFile(this.legacyResourcesFile(projectId), normalizeResource)) {
1093
+ byId.set(link.id, link);
1094
+ }
1095
+ const resourcesDir = join(this.projectDir(projectId), 'resources');
1096
+ if (existsSync(resourcesDir)) {
1097
+ for (const name of readdirSync(resourcesDir)) {
1098
+ if (!name.endsWith('.json') || !isLibraryId(name.slice(0, -'.json'.length)))
1099
+ continue;
1100
+ const resource = this.readJsonFile(join(resourcesDir, name), normalizeResource);
1101
+ if (resource !== undefined)
1102
+ byId.set(resource.id, resource);
1103
+ }
1104
+ }
1105
+ return [...byId.values()].sort(shelfOrder);
1106
+ }
1107
+ /**
1108
+ * One textbook's adopted roadmap, or its draft. The v5 file wins; the
1109
+ * legacy book directory follows. A retired project-level path (no
1110
+ * resource) reads as absent.
1111
+ */
1112
+ readRoadmap(projectId, resourceId, which = 'adopted') {
1113
+ if (resourceId === undefined)
1114
+ return undefined;
1115
+ const current = this.readJsonFile(this.roadmapFile(projectId, resourceId, which), normalizeRoadmap);
1116
+ if (current !== undefined)
1117
+ return this.withNotes(projectId, { ...current, primaryResourceId: resourceId });
1118
+ const legacy = this.readJsonFile(this.legacyRoadmapFile(projectId, resourceId, which), normalizeRoadmap);
1119
+ return legacy === undefined ? undefined : this.withNotes(projectId, { ...legacy, primaryResourceId: resourceId });
1120
+ }
1121
+ /** A path with each station's 讲义 text read from its file (a node whose file is gone loses its notes). */
1122
+ withNotes(projectId, roadmap) {
1123
+ if (!roadmap.nodes.some(node => node.notes !== undefined && node.notes.text === ''))
1124
+ return roadmap;
1125
+ return {
1126
+ ...roadmap,
1127
+ nodes: roadmap.nodes.map(node => {
1128
+ if (node.notes === undefined || node.notes.text !== '')
1129
+ return node;
1130
+ const text = this.readNotes(projectId, roadmap.primaryResourceId, node.id);
1131
+ if (text !== undefined)
1132
+ return { ...node, notes: { ...node.notes, text } };
1133
+ const { notes: _gone, ...rest } = node;
1134
+ return rest;
1135
+ }),
1136
+ };
1137
+ }
1138
+ /** One station's 讲义 markdown, when written. */
1139
+ readNotes(projectId, resourceId, nodeId) {
1140
+ try {
1141
+ return readFileSync(this.notesFile(projectId, resourceId, nodeId), 'utf8');
1142
+ }
1143
+ catch {
1144
+ return undefined;
1145
+ }
1146
+ }
1147
+ /** Keep the current 讲义 of a station as revision N before a rewrite (call inside `withProjectLock`). */
1148
+ archiveNotes(projectId, resourceId, nodeId, revision, text) {
1149
+ this.writeTextFile(this.notesFile(projectId, resourceId, nodeId, revision), text);
1150
+ }
1151
+ /**
1152
+ * One thick resource's photographed pages, oldest first — both layouts
1153
+ * merged: pages recorded before the v5 write cutover stay in the legacy
1154
+ * book directory, new ones append to `pages/<id>.jsonl`.
1155
+ */
1156
+ readPages(projectId, resourceId) {
1157
+ const legacy = this.readJsonlFile(join(this.legacyBookDir(projectId, resourceId), 'pages.jsonl'), normalizePage);
1158
+ const current = this.readJsonlFile(this.pagesFile(projectId, resourceId), normalizePage);
1159
+ return [...legacy, ...current];
1160
+ }
1161
+ /** Every knowledge card of one project (deleted ones included — callers filter). */
1162
+ readCards(projectId) {
1163
+ return this.readJsonlFile(this.cardsFile(projectId), normalizeCard);
1164
+ }
1165
+ /** Every attempt (the JSONL ledger, tolerant per line). */
1166
+ readAttempts(projectId) {
1167
+ return this.readJsonlFile(this.attemptsFile(projectId), normalizeAttempt);
1168
+ }
1169
+ /** The audit ledger, oldest first. */
1170
+ readEvents(projectId) {
1171
+ return this.readJsonlFile(this.eventsFile(projectId), normalizeEvent);
1172
+ }
1173
+ /**
1174
+ * One assessment; undefined when absent. The v5 file wins; a legacy quiz
1175
+ * file (`quizzes/<id>.json`) reads as a type=quiz assessment.
1176
+ */
1177
+ readAssessment(projectId, assessmentId) {
1178
+ const current = this.readJsonFile(this.assessmentFile(projectId, assessmentId), normalizeAssessment);
1179
+ if (current !== undefined)
1180
+ return current;
1181
+ return this.readJsonFile(this.legacyQuizFile(projectId, assessmentId), normalizeAssessment);
1182
+ }
1183
+ /** Every assessment of one project, oldest first, both layouts merged. */
1184
+ listAssessments(projectId) {
1185
+ const byId = new Map();
1186
+ const quizzesDir = join(this.projectDir(projectId), 'quizzes');
1187
+ if (existsSync(quizzesDir)) {
1188
+ for (const name of readdirSync(quizzesDir)) {
1189
+ if (!name.endsWith('.json') || !isLibraryId(name.slice(0, -'.json'.length)))
1190
+ continue;
1191
+ const quiz = this.readJsonFile(join(quizzesDir, name), normalizeAssessment);
1192
+ if (quiz !== undefined)
1193
+ byId.set(quiz.id, quiz);
1194
+ }
1195
+ }
1196
+ const assessmentsDir = join(this.projectDir(projectId), 'assessments');
1197
+ if (existsSync(assessmentsDir)) {
1198
+ for (const name of readdirSync(assessmentsDir)) {
1199
+ if (!name.endsWith('.json') || !isLibraryId(name.slice(0, -'.json'.length)))
1200
+ continue;
1201
+ const assessment = this.readJsonFile(join(assessmentsDir, name), normalizeAssessment);
1202
+ if (assessment !== undefined)
1203
+ byId.set(assessment.id, assessment);
1204
+ }
1205
+ }
1206
+ return [...byId.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
1207
+ }
1208
+ /* --------------------------------------------------------------- writes */
1209
+ /** Write the index (call inside `withIndexLock`). */
1210
+ writeIndex(doc) {
1211
+ this.writeJsonFile(this.indexFile, doc);
1212
+ }
1213
+ /** Write the bindings (call inside `withBindingsLock`). */
1214
+ writeBindings(bindings) {
1215
+ this.writeJsonFile(this.bindingsFile, Object.fromEntries(bindings));
1216
+ }
1217
+ /** Write one project's record (call inside `withProjectLock`). */
1218
+ writeProject(project) {
1219
+ this.writeJsonFile(this.projectFile(project.id), project);
1220
+ }
1221
+ /** Write one shelf entry (call inside `withProjectLock`) — the v5 layout. */
1222
+ writeResource(projectId, resource) {
1223
+ this.writeJsonFile(this.resourceFile(projectId, resource.id), resource);
1224
+ }
1225
+ /** Write one textbook's adopted roadmap or its draft (call inside `withProjectLock`) — the v5 layout. */
1226
+ writeRoadmap(projectId, roadmap) {
1227
+ const resourceId = roadmap.primaryResourceId;
1228
+ if (resourceId === undefined)
1229
+ throw new LibraryStoreError('a roadmap is written under its textbook (primaryResourceId)', 'corrupt');
1230
+ // 讲义 text goes to its own file; the path keeps only the stamp.
1231
+ const nodes = roadmap.nodes.map(node => {
1232
+ if (node.notes === undefined)
1233
+ return node;
1234
+ if (node.notes.text !== '' && this.readNotes(projectId, resourceId, node.id) !== node.notes.text) {
1235
+ this.writeTextFile(this.notesFile(projectId, resourceId, node.id), node.notes.text);
1236
+ }
1237
+ return { ...node, notes: { updatedAt: node.notes.updatedAt, revisions: node.notes.revisions } };
1238
+ });
1239
+ this.writeJsonFile(this.roadmapFile(projectId, resourceId, roadmap.status === 'adopted' ? 'adopted' : 'draft'), { ...roadmap, nodes });
1240
+ }
1241
+ /** Drop one textbook's draft (adopted or discarded), in BOTH layouts. */
1242
+ removeDraft(projectId, resourceId) {
1243
+ rmSync(this.roadmapFile(projectId, resourceId, 'draft'), { force: true });
1244
+ rmSync(this.legacyRoadmapFile(projectId, resourceId, 'draft'), { force: true });
1245
+ }
1246
+ /** Append one photographed page (call inside `withProjectLock`) — the v5 layout. */
1247
+ appendPage(projectId, page) {
1248
+ this.appendJsonlFile(this.pagesFile(projectId, page.resourceId), [page]);
1249
+ }
1250
+ /**
1251
+ * Rewrite the cards whole — a merge or a mastery move rewrites an existing
1252
+ * line. The temp+rename keeps the swap atomic; the project lock serializes it.
1253
+ */
1254
+ writeCards(projectId, cards) {
1255
+ this.writeTextFile(this.cardsFile(projectId), cards.map(card => `${JSON.stringify(card)}\n`).join(''));
1256
+ }
1257
+ /** Append one attempt (call inside `withProjectLock`). */
1258
+ appendAttempt(projectId, attempt) {
1259
+ this.appendJsonlFile(this.attemptsFile(projectId), [attempt]);
1260
+ }
1261
+ /** Append audit events (call inside `withProjectLock`). */
1262
+ appendEvents(projectId, events) {
1263
+ this.appendJsonlFile(this.eventsFile(projectId), events);
1264
+ }
1265
+ /** Write one assessment (call inside `withProjectLock`) — the v5 layout. */
1266
+ writeAssessment(projectId, assessment) {
1267
+ this.writeJsonFile(this.assessmentFile(projectId, assessment.id), assessment);
1268
+ }
1269
+ /**
1270
+ * Remove one shelf entry: the v5 file, its pages ledger, its roadmap files,
1271
+ * and any legacy trace (the book directory, the resources.jsonl line).
1272
+ */
1273
+ removeResource(projectId, resourceId) {
1274
+ rmSync(this.resourceFile(projectId, resourceId), { force: true });
1275
+ rmSync(this.pagesFile(projectId, resourceId), { force: true });
1276
+ rmSync(join(this.projectDir(projectId), 'notes', resourceId), { recursive: true, force: true });
1277
+ rmSync(this.roadmapFile(projectId, resourceId, 'adopted'), { force: true });
1278
+ rmSync(this.roadmapFile(projectId, resourceId, 'draft'), { force: true });
1279
+ rmSync(this.legacyBookDir(projectId, resourceId), { recursive: true, force: true });
1280
+ if (existsSync(this.legacyResourcesFile(projectId))) {
1281
+ const kept = this.readJsonlFile(this.legacyResourcesFile(projectId), normalizeResource).filter(resource => resource.id !== resourceId);
1282
+ this.writeTextFile(this.legacyResourcesFile(projectId), kept.map(resource => `${JSON.stringify(resource)}\n`).join(''));
1283
+ }
1284
+ }
1285
+ /** Remove one project's whole directory. */
1286
+ removeProject(projectId) {
1287
+ rmSync(this.projectDir(projectId), { recursive: true, force: true });
1288
+ rmSync(this.projectLock(projectId), { force: true });
1289
+ }
1290
+ /**
1291
+ * v2 → covers: wrap every stored roadmap node's `chapterRanges` into a book
1292
+ * cover (the v2 paths are all per-book). Idempotent, each project's files
1293
+ * rewritten under its own lock, the index bump last — a crash midway leaves
1294
+ * the old version and the next run resumes. v1 roots were never deployed:
1295
+ * they read as empty and are NOT migrated. Reads never wait for this pass —
1296
+ * `normalizeRoadmap` wraps a v2 node on the fly.
1297
+ *
1298
+ * @returns whether a migration ran.
1299
+ */
1300
+ async migrateState() {
1301
+ if (!this.available)
1302
+ return false;
1303
+ let raw;
1304
+ try {
1305
+ raw = readFileSync(this.indexFile, 'utf8');
1306
+ }
1307
+ catch {
1308
+ return false; // no index yet — nothing to migrate
1309
+ }
1310
+ let version;
1311
+ try {
1312
+ version = JSON.parse(raw).version;
1313
+ }
1314
+ catch {
1315
+ return false; // a corrupt index stays a hand-fix matter, not a migration's
1316
+ }
1317
+ if (version !== 2)
1318
+ return false;
1319
+ await this.withIndexLock(async () => {
1320
+ const index = this.readIndex();
1321
+ for (const project of index.projects) {
1322
+ await this.withProjectLock(project.id, () => {
1323
+ this.migrateProjectRoadmapsV2(project.id);
1324
+ });
1325
+ }
1326
+ this.writeIndex({ ...index, version: LIBRARY_STATE_VERSION });
1327
+ });
1328
+ return true;
1329
+ }
1330
+ /** Rewrite one project's v2 book roadmaps (adopted and draft) into covers. */
1331
+ migrateProjectRoadmapsV2(projectId) {
1332
+ const dir = join(this.projectDir(projectId), 'books');
1333
+ if (!existsSync(dir))
1334
+ return;
1335
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
1336
+ if (!entry.isDirectory() || !isLibraryId(entry.name))
1337
+ continue;
1338
+ for (const file of ['roadmap.json', 'roadmap.draft.json']) {
1339
+ const path = join(dir, entry.name, file);
1340
+ let parsed;
1341
+ try {
1342
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
1343
+ }
1344
+ catch {
1345
+ continue; // absent or corrupt — reads treat it as absent either way
1346
+ }
1347
+ const roadmap = asRecord(parsed);
1348
+ if (roadmap === undefined || !Array.isArray(roadmap.nodes))
1349
+ continue;
1350
+ let touched = false;
1351
+ const nodes = roadmap.nodes.map(item => {
1352
+ const node = asRecord(item);
1353
+ if (node === undefined || node.covers !== undefined || node.chapterRanges === undefined)
1354
+ return item;
1355
+ touched = true;
1356
+ const { chapterRanges, ...rest } = node;
1357
+ return { ...rest, covers: [{ kind: 'book', bookId: entry.name, ranges: Array.isArray(chapterRanges) ? chapterRanges : [] }] };
1358
+ });
1359
+ if (touched)
1360
+ this.writeJsonFile(path, { ...roadmap, nodes });
1361
+ }
1362
+ }
1363
+ }
1364
+ }
1365
+ /**
1366
+ * Resolve the state root: an explicit config value wins, then
1367
+ * `$DSH_HOME/state/dsh-library`, then `<cwd>/.dsh-library`.
1368
+ */
1369
+ export function resolveLibraryStateRoot(configured) {
1370
+ if (configured !== undefined && configured !== '')
1371
+ return configured;
1372
+ const home = process.env.DSH_HOME;
1373
+ if (home !== undefined && home !== '')
1374
+ return join(home, 'state', STATE_ROOT_SEGMENT);
1375
+ return join(process.cwd(), `.${STATE_ROOT_SEGMENT}`);
1376
+ }