@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
package/lib/index.js
ADDED
|
@@ -0,0 +1,3318 @@
|
|
|
1
|
+
import { A as LIBRARY_ID_PATTERN, B as coverTopic, C as normalizeRoadmap, D as LIBRARY_COLORS, E as LEITNER_INTERVAL_DAYS, F as MAX_WEAKNESS, G as parseLocator, H as isLibraryId, I as STATE_ROOT_SEGMENT, L as TEMPLATE_LOOK, M as LIBRARY_RESOURCE_ORIGINS, N as LIBRARY_STATE_VERSION, O as LIBRARY_EVENT_TYPES, P as MAX_BOX, R as acceptanceLines, S as normalizeResource, T as DEFAULT_PASS_RATIO, U as isThickResource, V as formatLocator, W as libraryErrorMessage, _ as normalizeLibraryIndex, a as LibraryStore, b as normalizeProject, c as assertLibraryId, d as normalizeAssessment, f as normalizeAttachmentRef, g as normalizeEvent, h as normalizeCard, i as stationMastery, j as LIBRARY_RESOURCE_KINDS, k as LIBRARY_ICONS, l as emptyLibraryIndex, m as normalizeBindings, n as masteryChanges, o as LibraryStoreError, p as normalizeAttempt, s as acquireLibraryLock, t as profileOf, u as mintLibraryId, v as normalizeNodeCover, w as resolveLibraryStateRoot, y as normalizePage, z as coverKind } from "./profiles-CFrPQdfO.js";
|
|
2
|
+
import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
3
|
+
//#region lib/types/service/core.js
|
|
4
|
+
const PACKAGE_NAME = "@khorsheed/dsh-library";
|
|
5
|
+
/**
|
|
6
|
+
* Thrown by every public verb after {@link LibraryService.dispose} ran (an
|
|
7
|
+
* explicit teardown, or the borrowed Context's own disposal): a coded,
|
|
8
|
+
* package-named refusal, never a silent operation on a dead Context. The
|
|
9
|
+
* disposed instance stays dead — a rebuild over the same state root is a NEW
|
|
10
|
+
* service instance's business (the canvas-novel/sidechat precedent).
|
|
11
|
+
*/
|
|
12
|
+
var LibraryDisposedError = class extends Error {
|
|
13
|
+
code;
|
|
14
|
+
constructor(message, code) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.name = "LibraryDisposedError";
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
/** A refusal in one line. */
|
|
21
|
+
function refuse(error, message) {
|
|
22
|
+
return {
|
|
23
|
+
ok: false,
|
|
24
|
+
error,
|
|
25
|
+
message
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** Wrap a store failure as a refusal (the service's error channel is values). */
|
|
29
|
+
function storeRefusal(error) {
|
|
30
|
+
if (error instanceof LibraryStoreError) return refuse(error.kind === "corrupt" ? "corrupt" : error.kind === "unavailable" ? "storage-unavailable" : "io", error.message);
|
|
31
|
+
return refuse("io", `library: ${libraryErrorMessage(error)}`);
|
|
32
|
+
}
|
|
33
|
+
const DAY_MS = 864e5;
|
|
34
|
+
/** The Leitner due date for a box: box b waits INTERVALS[min(b, 4)] days. */
|
|
35
|
+
function leitnerDueAt(box, from = /* @__PURE__ */ new Date()) {
|
|
36
|
+
const days = LEITNER_INTERVAL_DAYS[Math.min(Math.max(box, 0), LEITNER_INTERVAL_DAYS.length - 1)];
|
|
37
|
+
return new Date(from.getTime() + days * DAY_MS).toISOString();
|
|
38
|
+
}
|
|
39
|
+
/** Whitespace-normalize a quote before comparing it with a page's text. */
|
|
40
|
+
function normalizeFragment(value) {
|
|
41
|
+
return value.normalize("NFKC").replace(/\s+/g, " ").trim().toLowerCase();
|
|
42
|
+
}
|
|
43
|
+
/** Clamp a weakness score into 0..MAX. */
|
|
44
|
+
function clampWeakness(value) {
|
|
45
|
+
return Math.min(Math.max(value, 0), 3);
|
|
46
|
+
}
|
|
47
|
+
/** Live = not deleted. */
|
|
48
|
+
function live(card) {
|
|
49
|
+
return card.status !== "deleted";
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The library's service core. Constructing it never throws and never touches
|
|
53
|
+
* the disk: the store probes writability lazily, so a read-only deployment
|
|
54
|
+
* boots and answers `storage-unavailable` on writes. Disposal follows the
|
|
55
|
+
* borrowed Context: an instance mounted dynamically (per state root, say)
|
|
56
|
+
* dies with its mounting Context, and a dead instance refuses every verb
|
|
57
|
+
* coded, reads included.
|
|
58
|
+
*/
|
|
59
|
+
var LibraryServiceCore = class {
|
|
60
|
+
ctx;
|
|
61
|
+
/** The store; public for the package's own tests and diagnostics. */
|
|
62
|
+
store;
|
|
63
|
+
/** Listeners told after every project mutation (the tab's live refresh, the context cache). */
|
|
64
|
+
listeners = /* @__PURE__ */ new Set();
|
|
65
|
+
/** When this instance last wrote anything (a project created, an index edit — writes that log no event). */
|
|
66
|
+
lastChangeAt;
|
|
67
|
+
/** The newest session-made write (a library tool from a session): the client's cue to reveal the sidebar. */
|
|
68
|
+
lastSessionWrite;
|
|
69
|
+
/** Set by {@link dispose} (explicit, or the borrowed Context's teardown); every public verb refuses afterwards. */
|
|
70
|
+
disposed = false;
|
|
71
|
+
constructor(ctx, config = {}) {
|
|
72
|
+
this.ctx = ctx;
|
|
73
|
+
this.store = new LibraryStore(config);
|
|
74
|
+
ctx.effect(() => () => {
|
|
75
|
+
this.dispose();
|
|
76
|
+
}, "library: dispose on context teardown");
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Refuse one public verb after disposal: the instance is dead and nothing
|
|
80
|
+
* it could still answer is meant to be consumed — reads included, so a
|
|
81
|
+
* mounting side never keeps consuming a stale instance indefinitely.
|
|
82
|
+
*/
|
|
83
|
+
assertAlive(verb) {
|
|
84
|
+
if (this.disposed) throw new LibraryDisposedError(`${PACKAGE_NAME}: ${verb} after dispose`, "disposed");
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Tear the instance down (also wired to the borrowed Context's disposal at
|
|
88
|
+
* construction). Idempotent and TERMINAL: the listener set is dropped and
|
|
89
|
+
* afterwards every public verb refuses with {@link LibraryDisposedError};
|
|
90
|
+
* a write that already passed the write boundary may complete (the store's
|
|
91
|
+
* lock/temp-write/atomic-rename keeps the disk side safe), one reaching it
|
|
92
|
+
* after disposal refuses before lock acquisition. A rebuild over the same
|
|
93
|
+
* state root is a NEW instance's business.
|
|
94
|
+
*/
|
|
95
|
+
dispose() {
|
|
96
|
+
this.disposed = true;
|
|
97
|
+
this.listeners.clear();
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* What a client may rely on in this composition: storage availability with
|
|
101
|
+
* its reason, and the attachment probe's verdict at call time. A read-only
|
|
102
|
+
* display contract — enforcement stays at the write gates, unchanged.
|
|
103
|
+
*/
|
|
104
|
+
async capabilities() {
|
|
105
|
+
this.assertAlive("capabilities");
|
|
106
|
+
return {
|
|
107
|
+
protocolVersion: 1,
|
|
108
|
+
hasStorage: this.store.available,
|
|
109
|
+
...this.store.unavailableReason === void 0 ? {} : { storageUnavailableReason: this.store.unavailableReason },
|
|
110
|
+
hasAttachments: this.ctx.get("attachments") !== void 0
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** Subscribe to project mutations; returns the unsubscribe. */
|
|
114
|
+
onChange(listener) {
|
|
115
|
+
this.assertAlive("onChange");
|
|
116
|
+
this.listeners.add(listener);
|
|
117
|
+
return () => {
|
|
118
|
+
this.listeners.delete(listener);
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
changed(projectId) {
|
|
122
|
+
this.lastChangeAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
123
|
+
for (const listener of this.listeners) try {
|
|
124
|
+
listener(projectId);
|
|
125
|
+
} catch {}
|
|
126
|
+
}
|
|
127
|
+
/** Run a project mutation under its lock, then notify. */
|
|
128
|
+
async mutate(projectId, fn) {
|
|
129
|
+
this.assertAlive("mutate");
|
|
130
|
+
try {
|
|
131
|
+
const outcome = await this.store.withProjectLock(projectId, fn);
|
|
132
|
+
if (outcome.ok) this.changed(projectId);
|
|
133
|
+
return outcome;
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return storeRefusal(error);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** A library tool from this session just wrote (kit.autoBind calls it after every successful write). */
|
|
139
|
+
noteSessionWrite(session) {
|
|
140
|
+
this.lastSessionWrite = {
|
|
141
|
+
session,
|
|
142
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
event(projectId, type, fields = {}) {
|
|
146
|
+
this.store.appendEvents(projectId, [{
|
|
147
|
+
at: fields.at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
148
|
+
type,
|
|
149
|
+
...fields
|
|
150
|
+
}]);
|
|
151
|
+
}
|
|
152
|
+
/** The project, or a not-found refusal that teaches where the list lives. */
|
|
153
|
+
readProjectOrRefusal(projectId) {
|
|
154
|
+
let project;
|
|
155
|
+
try {
|
|
156
|
+
project = this.store.readProject(projectId);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
return storeRefusal(error);
|
|
159
|
+
}
|
|
160
|
+
if (project === void 0) return refuse("not-found", `no project ${JSON.stringify(projectId)} — library_read · projects lists every project; create one with library_project`);
|
|
161
|
+
return { value: project };
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
//#endregion
|
|
165
|
+
//#region lib/types/cards.js
|
|
166
|
+
/** Fold one English word's regular plural (instalments → instalment, theories → theory). */
|
|
167
|
+
function foldPlural(word) {
|
|
168
|
+
if (!/^[a-z]+$/.test(word) || word.length <= 3) return word;
|
|
169
|
+
if (word.endsWith("ies") && word.length > 4) return `${word.slice(0, -3)}y`;
|
|
170
|
+
if (/(?:s|x|z|ch|sh)es$/.test(word)) return word.slice(0, -2);
|
|
171
|
+
if (word.endsWith("s") && !word.endsWith("ss") && !word.endsWith("us") && !word.endsWith("is")) return word.slice(0, -1);
|
|
172
|
+
return word;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* A term's comparison key: NFKC (full-width → half-width), lower case, inner
|
|
176
|
+
* whitespace collapsed, surrounding punctuation and brackets dropped, each
|
|
177
|
+
* English word's plural folded.
|
|
178
|
+
*/
|
|
179
|
+
function cardKey(term) {
|
|
180
|
+
return term.normalize("NFKC").toLowerCase().replace(/\s+/g, " ").trim().replace(/^[\p{P}\p{S}\s]+|[\p{P}\p{S}\s]+$/gu, "").split(" ").map(foldPlural).join(" ");
|
|
181
|
+
}
|
|
182
|
+
/** Every key a card answers to: its term and its aliases. */
|
|
183
|
+
function cardKeys(card) {
|
|
184
|
+
return [...new Set([card.term, ...card.aliases].map(cardKey).filter((key) => key !== ""))];
|
|
185
|
+
}
|
|
186
|
+
/** The comparison units of a key: English words, or single CJK characters. */
|
|
187
|
+
function units(key) {
|
|
188
|
+
const out = /* @__PURE__ */ new Set();
|
|
189
|
+
for (const token of key.split(/[\s//、,,·・-]+/)) {
|
|
190
|
+
if (token === "") continue;
|
|
191
|
+
if (/[\p{Script=Han}]/u.test(token)) {
|
|
192
|
+
for (const char of token) if (/[\p{Script=Han}\p{L}\p{N}]/u.test(char)) out.add(char);
|
|
193
|
+
} else out.add(token);
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
/** How alike two keys look, 0..1 (1 = one contains the other). */
|
|
198
|
+
function keySimilarity(a, b) {
|
|
199
|
+
if (a === "" || b === "") return 0;
|
|
200
|
+
if (a === b) return 1;
|
|
201
|
+
const shorter = a.length <= b.length ? a : b;
|
|
202
|
+
const longer = shorter === a ? b : a;
|
|
203
|
+
const minimum = /[\p{Script=Han}]/u.test(shorter) ? 2 : 4;
|
|
204
|
+
if (shorter.length >= minimum && longer.includes(shorter)) return 1;
|
|
205
|
+
const left = units(a);
|
|
206
|
+
const right = units(b);
|
|
207
|
+
if (left.size === 0 || right.size === 0) return 0;
|
|
208
|
+
let shared = 0;
|
|
209
|
+
for (const unit of left) if (right.has(unit)) shared += 1;
|
|
210
|
+
return shared / Math.max(left.size, right.size);
|
|
211
|
+
}
|
|
212
|
+
/** The threshold above which two keys are a near-duplicate worth asking about. */
|
|
213
|
+
const NEAR_DUPLICATE = .6;
|
|
214
|
+
/** The live card an input key matches exactly, when one does. */
|
|
215
|
+
function exactMatch(cards, keys) {
|
|
216
|
+
const wanted = new Set(keys);
|
|
217
|
+
return cards.find((card) => card.status !== "deleted" && cardKeys(card).some((key) => wanted.has(key)));
|
|
218
|
+
}
|
|
219
|
+
/** Up to `limit` live cards that look like the input, most alike first. */
|
|
220
|
+
function nearMatches(cards, keys, limit = 3) {
|
|
221
|
+
const scored = [];
|
|
222
|
+
for (const card of cards) {
|
|
223
|
+
if (card.status === "deleted") continue;
|
|
224
|
+
let best = 0;
|
|
225
|
+
for (const own of cardKeys(card)) for (const key of keys) best = Math.max(best, keySimilarity(own, key));
|
|
226
|
+
if (best >= .6) scored.push({
|
|
227
|
+
card,
|
|
228
|
+
score: best
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
scored.sort((x, y) => y.score - x.score);
|
|
232
|
+
return scored.slice(0, limit).map((entry) => entry.card);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Whether a card answers a free-text query: any key or the gloss / user gloss
|
|
236
|
+
* / a context sentence contains the query's key (the sidebar's ⌕ and the
|
|
237
|
+
* coach's `library_read · cards` share this).
|
|
238
|
+
*/
|
|
239
|
+
function cardMatchesQuery(card, query) {
|
|
240
|
+
const needle = cardKey(query);
|
|
241
|
+
if (needle === "") return true;
|
|
242
|
+
if (cardKeys(card).some((key) => key.includes(needle))) return true;
|
|
243
|
+
return [
|
|
244
|
+
card.gloss,
|
|
245
|
+
card.userGloss ?? "",
|
|
246
|
+
...card.contexts.map((context) => context.sentence)
|
|
247
|
+
].join("\n").normalize("NFKC").toLowerCase().includes(query.normalize("NFKC").toLowerCase().trim());
|
|
248
|
+
}
|
|
249
|
+
//#endregion
|
|
250
|
+
//#region lib/types/service/rules.js
|
|
251
|
+
/**
|
|
252
|
+
* The library's pure rules: roadmap shape validation, the unlock pass, the
|
|
253
|
+
* covers' evidence gate, and two small helpers. No store, no context — the
|
|
254
|
+
* service layers and the tools call them.
|
|
255
|
+
*
|
|
256
|
+
* @module @khorsheed/dsh-library/service/rules
|
|
257
|
+
*/
|
|
258
|
+
/** A node's title, for event rows. */
|
|
259
|
+
function titleOf(nodes, nodeId) {
|
|
260
|
+
return nodes.find((node) => node.id === nodeId)?.title ?? "";
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* The option an answer key names — a letter in range, a lettered option, the
|
|
264
|
+
* option text, else a one-way containment — or -1. The sidebar's judge
|
|
265
|
+
* (`client/judge.ts`'s choiceIndex) resolves the same spellings; a question
|
|
266
|
+
* this resolves to -1 is judged wrong no matter what the learner picks, so
|
|
267
|
+
* the write gate refuses it.
|
|
268
|
+
*/
|
|
269
|
+
function choiceOptionIndex(options, answer) {
|
|
270
|
+
const key = normalizeFragment(answer);
|
|
271
|
+
if (/^[a-z]$/.test(key)) {
|
|
272
|
+
const at = key.charCodeAt(0) - 97;
|
|
273
|
+
return at < options.length ? at : -1;
|
|
274
|
+
}
|
|
275
|
+
const lettered = /^([a-z])[.、))::]\s*/.exec(key);
|
|
276
|
+
if (lettered !== null) {
|
|
277
|
+
const at = lettered[1].charCodeAt(0) - 97;
|
|
278
|
+
return at < options.length ? at : -1;
|
|
279
|
+
}
|
|
280
|
+
const exact = options.findIndex((option) => normalizeFragment(option) === key);
|
|
281
|
+
if (exact >= 0) return exact;
|
|
282
|
+
return options.findIndex((option) => normalizeFragment(option).includes(key) || key.includes(normalizeFragment(option)));
|
|
283
|
+
}
|
|
284
|
+
/** Move every locked node whose deps are all done to current. */
|
|
285
|
+
function unlockReady(nodes) {
|
|
286
|
+
const doneIds = new Set(nodes.filter((node) => node.state === "done").map((node) => node.id));
|
|
287
|
+
const unlocked = [];
|
|
288
|
+
return {
|
|
289
|
+
nodes: nodes.map((node) => {
|
|
290
|
+
if (node.state === "locked" && node.dependsOn.every((dep) => doneIds.has(dep))) {
|
|
291
|
+
unlocked.push(node.id);
|
|
292
|
+
return {
|
|
293
|
+
...node,
|
|
294
|
+
state: "current"
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
return node;
|
|
298
|
+
}),
|
|
299
|
+
unlocked
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Structural validation of a roadmap's nodes; returns the teaching error or
|
|
304
|
+
* undefined. `scope.resourceId` marks a resource-level draft: the special case
|
|
305
|
+
* whose every node covers that one resource only. Every per-node problem is
|
|
306
|
+
* collected and reported at once — a draft with five mistakes earns ONE
|
|
307
|
+
* refusal listing all five, not five fail-fast round trips (each retry
|
|
308
|
+
* resends the whole nodes payload).
|
|
309
|
+
*/
|
|
310
|
+
function validateRoadmapShape(nodes, scope) {
|
|
311
|
+
const errors = [];
|
|
312
|
+
const seen = /* @__PURE__ */ new Set();
|
|
313
|
+
for (const [index, node] of nodes.entries()) {
|
|
314
|
+
if (typeof node.id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(node.id) || seen.has(node.id)) errors.push(`nodes[${index}] needs a unique id made of letters, digits, '.', '_', '-' (n1, n2, … is plenty)`);
|
|
315
|
+
else seen.add(node.id);
|
|
316
|
+
if (typeof node.title !== "string" || node.title.trim() === "") errors.push(`nodes[${index}] (${node.id}) needs a title — what the learner masters here`);
|
|
317
|
+
const gateKnown = node.gate === "quiz" || node.gate === "read" || node.gate === "acceptance";
|
|
318
|
+
if (!gateKnown) errors.push(`nodes[${index}] (${node.id}) needs gate: "quiz" | "acceptance" | "read" — quiz nodes gate on a quiz; acceptance nodes gate on acceptance (a hands-on deliverable); read nodes are optional side stops (读完 completes them, they never gate)`);
|
|
319
|
+
else {
|
|
320
|
+
if (node.gate === "acceptance" && (node.acceptance === void 0 || node.acceptance.length === 0)) errors.push(`nodes[${index}] (${node.id}) is an acceptance station — it needs acceptance: ["...", ...], the lines the coach judges one verdict each at assess time`);
|
|
321
|
+
if (node.gate !== "acceptance" && node.acceptance !== void 0 && node.acceptance.length > 0) errors.push(`nodes[${index}] (${node.id}) carries acceptance but its gate is "${node.gate}" — if these lines are judged one verdict each at completion, set gate: "acceptance"; if they are descriptive expectations while a quiz gates the station, move them into goals: [...]`);
|
|
322
|
+
}
|
|
323
|
+
for (const [lineIndex, entry] of (Array.isArray(node.acceptance) ? node.acceptance : []).entries()) {
|
|
324
|
+
if (typeof entry === "string") continue;
|
|
325
|
+
if (entry === null || typeof entry !== "object" || typeof entry.line !== "string" || entry.line.trim() === "") {
|
|
326
|
+
errors.push(`nodes[${index}] (${node.id}) acceptance line ${lineIndex + 1} needs its line text`);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (entry.ref !== void 0 && !scope.hasAssessment(entry.ref)) errors.push(`nodes[${index}] (${node.id}) acceptance line ${lineIndex + 1} refs assessment ${JSON.stringify(entry.ref)}, which does not exist — compose it first (library_quiz), then draft`);
|
|
330
|
+
}
|
|
331
|
+
const pointCount = Array.isArray(node.points) ? node.points.length : 0;
|
|
332
|
+
if (node.gate === "quiz" && pointCount > 5) errors.push(`nodes[${index}] (${node.id}) has ${pointCount} points — one station is one lesson of 3–5 points: split it into consecutive stations, simple before hard`);
|
|
333
|
+
else if (node.gate === "quiz" && pointCount > 0 && pointCount < 3) errors.push(`nodes[${index}] (${node.id}) has ${pointCount} point${pointCount === 1 ? "" : "s"} — a lesson carries 3–5: merge it with a neighbour or name what else it teaches`);
|
|
334
|
+
if (!Array.isArray(node.covers) || node.covers.length === 0) errors.push(`nodes[${index}] (${node.id}) needs at least one cover — what the station draws on: "r_…#1-5" (a shelf resource + contents rows) | "https://…" (a web text) | "topic: …" (plain coached teaching)`);
|
|
335
|
+
else for (const cover of node.covers) {
|
|
336
|
+
if (cover === null || typeof cover !== "object" || typeof cover.ref !== "string" || cover.ref.trim() === "") {
|
|
337
|
+
errors.push(`nodes[${index}] (${node.id}) has a cover with no ref — "r_…#1-5" | "https://…" | "topic: …"`);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const kind = coverKind(cover);
|
|
341
|
+
if (kind === "resource") {
|
|
342
|
+
if (!isLibraryId(cover.ref)) {
|
|
343
|
+
errors.push(`nodes[${index}] (${node.id}) covers ${JSON.stringify(cover.ref)}, which is neither a shelf resource id (r_…) nor a usable ref — "r_…#1-5" | "https://…" | "topic: …"`);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
const covered = scope.resource(cover.ref);
|
|
347
|
+
if (covered === void 0) {
|
|
348
|
+
errors.push(`nodes[${index}] (${node.id}) covers resource ${JSON.stringify(cover.ref)}, which is not on the project's shelf — library_read · resources lists them; register it first (library_textbook)`);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (cover.locator !== void 0) {
|
|
352
|
+
if (!isThickResource(covered)) {
|
|
353
|
+
errors.push(`nodes[${index}] (${node.id}) puts a locator on 《${covered.title}》, which is a thin link (no contents) — a thin link is covered whole: drop the locator`);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
const ranges = parseLocator(cover.locator);
|
|
357
|
+
if (ranges.length === 0) {
|
|
358
|
+
errors.push(`nodes[${index}] (${node.id}) has an unreadable locator ${JSON.stringify(cover.locator)} — locators count contents rows, 1-based and inclusive: "1-5,7"`);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (covered.id === scope.resourceId && covered.origin === "authored" && ranges.reduce((rows, range) => rows + range.to - range.from + 1, 0) > 1) errors.push(`nodes[${index}] (${node.id}) spans ${cover.locator} of 《${covered.title}》, a textbook you authored — each of its chapters is one lesson and one station: one row per station (too much for one lesson → split the chapter in the contents first)`);
|
|
362
|
+
for (const range of ranges) if (range.to > (covered.toc?.length ?? 0)) {
|
|
363
|
+
const rows = covered.toc?.length ?? 0;
|
|
364
|
+
errors.push(`nodes[${index}] (${node.id}) covers up to row ${range.to}, but 《${covered.title}》's contents has ${rows} rows — library_read · toc lists them${rows < 3 ? `; a book with ${rows} row(s) was registered with its chapters joined — fix the contents first (library_textbook op update, resourceId ${covered.id}, toc one chapter per line), never drop the chapters` : ""}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
} else if (kind === "topic") {
|
|
368
|
+
if ((coverTopic(cover) ?? "").trim() === "") errors.push(`nodes[${index}] (${node.id}) covers a topic with no text — what the coach teaches here`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
for (const dep of Array.isArray(node.dependsOn) ? node.dependsOn : []) {
|
|
372
|
+
const depIndex = nodes.findIndex((candidate) => candidate.id === dep);
|
|
373
|
+
if (depIndex === -1) {
|
|
374
|
+
errors.push(`nodes[${index}] (${node.id}) depends on ${JSON.stringify(dep)}, which the roadmap does not declare`);
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (gateKnown && node.gate !== "read" && nodes[depIndex]?.gate === "read") {
|
|
378
|
+
errors.push(`nodes[${index}] (${node.id}) depends on read node ${JSON.stringify(dep)} — a read node is a side stop and never gates a level; depend on the previous quiz or acceptance node instead. How-to-use pages, answer keys, transcripts and appendices are not nodes at all: put them in the resource's extras`);
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if (depIndex >= index) errors.push(`nodes[${index}] (${node.id}) depends on ${JSON.stringify(dep)}, which comes AFTER it — list a node after everything it depends on`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (errors.length === 0) return void 0;
|
|
385
|
+
if (errors.length === 1) return errors[0];
|
|
386
|
+
return `${errors.length} shape errors — fix them all, then resend:\n${errors.map((error) => `- ${error}`).join("\n")}`;
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Why a node's covers forbid a memory-backed content question, or undefined
|
|
390
|
+
* when they allow one: only knowledge books and plain coached teaching (topic)
|
|
391
|
+
* may be quizzed from domain knowledge; a material book, a thin link or a
|
|
392
|
+
* bare url demands a verbatim quote. When the station covers its own
|
|
393
|
+
* textbook (`primaryId`), that cover alone decides — a side reference (a
|
|
394
|
+
* video, an article) never turns a knowledge chapter into a quote-only one.
|
|
395
|
+
* The evidence-gate's node-level half (a resource-scoped quiz without a node
|
|
396
|
+
* keeps the resource rule).
|
|
397
|
+
*/
|
|
398
|
+
function coversVerbatimBlocker(covers, resource, primaryId) {
|
|
399
|
+
const main = primaryId === void 0 ? [] : covers.filter((cover) => coverKind(cover) === "resource" && cover.ref === primaryId);
|
|
400
|
+
for (const cover of main.length > 0 ? main : covers) {
|
|
401
|
+
const kind = coverKind(cover);
|
|
402
|
+
if (kind === "resource") {
|
|
403
|
+
const covered = resource(cover.ref);
|
|
404
|
+
if (covered === void 0) continue;
|
|
405
|
+
if (isThickResource(covered)) {
|
|
406
|
+
if (covered.meta?.bookKind === "material") return `《${covered.title}》 is a material book (its exact text is what is tested)`;
|
|
407
|
+
} else return `the station draws on 《${covered.title}》, a thin link (a linked text you cannot recall reliably)`;
|
|
408
|
+
} else if (kind === "url") return `the station covers ${cover.ref} (a web text you cannot recall reliably)`;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* What keeps a 讲义 from reading as a lesson, or undefined: change logs and
|
|
413
|
+
* internal words never reach the learner; on a textbook you authored (the 讲义
|
|
414
|
+
* ARE its text) every knowledge point gets its own section — a heading naming
|
|
415
|
+
* the point, then enough to learn it from.
|
|
416
|
+
*/
|
|
417
|
+
function lessonProblems(text, node, authored) {
|
|
418
|
+
const problems = [];
|
|
419
|
+
if (/第\s*\d+\s*次更新|修正说明|更新说明|\bacceptance\b/i.test(text)) problems.push("the 讲义 are for the learner — drop change logs (第 N 次更新, 修正说明; the sidebar already counts revisions) and internal words (acceptance)");
|
|
420
|
+
const points = node.points ?? [];
|
|
421
|
+
if (authored && points.length > 0) {
|
|
422
|
+
const squash = (value) => value.replace(/[\s*_`]/g, "").toLowerCase();
|
|
423
|
+
const headings = [...text.matchAll(/^#{1,4}\s+(.+)$/gm)].map((match) => ({
|
|
424
|
+
title: squash(match[1] ?? ""),
|
|
425
|
+
at: match.index ?? 0,
|
|
426
|
+
end: (match.index ?? 0) + match[0].length
|
|
427
|
+
}));
|
|
428
|
+
const pointHeadings = points.map((point) => headings.find((heading) => heading.title.includes(squash(point.title))));
|
|
429
|
+
const starts = pointHeadings.flatMap((heading) => heading === void 0 ? [] : [heading.at]).sort((a, b) => a - b);
|
|
430
|
+
const missing = [];
|
|
431
|
+
const thin = [];
|
|
432
|
+
for (const [index, point] of points.entries()) {
|
|
433
|
+
const heading = pointHeadings[index];
|
|
434
|
+
if (heading === void 0) {
|
|
435
|
+
missing.push(point.title);
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
const next = starts.find((start) => start > heading.at) ?? text.length;
|
|
439
|
+
if (text.slice(heading.end, next).replace(/[\s#>*_`|-]/g, "").length < 120) thin.push(point.title);
|
|
440
|
+
}
|
|
441
|
+
if (missing.length > 0) problems.push(`the 讲义 are the lesson itself — the learner learns from them, not from a reading list: give each knowledge point its own section headed by its title (## ${points[0].title}) — missing: ${missing.join("、")}`);
|
|
442
|
+
if (thin.length > 0) problems.push(`too thin to learn from (each point: explain it plainly → an example or the steps → a common mistake → a 思考题; outside links belong in a final ## 参考): ${thin.join("、")}`);
|
|
443
|
+
}
|
|
444
|
+
return problems.length === 0 ? void 0 : problems.join("; ");
|
|
445
|
+
}
|
|
446
|
+
//#endregion
|
|
447
|
+
//#region lib/types/service/shelf.js
|
|
448
|
+
/**
|
|
449
|
+
* Projects and the shelf: create / edit / delete projects, register, place (seq), move and remove resources.
|
|
450
|
+
*
|
|
451
|
+
* One layer of the library service (see `service/core.ts` for the layering):
|
|
452
|
+
* the methods are the service's own, moved here verbatim by domain.
|
|
453
|
+
*
|
|
454
|
+
* @module @khorsheed/dsh-library/service/shelf
|
|
455
|
+
*/
|
|
456
|
+
var LibraryServiceShelf = class extends LibraryServiceCore {
|
|
457
|
+
/** One resource's progress row (a thick resource's roadmap; a thin link's bare row). */
|
|
458
|
+
resourceProgress(projectId, resource) {
|
|
459
|
+
const roadmap = isThickResource(resource) ? this.store.readRoadmap(projectId, resource.id) : void 0;
|
|
460
|
+
const current = roadmap?.nodes.find((node) => node.state === "current");
|
|
461
|
+
const draft = isThickResource(resource) ? this.store.readRoadmap(projectId, resource.id, "draft") : void 0;
|
|
462
|
+
return {
|
|
463
|
+
id: resource.id,
|
|
464
|
+
title: resource.title,
|
|
465
|
+
kind: resource.kind,
|
|
466
|
+
...resource.seq === void 0 ? {} : { seq: resource.seq },
|
|
467
|
+
...resource.origin === void 0 ? {} : { origin: resource.origin },
|
|
468
|
+
...roadmap === void 0 ? {} : { roadmap: {
|
|
469
|
+
done: roadmap.nodes.filter((node) => node.state === "done").length,
|
|
470
|
+
total: roadmap.nodes.length
|
|
471
|
+
} },
|
|
472
|
+
...current === void 0 ? {} : { current: {
|
|
473
|
+
id: current.id,
|
|
474
|
+
title: current.title
|
|
475
|
+
} },
|
|
476
|
+
hasDraft: draft !== void 0,
|
|
477
|
+
...draft === void 0 ? {} : { draftTotal: draft.nodes.length }
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
summarize(project) {
|
|
481
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
482
|
+
const cards = this.store.readCards(project.id).filter(live);
|
|
483
|
+
return {
|
|
484
|
+
...project,
|
|
485
|
+
resources: this.store.listResources(project.id).map((resource) => this.resourceProgress(project.id, resource)),
|
|
486
|
+
cardCount: cards.length,
|
|
487
|
+
dueCount: cards.filter((card) => card.status === "active" && card.mastery.dueAt <= now).length,
|
|
488
|
+
lastActiveAt: this.store.readEvents(project.id).at(-1)?.at ?? project.createdAt,
|
|
489
|
+
openMisses: (() => {
|
|
490
|
+
const assessments = this.store.listAssessments(project.id);
|
|
491
|
+
const attempts = this.store.readAttempts(project.id);
|
|
492
|
+
let count = 0;
|
|
493
|
+
for (const resource of this.store.listResources(project.id)) for (const node of this.store.readRoadmap(project.id, resource.id)?.nodes ?? []) if (node.gate === "quiz") count += stationMastery(node, assessments, attempts).open.length;
|
|
494
|
+
return count;
|
|
495
|
+
})()
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
/** Every project with its resources' progress and card tallies. */
|
|
499
|
+
async listProjects() {
|
|
500
|
+
this.assertAlive("listProjects");
|
|
501
|
+
const projects = this.store.readIndex().projects.map((project) => this.summarize(project));
|
|
502
|
+
projects.sort((a, b) => b.lastActiveAt.localeCompare(a.lastActiveAt));
|
|
503
|
+
return { projects };
|
|
504
|
+
}
|
|
505
|
+
/** One project's summary plus its resources' full records. */
|
|
506
|
+
async getProject(request) {
|
|
507
|
+
this.assertAlive("getProject");
|
|
508
|
+
const project = this.readProjectOrRefusal(request.id);
|
|
509
|
+
if ("error" in project) return project;
|
|
510
|
+
return {
|
|
511
|
+
ok: true,
|
|
512
|
+
project: this.summarize(project.value),
|
|
513
|
+
resources: this.store.listResources(project.value.id)
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
/** Create a project. */
|
|
517
|
+
async createProject(request) {
|
|
518
|
+
this.assertAlive("createProject");
|
|
519
|
+
const name = request.name.trim();
|
|
520
|
+
if (name === "") return refuse("invalid", "a project needs a name — what the learner calls the subject (「神经科学」「雅思」)");
|
|
521
|
+
if (request.template !== void 0 && ![
|
|
522
|
+
"language",
|
|
523
|
+
"subject",
|
|
524
|
+
"custom"
|
|
525
|
+
].includes(request.template)) return refuse("invalid", "template is language | subject | custom — language: marks are words; subject: marks are concepts");
|
|
526
|
+
try {
|
|
527
|
+
const outcome = await this.store.withIndexLock(async () => {
|
|
528
|
+
const index = this.store.readIndex();
|
|
529
|
+
if (index.projects.some((project) => project.name === name)) return refuse("exists", `a project named ${JSON.stringify(name)} already exists — open it (library_read · projects) instead of creating a twin`);
|
|
530
|
+
const template = request.template ?? "subject";
|
|
531
|
+
const project = {
|
|
532
|
+
id: mintLibraryId("p"),
|
|
533
|
+
name,
|
|
534
|
+
template,
|
|
535
|
+
color: request.color !== void 0 && LIBRARY_COLORS.includes(request.color) ? request.color : TEMPLATE_LOOK[template].color,
|
|
536
|
+
icon: request.icon !== void 0 && LIBRARY_ICONS.includes(request.icon) ? request.icon : TEMPLATE_LOOK[template].icon,
|
|
537
|
+
...request.templateNote === void 0 || request.templateNote.trim() === "" ? {} : { templateNote: request.templateNote.trim() },
|
|
538
|
+
...request.goal === void 0 || request.goal.trim() === "" ? {} : { goal: request.goal.trim() },
|
|
539
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
540
|
+
};
|
|
541
|
+
this.store.writeProject(project);
|
|
542
|
+
this.store.writeIndex({
|
|
543
|
+
...index,
|
|
544
|
+
projects: [...index.projects, project]
|
|
545
|
+
});
|
|
546
|
+
return {
|
|
547
|
+
ok: true,
|
|
548
|
+
project
|
|
549
|
+
};
|
|
550
|
+
});
|
|
551
|
+
if (outcome.ok) this.changed(outcome.project.id);
|
|
552
|
+
return outcome;
|
|
553
|
+
} catch (error) {
|
|
554
|
+
return storeRefusal(error);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
/** Change a project's look or goal (the sidebar's 「样式」). */
|
|
558
|
+
async updateProject(request) {
|
|
559
|
+
this.assertAlive("updateProject");
|
|
560
|
+
const patch = request.patch;
|
|
561
|
+
if (patch.color !== void 0 && !LIBRARY_COLORS.includes(patch.color)) return refuse("invalid", `color is one of ${LIBRARY_COLORS.join(" | ")}`);
|
|
562
|
+
if (patch.icon !== void 0 && !LIBRARY_ICONS.includes(patch.icon)) return refuse("invalid", `icon is one of ${LIBRARY_ICONS.join(" | ")}`);
|
|
563
|
+
if (patch.name !== void 0 && patch.name.trim() === "") return refuse("invalid", "a project keeps a name");
|
|
564
|
+
if (patch.template !== void 0 && ![
|
|
565
|
+
"language",
|
|
566
|
+
"subject",
|
|
567
|
+
"custom"
|
|
568
|
+
].includes(patch.template)) return refuse("invalid", "template is language | subject | custom — language: marks are words; subject: marks are concepts");
|
|
569
|
+
try {
|
|
570
|
+
const outcome = await this.store.withIndexLock(async () => {
|
|
571
|
+
const index = this.store.readIndex();
|
|
572
|
+
const project = index.projects.find((candidate) => candidate.id === request.id);
|
|
573
|
+
if (project === void 0) return refuse("not-found", `no project ${JSON.stringify(request.id)} — ids are minted (p_…); library_read · projects lists the ones that exist, and omitting projectId creates a new project`);
|
|
574
|
+
const { goal: _goal, ...rest } = project;
|
|
575
|
+
const goal = patch.goal === void 0 ? project.goal : patch.goal.trim();
|
|
576
|
+
const next = {
|
|
577
|
+
...rest,
|
|
578
|
+
...goal === void 0 || goal === "" ? {} : { goal },
|
|
579
|
+
...patch.color === void 0 ? {} : { color: patch.color },
|
|
580
|
+
...patch.icon === void 0 ? {} : { icon: patch.icon },
|
|
581
|
+
...patch.template === void 0 ? {} : { template: patch.template },
|
|
582
|
+
...patch.name === void 0 ? {} : { name: patch.name.trim() }
|
|
583
|
+
};
|
|
584
|
+
this.store.writeProject(next);
|
|
585
|
+
this.store.writeIndex({
|
|
586
|
+
...index,
|
|
587
|
+
projects: index.projects.map((candidate) => candidate.id === next.id ? next : candidate)
|
|
588
|
+
});
|
|
589
|
+
return {
|
|
590
|
+
ok: true,
|
|
591
|
+
project: next
|
|
592
|
+
};
|
|
593
|
+
});
|
|
594
|
+
if (outcome.ok) this.changed(request.id);
|
|
595
|
+
return outcome;
|
|
596
|
+
} catch (error) {
|
|
597
|
+
return storeRefusal(error);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
/** Delete a project and everything it holds. */
|
|
601
|
+
async deleteProject(request) {
|
|
602
|
+
this.assertAlive("deleteProject");
|
|
603
|
+
try {
|
|
604
|
+
const outcome = await this.store.withIndexLock(async () => {
|
|
605
|
+
const index = this.store.readIndex();
|
|
606
|
+
if (!index.projects.some((project) => project.id === request.id)) return refuse("not-found", `no project ${JSON.stringify(request.id)} — library_read · projects lists the ones that exist`);
|
|
607
|
+
this.store.removeProject(request.id);
|
|
608
|
+
this.store.writeIndex({
|
|
609
|
+
...index,
|
|
610
|
+
projects: index.projects.filter((project) => project.id !== request.id)
|
|
611
|
+
});
|
|
612
|
+
return {
|
|
613
|
+
ok: true,
|
|
614
|
+
id: request.id
|
|
615
|
+
};
|
|
616
|
+
});
|
|
617
|
+
if (outcome.ok) this.changed(request.id);
|
|
618
|
+
return outcome;
|
|
619
|
+
} catch (error) {
|
|
620
|
+
return storeRefusal(error);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Put one entry on the project's shelf — the v5 unified write. A THICK
|
|
625
|
+
* entry (kind "book", or any kind carrying `toc`) registers its contents:
|
|
626
|
+
* the coach's table of contents, verified on the web whenever it can be,
|
|
627
|
+
* and cover locators count its rows. A THIN link is title + url + the
|
|
628
|
+
* coach's one-line why. Pass `resourceId` to correct an existing entry.
|
|
629
|
+
*/
|
|
630
|
+
async putResource(request) {
|
|
631
|
+
this.assertAlive("putResource");
|
|
632
|
+
const project = this.readProjectOrRefusal(request.projectId);
|
|
633
|
+
if ("error" in project) return project;
|
|
634
|
+
if (request.from !== void 0) {
|
|
635
|
+
const source = this.store.readResource(request.from.projectId, request.from.resourceId);
|
|
636
|
+
if (source === void 0) return refuse("not-found", `no resource ${JSON.stringify(request.from.resourceId)} in project ${JSON.stringify(request.from.projectId)} — the clone source must exist; library_read · resources there lists them`);
|
|
637
|
+
return await this.mutate(project.value.id, () => {
|
|
638
|
+
const existing = request.resourceId === void 0 ? void 0 : this.store.readResource(project.value.id, request.resourceId);
|
|
639
|
+
if (request.resourceId !== void 0 && existing === void 0) return refuse("not-found", `no resource ${JSON.stringify(request.resourceId)} in this project — omit resourceId to clone a fresh one`);
|
|
640
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
641
|
+
const { seq: _sourceSeq, ...copied } = source;
|
|
642
|
+
const resource = {
|
|
643
|
+
...copied,
|
|
644
|
+
id: existing?.id ?? mintLibraryId("r"),
|
|
645
|
+
...existing?.seq === void 0 ? {} : { seq: existing.seq },
|
|
646
|
+
createdAt: existing?.createdAt ?? now
|
|
647
|
+
};
|
|
648
|
+
this.store.writeResource(project.value.id, resource);
|
|
649
|
+
if (isThickResource(resource)) this.placeTextbook(project.value.id, resource.id, existing?.seq ?? request.seq);
|
|
650
|
+
if (existing === void 0) this.event(project.value.id, "resource.added", {
|
|
651
|
+
id: resource.id,
|
|
652
|
+
title: resource.title,
|
|
653
|
+
resourceId: resource.id,
|
|
654
|
+
...resource.toc === void 0 ? {} : { count: resource.toc.length }
|
|
655
|
+
});
|
|
656
|
+
return {
|
|
657
|
+
ok: true,
|
|
658
|
+
resource
|
|
659
|
+
};
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
const title = request.title?.trim() ?? "";
|
|
663
|
+
if (title === "") return refuse("invalid", "a resource needs its title — what its own page / cover calls it");
|
|
664
|
+
if (request.origin !== void 0 && !LIBRARY_RESOURCE_ORIGINS.includes(request.origin)) return refuse("invalid", "origin is learner | picked | authored — learner: the learner's own; picked: an existing book / course / link you chose; authored: a textbook you wrote yourself (your contents, your 讲义 are its text)");
|
|
665
|
+
if (request.seq !== void 0 && !(Number.isInteger(request.seq) && request.seq > 0)) return refuse("invalid", "seq is the textbook's place in the study order — a whole number from 1");
|
|
666
|
+
if (!LIBRARY_RESOURCE_KINDS.includes(request.kind)) return refuse("invalid", `kind is ${LIBRARY_RESOURCE_KINDS.join(" | ")} — book: a book (contents required); course: a lecture series with episodes (contents required); article | video | repo | tool | hardware: a thin link (url required)`);
|
|
667
|
+
const wantsToc = request.toc !== void 0;
|
|
668
|
+
const toc = (request.toc ?? []).filter((entry) => entry.title.trim() !== "" && entry.title.trim() !== entry.part?.trim());
|
|
669
|
+
if (request.kind === "book" || wantsToc) {
|
|
670
|
+
if (toc.length === 0) return refuse("invalid", "a thick resource (a book, a course with episodes) needs its table of contents — search the web for the contents (publisher page, library catalog), or ask the learner to photograph the contents page; each row: { title, part? }");
|
|
671
|
+
if (request.tocSource === void 0 || ![
|
|
672
|
+
"web",
|
|
673
|
+
"model",
|
|
674
|
+
"photo"
|
|
675
|
+
].includes(request.tocSource.by)) return refuse("invalid", "tocSource.by is web | model | photo — say where the contents came from; web needs the url");
|
|
676
|
+
if (request.tocSource.by === "web" && (request.tocSource.url === void 0 || !/^https?:\/\//.test(request.tocSource.url))) return refuse("invalid", "a web-sourced table of contents carries the url it came from (tocSource.url) — the learner checks it on the draft card");
|
|
677
|
+
if (request.bookKind !== void 0 && request.bookKind !== "knowledge" && request.bookKind !== "material") return refuse("invalid", "bookKind is knowledge | material — knowledge: the content is domain knowledge (a textbook, popular science; the coach may quiz from what it knows); material: specific text the model cannot recall (exam papers, novels; quizzing needs a photo or a web quote)");
|
|
678
|
+
} else {
|
|
679
|
+
const url = request.url?.trim() ?? "";
|
|
680
|
+
if (!/^https?:\/\//.test(url)) return refuse("invalid", "a thin link's url starts with http:// or https:// — the learner opens it straight off the shelf");
|
|
681
|
+
}
|
|
682
|
+
return await this.mutate(project.value.id, () => {
|
|
683
|
+
const existing = request.resourceId === void 0 ? void 0 : this.store.readResource(project.value.id, request.resourceId);
|
|
684
|
+
if (request.resourceId !== void 0 && existing === void 0) return refuse("not-found", `no resource ${JSON.stringify(request.resourceId)} in this project — omit resourceId to add a new one; library_read · resources lists the existing ids`);
|
|
685
|
+
if (existing === void 0 && toc.length > 0 && this.store.listResources(project.value.id).some((resource) => resource.title === title && resource.meta?.edition === request.edition?.trim() && isThickResource(resource))) return refuse("exists", `《${title}》 is already on this project's shelf — pass its resourceId to correct it`);
|
|
686
|
+
if (existing === void 0 && toc.length === 0 && request.url !== void 0 && this.store.listResources(project.value.id).some((resource) => resource.url === request.url?.trim())) return refuse("exists", `${request.url.trim()} is already on this project's shelf — pass its resourceId to correct it`);
|
|
687
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
688
|
+
const meta = (() => {
|
|
689
|
+
const merged = {
|
|
690
|
+
...(() => {
|
|
691
|
+
const bookKind = request.bookKind ?? existing?.meta?.bookKind ?? (toc.length > 0 ? "knowledge" : void 0);
|
|
692
|
+
return bookKind === void 0 ? {} : { bookKind };
|
|
693
|
+
})(),
|
|
694
|
+
...request.short === void 0 || request.short.trim() === "" ? existing?.meta?.short === void 0 ? {} : { short: existing.meta.short } : { short: request.short.trim().slice(0, 8) },
|
|
695
|
+
...request.extras === void 0 ? existing?.meta?.extras === void 0 ? {} : { extras: existing.meta.extras } : { extras: request.extras.filter((extra) => typeof extra.title === "string" && extra.title.trim() !== "").map((extra) => ({
|
|
696
|
+
title: extra.title.trim(),
|
|
697
|
+
...extra.note === void 0 || extra.note.trim() === "" ? {} : { note: extra.note.trim() },
|
|
698
|
+
...extra.where === void 0 || extra.where.trim() === "" ? {} : { where: extra.where.trim() },
|
|
699
|
+
when: extra.when === "reference" ? "reference" : "before"
|
|
700
|
+
})) },
|
|
701
|
+
...request.author === void 0 || request.author.trim() === "" ? existing?.meta?.author === void 0 ? {} : { author: existing.meta.author } : { author: request.author.trim() },
|
|
702
|
+
...request.edition === void 0 || request.edition.trim() === "" ? existing?.meta?.edition === void 0 ? {} : { edition: existing.meta.edition } : { edition: request.edition.trim() },
|
|
703
|
+
...request.isbn === void 0 || request.isbn.trim() === "" ? existing?.meta?.isbn === void 0 ? {} : { isbn: existing.meta.isbn } : { isbn: request.isbn.trim() }
|
|
704
|
+
};
|
|
705
|
+
return Object.keys(merged).length === 0 ? void 0 : merged;
|
|
706
|
+
})();
|
|
707
|
+
const resource = {
|
|
708
|
+
id: existing?.id ?? mintLibraryId("r"),
|
|
709
|
+
title,
|
|
710
|
+
kind: request.kind,
|
|
711
|
+
...request.url === void 0 || request.url.trim() === "" ? existing?.url === void 0 ? {} : { url: existing.url } : { url: request.url.trim() },
|
|
712
|
+
...request.note === void 0 || request.note.trim() === "" ? existing?.note === void 0 ? {} : { note: existing.note } : { note: request.note.trim() },
|
|
713
|
+
origin: request.origin ?? existing?.origin ?? "picked",
|
|
714
|
+
...existing?.seq === void 0 || toc.length === 0 ? {} : { seq: existing.seq },
|
|
715
|
+
...toc.length === 0 ? {} : { toc: toc.map((entry, index) => ({
|
|
716
|
+
n: index + 1,
|
|
717
|
+
title: entry.title.trim(),
|
|
718
|
+
...entry.part === void 0 || entry.part.trim() === "" ? {} : { part: entry.part.trim() }
|
|
719
|
+
})) },
|
|
720
|
+
...request.tocSource === void 0 ? existing?.tocSource === void 0 ? {} : { tocSource: existing.tocSource } : { tocSource: {
|
|
721
|
+
...request.tocSource,
|
|
722
|
+
verifiedAt: request.tocSource.verifiedAt ?? now
|
|
723
|
+
} },
|
|
724
|
+
...meta === void 0 ? {} : { meta },
|
|
725
|
+
createdAt: existing?.createdAt ?? now
|
|
726
|
+
};
|
|
727
|
+
this.store.writeResource(project.value.id, resource);
|
|
728
|
+
const placed = isThickResource(resource) ? this.placeTextbook(project.value.id, resource.id, request.seq ?? existing?.seq) : resource;
|
|
729
|
+
if (existing === void 0) this.event(project.value.id, "resource.added", {
|
|
730
|
+
id: resource.id,
|
|
731
|
+
title: resource.title,
|
|
732
|
+
resourceId: resource.id,
|
|
733
|
+
...resource.toc === void 0 ? {} : { count: resource.toc.length }
|
|
734
|
+
});
|
|
735
|
+
return {
|
|
736
|
+
ok: true,
|
|
737
|
+
resource: placed
|
|
738
|
+
};
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Renumber a project's textbooks gapless from 1 with `resourceId` at
|
|
743
|
+
* `seq` (clamped; omitted = keep its place, or last when it has none).
|
|
744
|
+
* Call inside the project lock. Returns the placed resource as stored.
|
|
745
|
+
*/
|
|
746
|
+
placeTextbook(projectId, resourceId, seq) {
|
|
747
|
+
const textbooks = this.store.listResources(projectId).filter(isThickResource);
|
|
748
|
+
const moving = textbooks.find((candidate) => candidate.id === resourceId);
|
|
749
|
+
const others = textbooks.filter((candidate) => candidate.id !== resourceId);
|
|
750
|
+
if (moving !== void 0) {
|
|
751
|
+
const at = seq === void 0 ? moving.seq === void 0 ? others.length : Math.min(others.filter((other) => (other.seq ?? Infinity) < moving.seq).length, others.length) : Math.min(Math.max(seq - 1, 0), others.length);
|
|
752
|
+
others.splice(at, 0, moving);
|
|
753
|
+
}
|
|
754
|
+
let placed;
|
|
755
|
+
for (const [index, textbook] of others.entries()) {
|
|
756
|
+
const next = textbook.seq === index + 1 ? textbook : {
|
|
757
|
+
...textbook,
|
|
758
|
+
seq: index + 1
|
|
759
|
+
};
|
|
760
|
+
if (next !== textbook) this.store.writeResource(projectId, next);
|
|
761
|
+
if (next.id === resourceId) placed = next;
|
|
762
|
+
}
|
|
763
|
+
return placed ?? this.store.readResource(projectId, resourceId);
|
|
764
|
+
}
|
|
765
|
+
/** Switch a textbook between 知识型 (knowledge) and 材料型 (material) — the learner's call on the textbook page. */
|
|
766
|
+
async setBookKind(request) {
|
|
767
|
+
this.assertAlive("setBookKind");
|
|
768
|
+
const project = this.readProjectOrRefusal(request.projectId);
|
|
769
|
+
if ("error" in project) return project;
|
|
770
|
+
if (request.bookKind !== "knowledge" && request.bookKind !== "material") return refuse("invalid", "bookKind is knowledge | material");
|
|
771
|
+
return await this.mutate(project.value.id, () => {
|
|
772
|
+
const resource = this.store.readResource(project.value.id, request.resourceId);
|
|
773
|
+
if (resource === void 0 || !isThickResource(resource)) return refuse("not-found", `no textbook ${JSON.stringify(request.resourceId)} in this project`);
|
|
774
|
+
const next = {
|
|
775
|
+
...resource,
|
|
776
|
+
meta: {
|
|
777
|
+
...resource.meta,
|
|
778
|
+
bookKind: request.bookKind
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
this.store.writeResource(project.value.id, next);
|
|
782
|
+
return {
|
|
783
|
+
ok: true,
|
|
784
|
+
resource: next
|
|
785
|
+
};
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
/** Move a textbook to another place in the study order (the rest shift). */
|
|
789
|
+
async moveResource(request) {
|
|
790
|
+
this.assertAlive("moveResource");
|
|
791
|
+
const project = this.readProjectOrRefusal(request.projectId);
|
|
792
|
+
if ("error" in project) return project;
|
|
793
|
+
if (!(Number.isInteger(request.seq) && request.seq > 0)) return refuse("invalid", "seq is a place in the study order — a whole number from 1");
|
|
794
|
+
return await this.mutate(project.value.id, () => {
|
|
795
|
+
const resource = this.store.readResource(project.value.id, request.resourceId);
|
|
796
|
+
if (resource === void 0) return refuse("not-found", `no resource ${JSON.stringify(request.resourceId)} in this project — library_read · resources lists them`);
|
|
797
|
+
if (!isThickResource(resource)) return refuse("invalid", `《${resource.title}》 is a link without contents — only textbooks have a place in the study order`);
|
|
798
|
+
this.placeTextbook(project.value.id, resource.id, request.seq);
|
|
799
|
+
return {
|
|
800
|
+
ok: true,
|
|
801
|
+
resources: this.store.listResources(project.value.id)
|
|
802
|
+
};
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
/** Remove a shelf entry: its record, roadmaps and pages (cards stay, unhung); the textbooks behind it move up. */
|
|
806
|
+
async removeResource(request) {
|
|
807
|
+
this.assertAlive("removeResource");
|
|
808
|
+
const project = this.readProjectOrRefusal(request.projectId);
|
|
809
|
+
if ("error" in project) return project;
|
|
810
|
+
return await this.mutate(project.value.id, () => {
|
|
811
|
+
if (this.store.readResource(project.value.id, request.resourceId) === void 0) return refuse("not-found", `no resource ${JSON.stringify(request.resourceId)} in this project`);
|
|
812
|
+
this.store.removeResource(project.value.id, request.resourceId);
|
|
813
|
+
this.placeTextbook(project.value.id, request.resourceId, void 0);
|
|
814
|
+
const cards = this.store.readCards(project.value.id);
|
|
815
|
+
if (cards.some((card) => card.resourceId === request.resourceId)) this.store.writeCards(project.value.id, cards.map((card) => {
|
|
816
|
+
if (card.resourceId !== request.resourceId) return card;
|
|
817
|
+
const { resourceId: _dropped, ...rest } = card;
|
|
818
|
+
return rest;
|
|
819
|
+
}));
|
|
820
|
+
return {
|
|
821
|
+
ok: true,
|
|
822
|
+
id: request.resourceId
|
|
823
|
+
};
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
/** One shelf entry, whole. */
|
|
827
|
+
async getResource(request) {
|
|
828
|
+
this.assertAlive("getResource");
|
|
829
|
+
const resource = this.store.readResource(request.projectId, request.resourceId);
|
|
830
|
+
if (resource === void 0) return refuse("not-found", `no resource ${JSON.stringify(request.resourceId)} — library_read · resources lists the project's`);
|
|
831
|
+
return {
|
|
832
|
+
ok: true,
|
|
833
|
+
resource
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
/** A project's shelf: textbooks in study order, then links oldest first. */
|
|
837
|
+
async listResources(request) {
|
|
838
|
+
this.assertAlive("listResources");
|
|
839
|
+
return { resources: this.store.listResources(request.projectId) };
|
|
840
|
+
}
|
|
841
|
+
/** The project holding a resource, when an id is all the caller has (the tools' project fallback). */
|
|
842
|
+
findResourceOwner(resourceId) {
|
|
843
|
+
this.assertAlive("findResourceOwner");
|
|
844
|
+
for (const project of this.store.readIndex().projects) if (this.store.readResource(project.id, resourceId) !== void 0) return project.id;
|
|
845
|
+
}
|
|
846
|
+
};
|
|
847
|
+
//#endregion
|
|
848
|
+
//#region lib/types/service/cards.js
|
|
849
|
+
/**
|
|
850
|
+
* Knowledge cards: the dedupe write path, listing, editing, undo, review scheduling.
|
|
851
|
+
*
|
|
852
|
+
* One layer of the library service (see `service/core.ts` for the layering):
|
|
853
|
+
* the methods are the service's own, moved here verbatim by domain.
|
|
854
|
+
*
|
|
855
|
+
* @module @khorsheed/dsh-library/service/cards
|
|
856
|
+
*/
|
|
857
|
+
var LibraryServiceCards = class extends LibraryServiceShelf {
|
|
858
|
+
/**
|
|
859
|
+
* Apply card inputs to the project's cards (call inside the project lock).
|
|
860
|
+
* Exact keys merge; near matches are held back unless `mergeNear` (photo
|
|
861
|
+
* words — a plural or a case away is the same word) or the input decides.
|
|
862
|
+
*/
|
|
863
|
+
applyCardInputs(projectId, inputs, defaultResourceId, options = {}) {
|
|
864
|
+
const cards = this.store.readCards(projectId);
|
|
865
|
+
const byId = new Map(cards.map((card) => [card.id, card]));
|
|
866
|
+
const order = cards.map((card) => card.id);
|
|
867
|
+
const added = [];
|
|
868
|
+
const merged = [];
|
|
869
|
+
const pending = [];
|
|
870
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
871
|
+
for (const [index, input] of inputs.entries()) {
|
|
872
|
+
const keys = [input.term, ...input.aliases ?? []].map(cardKey).filter((key) => key !== "");
|
|
873
|
+
const pool = [...byId.values()];
|
|
874
|
+
let target;
|
|
875
|
+
if (input.mergeInto !== void 0) target = byId.get(input.mergeInto);
|
|
876
|
+
else if (input.asNew !== true) {
|
|
877
|
+
target = exactMatch(pool, keys);
|
|
878
|
+
if (target === void 0) {
|
|
879
|
+
const near = nearMatches(pool, keys);
|
|
880
|
+
if (near.length > 0) {
|
|
881
|
+
if (options.mergeNear === true) target = near[0];
|
|
882
|
+
else {
|
|
883
|
+
pending.push({
|
|
884
|
+
index,
|
|
885
|
+
term: input.term,
|
|
886
|
+
candidates: near.map((card) => ({
|
|
887
|
+
id: card.id,
|
|
888
|
+
term: card.term,
|
|
889
|
+
gloss: card.gloss
|
|
890
|
+
}))
|
|
891
|
+
});
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
const resourceId = input.resourceId ?? defaultResourceId;
|
|
898
|
+
const source = {
|
|
899
|
+
type: input.source.type,
|
|
900
|
+
...input.source.ref === void 0 ? {} : { ref: input.source.ref },
|
|
901
|
+
...input.source.note === void 0 || input.source.note.trim() === "" ? {} : { note: input.source.note.trim() },
|
|
902
|
+
...input.weaken === true ? { weakened: true } : {},
|
|
903
|
+
at: now
|
|
904
|
+
};
|
|
905
|
+
const context = input.context === void 0 || input.context.sentence.trim() === "" ? void 0 : {
|
|
906
|
+
sentence: input.context.sentence.trim(),
|
|
907
|
+
...resourceId === void 0 ? {} : { resourceId },
|
|
908
|
+
...input.context.pageId === void 0 ? {} : { pageId: input.context.pageId },
|
|
909
|
+
...input.context.nodeId === void 0 ? {} : { nodeId: input.context.nodeId }
|
|
910
|
+
};
|
|
911
|
+
if (target !== void 0) {
|
|
912
|
+
const aliases = new Set(target.aliases);
|
|
913
|
+
const ownKeys = new Set(cardKeys(target));
|
|
914
|
+
if (!ownKeys.has(cardKey(input.term))) aliases.add(input.term.trim());
|
|
915
|
+
for (const alias of input.aliases ?? []) if (!ownKeys.has(cardKey(alias))) aliases.add(alias.trim());
|
|
916
|
+
const placeholder = target.gloss === "(待补释义)";
|
|
917
|
+
const next = {
|
|
918
|
+
...target,
|
|
919
|
+
aliases: [...aliases],
|
|
920
|
+
gloss: placeholder ? input.gloss.trim() : target.gloss,
|
|
921
|
+
...target.userGloss === void 0 && input.userGloss !== void 0 ? { userGloss: input.userGloss.trim() } : {},
|
|
922
|
+
contexts: context === void 0 || target.contexts.some((existing) => existing.sentence === context.sentence) ? target.contexts : [...target.contexts, context],
|
|
923
|
+
...target.resourceId === void 0 && resourceId !== void 0 ? { resourceId } : {},
|
|
924
|
+
sources: [...target.sources, source],
|
|
925
|
+
weakness: clampWeakness(target.weakness + (input.weaken === true ? 1 : 0)),
|
|
926
|
+
status: target.status === "deleted" ? "active" : target.status,
|
|
927
|
+
updatedAt: now
|
|
928
|
+
};
|
|
929
|
+
byId.set(next.id, next);
|
|
930
|
+
merged.push(next);
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
const card = {
|
|
934
|
+
id: mintLibraryId("c"),
|
|
935
|
+
kind: input.kind,
|
|
936
|
+
term: input.term.trim(),
|
|
937
|
+
aliases: (input.aliases ?? []).map((alias) => alias.trim()).filter((alias) => alias !== "" && cardKey(alias) !== cardKey(input.term)),
|
|
938
|
+
gloss: input.gloss.trim(),
|
|
939
|
+
...input.userGloss === void 0 || input.userGloss.trim() === "" ? {} : { userGloss: input.userGloss.trim() },
|
|
940
|
+
contexts: context === void 0 ? [] : [context],
|
|
941
|
+
...resourceId === void 0 ? {} : { resourceId },
|
|
942
|
+
sources: [source],
|
|
943
|
+
weakness: input.weaken === true || input.source.type === "chat" ? 1 : 0,
|
|
944
|
+
mastery: {
|
|
945
|
+
box: 0,
|
|
946
|
+
dueAt: now,
|
|
947
|
+
streak: 0
|
|
948
|
+
},
|
|
949
|
+
status: "active",
|
|
950
|
+
createdAt: now,
|
|
951
|
+
updatedAt: now
|
|
952
|
+
};
|
|
953
|
+
byId.set(card.id, card);
|
|
954
|
+
order.push(card.id);
|
|
955
|
+
added.push(card);
|
|
956
|
+
}
|
|
957
|
+
if (added.length > 0 || merged.length > 0) {
|
|
958
|
+
this.store.writeCards(projectId, order.map((cardId) => byId.get(cardId)));
|
|
959
|
+
const touched = [...added, ...merged];
|
|
960
|
+
const sourceType = inputs[0]?.source.type;
|
|
961
|
+
this.event(projectId, "cards.recorded", {
|
|
962
|
+
at: now,
|
|
963
|
+
title: touched.map((card) => card.term).join("、").slice(0, 300),
|
|
964
|
+
ids: touched.map((card) => card.id),
|
|
965
|
+
...sourceType === void 0 ? {} : { source: sourceType },
|
|
966
|
+
...sourceType === "chat" && inputs[0]?.source.ref !== void 0 ? { session: inputs[0].source.ref } : {},
|
|
967
|
+
...defaultResourceId === void 0 ? {} : { resourceId: defaultResourceId },
|
|
968
|
+
count: added.length,
|
|
969
|
+
count2: merged.length
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
return {
|
|
973
|
+
added,
|
|
974
|
+
merged,
|
|
975
|
+
pending,
|
|
976
|
+
at: now
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Record cards (the coach's 「记下」, a manual add). Near-duplicates come back
|
|
981
|
+
* in `pending` with their candidates — resend each with `mergeInto` or `asNew`.
|
|
982
|
+
*/
|
|
983
|
+
async writeCards(request) {
|
|
984
|
+
this.assertAlive("writeCards");
|
|
985
|
+
const project = this.readProjectOrRefusal(request.projectId);
|
|
986
|
+
if ("error" in project) return project;
|
|
987
|
+
if (request.cards.length === 0) return refuse("invalid", "cards needs at least one card — each: { kind: word|concept, term, gloss, context?, source: { type: chat|photo|quiz|manual, note? }, weaken? }");
|
|
988
|
+
const errors = [];
|
|
989
|
+
for (const [index, input] of request.cards.entries()) {
|
|
990
|
+
if (input.kind !== "word" && input.kind !== "concept") errors.push(`cards[${index}].kind is word | concept`);
|
|
991
|
+
if (input.term.trim() === "" || input.gloss.trim() === "") errors.push(`cards[${index}] needs term and gloss — the gloss is the in-context meaning (a word) or a one-line explanation (a concept)`);
|
|
992
|
+
if (![
|
|
993
|
+
"photo",
|
|
994
|
+
"chat",
|
|
995
|
+
"quiz",
|
|
996
|
+
"manual"
|
|
997
|
+
].includes(input.source?.type)) errors.push(`cards[${index}].source.type is chat | photo | quiz | manual — chat: the learner asked / got stuck in conversation`);
|
|
998
|
+
}
|
|
999
|
+
const cards = this.store.readCards(project.value.id);
|
|
1000
|
+
for (const [index, input] of request.cards.entries()) if (input.mergeInto !== void 0 && !cards.some((card) => card.id === input.mergeInto && live(card))) errors.push(`cards[${index}].mergeInto names ${JSON.stringify(input.mergeInto)}, which is not a live card — use an id from the pending candidates`);
|
|
1001
|
+
if (errors.length === 1) return refuse("invalid", errors[0]);
|
|
1002
|
+
if (errors.length > 1) return refuse("invalid", `${errors.length} problems in cards — fix them all, then resend:\n${errors.map((error) => `- ${error}`).join("\n")}`);
|
|
1003
|
+
if (request.resourceId !== void 0 && this.store.readResource(project.value.id, request.resourceId) === void 0) return refuse("not-found", `no resource ${JSON.stringify(request.resourceId)} in this project — omit resource for a project-level card`);
|
|
1004
|
+
return await this.mutate(project.value.id, () => {
|
|
1005
|
+
const { at: _at, ...result } = this.applyCardInputs(project.value.id, request.cards, request.resourceId);
|
|
1006
|
+
return {
|
|
1007
|
+
ok: true,
|
|
1008
|
+
...result
|
|
1009
|
+
};
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
/** Cards, filtered and sorted (the sidebar list, the coach's search). */
|
|
1013
|
+
async listCards(request) {
|
|
1014
|
+
this.assertAlive("listCards");
|
|
1015
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1016
|
+
let cards = this.store.readCards(request.projectId).filter(live);
|
|
1017
|
+
if (request.includeMastered !== true) cards = cards.filter((card) => card.status === "active");
|
|
1018
|
+
if (request.resourceId !== void 0) cards = cards.filter((card) => card.resourceId === request.resourceId);
|
|
1019
|
+
if (request.nodeId !== void 0) cards = cards.filter((card) => card.contexts.some((context) => context.nodeId === request.nodeId));
|
|
1020
|
+
if (request.kind !== void 0) cards = cards.filter((card) => card.kind === request.kind);
|
|
1021
|
+
if (request.source !== void 0) cards = cards.filter((card) => card.sources.some((source) => source.type === request.source));
|
|
1022
|
+
if (request.weakMin !== void 0) cards = cards.filter((card) => card.weakness >= request.weakMin);
|
|
1023
|
+
if (request.due === true) cards = cards.filter((card) => card.mastery.dueAt <= now);
|
|
1024
|
+
if (request.query !== void 0 && request.query.trim() !== "") cards = cards.filter((card) => cardMatchesQuery(card, request.query));
|
|
1025
|
+
const sort = request.sort ?? "weak";
|
|
1026
|
+
cards.sort((a, b) => sort === "recent" ? b.updatedAt.localeCompare(a.updatedAt) : sort === "due" ? a.mastery.dueAt.localeCompare(b.mastery.dueAt) : b.weakness - a.weakness || a.mastery.dueAt.localeCompare(b.mastery.dueAt));
|
|
1027
|
+
return {
|
|
1028
|
+
cards: cards.slice(0, request.limit ?? 200),
|
|
1029
|
+
total: cards.length
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
/** The cards due for review across projects (or one), weakest first. */
|
|
1033
|
+
async dueCards(request) {
|
|
1034
|
+
this.assertAlive("dueCards");
|
|
1035
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1036
|
+
const projectIds = request.projectId !== void 0 ? [request.projectId] : this.store.readIndex().projects.map((project) => project.id);
|
|
1037
|
+
const due = [];
|
|
1038
|
+
for (const projectId of projectIds) for (const card of this.store.readCards(projectId)) if (card.status === "active" && card.mastery.dueAt <= now) due.push({
|
|
1039
|
+
...card,
|
|
1040
|
+
projectId
|
|
1041
|
+
});
|
|
1042
|
+
due.sort((a, b) => a.mastery.box - b.mastery.box || b.weakness - a.weakness || a.mastery.dueAt.localeCompare(b.mastery.dueAt));
|
|
1043
|
+
return { cards: due.slice(0, request.limit ?? 200) };
|
|
1044
|
+
}
|
|
1045
|
+
/** Edit a card by hand: its words, or its status (mastered / deleted / active). */
|
|
1046
|
+
async editCard(request) {
|
|
1047
|
+
this.assertAlive("editCard");
|
|
1048
|
+
const project = this.readProjectOrRefusal(request.projectId);
|
|
1049
|
+
if ("error" in project) return project;
|
|
1050
|
+
return await this.mutate(project.value.id, () => {
|
|
1051
|
+
const cards = this.store.readCards(project.value.id);
|
|
1052
|
+
const card = cards.find((candidate) => candidate.id === request.cardId);
|
|
1053
|
+
if (card === void 0) return refuse("not-found", `no card ${JSON.stringify(request.cardId)}`);
|
|
1054
|
+
const patch = request.patch;
|
|
1055
|
+
if (patch.term !== void 0 && patch.term.trim() === "") return refuse("invalid", "a card keeps a term");
|
|
1056
|
+
if (patch.gloss !== void 0 && patch.gloss.trim() === "") return refuse("invalid", "a card keeps a gloss");
|
|
1057
|
+
const { resourceId: _old, ...rest } = card;
|
|
1058
|
+
const next = {
|
|
1059
|
+
...patch.resourceId === null ? rest : card,
|
|
1060
|
+
...patch.term === void 0 ? {} : { term: patch.term.trim() },
|
|
1061
|
+
...patch.gloss === void 0 ? {} : { gloss: patch.gloss.trim() },
|
|
1062
|
+
...patch.userGloss === void 0 ? {} : { userGloss: patch.userGloss.trim() },
|
|
1063
|
+
...patch.aliases === void 0 ? {} : { aliases: patch.aliases.map((alias) => alias.trim()).filter((alias) => alias !== "") },
|
|
1064
|
+
...patch.status === void 0 ? {} : { status: patch.status },
|
|
1065
|
+
...typeof patch.resourceId === "string" ? { resourceId: patch.resourceId } : {},
|
|
1066
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1067
|
+
};
|
|
1068
|
+
this.store.writeCards(project.value.id, cards.map((candidate) => candidate.id === card.id ? next : candidate));
|
|
1069
|
+
this.event(project.value.id, "card.edited", {
|
|
1070
|
+
id: card.id,
|
|
1071
|
+
title: next.term
|
|
1072
|
+
});
|
|
1073
|
+
return {
|
|
1074
|
+
ok: true,
|
|
1075
|
+
card: next
|
|
1076
|
+
};
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Undo one recording (the toast's 撤销): cards it created are deleted; cards
|
|
1081
|
+
* it merged into lose the source it stacked (and the weakness it added).
|
|
1082
|
+
*/
|
|
1083
|
+
async undoCards(request) {
|
|
1084
|
+
this.assertAlive("undoCards");
|
|
1085
|
+
const project = this.readProjectOrRefusal(request.projectId);
|
|
1086
|
+
if ("error" in project) return project;
|
|
1087
|
+
return await this.mutate(project.value.id, () => {
|
|
1088
|
+
const wanted = new Set(request.ids);
|
|
1089
|
+
let removed = 0;
|
|
1090
|
+
let reverted = 0;
|
|
1091
|
+
const cards = this.store.readCards(project.value.id).map((card) => {
|
|
1092
|
+
if (!wanted.has(card.id)) return card;
|
|
1093
|
+
if (card.createdAt === request.at) {
|
|
1094
|
+
removed += 1;
|
|
1095
|
+
return {
|
|
1096
|
+
...card,
|
|
1097
|
+
status: "deleted",
|
|
1098
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
const dropped = card.sources.filter((source) => source.at === request.at);
|
|
1102
|
+
if (dropped.length === 0) return card;
|
|
1103
|
+
reverted += 1;
|
|
1104
|
+
return {
|
|
1105
|
+
...card,
|
|
1106
|
+
sources: card.sources.filter((source) => source.at !== request.at),
|
|
1107
|
+
weakness: clampWeakness(card.weakness - dropped.filter((source) => source.weakened === true).length),
|
|
1108
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1109
|
+
};
|
|
1110
|
+
});
|
|
1111
|
+
this.store.writeCards(project.value.id, cards);
|
|
1112
|
+
return {
|
|
1113
|
+
ok: true,
|
|
1114
|
+
removed,
|
|
1115
|
+
reverted
|
|
1116
|
+
};
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* One flashcard review (the daily 复习): only the schedule moves — forgot
|
|
1121
|
+
* resets to box 0 (tomorrow), fuzzy keeps the box, recalled climbs one.
|
|
1122
|
+
* Reviews never unlock nodes and never touch weakness.
|
|
1123
|
+
*/
|
|
1124
|
+
async reviewCard(request) {
|
|
1125
|
+
this.assertAlive("reviewCard");
|
|
1126
|
+
if (![
|
|
1127
|
+
"forgot",
|
|
1128
|
+
"fuzzy",
|
|
1129
|
+
"recalled"
|
|
1130
|
+
].includes(request.rating)) return refuse("invalid", "rating is forgot | fuzzy | recalled");
|
|
1131
|
+
const project = this.readProjectOrRefusal(request.projectId);
|
|
1132
|
+
if ("error" in project) return project;
|
|
1133
|
+
return await this.mutate(project.value.id, () => {
|
|
1134
|
+
const cards = this.store.readCards(project.value.id);
|
|
1135
|
+
const card = cards.find((candidate) => candidate.id === request.cardId && live(candidate));
|
|
1136
|
+
if (card === void 0) return refuse("not-found", `no card ${JSON.stringify(request.cardId)}`);
|
|
1137
|
+
const now = /* @__PURE__ */ new Date();
|
|
1138
|
+
const box = request.rating === "forgot" ? 0 : request.rating === "fuzzy" ? card.mastery.box : Math.min(card.mastery.box + 1, 5);
|
|
1139
|
+
const next = {
|
|
1140
|
+
...card,
|
|
1141
|
+
mastery: {
|
|
1142
|
+
box,
|
|
1143
|
+
dueAt: leitnerDueAt(box, now),
|
|
1144
|
+
streak: request.rating === "recalled" ? card.mastery.streak + 1 : 0
|
|
1145
|
+
},
|
|
1146
|
+
updatedAt: now.toISOString()
|
|
1147
|
+
};
|
|
1148
|
+
this.store.writeCards(project.value.id, cards.map((candidate) => candidate.id === card.id ? next : candidate));
|
|
1149
|
+
return {
|
|
1150
|
+
ok: true,
|
|
1151
|
+
card: next
|
|
1152
|
+
};
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
};
|
|
1156
|
+
//#endregion
|
|
1157
|
+
//#region lib/types/service/roadmap.js
|
|
1158
|
+
/**
|
|
1159
|
+
* Paths: drafts, adoption (with practice rubrics materialized), discard, station notes, 读完.
|
|
1160
|
+
*
|
|
1161
|
+
* One layer of the library service (see `service/core.ts` for the layering):
|
|
1162
|
+
* the methods are the service's own, moved here verbatim by domain.
|
|
1163
|
+
*
|
|
1164
|
+
* @module @khorsheed/dsh-library/service/roadmap
|
|
1165
|
+
*/
|
|
1166
|
+
var LibraryServiceRoadmap = class extends LibraryServiceCards {
|
|
1167
|
+
/** One textbook's adopted roadmap and its pending draft. */
|
|
1168
|
+
async getRoadmap(request) {
|
|
1169
|
+
this.assertAlive("getRoadmap");
|
|
1170
|
+
const roadmap = this.store.readRoadmap(request.projectId, request.resourceId);
|
|
1171
|
+
const draft = this.store.readRoadmap(request.projectId, request.resourceId, "draft");
|
|
1172
|
+
return {
|
|
1173
|
+
...roadmap === void 0 ? {} : { roadmap },
|
|
1174
|
+
...draft === void 0 ? {} : { draft }
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* Write the coach's draft of one textbook's path; it replaces the
|
|
1179
|
+
* textbook's previous draft. Shape rules: unique ids, a gate, at least one
|
|
1180
|
+
* cover per node, references that exist, locators inside the resource's
|
|
1181
|
+
* contents, dependencies pointing backwards. States are not the coach's to
|
|
1182
|
+
* set — adoption initializes them.
|
|
1183
|
+
*/
|
|
1184
|
+
async draftRoadmap(request) {
|
|
1185
|
+
this.assertAlive("draftRoadmap");
|
|
1186
|
+
const project = this.readProjectOrRefusal(request.projectId);
|
|
1187
|
+
if ("error" in project) return project;
|
|
1188
|
+
const textbooks = this.store.listResources(project.value.id).filter(isThickResource);
|
|
1189
|
+
const shelf = textbooks.length === 0 ? "this project has no textbook yet — register one first (library_textbook with a contents: the learner's book, one you pick, or one you author)" : `this project's textbooks: ${textbooks.map((textbook) => `${textbook.seq ?? "-"}. ${textbook.id} 《${textbook.title}》`).join("; ")}`;
|
|
1190
|
+
if (typeof request.resourceId !== "string" || request.resourceId.trim() === "") return refuse("invalid", `a path belongs to one textbook — pass resource; ${shelf}`);
|
|
1191
|
+
const primary = isLibraryId(request.resourceId) ? this.store.readResource(project.value.id, request.resourceId) : void 0;
|
|
1192
|
+
if (primary === void 0) return refuse("not-found", `no resource ${JSON.stringify(request.resourceId)} in project ${JSON.stringify(project.value.name)} — ${shelf}`);
|
|
1193
|
+
if (!isThickResource(primary)) return refuse("invalid", `《${primary.title}》 is a link without contents — it never carries a path; cite it inside a station of a textbook's path (also: ["${primary.id}"]). ${shelf}`);
|
|
1194
|
+
if (request.nodes.length === 0) return refuse("invalid", "a roadmap needs at least one node — split the material into learning stations; each: { id, title, gate: \"quiz\"|\"acceptance\"|\"read\", goals, covers: [\"r_…#1-5\" | \"https://…\" | \"topic: …\"], dependsOn }; an acceptance station also needs acceptance: [...]");
|
|
1195
|
+
if (request.passRatio !== void 0 && !(request.passRatio > 0 && request.passRatio <= 1)) return refuse("invalid", "passRatio is a fraction in (0, 1] — 0.8 means 8 of 10 right");
|
|
1196
|
+
for (const node of request.nodes) {
|
|
1197
|
+
if (node.points === void 0 || node.points.length === 0) continue;
|
|
1198
|
+
if (node.gate !== "quiz") return refuse("invalid", `${node.title} is a ${node.gate} node — points (考点) belong to quiz-gated stations only`);
|
|
1199
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1200
|
+
for (const point of node.points) {
|
|
1201
|
+
if (!isLibraryId(point.id)) return refuse("invalid", `point id ${JSON.stringify(point.id)} on ${node.title} is not a safe slug (letters, digits, . _ -) — it is the mastery key, stable across quizzes`);
|
|
1202
|
+
if (typeof point.title !== "string" || point.title.trim() === "") return refuse("invalid", `point ${point.id} on ${node.title} needs a title — the point as the learner reads it (「极限的定义」)`);
|
|
1203
|
+
if (seen.has(point.id)) return refuse("invalid", `duplicate point id ${point.id} on ${node.title} — reference the existing one`);
|
|
1204
|
+
seen.add(point.id);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
const nodes = request.nodes.map((node) => ({
|
|
1208
|
+
id: node.id,
|
|
1209
|
+
title: node.title,
|
|
1210
|
+
gate: node.gate,
|
|
1211
|
+
goals: typeof node.goals === "string" ? [node.goals] : (node.goals ?? []).filter((goal) => typeof goal === "string"),
|
|
1212
|
+
covers: node.covers ?? [],
|
|
1213
|
+
dependsOn: node.dependsOn ?? [],
|
|
1214
|
+
state: "locked",
|
|
1215
|
+
...node.points === void 0 || node.points.length === 0 ? {} : { points: node.points.map((point) => ({
|
|
1216
|
+
id: point.id,
|
|
1217
|
+
title: point.title.trim()
|
|
1218
|
+
})) },
|
|
1219
|
+
...Array.isArray(node.acceptance) ? { acceptance: node.acceptance.flatMap((entry) => {
|
|
1220
|
+
if (typeof entry === "string") return entry.trim() === "" ? [] : [entry];
|
|
1221
|
+
if (typeof entry?.line !== "string" || entry.line.trim() === "") return [];
|
|
1222
|
+
return [{
|
|
1223
|
+
line: entry.line.trim(),
|
|
1224
|
+
...typeof entry.ref === "string" && entry.ref.trim() !== "" ? { ref: entry.ref.trim() } : {}
|
|
1225
|
+
}];
|
|
1226
|
+
}) } : {}
|
|
1227
|
+
}));
|
|
1228
|
+
const shapeError = validateRoadmapShape(nodes, {
|
|
1229
|
+
resourceId: primary.id,
|
|
1230
|
+
resource: (candidate) => this.store.readResource(project.value.id, candidate),
|
|
1231
|
+
hasAssessment: (candidate) => this.store.readAssessment(project.value.id, candidate) !== void 0
|
|
1232
|
+
});
|
|
1233
|
+
if (shapeError !== void 0) return refuse("invalid", shapeError);
|
|
1234
|
+
return await this.mutate(project.value.id, () => {
|
|
1235
|
+
const draft = {
|
|
1236
|
+
primaryResourceId: primary.id,
|
|
1237
|
+
status: "draft",
|
|
1238
|
+
nodes,
|
|
1239
|
+
...request.passRatio === void 0 ? {} : { passRatio: request.passRatio },
|
|
1240
|
+
...request.note === void 0 || request.note.trim() === "" ? {} : { note: request.note.trim() },
|
|
1241
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1242
|
+
};
|
|
1243
|
+
this.store.writeRoadmap(project.value.id, draft);
|
|
1244
|
+
this.event(project.value.id, "roadmap.drafted", {
|
|
1245
|
+
id: primary.id,
|
|
1246
|
+
title: primary.title,
|
|
1247
|
+
resourceId: primary.id,
|
|
1248
|
+
count: nodes.length
|
|
1249
|
+
});
|
|
1250
|
+
return {
|
|
1251
|
+
ok: true,
|
|
1252
|
+
draft
|
|
1253
|
+
};
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* The learner's 采用: the draft becomes the path. Nodes whose deps are
|
|
1258
|
+
* all done start `current`; a node that survives a re-split (same id) keeps
|
|
1259
|
+
* its state, its quiz stats and its 讲义, so re-planning never erases
|
|
1260
|
+
* progress. Every acceptance station's rubric materializes as its practice
|
|
1261
|
+
* assessment (unchanged lines reuse the live one — its history stays put;
|
|
1262
|
+
* changed lines supersede it).
|
|
1263
|
+
*/
|
|
1264
|
+
async adoptRoadmap(request) {
|
|
1265
|
+
this.assertAlive("adoptRoadmap");
|
|
1266
|
+
const project = this.readProjectOrRefusal(request.projectId);
|
|
1267
|
+
if ("error" in project) return project;
|
|
1268
|
+
return await this.mutate(project.value.id, () => {
|
|
1269
|
+
const draft = this.store.readRoadmap(project.value.id, request.resourceId, "draft");
|
|
1270
|
+
if (draft === void 0) return refuse("not-found", "no draft to adopt here — ask the coach to draft a roadmap first");
|
|
1271
|
+
const prior = new Map((this.store.readRoadmap(project.value.id, request.resourceId)?.nodes ?? []).map((node) => [node.id, node]));
|
|
1272
|
+
const nodes = [];
|
|
1273
|
+
for (const node of draft.nodes) {
|
|
1274
|
+
const kept = prior.get(node.id);
|
|
1275
|
+
const state = kept?.state === "done" ? "done" : "current";
|
|
1276
|
+
nodes.push({
|
|
1277
|
+
...node,
|
|
1278
|
+
state,
|
|
1279
|
+
...node.gate === "quiz" ? { quizStats: kept?.quizStats ?? { attempts: 0 } } : {},
|
|
1280
|
+
...kept?.notes !== void 0 ? { notes: kept.notes } : {}
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1284
|
+
const roadmap = {
|
|
1285
|
+
...draft,
|
|
1286
|
+
primaryResourceId: request.resourceId,
|
|
1287
|
+
status: "adopted",
|
|
1288
|
+
nodes,
|
|
1289
|
+
adoptedAt: now
|
|
1290
|
+
};
|
|
1291
|
+
this.store.writeRoadmap(project.value.id, roadmap);
|
|
1292
|
+
this.store.removeDraft(project.value.id, request.resourceId);
|
|
1293
|
+
this.materializePracticeAssessments(project.value.id, nodes, now);
|
|
1294
|
+
const primary = this.store.readResource(project.value.id, request.resourceId);
|
|
1295
|
+
this.event(project.value.id, "roadmap.adopted", {
|
|
1296
|
+
at: now,
|
|
1297
|
+
id: request.resourceId,
|
|
1298
|
+
title: primary?.title ?? request.resourceId,
|
|
1299
|
+
resourceId: request.resourceId,
|
|
1300
|
+
count: nodes.length
|
|
1301
|
+
});
|
|
1302
|
+
return {
|
|
1303
|
+
ok: true,
|
|
1304
|
+
roadmap
|
|
1305
|
+
};
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* Give every acceptance node a practice assessment keyed to it: an
|
|
1310
|
+
* unchanged rubric reuses the node's live assessment (its attempts stay
|
|
1311
|
+
* attached); a changed rubric supersedes it (the old snapshot keeps the old
|
|
1312
|
+
* history). Call inside the project lock.
|
|
1313
|
+
*/
|
|
1314
|
+
materializePracticeAssessments(projectId, nodes, at) {
|
|
1315
|
+
const assessments = this.store.listAssessments(projectId);
|
|
1316
|
+
for (const node of nodes) {
|
|
1317
|
+
if (node.gate !== "acceptance") continue;
|
|
1318
|
+
const rubric = acceptanceLines(node);
|
|
1319
|
+
if (rubric.length === 0) continue;
|
|
1320
|
+
const liveAssessment = assessments.find((assessment) => assessment.type === "practice" && assessment.scope.nodeId === node.id && assessment.supersededBy === void 0);
|
|
1321
|
+
if (liveAssessment !== void 0 && (liveAssessment.rubric ?? []).length === rubric.length && rubric.every((line, index) => liveAssessment.rubric?.[index]?.line === line.line && liveAssessment.rubric?.[index]?.ref === line.ref)) continue;
|
|
1322
|
+
const assessment = {
|
|
1323
|
+
id: mintLibraryId("q"),
|
|
1324
|
+
type: "practice",
|
|
1325
|
+
scope: { nodeId: node.id },
|
|
1326
|
+
rubric,
|
|
1327
|
+
createdAt: at
|
|
1328
|
+
};
|
|
1329
|
+
this.store.writeAssessment(projectId, assessment);
|
|
1330
|
+
if (liveAssessment !== void 0) this.store.writeAssessment(projectId, {
|
|
1331
|
+
...liveAssessment,
|
|
1332
|
+
supersededBy: assessment.id
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
/** Drop a textbook's draft without adopting it. */
|
|
1337
|
+
async discardDraft(request) {
|
|
1338
|
+
this.assertAlive("discardDraft");
|
|
1339
|
+
const project = this.readProjectOrRefusal(request.projectId);
|
|
1340
|
+
if ("error" in project) return project;
|
|
1341
|
+
return await this.mutate(project.value.id, () => {
|
|
1342
|
+
this.store.removeDraft(project.value.id, request.resourceId);
|
|
1343
|
+
return {
|
|
1344
|
+
ok: true,
|
|
1345
|
+
resourceId: request.resourceId
|
|
1346
|
+
};
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* Write (or rewrite) a station's 讲义 — the coach's distilled teaching,
|
|
1351
|
+
* persisted on the adopted path's node. Replanning keeps it when the
|
|
1352
|
+
* station's id survives; every write bumps `revisions`.
|
|
1353
|
+
*/
|
|
1354
|
+
/** Why a 讲义 has nowhere to land, naming the fix: the path is only a draft, the station sits on another textbook, or here are this path's stations. */
|
|
1355
|
+
notesMiss(projectId, resourceId, nodeId) {
|
|
1356
|
+
const book = resourceId === void 0 ? void 0 : this.store.readResource(projectId, resourceId);
|
|
1357
|
+
const named = book === void 0 ? "this textbook" : `《${book.title}》 (${book.id})`;
|
|
1358
|
+
const roadmap = this.store.readRoadmap(projectId, resourceId);
|
|
1359
|
+
if (roadmap === void 0) return refuse("not-found", this.store.readRoadmap(projectId, resourceId, "draft") === void 0 ? `${named} has no path yet — draft one (library_roadmap); 讲义 land on an adopted path's stations` : `${named}'s path is still a draft — the learner adopts it in the sidebar (路线 → 采用) before 讲义 can land; teach in the conversation meanwhile, then write the 讲义`);
|
|
1360
|
+
const elsewhere = this.store.listResources(projectId).filter((resource) => resource.id !== roadmap.primaryResourceId).find((resource) => this.store.readRoadmap(projectId, resource.id)?.nodes.some((candidate) => candidate.id === nodeId) === true);
|
|
1361
|
+
if (elsewhere !== void 0) return refuse("not-found", `station ${JSON.stringify(nodeId)} is on 《${elsewhere.title}》's path — pass resource ${elsewhere.id}`);
|
|
1362
|
+
return refuse("not-found", `no station ${JSON.stringify(nodeId)} on ${named}'s path — its stations: ${roadmap.nodes.map((candidate) => candidate.id).join(", ")}`);
|
|
1363
|
+
}
|
|
1364
|
+
async putNotes(request) {
|
|
1365
|
+
this.assertAlive("putNotes");
|
|
1366
|
+
const project = this.readProjectOrRefusal(request.projectId);
|
|
1367
|
+
if ("error" in project) return project;
|
|
1368
|
+
const text = request.text.trim();
|
|
1369
|
+
if (text === "") return refuse("invalid", "notes need text — the distilled teaching for this station (markdown): the explanation, the points the learner got stuck on, the pitfalls");
|
|
1370
|
+
return await this.mutate(project.value.id, () => {
|
|
1371
|
+
const roadmap = this.store.readRoadmap(project.value.id, request.resourceId);
|
|
1372
|
+
const node = roadmap?.nodes.find((candidate) => candidate.id === request.nodeId);
|
|
1373
|
+
if (roadmap === void 0 || node === void 0) return this.notesMiss(project.value.id, request.resourceId, request.nodeId);
|
|
1374
|
+
const book = roadmap.primaryResourceId === void 0 ? void 0 : this.store.readResource(project.value.id, roadmap.primaryResourceId);
|
|
1375
|
+
const lesson = lessonProblems(text, node, book !== void 0);
|
|
1376
|
+
if (lesson !== void 0) return refuse("invalid", lesson);
|
|
1377
|
+
if (node.notes !== void 0 && node.notes.text !== "" && roadmap.primaryResourceId !== void 0) this.store.archiveNotes(project.value.id, roadmap.primaryResourceId, node.id, node.notes.revisions, node.notes.text);
|
|
1378
|
+
const notes = {
|
|
1379
|
+
text,
|
|
1380
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1381
|
+
revisions: (node.notes?.revisions ?? 0) + 1
|
|
1382
|
+
};
|
|
1383
|
+
const next = {
|
|
1384
|
+
...roadmap,
|
|
1385
|
+
nodes: roadmap.nodes.map((candidate) => candidate.id === node.id ? {
|
|
1386
|
+
...candidate,
|
|
1387
|
+
notes
|
|
1388
|
+
} : candidate)
|
|
1389
|
+
};
|
|
1390
|
+
this.store.writeRoadmap(project.value.id, next);
|
|
1391
|
+
this.event(project.value.id, "node.noted", {
|
|
1392
|
+
id: node.id,
|
|
1393
|
+
title: node.title,
|
|
1394
|
+
...request.resourceId === void 0 ? {} : { resourceId: request.resourceId },
|
|
1395
|
+
count: notes.revisions
|
|
1396
|
+
});
|
|
1397
|
+
return {
|
|
1398
|
+
ok: true,
|
|
1399
|
+
notes
|
|
1400
|
+
};
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
/** 「读完」: a current read node completes and unlocks what it gates. */
|
|
1404
|
+
async markNodeRead(request) {
|
|
1405
|
+
this.assertAlive("markNodeRead");
|
|
1406
|
+
const project = this.readProjectOrRefusal(request.projectId);
|
|
1407
|
+
if ("error" in project) return project;
|
|
1408
|
+
return await this.mutate(project.value.id, () => {
|
|
1409
|
+
const roadmap = this.store.readRoadmap(project.value.id, request.resourceId);
|
|
1410
|
+
const node = roadmap?.nodes.find((candidate) => candidate.id === request.nodeId);
|
|
1411
|
+
if (roadmap === void 0 || node === void 0) return refuse("not-found", `no node ${JSON.stringify(request.nodeId)} on the adopted path`);
|
|
1412
|
+
if (node.gate !== "read") return refuse("state-machine", `${node.title} is a ${node.gate} node — only a read node completes by 读完 (a quiz node by passing its quiz, an acceptance station by acceptance)`);
|
|
1413
|
+
if (node.state !== "current") return refuse("state-machine", `${node.title} is ${node.state} — only a current node can be marked read`);
|
|
1414
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1415
|
+
const { nodes, unlocked } = unlockReady(roadmap.nodes.map((candidate) => candidate.id === node.id ? {
|
|
1416
|
+
...candidate,
|
|
1417
|
+
state: "done"
|
|
1418
|
+
} : candidate));
|
|
1419
|
+
const next = {
|
|
1420
|
+
...roadmap,
|
|
1421
|
+
nodes
|
|
1422
|
+
};
|
|
1423
|
+
this.store.writeRoadmap(project.value.id, next);
|
|
1424
|
+
this.store.appendEvents(project.value.id, [{
|
|
1425
|
+
at: now,
|
|
1426
|
+
type: "node.completed",
|
|
1427
|
+
id: node.id,
|
|
1428
|
+
title: node.title,
|
|
1429
|
+
...request.resourceId === void 0 ? {} : { resourceId: request.resourceId }
|
|
1430
|
+
}, ...unlocked.map((nodeId) => ({
|
|
1431
|
+
at: now,
|
|
1432
|
+
type: "node.unlocked",
|
|
1433
|
+
id: nodeId,
|
|
1434
|
+
title: titleOf(nodes, nodeId),
|
|
1435
|
+
...request.resourceId === void 0 ? {} : { resourceId: request.resourceId }
|
|
1436
|
+
}))]);
|
|
1437
|
+
return {
|
|
1438
|
+
ok: true,
|
|
1439
|
+
roadmap: next,
|
|
1440
|
+
unlocked
|
|
1441
|
+
};
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1444
|
+
};
|
|
1445
|
+
//#endregion
|
|
1446
|
+
//#region lib/types/service/pages.js
|
|
1447
|
+
/**
|
|
1448
|
+
* Photographed pages: recording (marks carded), listing, photo reads and saves.
|
|
1449
|
+
*
|
|
1450
|
+
* One layer of the library service (see `service/core.ts` for the layering):
|
|
1451
|
+
* the methods are the service's own, moved here verbatim by domain.
|
|
1452
|
+
*
|
|
1453
|
+
* @module @khorsheed/dsh-library/service/pages
|
|
1454
|
+
*/
|
|
1455
|
+
var LibraryServicePages = class extends LibraryServiceRoadmap {
|
|
1456
|
+
/**
|
|
1457
|
+
* Record one photographed page and card every mark at once (「一键全收」).
|
|
1458
|
+
* Uncertain marks are kept on the page but never carded — the learner
|
|
1459
|
+
* decides them. Each card's gloss is the mark's recognized gloss, else the
|
|
1460
|
+
* learner's handwriting, else a placeholder the learner can fill in.
|
|
1461
|
+
*/
|
|
1462
|
+
async recordPage(request) {
|
|
1463
|
+
this.assertAlive("recordPage");
|
|
1464
|
+
const project = this.readProjectOrRefusal(request.projectId);
|
|
1465
|
+
if ("error" in project) return project;
|
|
1466
|
+
const resource = this.store.readResource(project.value.id, request.resourceId);
|
|
1467
|
+
if (resource === void 0) return refuse("not-found", `no resource ${JSON.stringify(request.resourceId)} in this project — a page belongs to a resource`);
|
|
1468
|
+
if (!isThickResource(resource)) return refuse("invalid", `《${resource.title}》 is a thin link — only a thick resource (one with a contents) has pages to photograph`);
|
|
1469
|
+
const ref = request.attachment;
|
|
1470
|
+
if (typeof ref?.attachmentId !== "string" || typeof ref.mediaType !== "string" || typeof ref.bytes !== "number" || typeof ref.width !== "number" || typeof ref.height !== "number") return refuse("ref-incomplete", "attachment needs the image block's whole durable reference — { attachmentId, mediaType, bytes, width, height }, copied verbatim; the photo itself is never copied");
|
|
1471
|
+
if (request.text.trim() === "") return refuse("invalid", "text is the page's printed text as recognized — a photo-backed question quotes it, so transcribe the page, not only the marks");
|
|
1472
|
+
const marks = request.marks.filter((mark) => mark.text.trim() !== "");
|
|
1473
|
+
const pageId = mintLibraryId("pg");
|
|
1474
|
+
const nodeId = request.nodeId;
|
|
1475
|
+
if (nodeId !== void 0) {
|
|
1476
|
+
const roadmap = this.store.readRoadmap(project.value.id, resource.id);
|
|
1477
|
+
if (roadmap !== void 0 && !roadmap.nodes.some((node) => node.id === nodeId)) return refuse("not-found", `no node ${JSON.stringify(nodeId)} on this resource's path — omit nodeId or read the roadmap`);
|
|
1478
|
+
}
|
|
1479
|
+
const kind = profileOf(project.value.template).markKind;
|
|
1480
|
+
const inputs = marks.filter((mark) => mark.uncertain !== true).map((mark) => ({
|
|
1481
|
+
kind,
|
|
1482
|
+
term: mark.text,
|
|
1483
|
+
gloss: mark.gloss ?? mark.handNote ?? "(待补释义)",
|
|
1484
|
+
...mark.handNote === void 0 ? {} : { userGloss: mark.handNote },
|
|
1485
|
+
...mark.sentence === void 0 ? {} : { context: {
|
|
1486
|
+
sentence: mark.sentence,
|
|
1487
|
+
pageId,
|
|
1488
|
+
...nodeId === void 0 ? {} : { nodeId }
|
|
1489
|
+
} },
|
|
1490
|
+
source: {
|
|
1491
|
+
type: "photo",
|
|
1492
|
+
ref: pageId,
|
|
1493
|
+
...request.label === void 0 ? {} : { note: request.label }
|
|
1494
|
+
},
|
|
1495
|
+
resourceId: resource.id
|
|
1496
|
+
}));
|
|
1497
|
+
return await this.mutate(project.value.id, () => {
|
|
1498
|
+
const cards = this.applyCardInputs(project.value.id, inputs, resource.id, { mergeNear: kind === "word" });
|
|
1499
|
+
const page = {
|
|
1500
|
+
id: pageId,
|
|
1501
|
+
resourceId: resource.id,
|
|
1502
|
+
...nodeId === void 0 ? {} : { nodeId },
|
|
1503
|
+
...request.label === void 0 || request.label.trim() === "" ? {} : { label: request.label.trim() },
|
|
1504
|
+
attachment: {
|
|
1505
|
+
attachmentId: ref.attachmentId,
|
|
1506
|
+
mediaType: ref.mediaType,
|
|
1507
|
+
bytes: ref.bytes,
|
|
1508
|
+
width: ref.width,
|
|
1509
|
+
height: ref.height
|
|
1510
|
+
},
|
|
1511
|
+
text: request.text,
|
|
1512
|
+
marks,
|
|
1513
|
+
cardIds: [...cards.added, ...cards.merged].map((card) => card.id),
|
|
1514
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1515
|
+
};
|
|
1516
|
+
this.store.appendPage(project.value.id, page);
|
|
1517
|
+
this.event(project.value.id, "page.recorded", {
|
|
1518
|
+
id: page.id,
|
|
1519
|
+
title: page.label ?? resource.title,
|
|
1520
|
+
resourceId: resource.id,
|
|
1521
|
+
count: cards.added.length,
|
|
1522
|
+
count2: cards.merged.length
|
|
1523
|
+
});
|
|
1524
|
+
return {
|
|
1525
|
+
ok: true,
|
|
1526
|
+
page,
|
|
1527
|
+
cards,
|
|
1528
|
+
uncertain: marks.filter((mark) => mark.uncertain === true).map((mark) => mark.text)
|
|
1529
|
+
};
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
/** One resource's pages, optionally one node's. */
|
|
1533
|
+
async listPages(request) {
|
|
1534
|
+
this.assertAlive("listPages");
|
|
1535
|
+
const pages = this.store.readPages(request.projectId, request.resourceId);
|
|
1536
|
+
return { pages: request.nodeId === void 0 ? pages : pages.filter((page) => page.nodeId === request.nodeId) };
|
|
1537
|
+
}
|
|
1538
|
+
/** Find one page anywhere in the project. */
|
|
1539
|
+
findPage(projectId, pageId) {
|
|
1540
|
+
for (const resource of this.store.listResources(projectId)) {
|
|
1541
|
+
const page = this.store.readPages(projectId, resource.id).find((candidate) => candidate.id === pageId);
|
|
1542
|
+
if (page !== void 0) return page;
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
/**
|
|
1546
|
+
* One page's photo, as base64 — whitelisted: the page must be one this
|
|
1547
|
+
* project recorded, so the Remote never reads an arbitrary attachment.
|
|
1548
|
+
*/
|
|
1549
|
+
async readPagePhoto(request) {
|
|
1550
|
+
this.assertAlive("readPagePhoto");
|
|
1551
|
+
const page = this.findPage(request.projectId, request.pageId);
|
|
1552
|
+
if (page === void 0) return refuse("not-found", `no page ${JSON.stringify(request.pageId)} in this project`);
|
|
1553
|
+
const attachments = this.ctx.get("attachments");
|
|
1554
|
+
if (attachments === void 0) return refuse("unavailable", "this deployment mounts no attachment store — the photo cannot be read back here");
|
|
1555
|
+
try {
|
|
1556
|
+
const stored = await attachments.readImage(page.attachment);
|
|
1557
|
+
return {
|
|
1558
|
+
ok: true,
|
|
1559
|
+
data: Buffer.from(stored.data).toString("base64"),
|
|
1560
|
+
mediaType: stored.ref.mediaType
|
|
1561
|
+
};
|
|
1562
|
+
} catch (error) {
|
|
1563
|
+
return refuse("io", `the photo could not be read — ${libraryErrorMessage(error)}`);
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
/** Save a browser-uploaded photo into the host attachment store (the sidebar's 拍照). */
|
|
1567
|
+
async savePhoto(request) {
|
|
1568
|
+
this.assertAlive("savePhoto");
|
|
1569
|
+
const attachments = this.ctx.get("attachments");
|
|
1570
|
+
if (attachments?.saveImage === void 0) return refuse("unavailable", "this deployment mounts no attachment store — photos cannot be saved here");
|
|
1571
|
+
try {
|
|
1572
|
+
return {
|
|
1573
|
+
ok: true,
|
|
1574
|
+
attachment: await attachments.saveImage({
|
|
1575
|
+
data: Buffer.from(request.data, "base64"),
|
|
1576
|
+
mediaType: request.mediaType
|
|
1577
|
+
})
|
|
1578
|
+
};
|
|
1579
|
+
} catch (error) {
|
|
1580
|
+
return refuse("invalid", `the photo was refused — ${libraryErrorMessage(error)}`);
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
};
|
|
1584
|
+
//#endregion
|
|
1585
|
+
//#region lib/types/service/assess.js
|
|
1586
|
+
/**
|
|
1587
|
+
* Assessments and attempts: quizzes (the two-axis checks), the assess cascade, acceptance sittings.
|
|
1588
|
+
*
|
|
1589
|
+
* One layer of the library service (see `service/core.ts` for the layering):
|
|
1590
|
+
* the methods are the service's own, moved here verbatim by domain.
|
|
1591
|
+
*
|
|
1592
|
+
* @module @khorsheed/dsh-library/service/assess
|
|
1593
|
+
*/
|
|
1594
|
+
var LibraryServiceAssess = class extends LibraryServicePages {
|
|
1595
|
+
/** A project's assessments, optionally one resource's / node's / one type's. */
|
|
1596
|
+
async listAssessments(request) {
|
|
1597
|
+
this.assertAlive("listAssessments");
|
|
1598
|
+
return { assessments: this.store.listAssessments(request.projectId).filter((assessment) => (request.resourceId === void 0 || assessment.scope.resourceId === request.resourceId) && (request.nodeId === void 0 || assessment.scope.nodeId === request.nodeId) && (request.type === void 0 || assessment.type === request.type)) };
|
|
1599
|
+
}
|
|
1600
|
+
/** One assessment, whole. */
|
|
1601
|
+
async getAssessment(request) {
|
|
1602
|
+
this.assertAlive("getAssessment");
|
|
1603
|
+
const assessment = this.store.readAssessment(request.projectId, request.assessmentId);
|
|
1604
|
+
if (assessment === void 0) return refuse("not-found", `no assessment ${JSON.stringify(request.assessmentId)} — library_read · quiz without an id lists the project's`);
|
|
1605
|
+
return {
|
|
1606
|
+
ok: true,
|
|
1607
|
+
assessment
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
/**
|
|
1611
|
+
* Store a quiz behind the two-axis checks. Target: a marked / asked / missed
|
|
1612
|
+
* question names the live cards it probes. Evidence: a photo quote names a
|
|
1613
|
+
* recorded page (a quote the page text lacks comes back as a warning); a web
|
|
1614
|
+
* quote carries its url; whether domain knowledge may back a CONTENT question
|
|
1615
|
+
* is judged on the gated node's covers (a material book, a thin link or a
|
|
1616
|
+
* bare url among them demands a verbatim quote) — a resource-scoped quiz
|
|
1617
|
+
* without a node keeps the resource-level rule, and a scopeless free quiz
|
|
1618
|
+
* allows knowledge. A node-scoped quiz supersedes the station's live one.
|
|
1619
|
+
*/
|
|
1620
|
+
async putQuiz(request) {
|
|
1621
|
+
this.assertAlive("putQuiz");
|
|
1622
|
+
const project = this.readProjectOrRefusal(request.projectId);
|
|
1623
|
+
if ("error" in project) return project;
|
|
1624
|
+
const scope = request.quiz.scope;
|
|
1625
|
+
const resource = scope.resourceId === void 0 ? void 0 : this.store.readResource(project.value.id, scope.resourceId);
|
|
1626
|
+
if (scope.resourceId !== void 0 && resource === void 0) return refuse("not-found", `quiz scope names resource ${JSON.stringify(scope.resourceId)}, which project ${JSON.stringify(project.value.name)} does not hold — library_read · resources lists them`);
|
|
1627
|
+
if (request.quiz.focus !== void 0 && ![
|
|
1628
|
+
"mine",
|
|
1629
|
+
"book",
|
|
1630
|
+
"mixed"
|
|
1631
|
+
].includes(request.quiz.focus)) return refuse("invalid", "quiz.focus is mine | book | mixed — mine: only the learner's own cards; book: only the chapter's content");
|
|
1632
|
+
if (request.quiz.questions.length === 0) return refuse("invalid", "a quiz needs at least one question — each: { id, kind, prompt, options?, answer, explanation?, target: { kind, cardIds? }, evidence: { kind, pageId?, url?, quote? } }");
|
|
1633
|
+
const nodeId = scope.nodeId;
|
|
1634
|
+
let lockedNote;
|
|
1635
|
+
let gatedNode;
|
|
1636
|
+
let gatedRoadmap;
|
|
1637
|
+
if (nodeId !== void 0) {
|
|
1638
|
+
const roadmaps = scope.resourceId !== void 0 ? [this.store.readRoadmap(project.value.id, scope.resourceId)] : this.store.listResources(project.value.id).filter(isThickResource).map((candidate) => this.store.readRoadmap(project.value.id, candidate.id));
|
|
1639
|
+
for (const roadmap of roadmaps) {
|
|
1640
|
+
const node = roadmap?.nodes.find((candidate) => candidate.id === nodeId);
|
|
1641
|
+
if (node !== void 0) {
|
|
1642
|
+
gatedRoadmap = roadmap;
|
|
1643
|
+
gatedNode = node;
|
|
1644
|
+
break;
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
if (gatedNode === void 0 || gatedRoadmap === void 0) return refuse("not-found", `quiz scope names node ${JSON.stringify(nodeId)}, which no adopted path holds — library_read · roadmap lists the nodes`);
|
|
1648
|
+
if (gatedNode.gate === "read") return refuse("invalid", `${gatedNode.title} is a read node — it has no quiz gate; quiz a quiz-gated node, or leave scope.node off for a free practice quiz`);
|
|
1649
|
+
if (gatedNode.gate === "acceptance") return refuse("invalid", `${gatedNode.title} is an acceptance station — it completes by acceptance (library_assess with node), not by a quiz; to gate it ON a quiz, compose the quiz without a node (resource-level or free), then reference it from the station's acceptance: { line: "…", ref: "<quiz id>" } — the line auto-passes when the quiz passes`);
|
|
1650
|
+
if (gatedNode.state === "locked") lockedNote = `${gatedNode.title} is still locked — the learner can take this quiz, but passing it completes the node only once the nodes before it are done`;
|
|
1651
|
+
}
|
|
1652
|
+
const pointAdds = [];
|
|
1653
|
+
if (request.quiz.addPoints !== void 0 && request.quiz.addPoints.length > 0) {
|
|
1654
|
+
if (gatedNode === void 0) return refuse("invalid", "addPoints belongs to a station quiz (scope.node) — a resource-level or free quiz has no points to extend");
|
|
1655
|
+
const seen = new Set((gatedNode.points ?? []).map((point) => point.id));
|
|
1656
|
+
for (const point of request.quiz.addPoints) {
|
|
1657
|
+
if (!isLibraryId(point.id)) return refuse("invalid", `point id ${JSON.stringify(point.id)} is not a safe slug (letters, digits, . _ -)`);
|
|
1658
|
+
if (typeof point.title !== "string" || point.title.trim() === "") return refuse("invalid", `point ${point.id} needs a title — the point as the learner reads it (「极限的定义」)`);
|
|
1659
|
+
if (seen.has(point.id)) return refuse("invalid", `point ${point.id} already exists on ${gatedNode.title} — reference it (point: ${JSON.stringify(point.id)}), never re-add`);
|
|
1660
|
+
seen.add(point.id);
|
|
1661
|
+
pointAdds.push({
|
|
1662
|
+
id: point.id,
|
|
1663
|
+
title: point.title.trim()
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
const knownPoints = [...gatedNode?.points ?? [], ...pointAdds];
|
|
1668
|
+
const knownPointIds = new Set(knownPoints.map((point) => point.id));
|
|
1669
|
+
const liveCards = new Set(this.store.readCards(project.value.id).filter(live).map((card) => card.id));
|
|
1670
|
+
const pageResourceIds = resource !== void 0 ? [resource.id] : gatedNode !== void 0 ? [...new Set(gatedNode.covers.flatMap((cover) => coverKind(cover) === "resource" ? [cover.ref] : []))] : this.store.listResources(project.value.id).map((candidate) => candidate.id);
|
|
1671
|
+
const pages = new Map(pageResourceIds.flatMap((candidate) => this.store.readPages(project.value.id, candidate).map((page) => [page.id, page])));
|
|
1672
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
1673
|
+
const warnings = lockedNote === void 0 ? [] : [lockedNote];
|
|
1674
|
+
const errors = [];
|
|
1675
|
+
for (const [index, question] of request.quiz.questions.entries()) {
|
|
1676
|
+
const label = `questions[${index}]${question.id === "" ? "" : ` (${question.id})`}`;
|
|
1677
|
+
if (question.id === "" || seenIds.has(question.id)) errors.push(`${label} needs a unique id — q1, q2, … is plenty`);
|
|
1678
|
+
seenIds.add(question.id);
|
|
1679
|
+
if (![
|
|
1680
|
+
"choice",
|
|
1681
|
+
"fill",
|
|
1682
|
+
"free"
|
|
1683
|
+
].includes(question.kind)) errors.push(`${label}.kind is choice | fill | free`);
|
|
1684
|
+
if (question.prompt.trim() === "" || question.answer.trim() === "") errors.push(`${label} needs both a prompt and an answer — a question without its answer cannot be graded`);
|
|
1685
|
+
if (question.kind === "choice" && (question.options === void 0 || question.options.length < 2)) errors.push(`${label} is a choice question with fewer than two options`);
|
|
1686
|
+
else if (question.kind === "choice" && question.options !== void 0 && choiceOptionIndex(question.options, question.answer) < 0) errors.push(`${label}.answer matches no option — a choice question's answer IS one of its options, copied verbatim (or its letter); write the correct option first, then the distractors around it`);
|
|
1687
|
+
const target = question.target;
|
|
1688
|
+
const targetOk = target !== void 0 && [
|
|
1689
|
+
"marked",
|
|
1690
|
+
"asked",
|
|
1691
|
+
"missed",
|
|
1692
|
+
"topic"
|
|
1693
|
+
].includes(target.kind);
|
|
1694
|
+
if (!targetOk) errors.push(`${label} needs target: { kind: marked|asked|missed|topic, cardIds? } — what the question aims at: the learner's marked cards, the points they asked about, the ones they missed before, or the chapter's topic`);
|
|
1695
|
+
else {
|
|
1696
|
+
const focus = request.quiz.focus;
|
|
1697
|
+
if (focus === "mine" && target.kind === "topic") errors.push(`${label} tests the chapter's topic, but the learner asked for their OWN points only (focus: mine) — every question must target their cards (marked / asked / missed, with cardIds from library_read · cards). If this node has too few cards, write fewer questions and tell the learner; never pad with book content`);
|
|
1698
|
+
if (focus === "book" && target.kind !== "topic") errors.push(`${label} targets the learner's cards, but they asked for the book's content only (focus: book) — target: { kind: "topic" } for every question`);
|
|
1699
|
+
if (target.kind !== "topic") {
|
|
1700
|
+
if (target.cardIds === void 0 || target.cardIds.length === 0) errors.push(`${label} targets ${target.kind} cards but names none — target.cardIds lists the cards it probes (library_read · cards finds them)`);
|
|
1701
|
+
else {
|
|
1702
|
+
const unknown = target.cardIds.filter((cardId) => !liveCards.has(cardId));
|
|
1703
|
+
if (unknown.length > 0) errors.push(`${label} names cards that do not exist: ${unknown.join(", ")} — use ids from library_read · cards`);
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
if (question.point !== void 0) {
|
|
1708
|
+
if (gatedNode === void 0) errors.push(`${label} names point ${JSON.stringify(question.point)}, but points belong to a station quiz (scope.node) — a resource-level or free quiz carries none`);
|
|
1709
|
+
else if (!knownPointIds.has(question.point)) errors.push(`${label} probes point ${JSON.stringify(question.point)}, which ${gatedNode.title} does not hold — its points are: ${knownPoints.map((point) => `${point.id} (${point.title})`).join(", ") || "none yet"}; reuse one or add it via addPoints`);
|
|
1710
|
+
} else if (knownPoints.length > 0) errors.push(`${label} names no point — this station's quizzes tag every question with the point it probes (point: one of ${[...knownPointIds].join(", ")})`);
|
|
1711
|
+
const evidence = question.evidence;
|
|
1712
|
+
if (evidence === void 0 || ![
|
|
1713
|
+
"photo",
|
|
1714
|
+
"web",
|
|
1715
|
+
"knowledge"
|
|
1716
|
+
].includes(evidence.kind)) errors.push(`${label} needs evidence: { kind: photo|web|knowledge, … } — what the answer rests on`);
|
|
1717
|
+
else {
|
|
1718
|
+
if (evidence.kind === "photo") {
|
|
1719
|
+
const page = evidence.pageId === void 0 ? void 0 : pages.get(evidence.pageId);
|
|
1720
|
+
if (page === void 0) errors.push(`${label} rests on a photo but names no recorded page — evidence.pageId comes from library_read · pages`);
|
|
1721
|
+
else {
|
|
1722
|
+
const quote = normalizeFragment(evidence.quote ?? "");
|
|
1723
|
+
if (quote.length < 4) errors.push(`${label} rests on a photo — quote the printed passage (evidence.quote) the answer comes from`);
|
|
1724
|
+
else if (!normalizeFragment(page.text).includes(quote)) warnings.push(`${label}: its quote is not verbatim in page ${page.label ?? page.id}'s text — stored; re-read the page and fix the quote`);
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
if (evidence.kind === "web") {
|
|
1728
|
+
if (evidence.url === void 0 || !/^https?:\/\//.test(evidence.url)) errors.push(`${label} rests on the web — evidence.url is the page the quote is from`);
|
|
1729
|
+
else if (normalizeFragment(evidence.quote ?? "").length < 8) errors.push(`${label} rests on the web — quote the passage (a sentence or two) so the learner can check it`);
|
|
1730
|
+
}
|
|
1731
|
+
if (evidence.kind === "knowledge" && targetOk && target.kind === "topic") {
|
|
1732
|
+
if (gatedNode === void 0) {
|
|
1733
|
+
if (resource !== void 0 && isThickResource(resource) && resource.meta?.bookKind === "material") errors.push(`${label} asks about 《${resource.title}》's content from memory — it is a material book (its exact text is what is tested). Ask the learner to photograph the page and quote it (evidence.kind photo); a web quote only from the SAME edition's text, verbatim — never your own words as a quote. If the book is really a textbook of domain knowledge, it is mis-typed: the learner switches it to 知识型 on the textbook page`);
|
|
1734
|
+
} else {
|
|
1735
|
+
const blocker = coversVerbatimBlocker(gatedNode.covers, (candidate) => this.store.readResource(project.value.id, candidate), gatedRoadmap?.primaryResourceId);
|
|
1736
|
+
if (blocker !== void 0) errors.push(`${label} asks the station's content from memory, but ${blocker}. Ask the learner to photograph the page and quote it (evidence.kind photo); a web quote only from the same text, verbatim — never your own words as a quote, never material from another project`);
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
const text = [
|
|
1741
|
+
question.prompt,
|
|
1742
|
+
question.answer,
|
|
1743
|
+
...question.options ?? [],
|
|
1744
|
+
question.explanation ?? ""
|
|
1745
|
+
].join(" ");
|
|
1746
|
+
if (!text.includes("$") && /[√∫∑∏∂∞±]|\\[a-zA-Z]{2,}|\^\{|\w\^\d/.test(text)) warnings.push(`${label} reads like math without any $…$ — the sidebar typesets LaTeX only inside $…$ / $$…$$, so this shows as raw text; rewrite the formula parts (e.g. $\\sqrt{x+1}$)`);
|
|
1747
|
+
}
|
|
1748
|
+
if (errors.length === 1) return refuse("evidence-gate", errors[0]);
|
|
1749
|
+
if (errors.length > 1) return refuse("evidence-gate", `${errors.length} problems in questions — fix them all, then resend:\n${errors.map((error) => `- ${error}`).join("\n")}`);
|
|
1750
|
+
const probed = new Set(request.quiz.questions.map((question) => question.point));
|
|
1751
|
+
const uncovered = knownPoints.filter((point) => !probed.has(point.id));
|
|
1752
|
+
if (uncovered.length > 0) warnings.push(`never asks about ${uncovered.map((point) => point.title).join(", ")} — fine for a retake; a full chapter quiz touches every point at least once`);
|
|
1753
|
+
if (nodeId === void 0) warnings.push(resource === void 0 ? "stored as a free quiz (no station, no resource): the learner finds it on the project page (自由测验) and it gates nothing — name node (a quiz-gated station) to gate one" : `stored as a resource-level quiz of 《${resource.title}》 (no station): the learner finds it on the resource page (本书测验) — name node (a quiz-gated station) to gate one`);
|
|
1754
|
+
return await this.mutate(project.value.id, () => {
|
|
1755
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1756
|
+
const quiz = {
|
|
1757
|
+
id: mintLibraryId("q"),
|
|
1758
|
+
type: "quiz",
|
|
1759
|
+
scope: {
|
|
1760
|
+
...resource === void 0 ? {} : { resourceId: resource.id },
|
|
1761
|
+
...nodeId === void 0 ? {} : { nodeId }
|
|
1762
|
+
},
|
|
1763
|
+
...request.quiz.focus === void 0 ? {} : { focus: request.quiz.focus },
|
|
1764
|
+
questions: request.quiz.questions,
|
|
1765
|
+
createdAt: now
|
|
1766
|
+
};
|
|
1767
|
+
this.store.writeAssessment(project.value.id, quiz);
|
|
1768
|
+
if (gatedNode !== void 0) {
|
|
1769
|
+
for (const assessment of this.store.listAssessments(project.value.id)) if (assessment.type === "quiz" && assessment.scope.nodeId === gatedNode.id && assessment.supersededBy === void 0) this.store.writeAssessment(project.value.id, {
|
|
1770
|
+
...assessment,
|
|
1771
|
+
supersededBy: quiz.id
|
|
1772
|
+
});
|
|
1773
|
+
}
|
|
1774
|
+
if (pointAdds.length > 0 && gatedNode !== void 0 && gatedRoadmap !== void 0) {
|
|
1775
|
+
const nodes = gatedRoadmap.nodes.map((node) => node.id === gatedNode.id ? {
|
|
1776
|
+
...node,
|
|
1777
|
+
points: [...node.points ?? [], ...pointAdds]
|
|
1778
|
+
} : node);
|
|
1779
|
+
this.store.writeRoadmap(project.value.id, {
|
|
1780
|
+
...gatedRoadmap,
|
|
1781
|
+
nodes
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
this.event(project.value.id, "quiz.created", {
|
|
1785
|
+
at: quiz.createdAt,
|
|
1786
|
+
id: quiz.id,
|
|
1787
|
+
...resource === void 0 ? {} : { resourceId: resource.id },
|
|
1788
|
+
count: request.quiz.questions.length,
|
|
1789
|
+
...pointAdds.length === 0 ? {} : { count2: pointAdds.length }
|
|
1790
|
+
});
|
|
1791
|
+
return {
|
|
1792
|
+
ok: true,
|
|
1793
|
+
quiz,
|
|
1794
|
+
warnings
|
|
1795
|
+
};
|
|
1796
|
+
});
|
|
1797
|
+
}
|
|
1798
|
+
/**
|
|
1799
|
+
* Grade a sitting — the only cascading write. Two shapes, exactly one key:
|
|
1800
|
+
* `assessmentId` (or the legacy alias `quizId`) judges a stored quiz;
|
|
1801
|
+
* `nodeId` accepts an ACCEPTANCE station — resolved to the node's current
|
|
1802
|
+
* practice assessment, each verdict's task naming one rubric line (its id
|
|
1803
|
+
* L1…, or the line verbatim).
|
|
1804
|
+
*
|
|
1805
|
+
* Void verdicts count for nothing. Every probed card moves: a pass climbs a
|
|
1806
|
+
* Leitner box and eases weakness by one; a fail resets to box 0 (due
|
|
1807
|
+
* tomorrow) and adds weakness. A failed line with no card gets one
|
|
1808
|
+
* (`missedCard`). The gated node's stats update (quiz nodes); at or above
|
|
1809
|
+
* the pass ratio it completes and unlocks its dependents.
|
|
1810
|
+
*/
|
|
1811
|
+
async putAttempt(request) {
|
|
1812
|
+
this.assertAlive("putAttempt");
|
|
1813
|
+
const project = this.readProjectOrRefusal(request.projectId);
|
|
1814
|
+
if ("error" in project) return project;
|
|
1815
|
+
const assessmentId = request.assessmentId ?? request.quizId;
|
|
1816
|
+
const nodeId = request.nodeId;
|
|
1817
|
+
if (assessmentId === void 0 === (nodeId === void 0)) return refuse("invalid", "assess takes exactly one of quiz / node — quiz: a graded sitting on a stored quiz; node: acceptance of an acceptance station");
|
|
1818
|
+
if (request.verdicts.length === 0) return refuse("invalid", "library_assess needs the verdicts — one per question: { task: <question id>, criterion, pass, ratio?, evidence, by, void?, missedCard? }");
|
|
1819
|
+
if (assessmentId !== void 0) return await this.putQuizAttempt(project.value, assessmentId, request.verdicts);
|
|
1820
|
+
return await this.putPracticeAttempt(project.value, nodeId ?? "", request.resourceId, request.verdicts);
|
|
1821
|
+
}
|
|
1822
|
+
/** The quiz path of putAttempt: verdicts judged against a stored quiz's questions. */
|
|
1823
|
+
async putQuizAttempt(project, assessmentId, verdicts) {
|
|
1824
|
+
const assessment = this.store.readAssessment(project.id, assessmentId);
|
|
1825
|
+
if (assessment === void 0 || assessment.type !== "quiz" && assessment.type !== "retake" || assessment.questions === void 0) return refuse("not-found", `no quiz ${JSON.stringify(assessmentId)} — store the quiz first (library_quiz), then assess it`);
|
|
1826
|
+
const questionById = new Map(assessment.questions.map((question) => [question.id, question]));
|
|
1827
|
+
for (const [index, verdict] of verdicts.entries()) {
|
|
1828
|
+
if (!questionById.has(verdict.task)) return refuse("invalid", `verdicts[${index}] judges task ${JSON.stringify(verdict.task)}, which quiz ${assessment.id} does not ask — its questions are: ${assessment.questions.map((question) => question.id).join(", ")}`);
|
|
1829
|
+
if (verdict.criterion.trim() === "" || verdict.evidence.trim() === "") return refuse("invalid", `verdicts[${index}] (${verdict.task}) needs criterion and evidence — WHAT was judged and WHERE the answer is grounded`);
|
|
1830
|
+
if (verdict.by !== "llm" && verdict.by !== "self" && verdict.by !== "user") return refuse("invalid", `verdicts[${index}].by must be llm | self | user`);
|
|
1831
|
+
if (verdict.ratio !== void 0 && !(verdict.ratio >= 0 && verdict.ratio <= 1)) return refuse("invalid", `verdicts[${index}].ratio is partial credit in [0, 1]`);
|
|
1832
|
+
}
|
|
1833
|
+
const counted = verdicts.filter((verdict) => verdict.void !== true);
|
|
1834
|
+
const ratio = counted.length === 0 ? 0 : counted.reduce((sum, verdict) => sum + (verdict.ratio ?? (verdict.pass ? 1 : 0)), 0) / counted.length;
|
|
1835
|
+
return await this.mutate(project.id, () => {
|
|
1836
|
+
const now = /* @__PURE__ */ new Date();
|
|
1837
|
+
const at = now.toISOString();
|
|
1838
|
+
const befores = (assessment.scope.nodeId !== void 0 ? [assessment.scope.nodeId] : [...new Set((assessment.questions ?? []).flatMap((question) => {
|
|
1839
|
+
const origin = question.origin === void 0 ? void 0 : this.store.readAssessment(project.id, question.origin.assessmentId);
|
|
1840
|
+
return origin?.scope.nodeId === void 0 ? [] : [origin.scope.nodeId];
|
|
1841
|
+
}))]).map((stationId) => [stationId, this.stationMasteryOf(project.id, stationId, stationId === assessment.scope.nodeId ? assessment.scope.resourceId : void 0)]);
|
|
1842
|
+
const attempt = {
|
|
1843
|
+
id: mintLibraryId("a"),
|
|
1844
|
+
assessmentId: assessment.id,
|
|
1845
|
+
verdicts,
|
|
1846
|
+
ratio,
|
|
1847
|
+
at
|
|
1848
|
+
};
|
|
1849
|
+
this.store.appendAttempt(project.id, attempt);
|
|
1850
|
+
const cards = this.store.readCards(project.id);
|
|
1851
|
+
const byId = new Map(cards.map((card) => [card.id, card]));
|
|
1852
|
+
const order = cards.map((card) => card.id);
|
|
1853
|
+
const touched = /* @__PURE__ */ new Map();
|
|
1854
|
+
const resource = assessment.scope.resourceId === void 0 ? void 0 : this.store.readResource(project.id, assessment.scope.resourceId);
|
|
1855
|
+
for (const verdict of counted) {
|
|
1856
|
+
const question = questionById.get(verdict.task);
|
|
1857
|
+
const credit = verdict.ratio ?? (verdict.pass ? 1 : 0);
|
|
1858
|
+
const passed = verdict.pass && credit >= .5;
|
|
1859
|
+
const cardIds = [...question.target.cardIds ?? []];
|
|
1860
|
+
if (cardIds.length === 0 && !passed && (verdict.missedCard?.term.trim() ?? "") !== "") {
|
|
1861
|
+
const created = this.missedCardFor(question, verdict, assessment, resource?.id, project.template, at);
|
|
1862
|
+
const existing = exactMatch([...byId.values()], [cardKey(created.term)]);
|
|
1863
|
+
if (existing === void 0) {
|
|
1864
|
+
byId.set(created.id, created);
|
|
1865
|
+
order.push(created.id);
|
|
1866
|
+
touched.set(created.id, {
|
|
1867
|
+
id: created.id,
|
|
1868
|
+
term: created.term,
|
|
1869
|
+
weakness: created.weakness,
|
|
1870
|
+
mastery: created.mastery,
|
|
1871
|
+
created: true
|
|
1872
|
+
});
|
|
1873
|
+
continue;
|
|
1874
|
+
}
|
|
1875
|
+
cardIds.push(existing.id);
|
|
1876
|
+
}
|
|
1877
|
+
for (const cardId of cardIds) {
|
|
1878
|
+
const card = byId.get(cardId);
|
|
1879
|
+
if (card === void 0 || !live(card)) continue;
|
|
1880
|
+
const box = passed ? Math.min(card.mastery.box + 1, 5) : 0;
|
|
1881
|
+
const next = {
|
|
1882
|
+
...card,
|
|
1883
|
+
mastery: {
|
|
1884
|
+
box,
|
|
1885
|
+
dueAt: leitnerDueAt(box, now),
|
|
1886
|
+
streak: passed ? card.mastery.streak + 1 : 0
|
|
1887
|
+
},
|
|
1888
|
+
weakness: clampWeakness(card.weakness + (passed ? -1 : 1)),
|
|
1889
|
+
sources: passed ? card.sources : [...card.sources, {
|
|
1890
|
+
type: "quiz",
|
|
1891
|
+
ref: assessment.id,
|
|
1892
|
+
note: verdict.criterion.slice(0, 120),
|
|
1893
|
+
weakened: true,
|
|
1894
|
+
at
|
|
1895
|
+
}],
|
|
1896
|
+
updatedAt: at
|
|
1897
|
+
};
|
|
1898
|
+
byId.set(card.id, next);
|
|
1899
|
+
touched.set(card.id, {
|
|
1900
|
+
id: card.id,
|
|
1901
|
+
term: card.term,
|
|
1902
|
+
weakness: next.weakness,
|
|
1903
|
+
mastery: next.mastery
|
|
1904
|
+
});
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
if (touched.size > 0) this.store.writeCards(project.id, order.map((cardId) => byId.get(cardId)));
|
|
1908
|
+
let unlocked = [];
|
|
1909
|
+
let completed;
|
|
1910
|
+
const roadmap = assessment.scope.nodeId === void 0 ? void 0 : this.findNodeRoadmap(project.id, assessment.scope.nodeId, assessment.scope.resourceId);
|
|
1911
|
+
const passRatio = roadmap?.passRatio ?? .8;
|
|
1912
|
+
const gated = assessment.scope.nodeId === void 0 ? void 0 : roadmap?.nodes.find((node) => node.id === assessment.scope.nodeId);
|
|
1913
|
+
if (roadmap !== void 0 && gated !== void 0 && gated.gate === "quiz" && assessment.type === "quiz") {
|
|
1914
|
+
const updated = roadmap.nodes.map((node) => {
|
|
1915
|
+
if (node.id !== gated.id) return node;
|
|
1916
|
+
const stats = node.quizStats ?? { attempts: 0 };
|
|
1917
|
+
const next = {
|
|
1918
|
+
...node,
|
|
1919
|
+
quizStats: {
|
|
1920
|
+
attempts: stats.attempts + 1,
|
|
1921
|
+
lastRatio: ratio,
|
|
1922
|
+
bestRatio: Math.max(stats.bestRatio ?? 0, ratio)
|
|
1923
|
+
}
|
|
1924
|
+
};
|
|
1925
|
+
if (ratio >= passRatio && node.state === "current") {
|
|
1926
|
+
completed = node.id;
|
|
1927
|
+
return {
|
|
1928
|
+
...next,
|
|
1929
|
+
state: "done"
|
|
1930
|
+
};
|
|
1931
|
+
}
|
|
1932
|
+
return next;
|
|
1933
|
+
});
|
|
1934
|
+
const result = completed === void 0 ? {
|
|
1935
|
+
nodes: updated,
|
|
1936
|
+
unlocked: []
|
|
1937
|
+
} : unlockReady(updated);
|
|
1938
|
+
unlocked = result.unlocked;
|
|
1939
|
+
this.store.writeRoadmap(project.id, {
|
|
1940
|
+
...roadmap,
|
|
1941
|
+
nodes: result.nodes
|
|
1942
|
+
});
|
|
1943
|
+
}
|
|
1944
|
+
const created = [...touched.values()].filter((update) => update.created === true);
|
|
1945
|
+
const onResource = assessment.scope.resourceId === void 0 ? {} : { resourceId: assessment.scope.resourceId };
|
|
1946
|
+
const pointChanges = befores.flatMap(([stationId, before]) => this.pointChangesSince(project.id, stationId, stationId === assessment.scope.nodeId ? assessment.scope.resourceId : void 0, before, at));
|
|
1947
|
+
this.store.appendEvents(project.id, [
|
|
1948
|
+
{
|
|
1949
|
+
at,
|
|
1950
|
+
type: "attempt.assessed",
|
|
1951
|
+
id: assessment.id,
|
|
1952
|
+
...onResource,
|
|
1953
|
+
ratio
|
|
1954
|
+
},
|
|
1955
|
+
...created.length === 0 ? [] : [{
|
|
1956
|
+
at,
|
|
1957
|
+
type: "cards.recorded",
|
|
1958
|
+
source: "quiz",
|
|
1959
|
+
title: created.map((update) => update.term).join("、").slice(0, 300),
|
|
1960
|
+
ids: created.map((update) => update.id),
|
|
1961
|
+
...onResource,
|
|
1962
|
+
count: created.length
|
|
1963
|
+
}],
|
|
1964
|
+
...completed === void 0 ? [] : [{
|
|
1965
|
+
at,
|
|
1966
|
+
type: "node.completed",
|
|
1967
|
+
id: completed,
|
|
1968
|
+
title: titleOf(roadmap?.nodes ?? [], completed),
|
|
1969
|
+
...onResource,
|
|
1970
|
+
ratio
|
|
1971
|
+
}],
|
|
1972
|
+
...unlocked.map((nodeId) => ({
|
|
1973
|
+
at,
|
|
1974
|
+
type: "node.unlocked",
|
|
1975
|
+
id: nodeId,
|
|
1976
|
+
title: titleOf(roadmap?.nodes ?? [], nodeId),
|
|
1977
|
+
...onResource
|
|
1978
|
+
}))
|
|
1979
|
+
]);
|
|
1980
|
+
return {
|
|
1981
|
+
ok: true,
|
|
1982
|
+
attempt,
|
|
1983
|
+
cardUpdates: [...touched.values()],
|
|
1984
|
+
unlocked,
|
|
1985
|
+
...completed === void 0 ? {} : { completed },
|
|
1986
|
+
passRatio,
|
|
1987
|
+
...pointChanges.length === 0 ? {} : { pointChanges }
|
|
1988
|
+
};
|
|
1989
|
+
});
|
|
1990
|
+
}
|
|
1991
|
+
/**
|
|
1992
|
+
* The practice path of putAttempt: acceptance of a hands-on station, judged
|
|
1993
|
+
* against the node's current practice assessment (materialized at adoption).
|
|
1994
|
+
* No probed cards — a failed acceptance line with a `missedCard` leaves (or
|
|
1995
|
+
* re-weakens) a card; at the pass ratio the station completes.
|
|
1996
|
+
*/
|
|
1997
|
+
async putPracticeAttempt(project, nodeId, resourceId, verdicts) {
|
|
1998
|
+
const roadmap = this.findNodeRoadmap(project.id, nodeId, resourceId);
|
|
1999
|
+
const node = roadmap?.nodes.find((candidate) => candidate.id === nodeId);
|
|
2000
|
+
if (roadmap === void 0 || node === void 0) return refuse("not-found", `no station ${JSON.stringify(nodeId)} on the adopted path — library_read · roadmap lists the nodes`);
|
|
2001
|
+
if (node.gate !== "acceptance") return refuse("state-machine", `${node.title} is a ${node.gate} node — a quiz node completes by passing its quiz (assess with quiz), a read node by 读完; only an acceptance station takes acceptance verdicts`);
|
|
2002
|
+
const assessment = this.store.listAssessments(project.id).find((candidate) => candidate.type === "practice" && candidate.scope.nodeId === node.id && candidate.supersededBy === void 0);
|
|
2003
|
+
const rubric = assessment?.rubric ?? acceptanceLines(node);
|
|
2004
|
+
if (rubric.length === 0) return refuse("invalid", `${node.title} carries no acceptance lines — the coach drafts them into the roadmap (library_roadmap)`);
|
|
2005
|
+
const passRatio = roadmap.passRatio ?? .8;
|
|
2006
|
+
const attempts = this.store.readAttempts(project.id);
|
|
2007
|
+
const auto = [];
|
|
2008
|
+
const autoTasks = /* @__PURE__ */ new Set();
|
|
2009
|
+
for (const line of rubric) {
|
|
2010
|
+
if (line.ref === void 0) continue;
|
|
2011
|
+
const referenced = this.store.readAssessment(project.id, line.ref);
|
|
2012
|
+
const latest = referenced === void 0 ? void 0 : attempts.filter((attempt) => attempt.assessmentId === referenced.id).at(-1);
|
|
2013
|
+
const quizRatio = latest?.ratio ?? 0;
|
|
2014
|
+
auto.push({
|
|
2015
|
+
task: line.id,
|
|
2016
|
+
criterion: line.line,
|
|
2017
|
+
pass: latest !== void 0 && quizRatio >= passRatio,
|
|
2018
|
+
ratio: quizRatio,
|
|
2019
|
+
evidence: latest === void 0 ? `机检:${line.ref} 还没有判分记录` : `机检:${line.ref} 最新一场 ${Math.round(quizRatio * 100)}%(过线 ${Math.round(passRatio * 100)}%)`,
|
|
2020
|
+
by: "auto"
|
|
2021
|
+
});
|
|
2022
|
+
autoTasks.add(line.id);
|
|
2023
|
+
}
|
|
2024
|
+
const judged = [];
|
|
2025
|
+
for (const [index, verdict] of verdicts.entries()) {
|
|
2026
|
+
const line = rubric.find((candidate) => candidate.id === verdict.task) ?? rubric.find((candidate) => candidate.line === verdict.criterion);
|
|
2027
|
+
if (line === void 0) return refuse("invalid", `verdicts[${index}] names no rubric line — task is the line id, criterion the line verbatim; the rubric is: ${rubric.map((candidate) => `${candidate.id} ${JSON.stringify(candidate.line)}`).join(", ")}`);
|
|
2028
|
+
if (autoTasks.has(line.id)) return refuse("invalid", `verdicts[${index}] judges ${line.id}, which assessment ${line.ref} machine-checks — a ref line takes no verdict`);
|
|
2029
|
+
if (verdict.evidence.trim() === "") return refuse("invalid", `verdicts[${index}] needs evidence — what the learner showed (terminal output, a screenshot, a repo link)`);
|
|
2030
|
+
if (verdict.by !== "llm" && verdict.by !== "self" && verdict.by !== "user") return refuse("invalid", `verdicts[${index}].by must be llm | self | user`);
|
|
2031
|
+
if (verdict.ratio !== void 0 && !(verdict.ratio >= 0 && verdict.ratio <= 1)) return refuse("invalid", `verdicts[${index}].ratio is partial credit in [0, 1]`);
|
|
2032
|
+
judged.push({
|
|
2033
|
+
...verdict,
|
|
2034
|
+
task: line.id,
|
|
2035
|
+
criterion: line.line
|
|
2036
|
+
});
|
|
2037
|
+
}
|
|
2038
|
+
const all = [...judged, ...auto];
|
|
2039
|
+
const counted = all.filter((verdict) => verdict.void !== true);
|
|
2040
|
+
const ratio = counted.length === 0 ? 0 : counted.reduce((sum, verdict) => sum + (verdict.ratio ?? (verdict.pass ? 1 : 0)), 0) / counted.length;
|
|
2041
|
+
return await this.mutate(project.id, () => {
|
|
2042
|
+
const now = /* @__PURE__ */ new Date();
|
|
2043
|
+
const at = now.toISOString();
|
|
2044
|
+
let key = assessment;
|
|
2045
|
+
if (key === void 0) {
|
|
2046
|
+
key = {
|
|
2047
|
+
id: mintLibraryId("q"),
|
|
2048
|
+
type: "practice",
|
|
2049
|
+
scope: { nodeId: node.id },
|
|
2050
|
+
rubric,
|
|
2051
|
+
createdAt: at
|
|
2052
|
+
};
|
|
2053
|
+
this.store.writeAssessment(project.id, key);
|
|
2054
|
+
}
|
|
2055
|
+
const attempt = {
|
|
2056
|
+
id: mintLibraryId("a"),
|
|
2057
|
+
assessmentId: key.id,
|
|
2058
|
+
verdicts: all,
|
|
2059
|
+
ratio,
|
|
2060
|
+
at
|
|
2061
|
+
};
|
|
2062
|
+
this.store.appendAttempt(project.id, attempt);
|
|
2063
|
+
const cards = this.store.readCards(project.id);
|
|
2064
|
+
const byId = new Map(cards.map((card) => [card.id, card]));
|
|
2065
|
+
const order = cards.map((card) => card.id);
|
|
2066
|
+
const touched = /* @__PURE__ */ new Map();
|
|
2067
|
+
for (const verdict of counted) {
|
|
2068
|
+
if (verdict.pass && (verdict.ratio ?? 1) >= .5 || verdict.missedCard === void 0) continue;
|
|
2069
|
+
const term = verdict.missedCard.term.trim() || verdict.criterion.slice(0, 80);
|
|
2070
|
+
const gloss = verdict.missedCard.gloss.trim() || verdict.criterion;
|
|
2071
|
+
const existing = exactMatch([...byId.values()], [cardKey(term)]);
|
|
2072
|
+
if (existing !== void 0) {
|
|
2073
|
+
if (!live(existing)) continue;
|
|
2074
|
+
const next = {
|
|
2075
|
+
...existing,
|
|
2076
|
+
mastery: {
|
|
2077
|
+
box: 0,
|
|
2078
|
+
dueAt: leitnerDueAt(0, now),
|
|
2079
|
+
streak: 0
|
|
2080
|
+
},
|
|
2081
|
+
weakness: clampWeakness(existing.weakness + 1),
|
|
2082
|
+
sources: [...existing.sources, {
|
|
2083
|
+
type: "quiz",
|
|
2084
|
+
note: verdict.criterion.slice(0, 120),
|
|
2085
|
+
weakened: true,
|
|
2086
|
+
at
|
|
2087
|
+
}],
|
|
2088
|
+
updatedAt: at
|
|
2089
|
+
};
|
|
2090
|
+
byId.set(next.id, next);
|
|
2091
|
+
touched.set(next.id, {
|
|
2092
|
+
id: next.id,
|
|
2093
|
+
term: next.term,
|
|
2094
|
+
weakness: next.weakness,
|
|
2095
|
+
mastery: next.mastery
|
|
2096
|
+
});
|
|
2097
|
+
continue;
|
|
2098
|
+
}
|
|
2099
|
+
const card = {
|
|
2100
|
+
id: mintLibraryId("c"),
|
|
2101
|
+
kind: project.template === "language" && !/\s/.test(term) ? "word" : "concept",
|
|
2102
|
+
term,
|
|
2103
|
+
aliases: [],
|
|
2104
|
+
gloss,
|
|
2105
|
+
contexts: [],
|
|
2106
|
+
sources: [{
|
|
2107
|
+
type: "quiz",
|
|
2108
|
+
note: verdict.criterion.slice(0, 120),
|
|
2109
|
+
weakened: true,
|
|
2110
|
+
at
|
|
2111
|
+
}],
|
|
2112
|
+
weakness: 1,
|
|
2113
|
+
mastery: {
|
|
2114
|
+
box: 0,
|
|
2115
|
+
dueAt: leitnerDueAt(0, now),
|
|
2116
|
+
streak: 0
|
|
2117
|
+
},
|
|
2118
|
+
status: "active",
|
|
2119
|
+
createdAt: at,
|
|
2120
|
+
updatedAt: at
|
|
2121
|
+
};
|
|
2122
|
+
byId.set(card.id, card);
|
|
2123
|
+
order.push(card.id);
|
|
2124
|
+
touched.set(card.id, {
|
|
2125
|
+
id: card.id,
|
|
2126
|
+
term: card.term,
|
|
2127
|
+
weakness: card.weakness,
|
|
2128
|
+
mastery: card.mastery,
|
|
2129
|
+
created: true
|
|
2130
|
+
});
|
|
2131
|
+
}
|
|
2132
|
+
if (touched.size > 0) this.store.writeCards(project.id, order.map((cardId) => byId.get(cardId)));
|
|
2133
|
+
let unlocked = [];
|
|
2134
|
+
let completed;
|
|
2135
|
+
if (ratio >= passRatio && node.state === "current") {
|
|
2136
|
+
completed = node.id;
|
|
2137
|
+
const result = unlockReady(roadmap.nodes.map((candidate) => candidate.id === node.id ? {
|
|
2138
|
+
...candidate,
|
|
2139
|
+
state: "done"
|
|
2140
|
+
} : candidate));
|
|
2141
|
+
unlocked = result.unlocked;
|
|
2142
|
+
this.store.writeRoadmap(project.id, {
|
|
2143
|
+
...roadmap,
|
|
2144
|
+
nodes: result.nodes
|
|
2145
|
+
});
|
|
2146
|
+
}
|
|
2147
|
+
const created = [...touched.values()].filter((update) => update.created === true);
|
|
2148
|
+
const onResource = resourceId === void 0 ? {} : { resourceId };
|
|
2149
|
+
this.store.appendEvents(project.id, [
|
|
2150
|
+
{
|
|
2151
|
+
at,
|
|
2152
|
+
type: "attempt.assessed",
|
|
2153
|
+
id: node.id,
|
|
2154
|
+
...onResource,
|
|
2155
|
+
ratio
|
|
2156
|
+
},
|
|
2157
|
+
...created.length === 0 ? [] : [{
|
|
2158
|
+
at,
|
|
2159
|
+
type: "cards.recorded",
|
|
2160
|
+
source: "quiz",
|
|
2161
|
+
title: created.map((update) => update.term).join("、").slice(0, 300),
|
|
2162
|
+
ids: created.map((update) => update.id),
|
|
2163
|
+
...onResource,
|
|
2164
|
+
count: created.length
|
|
2165
|
+
}],
|
|
2166
|
+
...completed === void 0 ? [] : [{
|
|
2167
|
+
at,
|
|
2168
|
+
type: "node.completed",
|
|
2169
|
+
id: completed,
|
|
2170
|
+
title: titleOf(roadmap.nodes, completed),
|
|
2171
|
+
...onResource,
|
|
2172
|
+
ratio
|
|
2173
|
+
}],
|
|
2174
|
+
...unlocked.map((unlockedId) => ({
|
|
2175
|
+
at,
|
|
2176
|
+
type: "node.unlocked",
|
|
2177
|
+
id: unlockedId,
|
|
2178
|
+
title: titleOf(roadmap.nodes, unlockedId),
|
|
2179
|
+
...onResource
|
|
2180
|
+
}))
|
|
2181
|
+
]);
|
|
2182
|
+
return {
|
|
2183
|
+
ok: true,
|
|
2184
|
+
attempt,
|
|
2185
|
+
cardUpdates: [...touched.values()],
|
|
2186
|
+
unlocked,
|
|
2187
|
+
...completed === void 0 ? {} : { completed },
|
|
2188
|
+
passRatio
|
|
2189
|
+
};
|
|
2190
|
+
});
|
|
2191
|
+
}
|
|
2192
|
+
/** Every open miss across the project's adopted quiz stations, newest first. */
|
|
2193
|
+
projectOpenMisses(projectId) {
|
|
2194
|
+
const assessments = this.store.listAssessments(projectId);
|
|
2195
|
+
const attempts = this.store.readAttempts(projectId);
|
|
2196
|
+
const open = [];
|
|
2197
|
+
for (const resource of this.store.listResources(projectId)) for (const node of this.store.readRoadmap(projectId, resource.id)?.nodes ?? []) if (node.gate === "quiz") open.push(...stationMastery(node, assessments, attempts).open);
|
|
2198
|
+
return open.sort((a, b) => b.at.localeCompare(a.at));
|
|
2199
|
+
}
|
|
2200
|
+
/** A station's mastery as the ledger stands (undefined off a station, or for a node no adopted path holds). */
|
|
2201
|
+
stationMasteryOf(projectId, nodeId, resourceId) {
|
|
2202
|
+
if (nodeId === void 0) return void 0;
|
|
2203
|
+
const node = this.findNodeRoadmap(projectId, nodeId, resourceId)?.nodes.find((candidate) => candidate.id === nodeId);
|
|
2204
|
+
if (node === void 0) return void 0;
|
|
2205
|
+
return stationMastery(node, this.store.listAssessments(projectId), this.store.readAttempts(projectId));
|
|
2206
|
+
}
|
|
2207
|
+
/**
|
|
2208
|
+
* The points a write moved, read against `before`; each change is ledgered
|
|
2209
|
+
* as a `point.changed` event (今日 「最近的变化」). Call inside the lock,
|
|
2210
|
+
* after the write.
|
|
2211
|
+
*/
|
|
2212
|
+
pointChangesSince(projectId, nodeId, resourceId, before, at) {
|
|
2213
|
+
if (nodeId === void 0 || before === void 0) return [];
|
|
2214
|
+
const after = this.stationMasteryOf(projectId, nodeId, resourceId);
|
|
2215
|
+
if (after === void 0) return [];
|
|
2216
|
+
const changes = masteryChanges(nodeId, before, after);
|
|
2217
|
+
const roadmap = this.findNodeRoadmap(projectId, nodeId, resourceId);
|
|
2218
|
+
const onResource = roadmap?.primaryResourceId === void 0 ? {} : { resourceId: roadmap.primaryResourceId };
|
|
2219
|
+
if (changes.length > 0) this.store.appendEvents(projectId, changes.map((change) => {
|
|
2220
|
+
const point = after.points.find((candidate) => candidate.id === change.pointId);
|
|
2221
|
+
const note = point?.notes.at(-1);
|
|
2222
|
+
return {
|
|
2223
|
+
at,
|
|
2224
|
+
type: "point.changed",
|
|
2225
|
+
id: change.pointId,
|
|
2226
|
+
nodeId,
|
|
2227
|
+
title: change.title,
|
|
2228
|
+
...onResource,
|
|
2229
|
+
from: change.from,
|
|
2230
|
+
to: change.to,
|
|
2231
|
+
count: point?.open.length ?? 0,
|
|
2232
|
+
...change.to === "weak" && note !== void 0 && (point?.open.length ?? 0) === 0 ? { detail: (note.term ?? note.text).slice(0, 300) } : {}
|
|
2233
|
+
};
|
|
2234
|
+
}));
|
|
2235
|
+
return changes;
|
|
2236
|
+
}
|
|
2237
|
+
/**
|
|
2238
|
+
* 订正: a retake paper re-asking a station's open misses (all, or the keys
|
|
2239
|
+
* named — `${assessmentId}:${questionId}`). Its verdicts move the points,
|
|
2240
|
+
* never the station's pass record. No coach needed: the questions exist.
|
|
2241
|
+
*/
|
|
2242
|
+
async createRetake(request) {
|
|
2243
|
+
this.assertAlive("createRetake");
|
|
2244
|
+
const project = this.readProjectOrRefusal(request.projectId);
|
|
2245
|
+
if ("error" in project) return project;
|
|
2246
|
+
return await this.mutate(project.value.id, () => {
|
|
2247
|
+
let open;
|
|
2248
|
+
if (request.nodeId === void 0) open = this.projectOpenMisses(project.value.id);
|
|
2249
|
+
else {
|
|
2250
|
+
const mastery = this.stationMasteryOf(project.value.id, request.nodeId, request.resourceId);
|
|
2251
|
+
if (mastery === void 0) return refuse("not-found", `no adopted station ${JSON.stringify(request.nodeId)}`);
|
|
2252
|
+
open = [...mastery.open];
|
|
2253
|
+
}
|
|
2254
|
+
const wanted = request.keys === void 0 || request.keys.length === 0 ? void 0 : new Set(request.keys);
|
|
2255
|
+
const misses = open.filter((miss) => wanted === void 0 || wanted.has(miss.key));
|
|
2256
|
+
if (misses.length === 0) return refuse("invalid", `nothing to retake here — ${request.nodeId === void 0 ? "the project" : "this station"} has no open misses`);
|
|
2257
|
+
const roadmap = request.nodeId === void 0 ? void 0 : this.findNodeRoadmap(project.value.id, request.nodeId, request.resourceId);
|
|
2258
|
+
const assessment = {
|
|
2259
|
+
id: mintLibraryId("q"),
|
|
2260
|
+
type: "retake",
|
|
2261
|
+
scope: request.nodeId === void 0 ? {} : {
|
|
2262
|
+
nodeId: request.nodeId,
|
|
2263
|
+
...roadmap?.primaryResourceId === void 0 ? {} : { resourceId: roadmap.primaryResourceId }
|
|
2264
|
+
},
|
|
2265
|
+
questions: misses.map((miss, index) => ({
|
|
2266
|
+
...miss.question,
|
|
2267
|
+
id: `r${index + 1}`,
|
|
2268
|
+
origin: {
|
|
2269
|
+
assessmentId: miss.assessmentId,
|
|
2270
|
+
questionId: miss.questionId
|
|
2271
|
+
}
|
|
2272
|
+
})),
|
|
2273
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2274
|
+
};
|
|
2275
|
+
this.store.writeAssessment(project.value.id, assessment);
|
|
2276
|
+
return {
|
|
2277
|
+
ok: true,
|
|
2278
|
+
assessment
|
|
2279
|
+
};
|
|
2280
|
+
});
|
|
2281
|
+
}
|
|
2282
|
+
/**
|
|
2283
|
+
* A coach note on a knowledge point: the learner stuck on it here. The
|
|
2284
|
+
* point turns weak until a newer probe passes (subject projects — a weak
|
|
2285
|
+
* point lands on its point, not on a card).
|
|
2286
|
+
*/
|
|
2287
|
+
async notePoint(request) {
|
|
2288
|
+
this.assertAlive("notePoint");
|
|
2289
|
+
const project = this.readProjectOrRefusal(request.projectId);
|
|
2290
|
+
if ("error" in project) return project;
|
|
2291
|
+
const text = request.text.trim();
|
|
2292
|
+
if (text === "") return refuse("invalid", "a point note needs its text — what the learner stuck on, in one line");
|
|
2293
|
+
return await this.mutate(project.value.id, () => {
|
|
2294
|
+
const roadmap = this.findNodeRoadmap(project.value.id, request.nodeId, request.resourceId);
|
|
2295
|
+
const node = roadmap?.nodes.find((candidate) => candidate.id === request.nodeId);
|
|
2296
|
+
if (roadmap === void 0 || node === void 0) return refuse("not-found", `no adopted station ${JSON.stringify(request.nodeId)} — library_read · roadmap lists them`);
|
|
2297
|
+
const point = node.points?.find((candidate) => candidate.id === request.pointId);
|
|
2298
|
+
if (point === void 0) return refuse("not-found", `station ${node.title} has no point ${JSON.stringify(request.pointId)} — its points: ${(node.points ?? []).map((candidate) => `${candidate.id} (${candidate.title})`).join(", ") || "none"}`);
|
|
2299
|
+
const before = this.stationMasteryOf(project.value.id, request.nodeId, request.resourceId);
|
|
2300
|
+
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
2301
|
+
const note = {
|
|
2302
|
+
text: text.slice(0, 1e3),
|
|
2303
|
+
...request.term === void 0 || request.term.trim() === "" ? {} : { term: request.term.trim().slice(0, 100) },
|
|
2304
|
+
at,
|
|
2305
|
+
...request.session === void 0 ? {} : { session: request.session }
|
|
2306
|
+
};
|
|
2307
|
+
this.store.writeRoadmap(project.value.id, {
|
|
2308
|
+
...roadmap,
|
|
2309
|
+
nodes: roadmap.nodes.map((candidate) => candidate.id !== node.id ? candidate : {
|
|
2310
|
+
...candidate,
|
|
2311
|
+
points: (candidate.points ?? []).map((entry) => entry.id !== point.id ? entry : {
|
|
2312
|
+
...entry,
|
|
2313
|
+
notes: [...entry.notes ?? [], note]
|
|
2314
|
+
})
|
|
2315
|
+
})
|
|
2316
|
+
});
|
|
2317
|
+
const changes = this.pointChangesSince(project.value.id, request.nodeId, request.resourceId, before, at);
|
|
2318
|
+
return {
|
|
2319
|
+
ok: true,
|
|
2320
|
+
point: this.stationMasteryOf(project.value.id, request.nodeId, request.resourceId).points.find((candidate) => candidate.id === point.id),
|
|
2321
|
+
changes
|
|
2322
|
+
};
|
|
2323
|
+
});
|
|
2324
|
+
}
|
|
2325
|
+
/** The roadmap holding one node: the hinted textbook's first, then every adopted path of the project. */
|
|
2326
|
+
findNodeRoadmap(projectId, nodeId, resourceId) {
|
|
2327
|
+
const hinted = this.store.readRoadmap(projectId, resourceId);
|
|
2328
|
+
if (hinted?.nodes.some((node) => node.id === nodeId)) return hinted;
|
|
2329
|
+
for (const resource of this.store.listResources(projectId)) {
|
|
2330
|
+
const roadmap = this.store.readRoadmap(projectId, resource.id);
|
|
2331
|
+
if (roadmap?.nodes.some((node) => node.id === nodeId)) return roadmap;
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
/** The card a missed, card-less question leaves behind. */
|
|
2335
|
+
missedCardFor(question, verdict, quiz, resourceId, template, at) {
|
|
2336
|
+
const term = verdict.missedCard?.term.trim() ?? "";
|
|
2337
|
+
const gloss = verdict.missedCard?.gloss.trim() || question.answer.trim();
|
|
2338
|
+
return {
|
|
2339
|
+
id: mintLibraryId("c"),
|
|
2340
|
+
kind: template === "language" && verdict.missedCard !== void 0 && !/\s/.test(term) ? "word" : "concept",
|
|
2341
|
+
term,
|
|
2342
|
+
aliases: [],
|
|
2343
|
+
gloss,
|
|
2344
|
+
contexts: question.evidence.quote === void 0 ? [] : [{
|
|
2345
|
+
sentence: question.evidence.quote,
|
|
2346
|
+
...resourceId === void 0 ? {} : { resourceId },
|
|
2347
|
+
...question.evidence.pageId === void 0 ? {} : { pageId: question.evidence.pageId }
|
|
2348
|
+
}],
|
|
2349
|
+
...resourceId === void 0 ? {} : { resourceId },
|
|
2350
|
+
sources: [{
|
|
2351
|
+
type: "quiz",
|
|
2352
|
+
ref: quiz.id,
|
|
2353
|
+
note: verdict.criterion.slice(0, 120),
|
|
2354
|
+
weakened: true,
|
|
2355
|
+
at
|
|
2356
|
+
}],
|
|
2357
|
+
weakness: 1,
|
|
2358
|
+
mastery: {
|
|
2359
|
+
box: 0,
|
|
2360
|
+
dueAt: leitnerDueAt(0, new Date(at)),
|
|
2361
|
+
streak: 0
|
|
2362
|
+
},
|
|
2363
|
+
status: "active",
|
|
2364
|
+
createdAt: at,
|
|
2365
|
+
updatedAt: at
|
|
2366
|
+
};
|
|
2367
|
+
}
|
|
2368
|
+
/**
|
|
2369
|
+
* A project's graded sittings, newest first. `nodeId` narrows to one node's
|
|
2370
|
+
* history: sittings keyed to an assessment scoped on it, plus legacy v4
|
|
2371
|
+
* acceptance sittings that named the node directly. `assessmentId` narrows
|
|
2372
|
+
* to one assessment's sittings.
|
|
2373
|
+
*/
|
|
2374
|
+
async listAttempts(request) {
|
|
2375
|
+
this.assertAlive("listAttempts");
|
|
2376
|
+
let attempts = this.store.readAttempts(request.projectId);
|
|
2377
|
+
if (request.assessmentId !== void 0) attempts = attempts.filter((attempt) => attempt.assessmentId === request.assessmentId);
|
|
2378
|
+
if (request.nodeId !== void 0) {
|
|
2379
|
+
const nodeAssessmentIds = new Set(this.store.listAssessments(request.projectId).filter((assessment) => assessment.scope.nodeId === request.nodeId).map((assessment) => assessment.id));
|
|
2380
|
+
attempts = attempts.filter((attempt) => attempt.nodeId === request.nodeId || attempt.assessmentId !== void 0 && nodeAssessmentIds.has(attempt.assessmentId));
|
|
2381
|
+
}
|
|
2382
|
+
return { attempts: attempts.reverse() };
|
|
2383
|
+
}
|
|
2384
|
+
};
|
|
2385
|
+
//#endregion
|
|
2386
|
+
//#region lib/types/service/bindings.js
|
|
2387
|
+
/**
|
|
2388
|
+
* Session bindings, the per-turn binding summary, events, activity and stats — the top layer, exported as LibraryService.
|
|
2389
|
+
*
|
|
2390
|
+
* One layer of the library service (see `service/core.ts` for the layering):
|
|
2391
|
+
* the methods are the service's own, moved here verbatim by domain.
|
|
2392
|
+
*
|
|
2393
|
+
* @module @khorsheed/dsh-library/service/bindings
|
|
2394
|
+
*/
|
|
2395
|
+
var LibraryService = class extends LibraryServiceAssess {
|
|
2396
|
+
/** The project / resource a session works on, when it is bound. */
|
|
2397
|
+
getBinding(sessionId) {
|
|
2398
|
+
this.assertAlive("getBinding");
|
|
2399
|
+
return this.store.readBindings().get(sessionId);
|
|
2400
|
+
}
|
|
2401
|
+
/** Bind a session (null unbinds). The project and resource must exist. */
|
|
2402
|
+
async setBinding(sessionId, binding) {
|
|
2403
|
+
this.assertAlive("setBinding");
|
|
2404
|
+
if (sessionId === "") return refuse("invalid", "no session to bind");
|
|
2405
|
+
if (binding !== null) {
|
|
2406
|
+
const project = this.readProjectOrRefusal(binding.projectId);
|
|
2407
|
+
if ("error" in project) return project;
|
|
2408
|
+
if (binding.resourceId !== void 0 && this.store.readResource(binding.projectId, binding.resourceId) === void 0) return refuse("not-found", `no resource ${JSON.stringify(binding.resourceId)} in project ${JSON.stringify(project.value.name)} — resource ids are minted (r_…), never chosen by the caller; library_read · resources lists them. Registering the resource (library_textbook) binds this session itself — no separate library_binding call needed`);
|
|
2409
|
+
}
|
|
2410
|
+
try {
|
|
2411
|
+
await this.store.withBindingsLock(() => {
|
|
2412
|
+
const bindings = new Map(this.store.readBindings());
|
|
2413
|
+
const before = bindings.get(sessionId);
|
|
2414
|
+
const previous = before === void 0 ? void 0 : before.projectId !== binding?.projectId ? before.projectId : before.previousProjectId;
|
|
2415
|
+
if (binding === null) bindings.delete(sessionId);
|
|
2416
|
+
else bindings.set(sessionId, {
|
|
2417
|
+
projectId: binding.projectId,
|
|
2418
|
+
...binding.resourceId === void 0 ? {} : { resourceId: binding.resourceId },
|
|
2419
|
+
...previous === void 0 ? {} : { previousProjectId: previous }
|
|
2420
|
+
});
|
|
2421
|
+
this.store.writeBindings(bindings);
|
|
2422
|
+
});
|
|
2423
|
+
if (binding !== null) this.changed(binding.projectId);
|
|
2424
|
+
return {
|
|
2425
|
+
ok: true,
|
|
2426
|
+
binding
|
|
2427
|
+
};
|
|
2428
|
+
} catch (error) {
|
|
2429
|
+
return storeRefusal(error);
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
/**
|
|
2433
|
+
* The bound session's working set, read synchronously (the per-turn prompt
|
|
2434
|
+
* context renders inside a synchronous callback): the project, the bound
|
|
2435
|
+
* resource, every textbook's path state in study order (so the coach reads
|
|
2436
|
+
* what the learner adopted, discarded or reordered in the sidebar, every
|
|
2437
|
+
* turn), and the weakest live cards.
|
|
2438
|
+
*/
|
|
2439
|
+
bindingSummary(sessionId, cardLimit = 12) {
|
|
2440
|
+
this.assertAlive("bindingSummary");
|
|
2441
|
+
const binding = this.getBinding(sessionId);
|
|
2442
|
+
if (binding === void 0) return {
|
|
2443
|
+
textbooks: [],
|
|
2444
|
+
weakCards: []
|
|
2445
|
+
};
|
|
2446
|
+
let project;
|
|
2447
|
+
try {
|
|
2448
|
+
project = this.store.readProject(binding.projectId);
|
|
2449
|
+
} catch {
|
|
2450
|
+
project = void 0;
|
|
2451
|
+
}
|
|
2452
|
+
if (project === void 0) return {
|
|
2453
|
+
binding,
|
|
2454
|
+
textbooks: [],
|
|
2455
|
+
weakCards: []
|
|
2456
|
+
};
|
|
2457
|
+
const resource = binding.resourceId === void 0 ? void 0 : this.store.readResource(project.id, binding.resourceId);
|
|
2458
|
+
const textbooks = this.store.listResources(project.id).filter(isThickResource).map((textbook) => this.resourceProgress(project.id, textbook));
|
|
2459
|
+
const weakCards = this.store.readCards(project.id).filter((card) => card.status === "active" && card.weakness > 0 && (resource === void 0 || card.resourceId === resource.id)).sort((a, b) => b.weakness - a.weakness || b.updatedAt.localeCompare(a.updatedAt)).slice(0, cardLimit);
|
|
2460
|
+
const current = resource === void 0 ? void 0 : this.store.readRoadmap(project.id, resource.id)?.nodes.find((node) => node.state === "current" && node.gate === "quiz");
|
|
2461
|
+
const mastery = current === void 0 ? void 0 : this.stationMasteryOf(project.id, current.id, resource?.id);
|
|
2462
|
+
return {
|
|
2463
|
+
binding,
|
|
2464
|
+
project,
|
|
2465
|
+
...resource === void 0 ? {} : { resource },
|
|
2466
|
+
textbooks,
|
|
2467
|
+
...current === void 0 || mastery === void 0 ? {} : { station: {
|
|
2468
|
+
id: current.id,
|
|
2469
|
+
title: current.title,
|
|
2470
|
+
mastery
|
|
2471
|
+
} },
|
|
2472
|
+
weakCards
|
|
2473
|
+
};
|
|
2474
|
+
}
|
|
2475
|
+
/** One project's audit trail, newest first, optionally some types only. */
|
|
2476
|
+
async listEvents(request) {
|
|
2477
|
+
this.assertAlive("listEvents");
|
|
2478
|
+
const types = request.types === void 0 ? void 0 : new Set(request.types);
|
|
2479
|
+
return { events: this.store.readEvents(request.projectId).filter((event) => types === void 0 || types.has(event.type)).reverse().slice(0, request.limit ?? 20) };
|
|
2480
|
+
}
|
|
2481
|
+
/** 「教练最近记下」: card recordings from chats and quizzes, across projects, newest first. */
|
|
2482
|
+
/** A project's name, when it exists (synchronous — the per-turn context reads it). */
|
|
2483
|
+
projectName(projectId) {
|
|
2484
|
+
try {
|
|
2485
|
+
return this.store.readProject(projectId)?.name;
|
|
2486
|
+
} catch {
|
|
2487
|
+
return;
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
/** The newest knowledge-point state changes across projects (今日 「最近的变化」). */
|
|
2491
|
+
async recentChanges(request) {
|
|
2492
|
+
this.assertAlive("recentChanges");
|
|
2493
|
+
const changes = [];
|
|
2494
|
+
for (const project of this.store.readIndex().projects) for (const event of this.store.readEvents(project.id)) {
|
|
2495
|
+
if (event.type !== "point.changed" || event.nodeId === void 0 || event.resourceId === void 0 || event.id === void 0) continue;
|
|
2496
|
+
if ((this.store.readRoadmap(project.id, event.resourceId)?.nodes.find((candidate) => candidate.id === event.nodeId))?.points?.some((point) => point.id === event.id) !== true) continue;
|
|
2497
|
+
changes.push({
|
|
2498
|
+
...event,
|
|
2499
|
+
projectId: project.id,
|
|
2500
|
+
projectName: project.name
|
|
2501
|
+
});
|
|
2502
|
+
}
|
|
2503
|
+
changes.sort((a, b) => b.at.localeCompare(a.at));
|
|
2504
|
+
return { changes: changes.slice(0, request.limit ?? 10) };
|
|
2505
|
+
}
|
|
2506
|
+
async recentRecords(request) {
|
|
2507
|
+
this.assertAlive("recentRecords");
|
|
2508
|
+
const records = [];
|
|
2509
|
+
for (const project of this.store.readIndex().projects) {
|
|
2510
|
+
const liveTerms = new Map(this.store.readCards(project.id).filter((card) => card.status !== "deleted").map((card) => [card.id, card.term]));
|
|
2511
|
+
for (const event of this.store.readEvents(project.id)) {
|
|
2512
|
+
if (event.type !== "cards.recorded" || event.source !== "chat" && event.source !== "quiz") continue;
|
|
2513
|
+
const ids = (event.ids ?? []).filter((cardId) => liveTerms.has(cardId));
|
|
2514
|
+
if (event.ids !== void 0 && ids.length === 0) continue;
|
|
2515
|
+
records.push({
|
|
2516
|
+
...event,
|
|
2517
|
+
...event.ids === void 0 ? {} : {
|
|
2518
|
+
ids,
|
|
2519
|
+
title: ids.map((cardId) => liveTerms.get(cardId)).join("、").slice(0, 300)
|
|
2520
|
+
},
|
|
2521
|
+
projectId: project.id,
|
|
2522
|
+
projectName: project.name
|
|
2523
|
+
});
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
records.sort((a, b) => b.at.localeCompare(a.at));
|
|
2527
|
+
return { records: records.slice(0, request.limit ?? 10) };
|
|
2528
|
+
}
|
|
2529
|
+
/**
|
|
2530
|
+
* The newest event's timestamp across the library — the pane's change poll.
|
|
2531
|
+
* The Remote face is request/response (no push), so the sidebar learns
|
|
2532
|
+
* host-side writes (a landed quiz, a recorded page) by polling this and
|
|
2533
|
+
* re-reading when it moves. Writes that log no event (creating a project)
|
|
2534
|
+
* move it too. `session` names the newest session-made write, so the client
|
|
2535
|
+
* can reveal the library next to the conversation that just started using it.
|
|
2536
|
+
*/
|
|
2537
|
+
async latestActivity() {
|
|
2538
|
+
this.assertAlive("latestActivity");
|
|
2539
|
+
let latest = this.lastChangeAt;
|
|
2540
|
+
for (const project of this.store.readIndex().projects) {
|
|
2541
|
+
const at = this.store.readEvents(project.id).at(-1)?.at;
|
|
2542
|
+
if (at !== void 0 && (latest === void 0 || at > latest)) latest = at;
|
|
2543
|
+
}
|
|
2544
|
+
return {
|
|
2545
|
+
...latest === void 0 ? {} : { at: latest },
|
|
2546
|
+
...this.lastSessionWrite === void 0 ? {} : { session: {
|
|
2547
|
+
id: this.lastSessionWrite.session,
|
|
2548
|
+
at: this.lastSessionWrite.at
|
|
2549
|
+
} }
|
|
2550
|
+
};
|
|
2551
|
+
}
|
|
2552
|
+
/** The whole-library tally. */
|
|
2553
|
+
async stats() {
|
|
2554
|
+
this.assertAlive("stats");
|
|
2555
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2556
|
+
let books = 0;
|
|
2557
|
+
let cards = 0;
|
|
2558
|
+
let due = 0;
|
|
2559
|
+
let quizzes = 0;
|
|
2560
|
+
let attempts = 0;
|
|
2561
|
+
const index = this.store.readIndex();
|
|
2562
|
+
for (const project of index.projects) {
|
|
2563
|
+
books += this.store.listResources(project.id).filter(isThickResource).length;
|
|
2564
|
+
quizzes += this.store.listAssessments(project.id).filter((assessment) => assessment.type === "quiz").length;
|
|
2565
|
+
attempts += this.store.readAttempts(project.id).length;
|
|
2566
|
+
for (const card of this.store.readCards(project.id)) {
|
|
2567
|
+
if (card.status !== "active") continue;
|
|
2568
|
+
cards += 1;
|
|
2569
|
+
if (card.mastery.dueAt <= now) due += 1;
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
return {
|
|
2573
|
+
projects: index.projects.length,
|
|
2574
|
+
books,
|
|
2575
|
+
cards,
|
|
2576
|
+
due,
|
|
2577
|
+
quizzes,
|
|
2578
|
+
attempts
|
|
2579
|
+
};
|
|
2580
|
+
}
|
|
2581
|
+
};
|
|
2582
|
+
//#endregion
|
|
2583
|
+
//#region lib/types/remote.js
|
|
2584
|
+
var __runInitializers = function(thisArg, initializers, value) {
|
|
2585
|
+
var useValue = arguments.length > 2;
|
|
2586
|
+
for (var i = 0; i < initializers.length; i++) value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
|
2587
|
+
return useValue ? value : void 0;
|
|
2588
|
+
};
|
|
2589
|
+
var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
|
2590
|
+
function accept(f) {
|
|
2591
|
+
if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected");
|
|
2592
|
+
return f;
|
|
2593
|
+
}
|
|
2594
|
+
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
|
2595
|
+
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
|
2596
|
+
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
|
2597
|
+
var _, done = false;
|
|
2598
|
+
for (var i = decorators.length - 1; i >= 0; i--) {
|
|
2599
|
+
var context = {};
|
|
2600
|
+
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
|
2601
|
+
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
|
2602
|
+
context.addInitializer = function(f) {
|
|
2603
|
+
if (done) throw new TypeError("Cannot add initializers after decoration has completed");
|
|
2604
|
+
extraInitializers.push(accept(f || null));
|
|
2605
|
+
};
|
|
2606
|
+
var result = (0, decorators[i])(kind === "accessor" ? {
|
|
2607
|
+
get: descriptor.get,
|
|
2608
|
+
set: descriptor.set
|
|
2609
|
+
} : descriptor[key], context);
|
|
2610
|
+
if (kind === "accessor") {
|
|
2611
|
+
if (result === void 0) continue;
|
|
2612
|
+
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
|
2613
|
+
if (_ = accept(result.get)) descriptor.get = _;
|
|
2614
|
+
if (_ = accept(result.set)) descriptor.set = _;
|
|
2615
|
+
if (_ = accept(result.init)) initializers.unshift(_);
|
|
2616
|
+
} else if (_ = accept(result)) {
|
|
2617
|
+
if (kind === "field") initializers.unshift(_);
|
|
2618
|
+
else descriptor[key] = _;
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
2622
|
+
done = true;
|
|
2623
|
+
};
|
|
2624
|
+
/** The wire namespace is `library`: the browser calls `remote.library.*`. */
|
|
2625
|
+
let LibraryRemoteService = (() => {
|
|
2626
|
+
let _classSuper = TypertRemoteService;
|
|
2627
|
+
let _instanceExtraInitializers = [];
|
|
2628
|
+
let _capabilities_decorators;
|
|
2629
|
+
let _listProjects_decorators;
|
|
2630
|
+
let _getProject_decorators;
|
|
2631
|
+
let _createProject_decorators;
|
|
2632
|
+
let _updateProject_decorators;
|
|
2633
|
+
let _deleteProject_decorators;
|
|
2634
|
+
let _getResource_decorators;
|
|
2635
|
+
let _listResources_decorators;
|
|
2636
|
+
let _putResource_decorators;
|
|
2637
|
+
let _setBookKind_decorators;
|
|
2638
|
+
let _moveResource_decorators;
|
|
2639
|
+
let _removeResource_decorators;
|
|
2640
|
+
let _getRoadmap_decorators;
|
|
2641
|
+
let _adoptRoadmap_decorators;
|
|
2642
|
+
let _discardDraft_decorators;
|
|
2643
|
+
let _markNodeRead_decorators;
|
|
2644
|
+
let _listPages_decorators;
|
|
2645
|
+
let _readPagePhoto_decorators;
|
|
2646
|
+
let _savePhoto_decorators;
|
|
2647
|
+
let _recordPage_decorators;
|
|
2648
|
+
let _listCards_decorators;
|
|
2649
|
+
let _dueCards_decorators;
|
|
2650
|
+
let _writeCards_decorators;
|
|
2651
|
+
let _editCard_decorators;
|
|
2652
|
+
let _undoCards_decorators;
|
|
2653
|
+
let _reviewCard_decorators;
|
|
2654
|
+
let _listAssessments_decorators;
|
|
2655
|
+
let _getAssessment_decorators;
|
|
2656
|
+
let _putAttempt_decorators;
|
|
2657
|
+
let _listAttempts_decorators;
|
|
2658
|
+
let _boundTarget_decorators;
|
|
2659
|
+
let _bindTarget_decorators;
|
|
2660
|
+
let _listEvents_decorators;
|
|
2661
|
+
let _recentChanges_decorators;
|
|
2662
|
+
let _createRetake_decorators;
|
|
2663
|
+
let _recentRecords_decorators;
|
|
2664
|
+
let _latestActivity_decorators;
|
|
2665
|
+
let _stats_decorators;
|
|
2666
|
+
return class LibraryRemoteService extends _classSuper {
|
|
2667
|
+
static {
|
|
2668
|
+
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
2669
|
+
_capabilities_decorators = [Remote("capabilities")];
|
|
2670
|
+
_listProjects_decorators = [Remote("listProjects")];
|
|
2671
|
+
_getProject_decorators = [Remote("getProject")];
|
|
2672
|
+
_createProject_decorators = [Remote("createProject")];
|
|
2673
|
+
_updateProject_decorators = [Remote("updateProject")];
|
|
2674
|
+
_deleteProject_decorators = [Remote("deleteProject")];
|
|
2675
|
+
_getResource_decorators = [Remote("getResource")];
|
|
2676
|
+
_listResources_decorators = [Remote("listResources")];
|
|
2677
|
+
_putResource_decorators = [Remote("putResource")];
|
|
2678
|
+
_setBookKind_decorators = [Remote("setBookKind")];
|
|
2679
|
+
_moveResource_decorators = [Remote("moveResource")];
|
|
2680
|
+
_removeResource_decorators = [Remote("removeResource")];
|
|
2681
|
+
_getRoadmap_decorators = [Remote("getRoadmap")];
|
|
2682
|
+
_adoptRoadmap_decorators = [Remote("adoptRoadmap")];
|
|
2683
|
+
_discardDraft_decorators = [Remote("discardDraft")];
|
|
2684
|
+
_markNodeRead_decorators = [Remote("markNodeRead")];
|
|
2685
|
+
_listPages_decorators = [Remote("listPages")];
|
|
2686
|
+
_readPagePhoto_decorators = [Remote("readPagePhoto")];
|
|
2687
|
+
_savePhoto_decorators = [Remote("savePhoto")];
|
|
2688
|
+
_recordPage_decorators = [Remote("recordPage")];
|
|
2689
|
+
_listCards_decorators = [Remote("listCards")];
|
|
2690
|
+
_dueCards_decorators = [Remote("dueCards")];
|
|
2691
|
+
_writeCards_decorators = [Remote("writeCards")];
|
|
2692
|
+
_editCard_decorators = [Remote("editCard")];
|
|
2693
|
+
_undoCards_decorators = [Remote("undoCards")];
|
|
2694
|
+
_reviewCard_decorators = [Remote("reviewCard")];
|
|
2695
|
+
_listAssessments_decorators = [Remote("listAssessments")];
|
|
2696
|
+
_getAssessment_decorators = [Remote("getAssessment")];
|
|
2697
|
+
_putAttempt_decorators = [Remote("putAttempt")];
|
|
2698
|
+
_listAttempts_decorators = [Remote("listAttempts")];
|
|
2699
|
+
_boundTarget_decorators = [Remote("boundTarget")];
|
|
2700
|
+
_bindTarget_decorators = [Remote("bindTarget")];
|
|
2701
|
+
_listEvents_decorators = [Remote("listEvents")];
|
|
2702
|
+
_recentChanges_decorators = [Remote("recentChanges")];
|
|
2703
|
+
_createRetake_decorators = [Remote("createRetake")];
|
|
2704
|
+
_recentRecords_decorators = [Remote("recentRecords")];
|
|
2705
|
+
_latestActivity_decorators = [Remote("latestActivity")];
|
|
2706
|
+
_stats_decorators = [Remote("stats")];
|
|
2707
|
+
__esDecorate(this, null, _capabilities_decorators, {
|
|
2708
|
+
kind: "method",
|
|
2709
|
+
name: "capabilities",
|
|
2710
|
+
static: false,
|
|
2711
|
+
private: false,
|
|
2712
|
+
access: {
|
|
2713
|
+
has: (obj) => "capabilities" in obj,
|
|
2714
|
+
get: (obj) => obj.capabilities
|
|
2715
|
+
},
|
|
2716
|
+
metadata: _metadata
|
|
2717
|
+
}, null, _instanceExtraInitializers);
|
|
2718
|
+
__esDecorate(this, null, _listProjects_decorators, {
|
|
2719
|
+
kind: "method",
|
|
2720
|
+
name: "listProjects",
|
|
2721
|
+
static: false,
|
|
2722
|
+
private: false,
|
|
2723
|
+
access: {
|
|
2724
|
+
has: (obj) => "listProjects" in obj,
|
|
2725
|
+
get: (obj) => obj.listProjects
|
|
2726
|
+
},
|
|
2727
|
+
metadata: _metadata
|
|
2728
|
+
}, null, _instanceExtraInitializers);
|
|
2729
|
+
__esDecorate(this, null, _getProject_decorators, {
|
|
2730
|
+
kind: "method",
|
|
2731
|
+
name: "getProject",
|
|
2732
|
+
static: false,
|
|
2733
|
+
private: false,
|
|
2734
|
+
access: {
|
|
2735
|
+
has: (obj) => "getProject" in obj,
|
|
2736
|
+
get: (obj) => obj.getProject
|
|
2737
|
+
},
|
|
2738
|
+
metadata: _metadata
|
|
2739
|
+
}, null, _instanceExtraInitializers);
|
|
2740
|
+
__esDecorate(this, null, _createProject_decorators, {
|
|
2741
|
+
kind: "method",
|
|
2742
|
+
name: "createProject",
|
|
2743
|
+
static: false,
|
|
2744
|
+
private: false,
|
|
2745
|
+
access: {
|
|
2746
|
+
has: (obj) => "createProject" in obj,
|
|
2747
|
+
get: (obj) => obj.createProject
|
|
2748
|
+
},
|
|
2749
|
+
metadata: _metadata
|
|
2750
|
+
}, null, _instanceExtraInitializers);
|
|
2751
|
+
__esDecorate(this, null, _updateProject_decorators, {
|
|
2752
|
+
kind: "method",
|
|
2753
|
+
name: "updateProject",
|
|
2754
|
+
static: false,
|
|
2755
|
+
private: false,
|
|
2756
|
+
access: {
|
|
2757
|
+
has: (obj) => "updateProject" in obj,
|
|
2758
|
+
get: (obj) => obj.updateProject
|
|
2759
|
+
},
|
|
2760
|
+
metadata: _metadata
|
|
2761
|
+
}, null, _instanceExtraInitializers);
|
|
2762
|
+
__esDecorate(this, null, _deleteProject_decorators, {
|
|
2763
|
+
kind: "method",
|
|
2764
|
+
name: "deleteProject",
|
|
2765
|
+
static: false,
|
|
2766
|
+
private: false,
|
|
2767
|
+
access: {
|
|
2768
|
+
has: (obj) => "deleteProject" in obj,
|
|
2769
|
+
get: (obj) => obj.deleteProject
|
|
2770
|
+
},
|
|
2771
|
+
metadata: _metadata
|
|
2772
|
+
}, null, _instanceExtraInitializers);
|
|
2773
|
+
__esDecorate(this, null, _getResource_decorators, {
|
|
2774
|
+
kind: "method",
|
|
2775
|
+
name: "getResource",
|
|
2776
|
+
static: false,
|
|
2777
|
+
private: false,
|
|
2778
|
+
access: {
|
|
2779
|
+
has: (obj) => "getResource" in obj,
|
|
2780
|
+
get: (obj) => obj.getResource
|
|
2781
|
+
},
|
|
2782
|
+
metadata: _metadata
|
|
2783
|
+
}, null, _instanceExtraInitializers);
|
|
2784
|
+
__esDecorate(this, null, _listResources_decorators, {
|
|
2785
|
+
kind: "method",
|
|
2786
|
+
name: "listResources",
|
|
2787
|
+
static: false,
|
|
2788
|
+
private: false,
|
|
2789
|
+
access: {
|
|
2790
|
+
has: (obj) => "listResources" in obj,
|
|
2791
|
+
get: (obj) => obj.listResources
|
|
2792
|
+
},
|
|
2793
|
+
metadata: _metadata
|
|
2794
|
+
}, null, _instanceExtraInitializers);
|
|
2795
|
+
__esDecorate(this, null, _putResource_decorators, {
|
|
2796
|
+
kind: "method",
|
|
2797
|
+
name: "putResource",
|
|
2798
|
+
static: false,
|
|
2799
|
+
private: false,
|
|
2800
|
+
access: {
|
|
2801
|
+
has: (obj) => "putResource" in obj,
|
|
2802
|
+
get: (obj) => obj.putResource
|
|
2803
|
+
},
|
|
2804
|
+
metadata: _metadata
|
|
2805
|
+
}, null, _instanceExtraInitializers);
|
|
2806
|
+
__esDecorate(this, null, _setBookKind_decorators, {
|
|
2807
|
+
kind: "method",
|
|
2808
|
+
name: "setBookKind",
|
|
2809
|
+
static: false,
|
|
2810
|
+
private: false,
|
|
2811
|
+
access: {
|
|
2812
|
+
has: (obj) => "setBookKind" in obj,
|
|
2813
|
+
get: (obj) => obj.setBookKind
|
|
2814
|
+
},
|
|
2815
|
+
metadata: _metadata
|
|
2816
|
+
}, null, _instanceExtraInitializers);
|
|
2817
|
+
__esDecorate(this, null, _moveResource_decorators, {
|
|
2818
|
+
kind: "method",
|
|
2819
|
+
name: "moveResource",
|
|
2820
|
+
static: false,
|
|
2821
|
+
private: false,
|
|
2822
|
+
access: {
|
|
2823
|
+
has: (obj) => "moveResource" in obj,
|
|
2824
|
+
get: (obj) => obj.moveResource
|
|
2825
|
+
},
|
|
2826
|
+
metadata: _metadata
|
|
2827
|
+
}, null, _instanceExtraInitializers);
|
|
2828
|
+
__esDecorate(this, null, _removeResource_decorators, {
|
|
2829
|
+
kind: "method",
|
|
2830
|
+
name: "removeResource",
|
|
2831
|
+
static: false,
|
|
2832
|
+
private: false,
|
|
2833
|
+
access: {
|
|
2834
|
+
has: (obj) => "removeResource" in obj,
|
|
2835
|
+
get: (obj) => obj.removeResource
|
|
2836
|
+
},
|
|
2837
|
+
metadata: _metadata
|
|
2838
|
+
}, null, _instanceExtraInitializers);
|
|
2839
|
+
__esDecorate(this, null, _getRoadmap_decorators, {
|
|
2840
|
+
kind: "method",
|
|
2841
|
+
name: "getRoadmap",
|
|
2842
|
+
static: false,
|
|
2843
|
+
private: false,
|
|
2844
|
+
access: {
|
|
2845
|
+
has: (obj) => "getRoadmap" in obj,
|
|
2846
|
+
get: (obj) => obj.getRoadmap
|
|
2847
|
+
},
|
|
2848
|
+
metadata: _metadata
|
|
2849
|
+
}, null, _instanceExtraInitializers);
|
|
2850
|
+
__esDecorate(this, null, _adoptRoadmap_decorators, {
|
|
2851
|
+
kind: "method",
|
|
2852
|
+
name: "adoptRoadmap",
|
|
2853
|
+
static: false,
|
|
2854
|
+
private: false,
|
|
2855
|
+
access: {
|
|
2856
|
+
has: (obj) => "adoptRoadmap" in obj,
|
|
2857
|
+
get: (obj) => obj.adoptRoadmap
|
|
2858
|
+
},
|
|
2859
|
+
metadata: _metadata
|
|
2860
|
+
}, null, _instanceExtraInitializers);
|
|
2861
|
+
__esDecorate(this, null, _discardDraft_decorators, {
|
|
2862
|
+
kind: "method",
|
|
2863
|
+
name: "discardDraft",
|
|
2864
|
+
static: false,
|
|
2865
|
+
private: false,
|
|
2866
|
+
access: {
|
|
2867
|
+
has: (obj) => "discardDraft" in obj,
|
|
2868
|
+
get: (obj) => obj.discardDraft
|
|
2869
|
+
},
|
|
2870
|
+
metadata: _metadata
|
|
2871
|
+
}, null, _instanceExtraInitializers);
|
|
2872
|
+
__esDecorate(this, null, _markNodeRead_decorators, {
|
|
2873
|
+
kind: "method",
|
|
2874
|
+
name: "markNodeRead",
|
|
2875
|
+
static: false,
|
|
2876
|
+
private: false,
|
|
2877
|
+
access: {
|
|
2878
|
+
has: (obj) => "markNodeRead" in obj,
|
|
2879
|
+
get: (obj) => obj.markNodeRead
|
|
2880
|
+
},
|
|
2881
|
+
metadata: _metadata
|
|
2882
|
+
}, null, _instanceExtraInitializers);
|
|
2883
|
+
__esDecorate(this, null, _listPages_decorators, {
|
|
2884
|
+
kind: "method",
|
|
2885
|
+
name: "listPages",
|
|
2886
|
+
static: false,
|
|
2887
|
+
private: false,
|
|
2888
|
+
access: {
|
|
2889
|
+
has: (obj) => "listPages" in obj,
|
|
2890
|
+
get: (obj) => obj.listPages
|
|
2891
|
+
},
|
|
2892
|
+
metadata: _metadata
|
|
2893
|
+
}, null, _instanceExtraInitializers);
|
|
2894
|
+
__esDecorate(this, null, _readPagePhoto_decorators, {
|
|
2895
|
+
kind: "method",
|
|
2896
|
+
name: "readPagePhoto",
|
|
2897
|
+
static: false,
|
|
2898
|
+
private: false,
|
|
2899
|
+
access: {
|
|
2900
|
+
has: (obj) => "readPagePhoto" in obj,
|
|
2901
|
+
get: (obj) => obj.readPagePhoto
|
|
2902
|
+
},
|
|
2903
|
+
metadata: _metadata
|
|
2904
|
+
}, null, _instanceExtraInitializers);
|
|
2905
|
+
__esDecorate(this, null, _savePhoto_decorators, {
|
|
2906
|
+
kind: "method",
|
|
2907
|
+
name: "savePhoto",
|
|
2908
|
+
static: false,
|
|
2909
|
+
private: false,
|
|
2910
|
+
access: {
|
|
2911
|
+
has: (obj) => "savePhoto" in obj,
|
|
2912
|
+
get: (obj) => obj.savePhoto
|
|
2913
|
+
},
|
|
2914
|
+
metadata: _metadata
|
|
2915
|
+
}, null, _instanceExtraInitializers);
|
|
2916
|
+
__esDecorate(this, null, _recordPage_decorators, {
|
|
2917
|
+
kind: "method",
|
|
2918
|
+
name: "recordPage",
|
|
2919
|
+
static: false,
|
|
2920
|
+
private: false,
|
|
2921
|
+
access: {
|
|
2922
|
+
has: (obj) => "recordPage" in obj,
|
|
2923
|
+
get: (obj) => obj.recordPage
|
|
2924
|
+
},
|
|
2925
|
+
metadata: _metadata
|
|
2926
|
+
}, null, _instanceExtraInitializers);
|
|
2927
|
+
__esDecorate(this, null, _listCards_decorators, {
|
|
2928
|
+
kind: "method",
|
|
2929
|
+
name: "listCards",
|
|
2930
|
+
static: false,
|
|
2931
|
+
private: false,
|
|
2932
|
+
access: {
|
|
2933
|
+
has: (obj) => "listCards" in obj,
|
|
2934
|
+
get: (obj) => obj.listCards
|
|
2935
|
+
},
|
|
2936
|
+
metadata: _metadata
|
|
2937
|
+
}, null, _instanceExtraInitializers);
|
|
2938
|
+
__esDecorate(this, null, _dueCards_decorators, {
|
|
2939
|
+
kind: "method",
|
|
2940
|
+
name: "dueCards",
|
|
2941
|
+
static: false,
|
|
2942
|
+
private: false,
|
|
2943
|
+
access: {
|
|
2944
|
+
has: (obj) => "dueCards" in obj,
|
|
2945
|
+
get: (obj) => obj.dueCards
|
|
2946
|
+
},
|
|
2947
|
+
metadata: _metadata
|
|
2948
|
+
}, null, _instanceExtraInitializers);
|
|
2949
|
+
__esDecorate(this, null, _writeCards_decorators, {
|
|
2950
|
+
kind: "method",
|
|
2951
|
+
name: "writeCards",
|
|
2952
|
+
static: false,
|
|
2953
|
+
private: false,
|
|
2954
|
+
access: {
|
|
2955
|
+
has: (obj) => "writeCards" in obj,
|
|
2956
|
+
get: (obj) => obj.writeCards
|
|
2957
|
+
},
|
|
2958
|
+
metadata: _metadata
|
|
2959
|
+
}, null, _instanceExtraInitializers);
|
|
2960
|
+
__esDecorate(this, null, _editCard_decorators, {
|
|
2961
|
+
kind: "method",
|
|
2962
|
+
name: "editCard",
|
|
2963
|
+
static: false,
|
|
2964
|
+
private: false,
|
|
2965
|
+
access: {
|
|
2966
|
+
has: (obj) => "editCard" in obj,
|
|
2967
|
+
get: (obj) => obj.editCard
|
|
2968
|
+
},
|
|
2969
|
+
metadata: _metadata
|
|
2970
|
+
}, null, _instanceExtraInitializers);
|
|
2971
|
+
__esDecorate(this, null, _undoCards_decorators, {
|
|
2972
|
+
kind: "method",
|
|
2973
|
+
name: "undoCards",
|
|
2974
|
+
static: false,
|
|
2975
|
+
private: false,
|
|
2976
|
+
access: {
|
|
2977
|
+
has: (obj) => "undoCards" in obj,
|
|
2978
|
+
get: (obj) => obj.undoCards
|
|
2979
|
+
},
|
|
2980
|
+
metadata: _metadata
|
|
2981
|
+
}, null, _instanceExtraInitializers);
|
|
2982
|
+
__esDecorate(this, null, _reviewCard_decorators, {
|
|
2983
|
+
kind: "method",
|
|
2984
|
+
name: "reviewCard",
|
|
2985
|
+
static: false,
|
|
2986
|
+
private: false,
|
|
2987
|
+
access: {
|
|
2988
|
+
has: (obj) => "reviewCard" in obj,
|
|
2989
|
+
get: (obj) => obj.reviewCard
|
|
2990
|
+
},
|
|
2991
|
+
metadata: _metadata
|
|
2992
|
+
}, null, _instanceExtraInitializers);
|
|
2993
|
+
__esDecorate(this, null, _listAssessments_decorators, {
|
|
2994
|
+
kind: "method",
|
|
2995
|
+
name: "listAssessments",
|
|
2996
|
+
static: false,
|
|
2997
|
+
private: false,
|
|
2998
|
+
access: {
|
|
2999
|
+
has: (obj) => "listAssessments" in obj,
|
|
3000
|
+
get: (obj) => obj.listAssessments
|
|
3001
|
+
},
|
|
3002
|
+
metadata: _metadata
|
|
3003
|
+
}, null, _instanceExtraInitializers);
|
|
3004
|
+
__esDecorate(this, null, _getAssessment_decorators, {
|
|
3005
|
+
kind: "method",
|
|
3006
|
+
name: "getAssessment",
|
|
3007
|
+
static: false,
|
|
3008
|
+
private: false,
|
|
3009
|
+
access: {
|
|
3010
|
+
has: (obj) => "getAssessment" in obj,
|
|
3011
|
+
get: (obj) => obj.getAssessment
|
|
3012
|
+
},
|
|
3013
|
+
metadata: _metadata
|
|
3014
|
+
}, null, _instanceExtraInitializers);
|
|
3015
|
+
__esDecorate(this, null, _putAttempt_decorators, {
|
|
3016
|
+
kind: "method",
|
|
3017
|
+
name: "putAttempt",
|
|
3018
|
+
static: false,
|
|
3019
|
+
private: false,
|
|
3020
|
+
access: {
|
|
3021
|
+
has: (obj) => "putAttempt" in obj,
|
|
3022
|
+
get: (obj) => obj.putAttempt
|
|
3023
|
+
},
|
|
3024
|
+
metadata: _metadata
|
|
3025
|
+
}, null, _instanceExtraInitializers);
|
|
3026
|
+
__esDecorate(this, null, _listAttempts_decorators, {
|
|
3027
|
+
kind: "method",
|
|
3028
|
+
name: "listAttempts",
|
|
3029
|
+
static: false,
|
|
3030
|
+
private: false,
|
|
3031
|
+
access: {
|
|
3032
|
+
has: (obj) => "listAttempts" in obj,
|
|
3033
|
+
get: (obj) => obj.listAttempts
|
|
3034
|
+
},
|
|
3035
|
+
metadata: _metadata
|
|
3036
|
+
}, null, _instanceExtraInitializers);
|
|
3037
|
+
__esDecorate(this, null, _boundTarget_decorators, {
|
|
3038
|
+
kind: "method",
|
|
3039
|
+
name: "boundTarget",
|
|
3040
|
+
static: false,
|
|
3041
|
+
private: false,
|
|
3042
|
+
access: {
|
|
3043
|
+
has: (obj) => "boundTarget" in obj,
|
|
3044
|
+
get: (obj) => obj.boundTarget
|
|
3045
|
+
},
|
|
3046
|
+
metadata: _metadata
|
|
3047
|
+
}, null, _instanceExtraInitializers);
|
|
3048
|
+
__esDecorate(this, null, _bindTarget_decorators, {
|
|
3049
|
+
kind: "method",
|
|
3050
|
+
name: "bindTarget",
|
|
3051
|
+
static: false,
|
|
3052
|
+
private: false,
|
|
3053
|
+
access: {
|
|
3054
|
+
has: (obj) => "bindTarget" in obj,
|
|
3055
|
+
get: (obj) => obj.bindTarget
|
|
3056
|
+
},
|
|
3057
|
+
metadata: _metadata
|
|
3058
|
+
}, null, _instanceExtraInitializers);
|
|
3059
|
+
__esDecorate(this, null, _listEvents_decorators, {
|
|
3060
|
+
kind: "method",
|
|
3061
|
+
name: "listEvents",
|
|
3062
|
+
static: false,
|
|
3063
|
+
private: false,
|
|
3064
|
+
access: {
|
|
3065
|
+
has: (obj) => "listEvents" in obj,
|
|
3066
|
+
get: (obj) => obj.listEvents
|
|
3067
|
+
},
|
|
3068
|
+
metadata: _metadata
|
|
3069
|
+
}, null, _instanceExtraInitializers);
|
|
3070
|
+
__esDecorate(this, null, _recentChanges_decorators, {
|
|
3071
|
+
kind: "method",
|
|
3072
|
+
name: "recentChanges",
|
|
3073
|
+
static: false,
|
|
3074
|
+
private: false,
|
|
3075
|
+
access: {
|
|
3076
|
+
has: (obj) => "recentChanges" in obj,
|
|
3077
|
+
get: (obj) => obj.recentChanges
|
|
3078
|
+
},
|
|
3079
|
+
metadata: _metadata
|
|
3080
|
+
}, null, _instanceExtraInitializers);
|
|
3081
|
+
__esDecorate(this, null, _createRetake_decorators, {
|
|
3082
|
+
kind: "method",
|
|
3083
|
+
name: "createRetake",
|
|
3084
|
+
static: false,
|
|
3085
|
+
private: false,
|
|
3086
|
+
access: {
|
|
3087
|
+
has: (obj) => "createRetake" in obj,
|
|
3088
|
+
get: (obj) => obj.createRetake
|
|
3089
|
+
},
|
|
3090
|
+
metadata: _metadata
|
|
3091
|
+
}, null, _instanceExtraInitializers);
|
|
3092
|
+
__esDecorate(this, null, _recentRecords_decorators, {
|
|
3093
|
+
kind: "method",
|
|
3094
|
+
name: "recentRecords",
|
|
3095
|
+
static: false,
|
|
3096
|
+
private: false,
|
|
3097
|
+
access: {
|
|
3098
|
+
has: (obj) => "recentRecords" in obj,
|
|
3099
|
+
get: (obj) => obj.recentRecords
|
|
3100
|
+
},
|
|
3101
|
+
metadata: _metadata
|
|
3102
|
+
}, null, _instanceExtraInitializers);
|
|
3103
|
+
__esDecorate(this, null, _latestActivity_decorators, {
|
|
3104
|
+
kind: "method",
|
|
3105
|
+
name: "latestActivity",
|
|
3106
|
+
static: false,
|
|
3107
|
+
private: false,
|
|
3108
|
+
access: {
|
|
3109
|
+
has: (obj) => "latestActivity" in obj,
|
|
3110
|
+
get: (obj) => obj.latestActivity
|
|
3111
|
+
},
|
|
3112
|
+
metadata: _metadata
|
|
3113
|
+
}, null, _instanceExtraInitializers);
|
|
3114
|
+
__esDecorate(this, null, _stats_decorators, {
|
|
3115
|
+
kind: "method",
|
|
3116
|
+
name: "stats",
|
|
3117
|
+
static: false,
|
|
3118
|
+
private: false,
|
|
3119
|
+
access: {
|
|
3120
|
+
has: (obj) => "stats" in obj,
|
|
3121
|
+
get: (obj) => obj.stats
|
|
3122
|
+
},
|
|
3123
|
+
metadata: _metadata
|
|
3124
|
+
}, null, _instanceExtraInitializers);
|
|
3125
|
+
if (_metadata) Object.defineProperty(this, Symbol.metadata, {
|
|
3126
|
+
enumerable: true,
|
|
3127
|
+
configurable: true,
|
|
3128
|
+
writable: true,
|
|
3129
|
+
value: _metadata
|
|
3130
|
+
});
|
|
3131
|
+
}
|
|
3132
|
+
static inject = ["library"];
|
|
3133
|
+
constructor(ctx, _config = {}) {
|
|
3134
|
+
super(ctx, "libraryRemote", { namespace: "library" });
|
|
3135
|
+
__runInitializers(this, _instanceExtraInitializers);
|
|
3136
|
+
}
|
|
3137
|
+
/** The service core this face delegates to. */
|
|
3138
|
+
get core() {
|
|
3139
|
+
return this.ctx.library;
|
|
3140
|
+
}
|
|
3141
|
+
/** What this composition can do: storage and attachments, with the reason — the display contract. */
|
|
3142
|
+
capabilities() {
|
|
3143
|
+
return this.core.capabilities();
|
|
3144
|
+
}
|
|
3145
|
+
/** Every project with its shelf progress and card tallies. */
|
|
3146
|
+
listProjects() {
|
|
3147
|
+
return this.core.listProjects();
|
|
3148
|
+
}
|
|
3149
|
+
/** One project's summary plus its resources. */
|
|
3150
|
+
getProject(request) {
|
|
3151
|
+
return this.core.getProject(request);
|
|
3152
|
+
}
|
|
3153
|
+
/** Create a project. */
|
|
3154
|
+
createProject(request) {
|
|
3155
|
+
return this.core.createProject(request);
|
|
3156
|
+
}
|
|
3157
|
+
/** Change a project's look, goal or name. */
|
|
3158
|
+
updateProject(request) {
|
|
3159
|
+
return this.core.updateProject(request);
|
|
3160
|
+
}
|
|
3161
|
+
/** Delete a project and everything it holds. */
|
|
3162
|
+
deleteProject(request) {
|
|
3163
|
+
return this.core.deleteProject(request);
|
|
3164
|
+
}
|
|
3165
|
+
/** One shelf entry, whole. */
|
|
3166
|
+
getResource(request) {
|
|
3167
|
+
return this.core.getResource(request);
|
|
3168
|
+
}
|
|
3169
|
+
/** A project's shelf: textbooks in study order, then links oldest first. */
|
|
3170
|
+
listResources(request) {
|
|
3171
|
+
return this.core.listResources(request);
|
|
3172
|
+
}
|
|
3173
|
+
/** Put one entry on the shelf (a thin link from the sidebar's add form). */
|
|
3174
|
+
putResource(request) {
|
|
3175
|
+
return this.core.putResource(request);
|
|
3176
|
+
}
|
|
3177
|
+
/** Switch a textbook between 知识型 and 材料型. */
|
|
3178
|
+
setBookKind(request) {
|
|
3179
|
+
return this.core.setBookKind(request);
|
|
3180
|
+
}
|
|
3181
|
+
/** Move a textbook to another place in the study order. */
|
|
3182
|
+
moveResource(request) {
|
|
3183
|
+
return this.core.moveResource(request);
|
|
3184
|
+
}
|
|
3185
|
+
/** Remove a shelf entry (its cards stay, unhung). */
|
|
3186
|
+
removeResource(request) {
|
|
3187
|
+
return this.core.removeResource(request);
|
|
3188
|
+
}
|
|
3189
|
+
/** One textbook's adopted roadmap and its pending draft. */
|
|
3190
|
+
getRoadmap(request) {
|
|
3191
|
+
return this.core.getRoadmap(request);
|
|
3192
|
+
}
|
|
3193
|
+
/** The learner's 采用. */
|
|
3194
|
+
adoptRoadmap(request) {
|
|
3195
|
+
return this.core.adoptRoadmap(request);
|
|
3196
|
+
}
|
|
3197
|
+
/** Drop a draft. */
|
|
3198
|
+
discardDraft(request) {
|
|
3199
|
+
return this.core.discardDraft(request);
|
|
3200
|
+
}
|
|
3201
|
+
/** 「读完」 a read node. */
|
|
3202
|
+
markNodeRead(request) {
|
|
3203
|
+
return this.core.markNodeRead(request);
|
|
3204
|
+
}
|
|
3205
|
+
/** One resource's pages, optionally one node's. */
|
|
3206
|
+
listPages(request) {
|
|
3207
|
+
return this.core.listPages(request);
|
|
3208
|
+
}
|
|
3209
|
+
/** One page's photo, base64 — whitelisted to recorded pages. */
|
|
3210
|
+
readPagePhoto(request) {
|
|
3211
|
+
return this.core.readPagePhoto(request);
|
|
3212
|
+
}
|
|
3213
|
+
/** Save a browser-uploaded photo into the attachment store. */
|
|
3214
|
+
savePhoto(request) {
|
|
3215
|
+
return this.core.savePhoto(request);
|
|
3216
|
+
}
|
|
3217
|
+
/** Record a recognized page and card its marks. */
|
|
3218
|
+
recordPage(request) {
|
|
3219
|
+
return this.core.recordPage(request);
|
|
3220
|
+
}
|
|
3221
|
+
/** Cards, filtered and sorted. */
|
|
3222
|
+
listCards(request) {
|
|
3223
|
+
return this.core.listCards(request);
|
|
3224
|
+
}
|
|
3225
|
+
/** Due cards across projects (or one). */
|
|
3226
|
+
dueCards(request) {
|
|
3227
|
+
return this.core.dueCards(request);
|
|
3228
|
+
}
|
|
3229
|
+
/** Record cards by hand. */
|
|
3230
|
+
writeCards(request) {
|
|
3231
|
+
return this.core.writeCards(request);
|
|
3232
|
+
}
|
|
3233
|
+
/** Edit one card. */
|
|
3234
|
+
editCard(request) {
|
|
3235
|
+
return this.core.editCard(request);
|
|
3236
|
+
}
|
|
3237
|
+
/** The toast's 撤销. */
|
|
3238
|
+
undoCards(request) {
|
|
3239
|
+
return this.core.undoCards(request);
|
|
3240
|
+
}
|
|
3241
|
+
/** One flashcard review. */
|
|
3242
|
+
reviewCard(request) {
|
|
3243
|
+
return this.core.reviewCard(request);
|
|
3244
|
+
}
|
|
3245
|
+
/** A project's assessments, optionally one resource's / node's / one type's. */
|
|
3246
|
+
listAssessments(request) {
|
|
3247
|
+
return this.core.listAssessments(request);
|
|
3248
|
+
}
|
|
3249
|
+
/** One assessment, whole. */
|
|
3250
|
+
getAssessment(request) {
|
|
3251
|
+
return this.core.getAssessment(request);
|
|
3252
|
+
}
|
|
3253
|
+
/** Grade a sitting answered in the sidebar (choice / fill judged in the page). */
|
|
3254
|
+
putAttempt(request) {
|
|
3255
|
+
return this.core.putAttempt(request);
|
|
3256
|
+
}
|
|
3257
|
+
/** A project's graded sittings, newest first; `nodeId` narrows to one station's history, `assessmentId` to one assessment's. */
|
|
3258
|
+
listAttempts(request) {
|
|
3259
|
+
return this.core.listAttempts(request);
|
|
3260
|
+
}
|
|
3261
|
+
/** The calling session's binding, or null. */
|
|
3262
|
+
async boundTarget(sessionId) {
|
|
3263
|
+
return this.core.getBinding(String(sessionId)) ?? null;
|
|
3264
|
+
}
|
|
3265
|
+
/** Bind the calling session to a project / resource (null unbinds). */
|
|
3266
|
+
bindTarget(sessionId, request) {
|
|
3267
|
+
return this.core.setBinding(String(sessionId), request.binding);
|
|
3268
|
+
}
|
|
3269
|
+
/** One project's audit trail, newest first. */
|
|
3270
|
+
listEvents(request) {
|
|
3271
|
+
return this.core.listEvents(request);
|
|
3272
|
+
}
|
|
3273
|
+
/** 「教练最近记下」 across projects. */
|
|
3274
|
+
/** The newest knowledge-point state changes (今日 「最近的变化」). */
|
|
3275
|
+
recentChanges(request) {
|
|
3276
|
+
return this.core.recentChanges(request);
|
|
3277
|
+
}
|
|
3278
|
+
/** 订正: a retake paper over a station's open misses (all, or the keys named). */
|
|
3279
|
+
createRetake(request) {
|
|
3280
|
+
return this.core.createRetake(request);
|
|
3281
|
+
}
|
|
3282
|
+
recentRecords(request) {
|
|
3283
|
+
return this.core.recentRecords(request);
|
|
3284
|
+
}
|
|
3285
|
+
/** The newest event's timestamp anywhere — the pane polls it to re-read (no push channel). */
|
|
3286
|
+
latestActivity() {
|
|
3287
|
+
return this.core.latestActivity();
|
|
3288
|
+
}
|
|
3289
|
+
/** The whole-library tally. */
|
|
3290
|
+
stats() {
|
|
3291
|
+
return this.core.stats();
|
|
3292
|
+
}
|
|
3293
|
+
};
|
|
3294
|
+
})();
|
|
3295
|
+
//#endregion
|
|
3296
|
+
//#region lib/types/index.js
|
|
3297
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
3298
|
+
const name = "library";
|
|
3299
|
+
/**
|
|
3300
|
+
* Plugin body: provide the service core, then mount the Remote data face.
|
|
3301
|
+
*
|
|
3302
|
+
* No `static inject`: every optional capability (`attachments`) is probed at
|
|
3303
|
+
* the point of use, so the row boots in any composition and degrades —
|
|
3304
|
+
* `unavailable`/`storage-unavailable` refusals instead of a boot failure.
|
|
3305
|
+
*
|
|
3306
|
+
* @param ctx - owning Cordis context.
|
|
3307
|
+
* @param config - optional plugin config.
|
|
3308
|
+
*/
|
|
3309
|
+
function apply(ctx, config = {}) {
|
|
3310
|
+
const service = new LibraryService(ctx, config);
|
|
3311
|
+
ctx.provide("library", service);
|
|
3312
|
+
ctx.plugin(LibraryRemoteService, {});
|
|
3313
|
+
service.store.migrateState().catch((error) => {
|
|
3314
|
+
ctx.logger.warn(`library: state migration did not run — ${libraryErrorMessage(error)}`);
|
|
3315
|
+
});
|
|
3316
|
+
}
|
|
3317
|
+
//#endregion
|
|
3318
|
+
export { DEFAULT_PASS_RATIO, LEITNER_INTERVAL_DAYS, LIBRARY_COLORS, LIBRARY_EVENT_TYPES, LIBRARY_ICONS, LIBRARY_ID_PATTERN, LIBRARY_RESOURCE_KINDS, LIBRARY_RESOURCE_ORIGINS, LIBRARY_STATE_VERSION, LibraryDisposedError, LibraryRemoteService, LibraryService, LibraryStore, LibraryStoreError, MAX_BOX, MAX_WEAKNESS, NEAR_DUPLICATE, STATE_ROOT_SEGMENT, TEMPLATE_LOOK, acceptanceLines, acquireLibraryLock, apply, assertLibraryId, cardKey, cardKeys, cardMatchesQuery, coverKind, coverTopic, coversVerbatimBlocker, emptyLibraryIndex, exactMatch, formatLocator, isLibraryId, isThickResource, keySimilarity, leitnerDueAt, libraryErrorMessage, mintLibraryId, name, nearMatches, normalizeAssessment, normalizeAttachmentRef, normalizeAttempt, normalizeBindings, normalizeCard, normalizeEvent, normalizeLibraryIndex, normalizeNodeCover, normalizePage, normalizeProject, normalizeResource, normalizeRoadmap, parseLocator, resolveLibraryStateRoot, unlockReady, validateRoadmapShape };
|