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