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