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