@khorsheed/dsh-library 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -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 +66334 -0
- package/lib/index.js +3318 -0
- package/lib/profiles-CFrPQdfO.js +1692 -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 +129 -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 +167 -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 +231 -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/reveal.d.ts +21 -0
- package/lib/types/client/reveal.js +31 -0
- package/lib/types/client/store.d.ts +111 -0
- package/lib/types/client/store.js +48 -0
- package/lib/types/client/titles.d.ts +17 -0
- package/lib/types/client/titles.js +23 -0
- package/lib/types/client/ui.d.ts +96 -0
- package/lib/types/client/ui.js +124 -0
- package/lib/types/client/views/book.d.ts +57 -0
- package/lib/types/client/views/book.js +164 -0
- package/lib/types/client/views/cards.d.ts +17 -0
- package/lib/types/client/views/cards.js +53 -0
- package/lib/types/client/views/home.d.ts +26 -0
- package/lib/types/client/views/home.js +263 -0
- package/lib/types/client/views/lesson.d.ts +42 -0
- package/lib/types/client/views/lesson.js +146 -0
- package/lib/types/client/views/path.d.ts +32 -0
- package/lib/types/client/views/path.js +64 -0
- package/lib/types/client/views/quiz.d.ts +42 -0
- package/lib/types/client/views/quiz.js +366 -0
- package/lib/types/client/views/station-quiz.d.ts +43 -0
- package/lib/types/client/views/station-quiz.js +203 -0
- package/lib/types/client/views/station.d.ts +18 -0
- package/lib/types/client/views/station.js +142 -0
- package/lib/types/image-refs.d.ts +35 -0
- package/lib/types/image-refs.js +71 -0
- package/lib/types/index.d.ts +51 -0
- package/lib/types/index.js +31 -0
- package/lib/types/mastery.d.ts +81 -0
- package/lib/types/mastery.js +110 -0
- package/lib/types/profiles.d.ts +31 -0
- package/lib/types/profiles.js +25 -0
- package/lib/types/remote.d.ts +355 -0
- package/lib/types/remote.js +332 -0
- package/lib/types/service/assess.d.ts +144 -0
- package/lib/types/service/assess.js +773 -0
- package/lib/types/service/bindings.d.ts +92 -0
- package/lib/types/service/bindings.js +198 -0
- package/lib/types/service/cards.d.ts +85 -0
- package/lib/types/service/cards.js +320 -0
- package/lib/types/service/core.d.ts +124 -0
- package/lib/types/service/core.js +169 -0
- package/lib/types/service/pages.d.ts +60 -0
- package/lib/types/service/pages.js +125 -0
- package/lib/types/service/roadmap.d.ts +99 -0
- package/lib/types/service/roadmap.js +284 -0
- package/lib/types/service/rules.d.ts +63 -0
- package/lib/types/service/rules.js +245 -0
- package/lib/types/service/shelf.d.ts +136 -0
- package/lib/types/service/shelf.js +422 -0
- package/lib/types/service.d.ts +42 -0
- package/lib/types/service.js +41 -0
- package/lib/types/store.d.ts +257 -0
- package/lib/types/store.js +1376 -0
- package/lib/types/tool-definition.d.ts +49 -0
- package/lib/types/tool-definition.js +49 -0
- package/lib/types/tool.d.ts +71 -0
- package/lib/types/tool.js +174 -0
- package/lib/types/tools/assess.d.ts +11 -0
- package/lib/types/tools/assess.js +82 -0
- package/lib/types/tools/binding.d.ts +11 -0
- package/lib/types/tools/binding.js +46 -0
- package/lib/types/tools/cards.d.ts +11 -0
- package/lib/types/tools/cards.js +100 -0
- package/lib/types/tools/kit.d.ts +215 -0
- package/lib/types/tools/kit.js +281 -0
- package/lib/types/tools/notes.d.ts +11 -0
- package/lib/types/tools/notes.js +40 -0
- package/lib/types/tools/page.d.ts +11 -0
- package/lib/types/tools/page.js +59 -0
- package/lib/types/tools/project.d.ts +11 -0
- package/lib/types/tools/project.js +67 -0
- package/lib/types/tools/quiz.d.ts +11 -0
- package/lib/types/tools/quiz.js +74 -0
- package/lib/types/tools/read.d.ts +11 -0
- package/lib/types/tools/read.js +127 -0
- package/lib/types/tools/resource.d.ts +11 -0
- package/lib/types/tools/resource.js +147 -0
- package/lib/types/tools/roadmap.d.ts +11 -0
- package/lib/types/tools/roadmap.js +102 -0
- package/lib/types/types.d.ts +752 -0
- package/lib/types/types.js +159 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +135 -3
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projects and the shelf: create / edit / delete projects, register, place (seq), move and remove resources.
|
|
3
|
+
*
|
|
4
|
+
* One layer of the library service (see `service/core.ts` for the layering):
|
|
5
|
+
* the methods are the service's own, moved here verbatim by domain.
|
|
6
|
+
*
|
|
7
|
+
* @module @khorsheed/dsh-library/service/shelf
|
|
8
|
+
*/
|
|
9
|
+
import { LIBRARY_COLORS, LIBRARY_ICONS, LIBRARY_RESOURCE_KINDS, LIBRARY_RESOURCE_ORIGINS, TEMPLATE_LOOK, isThickResource, } from "../types.js";
|
|
10
|
+
import { mintLibraryId } from "../store.js";
|
|
11
|
+
import { refuse, storeRefusal, live } from "./core.js";
|
|
12
|
+
import { stationMastery } from "../mastery.js";
|
|
13
|
+
import { LibraryServiceCore } from "./core.js";
|
|
14
|
+
export class LibraryServiceShelf extends LibraryServiceCore {
|
|
15
|
+
/* ------------------------------------------------------------ projects */
|
|
16
|
+
/** One resource's progress row (a thick resource's roadmap; a thin link's bare row). */
|
|
17
|
+
resourceProgress(projectId, resource) {
|
|
18
|
+
const roadmap = isThickResource(resource) ? this.store.readRoadmap(projectId, resource.id) : undefined;
|
|
19
|
+
const current = roadmap?.nodes.find(node => node.state === 'current');
|
|
20
|
+
const draft = isThickResource(resource) ? this.store.readRoadmap(projectId, resource.id, 'draft') : undefined;
|
|
21
|
+
return {
|
|
22
|
+
id: resource.id,
|
|
23
|
+
title: resource.title,
|
|
24
|
+
kind: resource.kind,
|
|
25
|
+
...(resource.seq === undefined ? {} : { seq: resource.seq }),
|
|
26
|
+
...(resource.origin === undefined ? {} : { origin: resource.origin }),
|
|
27
|
+
...(roadmap === undefined ? {} : {
|
|
28
|
+
roadmap: { done: roadmap.nodes.filter(node => node.state === 'done').length, total: roadmap.nodes.length },
|
|
29
|
+
}),
|
|
30
|
+
...(current === undefined ? {} : { current: { id: current.id, title: current.title } }),
|
|
31
|
+
hasDraft: draft !== undefined,
|
|
32
|
+
...(draft === undefined ? {} : { draftTotal: draft.nodes.length }),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
summarize(project) {
|
|
36
|
+
const now = new Date().toISOString();
|
|
37
|
+
const cards = this.store.readCards(project.id).filter(live);
|
|
38
|
+
return {
|
|
39
|
+
...project,
|
|
40
|
+
resources: this.store.listResources(project.id).map(resource => this.resourceProgress(project.id, resource)),
|
|
41
|
+
cardCount: cards.length,
|
|
42
|
+
dueCount: cards.filter(card => card.status === 'active' && card.mastery.dueAt <= now).length,
|
|
43
|
+
lastActiveAt: this.store.readEvents(project.id).at(-1)?.at ?? project.createdAt,
|
|
44
|
+
openMisses: (() => {
|
|
45
|
+
const assessments = this.store.listAssessments(project.id);
|
|
46
|
+
const attempts = this.store.readAttempts(project.id);
|
|
47
|
+
let count = 0;
|
|
48
|
+
for (const resource of this.store.listResources(project.id)) {
|
|
49
|
+
for (const node of this.store.readRoadmap(project.id, resource.id)?.nodes ?? []) {
|
|
50
|
+
if (node.gate === 'quiz')
|
|
51
|
+
count += stationMastery(node, assessments, attempts).open.length;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return count;
|
|
55
|
+
})(),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Every project with its resources' progress and card tallies. */
|
|
59
|
+
async listProjects() {
|
|
60
|
+
this.assertAlive('listProjects');
|
|
61
|
+
// Most recently used first: the list is where the learner picks up again.
|
|
62
|
+
const projects = this.store.readIndex().projects.map(project => this.summarize(project));
|
|
63
|
+
projects.sort((a, b) => b.lastActiveAt.localeCompare(a.lastActiveAt));
|
|
64
|
+
return { projects };
|
|
65
|
+
}
|
|
66
|
+
/** One project's summary plus its resources' full records. */
|
|
67
|
+
async getProject(request) {
|
|
68
|
+
this.assertAlive('getProject');
|
|
69
|
+
const project = this.readProjectOrRefusal(request.id);
|
|
70
|
+
if ('error' in project)
|
|
71
|
+
return project;
|
|
72
|
+
return { ok: true, project: this.summarize(project.value), resources: this.store.listResources(project.value.id) };
|
|
73
|
+
}
|
|
74
|
+
/** Create a project. */
|
|
75
|
+
async createProject(request) {
|
|
76
|
+
this.assertAlive('createProject');
|
|
77
|
+
const name = request.name.trim();
|
|
78
|
+
if (name === '') {
|
|
79
|
+
return refuse('invalid', 'a project needs a name — what the learner calls the subject (「神经科学」「雅思」)');
|
|
80
|
+
}
|
|
81
|
+
if (request.template !== undefined && !['language', 'subject', 'custom'].includes(request.template)) {
|
|
82
|
+
return refuse('invalid', 'template is language | subject | custom — language: marks are words; subject: marks are concepts');
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const outcome = await this.store.withIndexLock(async () => {
|
|
86
|
+
const index = this.store.readIndex();
|
|
87
|
+
if (index.projects.some(project => project.name === name)) {
|
|
88
|
+
return refuse('exists', `a project named ${JSON.stringify(name)} already exists — open it (library_read · projects) instead of creating a twin`);
|
|
89
|
+
}
|
|
90
|
+
const template = request.template ?? 'subject';
|
|
91
|
+
const project = {
|
|
92
|
+
id: mintLibraryId('p'),
|
|
93
|
+
name,
|
|
94
|
+
template,
|
|
95
|
+
color: request.color !== undefined && LIBRARY_COLORS.includes(request.color) ? request.color : TEMPLATE_LOOK[template].color,
|
|
96
|
+
icon: request.icon !== undefined && LIBRARY_ICONS.includes(request.icon) ? request.icon : TEMPLATE_LOOK[template].icon,
|
|
97
|
+
...(request.templateNote === undefined || request.templateNote.trim() === '' ? {} : { templateNote: request.templateNote.trim() }),
|
|
98
|
+
...(request.goal === undefined || request.goal.trim() === '' ? {} : { goal: request.goal.trim() }),
|
|
99
|
+
createdAt: new Date().toISOString(),
|
|
100
|
+
};
|
|
101
|
+
this.store.writeProject(project);
|
|
102
|
+
this.store.writeIndex({ ...index, projects: [...index.projects, project] });
|
|
103
|
+
return { ok: true, project };
|
|
104
|
+
});
|
|
105
|
+
if (outcome.ok)
|
|
106
|
+
this.changed(outcome.project.id);
|
|
107
|
+
return outcome;
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
return storeRefusal(error);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Change a project's look or goal (the sidebar's 「样式」). */
|
|
114
|
+
async updateProject(request) {
|
|
115
|
+
this.assertAlive('updateProject');
|
|
116
|
+
const patch = request.patch;
|
|
117
|
+
if (patch.color !== undefined && !LIBRARY_COLORS.includes(patch.color))
|
|
118
|
+
return refuse('invalid', `color is one of ${LIBRARY_COLORS.join(' | ')}`);
|
|
119
|
+
if (patch.icon !== undefined && !LIBRARY_ICONS.includes(patch.icon))
|
|
120
|
+
return refuse('invalid', `icon is one of ${LIBRARY_ICONS.join(' | ')}`);
|
|
121
|
+
if (patch.name !== undefined && patch.name.trim() === '')
|
|
122
|
+
return refuse('invalid', 'a project keeps a name');
|
|
123
|
+
if (patch.template !== undefined && !['language', 'subject', 'custom'].includes(patch.template)) {
|
|
124
|
+
return refuse('invalid', 'template is language | subject | custom — language: marks are words; subject: marks are concepts');
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
const outcome = await this.store.withIndexLock(async () => {
|
|
128
|
+
const index = this.store.readIndex();
|
|
129
|
+
const project = index.projects.find(candidate => candidate.id === request.id);
|
|
130
|
+
if (project === undefined)
|
|
131
|
+
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`);
|
|
132
|
+
const { goal: _goal, ...rest } = project;
|
|
133
|
+
const goal = patch.goal === undefined ? project.goal : patch.goal.trim();
|
|
134
|
+
const next = {
|
|
135
|
+
...rest,
|
|
136
|
+
...(goal === undefined || goal === '' ? {} : { goal }),
|
|
137
|
+
...(patch.color === undefined ? {} : { color: patch.color }),
|
|
138
|
+
...(patch.icon === undefined ? {} : { icon: patch.icon }),
|
|
139
|
+
...(patch.template === undefined ? {} : { template: patch.template }),
|
|
140
|
+
...(patch.name === undefined ? {} : { name: patch.name.trim() }),
|
|
141
|
+
};
|
|
142
|
+
this.store.writeProject(next);
|
|
143
|
+
this.store.writeIndex({ ...index, projects: index.projects.map(candidate => candidate.id === next.id ? next : candidate) });
|
|
144
|
+
return { ok: true, project: next };
|
|
145
|
+
});
|
|
146
|
+
if (outcome.ok)
|
|
147
|
+
this.changed(request.id);
|
|
148
|
+
return outcome;
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
return storeRefusal(error);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** Delete a project and everything it holds. */
|
|
155
|
+
async deleteProject(request) {
|
|
156
|
+
this.assertAlive('deleteProject');
|
|
157
|
+
try {
|
|
158
|
+
const outcome = await this.store.withIndexLock(async () => {
|
|
159
|
+
const index = this.store.readIndex();
|
|
160
|
+
if (!index.projects.some(project => project.id === request.id)) {
|
|
161
|
+
return refuse('not-found', `no project ${JSON.stringify(request.id)} — library_read · projects lists the ones that exist`);
|
|
162
|
+
}
|
|
163
|
+
this.store.removeProject(request.id);
|
|
164
|
+
this.store.writeIndex({ ...index, projects: index.projects.filter(project => project.id !== request.id) });
|
|
165
|
+
return { ok: true, id: request.id };
|
|
166
|
+
});
|
|
167
|
+
if (outcome.ok)
|
|
168
|
+
this.changed(request.id);
|
|
169
|
+
return outcome;
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
return storeRefusal(error);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/* ------------------------------------------------------------ resources */
|
|
176
|
+
/**
|
|
177
|
+
* Put one entry on the project's shelf — the v5 unified write. A THICK
|
|
178
|
+
* entry (kind "book", or any kind carrying `toc`) registers its contents:
|
|
179
|
+
* the coach's table of contents, verified on the web whenever it can be,
|
|
180
|
+
* and cover locators count its rows. A THIN link is title + url + the
|
|
181
|
+
* coach's one-line why. Pass `resourceId` to correct an existing entry.
|
|
182
|
+
*/
|
|
183
|
+
async putResource(request) {
|
|
184
|
+
this.assertAlive('putResource');
|
|
185
|
+
const project = this.readProjectOrRefusal(request.projectId);
|
|
186
|
+
if ('error' in project)
|
|
187
|
+
return project;
|
|
188
|
+
// The clone path: copy the source's record under a new id (or over an
|
|
189
|
+
// existing entry when resourceId names it).
|
|
190
|
+
if (request.from !== undefined) {
|
|
191
|
+
const source = this.store.readResource(request.from.projectId, request.from.resourceId);
|
|
192
|
+
if (source === undefined) {
|
|
193
|
+
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`);
|
|
194
|
+
}
|
|
195
|
+
return await this.mutate(project.value.id, () => {
|
|
196
|
+
const existing = request.resourceId === undefined ? undefined : this.store.readResource(project.value.id, request.resourceId);
|
|
197
|
+
if (request.resourceId !== undefined && existing === undefined) {
|
|
198
|
+
return refuse('not-found', `no resource ${JSON.stringify(request.resourceId)} in this project — omit resourceId to clone a fresh one`);
|
|
199
|
+
}
|
|
200
|
+
const now = new Date().toISOString();
|
|
201
|
+
const { seq: _sourceSeq, ...copied } = source;
|
|
202
|
+
const resource = {
|
|
203
|
+
...copied,
|
|
204
|
+
id: existing?.id ?? mintLibraryId('r'),
|
|
205
|
+
...(existing?.seq === undefined ? {} : { seq: existing.seq }),
|
|
206
|
+
createdAt: existing?.createdAt ?? now,
|
|
207
|
+
};
|
|
208
|
+
this.store.writeResource(project.value.id, resource);
|
|
209
|
+
if (isThickResource(resource))
|
|
210
|
+
this.placeTextbook(project.value.id, resource.id, existing?.seq ?? request.seq);
|
|
211
|
+
if (existing === undefined)
|
|
212
|
+
this.event(project.value.id, 'resource.added', { id: resource.id, title: resource.title, resourceId: resource.id, ...(resource.toc === undefined ? {} : { count: resource.toc.length }) });
|
|
213
|
+
return { ok: true, resource };
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
const title = request.title?.trim() ?? '';
|
|
217
|
+
if (title === '')
|
|
218
|
+
return refuse('invalid', 'a resource needs its title — what its own page / cover calls it');
|
|
219
|
+
if (request.origin !== undefined && !LIBRARY_RESOURCE_ORIGINS.includes(request.origin)) {
|
|
220
|
+
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)');
|
|
221
|
+
}
|
|
222
|
+
if (request.seq !== undefined && !(Number.isInteger(request.seq) && request.seq > 0)) {
|
|
223
|
+
return refuse('invalid', 'seq is the textbook\'s place in the study order — a whole number from 1');
|
|
224
|
+
}
|
|
225
|
+
if (!LIBRARY_RESOURCE_KINDS.includes(request.kind)) {
|
|
226
|
+
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)`);
|
|
227
|
+
}
|
|
228
|
+
const wantsToc = request.toc !== undefined;
|
|
229
|
+
// One row per chapter: a row that only repeats its part's heading is the
|
|
230
|
+
// part, not a chapter (a live run stored nine of them among 64 chapters).
|
|
231
|
+
const toc = (request.toc ?? []).filter(entry => entry.title.trim() !== '' && entry.title.trim() !== entry.part?.trim());
|
|
232
|
+
if (request.kind === 'book' || wantsToc) {
|
|
233
|
+
if (toc.length === 0) {
|
|
234
|
+
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? }');
|
|
235
|
+
}
|
|
236
|
+
if (request.tocSource === undefined || !['web', 'model', 'photo'].includes(request.tocSource.by)) {
|
|
237
|
+
return refuse('invalid', 'tocSource.by is web | model | photo — say where the contents came from; web needs the url');
|
|
238
|
+
}
|
|
239
|
+
if (request.tocSource.by === 'web' && (request.tocSource.url === undefined || !/^https?:\/\//.test(request.tocSource.url))) {
|
|
240
|
+
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');
|
|
241
|
+
}
|
|
242
|
+
if (request.bookKind !== undefined && request.bookKind !== 'knowledge' && request.bookKind !== 'material') {
|
|
243
|
+
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)');
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
const url = request.url?.trim() ?? '';
|
|
248
|
+
if (!/^https?:\/\//.test(url)) {
|
|
249
|
+
return refuse('invalid', 'a thin link\'s url starts with http:// or https:// — the learner opens it straight off the shelf');
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return await this.mutate(project.value.id, () => {
|
|
253
|
+
const existing = request.resourceId === undefined ? undefined : this.store.readResource(project.value.id, request.resourceId);
|
|
254
|
+
if (request.resourceId !== undefined && existing === undefined) {
|
|
255
|
+
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`);
|
|
256
|
+
}
|
|
257
|
+
if (existing === undefined && toc.length > 0 && this.store.listResources(project.value.id).some(resource => resource.title === title && resource.meta?.edition === request.edition?.trim() && isThickResource(resource))) {
|
|
258
|
+
return refuse('exists', `《${title}》 is already on this project's shelf — pass its resourceId to correct it`);
|
|
259
|
+
}
|
|
260
|
+
if (existing === undefined && toc.length === 0 && request.url !== undefined && this.store.listResources(project.value.id).some(resource => resource.url === request.url?.trim())) {
|
|
261
|
+
return refuse('exists', `${request.url.trim()} is already on this project's shelf — pass its resourceId to correct it`);
|
|
262
|
+
}
|
|
263
|
+
const now = new Date().toISOString();
|
|
264
|
+
const meta = (() => {
|
|
265
|
+
const merged = {
|
|
266
|
+
// A textbook is knowledge unless said otherwise — material is the rare case whose exact text is tested.
|
|
267
|
+
...(() => {
|
|
268
|
+
const bookKind = request.bookKind ?? existing?.meta?.bookKind ?? (toc.length > 0 ? 'knowledge' : undefined);
|
|
269
|
+
return bookKind === undefined ? {} : { bookKind };
|
|
270
|
+
})(),
|
|
271
|
+
...(request.short === undefined || request.short.trim() === ''
|
|
272
|
+
? (existing?.meta?.short === undefined ? {} : { short: existing.meta.short })
|
|
273
|
+
: { short: request.short.trim().slice(0, 8) }),
|
|
274
|
+
...(request.extras === undefined
|
|
275
|
+
? (existing?.meta?.extras === undefined ? {} : { extras: existing.meta.extras })
|
|
276
|
+
: {
|
|
277
|
+
extras: request.extras.filter(extra => typeof extra.title === 'string' && extra.title.trim() !== '').map(extra => ({
|
|
278
|
+
title: extra.title.trim(),
|
|
279
|
+
...(extra.note === undefined || extra.note.trim() === '' ? {} : { note: extra.note.trim() }),
|
|
280
|
+
...(extra.where === undefined || extra.where.trim() === '' ? {} : { where: extra.where.trim() }),
|
|
281
|
+
when: extra.when === 'reference' ? 'reference' : 'before',
|
|
282
|
+
})),
|
|
283
|
+
}),
|
|
284
|
+
...(request.author === undefined || request.author.trim() === '' ? (existing?.meta?.author === undefined ? {} : { author: existing.meta.author }) : { author: request.author.trim() }),
|
|
285
|
+
...(request.edition === undefined || request.edition.trim() === '' ? (existing?.meta?.edition === undefined ? {} : { edition: existing.meta.edition }) : { edition: request.edition.trim() }),
|
|
286
|
+
...(request.isbn === undefined || request.isbn.trim() === '' ? (existing?.meta?.isbn === undefined ? {} : { isbn: existing.meta.isbn }) : { isbn: request.isbn.trim() }),
|
|
287
|
+
};
|
|
288
|
+
return Object.keys(merged).length === 0 ? undefined : merged;
|
|
289
|
+
})();
|
|
290
|
+
const resource = {
|
|
291
|
+
id: existing?.id ?? mintLibraryId('r'),
|
|
292
|
+
title,
|
|
293
|
+
kind: request.kind,
|
|
294
|
+
...(request.url === undefined || request.url.trim() === '' ? (existing?.url === undefined ? {} : { url: existing.url }) : { url: request.url.trim() }),
|
|
295
|
+
...(request.note === undefined || request.note.trim() === '' ? (existing?.note === undefined ? {} : { note: existing.note }) : { note: request.note.trim() }),
|
|
296
|
+
origin: request.origin ?? existing?.origin ?? 'picked',
|
|
297
|
+
...(existing?.seq === undefined || toc.length === 0 ? {} : { seq: existing.seq }),
|
|
298
|
+
...(toc.length === 0 ? {} : {
|
|
299
|
+
toc: toc.map((entry, index) => ({
|
|
300
|
+
n: index + 1,
|
|
301
|
+
title: entry.title.trim(),
|
|
302
|
+
...(entry.part === undefined || entry.part.trim() === '' ? {} : { part: entry.part.trim() }),
|
|
303
|
+
})),
|
|
304
|
+
}),
|
|
305
|
+
...(request.tocSource === undefined ? (existing?.tocSource === undefined ? {} : { tocSource: existing.tocSource }) : { tocSource: { ...request.tocSource, verifiedAt: request.tocSource.verifiedAt ?? now } }),
|
|
306
|
+
...(meta === undefined ? {} : { meta }),
|
|
307
|
+
createdAt: existing?.createdAt ?? now,
|
|
308
|
+
};
|
|
309
|
+
this.store.writeResource(project.value.id, resource);
|
|
310
|
+
const placed = isThickResource(resource) ? this.placeTextbook(project.value.id, resource.id, request.seq ?? existing?.seq) : resource;
|
|
311
|
+
if (existing === undefined)
|
|
312
|
+
this.event(project.value.id, 'resource.added', { id: resource.id, title: resource.title, resourceId: resource.id, ...(resource.toc === undefined ? {} : { count: resource.toc.length }) });
|
|
313
|
+
return { ok: true, resource: placed };
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Renumber a project's textbooks gapless from 1 with `resourceId` at
|
|
318
|
+
* `seq` (clamped; omitted = keep its place, or last when it has none).
|
|
319
|
+
* Call inside the project lock. Returns the placed resource as stored.
|
|
320
|
+
*/
|
|
321
|
+
placeTextbook(projectId, resourceId, seq) {
|
|
322
|
+
const textbooks = this.store.listResources(projectId).filter(isThickResource);
|
|
323
|
+
const moving = textbooks.find(candidate => candidate.id === resourceId);
|
|
324
|
+
const others = textbooks.filter(candidate => candidate.id !== resourceId);
|
|
325
|
+
if (moving !== undefined) {
|
|
326
|
+
const at = seq === undefined
|
|
327
|
+
? (moving.seq === undefined ? others.length : Math.min(others.filter(other => (other.seq ?? Infinity) < moving.seq).length, others.length))
|
|
328
|
+
: Math.min(Math.max(seq - 1, 0), others.length);
|
|
329
|
+
others.splice(at, 0, moving);
|
|
330
|
+
}
|
|
331
|
+
let placed;
|
|
332
|
+
for (const [index, textbook] of others.entries()) {
|
|
333
|
+
const next = textbook.seq === index + 1 ? textbook : { ...textbook, seq: index + 1 };
|
|
334
|
+
if (next !== textbook)
|
|
335
|
+
this.store.writeResource(projectId, next);
|
|
336
|
+
if (next.id === resourceId)
|
|
337
|
+
placed = next;
|
|
338
|
+
}
|
|
339
|
+
return placed ?? this.store.readResource(projectId, resourceId);
|
|
340
|
+
}
|
|
341
|
+
/** Switch a textbook between 知识型 (knowledge) and 材料型 (material) — the learner's call on the textbook page. */
|
|
342
|
+
async setBookKind(request) {
|
|
343
|
+
this.assertAlive('setBookKind');
|
|
344
|
+
const project = this.readProjectOrRefusal(request.projectId);
|
|
345
|
+
if ('error' in project)
|
|
346
|
+
return project;
|
|
347
|
+
if (request.bookKind !== 'knowledge' && request.bookKind !== 'material')
|
|
348
|
+
return refuse('invalid', 'bookKind is knowledge | material');
|
|
349
|
+
return await this.mutate(project.value.id, () => {
|
|
350
|
+
const resource = this.store.readResource(project.value.id, request.resourceId);
|
|
351
|
+
if (resource === undefined || !isThickResource(resource))
|
|
352
|
+
return refuse('not-found', `no textbook ${JSON.stringify(request.resourceId)} in this project`);
|
|
353
|
+
const next = { ...resource, meta: { ...resource.meta, bookKind: request.bookKind } };
|
|
354
|
+
this.store.writeResource(project.value.id, next);
|
|
355
|
+
return { ok: true, resource: next };
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
/** Move a textbook to another place in the study order (the rest shift). */
|
|
359
|
+
async moveResource(request) {
|
|
360
|
+
this.assertAlive('moveResource');
|
|
361
|
+
const project = this.readProjectOrRefusal(request.projectId);
|
|
362
|
+
if ('error' in project)
|
|
363
|
+
return project;
|
|
364
|
+
if (!(Number.isInteger(request.seq) && request.seq > 0))
|
|
365
|
+
return refuse('invalid', 'seq is a place in the study order — a whole number from 1');
|
|
366
|
+
return await this.mutate(project.value.id, () => {
|
|
367
|
+
const resource = this.store.readResource(project.value.id, request.resourceId);
|
|
368
|
+
if (resource === undefined)
|
|
369
|
+
return refuse('not-found', `no resource ${JSON.stringify(request.resourceId)} in this project — library_read · resources lists them`);
|
|
370
|
+
if (!isThickResource(resource))
|
|
371
|
+
return refuse('invalid', `《${resource.title}》 is a link without contents — only textbooks have a place in the study order`);
|
|
372
|
+
this.placeTextbook(project.value.id, resource.id, request.seq);
|
|
373
|
+
return { ok: true, resources: this.store.listResources(project.value.id) };
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
/** Remove a shelf entry: its record, roadmaps and pages (cards stay, unhung); the textbooks behind it move up. */
|
|
377
|
+
async removeResource(request) {
|
|
378
|
+
this.assertAlive('removeResource');
|
|
379
|
+
const project = this.readProjectOrRefusal(request.projectId);
|
|
380
|
+
if ('error' in project)
|
|
381
|
+
return project;
|
|
382
|
+
return await this.mutate(project.value.id, () => {
|
|
383
|
+
if (this.store.readResource(project.value.id, request.resourceId) === undefined) {
|
|
384
|
+
return refuse('not-found', `no resource ${JSON.stringify(request.resourceId)} in this project`);
|
|
385
|
+
}
|
|
386
|
+
this.store.removeResource(project.value.id, request.resourceId);
|
|
387
|
+
this.placeTextbook(project.value.id, request.resourceId, undefined);
|
|
388
|
+
const cards = this.store.readCards(project.value.id);
|
|
389
|
+
if (cards.some(card => card.resourceId === request.resourceId)) {
|
|
390
|
+
this.store.writeCards(project.value.id, cards.map(card => {
|
|
391
|
+
if (card.resourceId !== request.resourceId)
|
|
392
|
+
return card;
|
|
393
|
+
const { resourceId: _dropped, ...rest } = card;
|
|
394
|
+
return rest;
|
|
395
|
+
}));
|
|
396
|
+
}
|
|
397
|
+
return { ok: true, id: request.resourceId };
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
/** One shelf entry, whole. */
|
|
401
|
+
async getResource(request) {
|
|
402
|
+
this.assertAlive('getResource');
|
|
403
|
+
const resource = this.store.readResource(request.projectId, request.resourceId);
|
|
404
|
+
if (resource === undefined)
|
|
405
|
+
return refuse('not-found', `no resource ${JSON.stringify(request.resourceId)} — library_read · resources lists the project's`);
|
|
406
|
+
return { ok: true, resource };
|
|
407
|
+
}
|
|
408
|
+
/** A project's shelf: textbooks in study order, then links oldest first. */
|
|
409
|
+
async listResources(request) {
|
|
410
|
+
this.assertAlive('listResources');
|
|
411
|
+
return { resources: this.store.listResources(request.projectId) };
|
|
412
|
+
}
|
|
413
|
+
/** The project holding a resource, when an id is all the caller has (the tools' project fallback). */
|
|
414
|
+
findResourceOwner(resourceId) {
|
|
415
|
+
this.assertAlive('findResourceOwner');
|
|
416
|
+
for (const project of this.store.readIndex().projects) {
|
|
417
|
+
if (this.store.readResource(project.id, resourceId) !== undefined)
|
|
418
|
+
return project.id;
|
|
419
|
+
}
|
|
420
|
+
return undefined;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The library service core: owns the store and every rule the data answers
|
|
3
|
+
* to. The Remote face next door is a thin adapter with no logic of its own;
|
|
4
|
+
* the model tools (`tool-definition.ts`) translate these same outcomes into
|
|
5
|
+
* thrown, teaching errors.
|
|
6
|
+
*
|
|
7
|
+
* The rules that live here and nowhere else (v5 — the six-layer model):
|
|
8
|
+
*
|
|
9
|
+
* - **Resources are registered, never uploaded**: a thick one (toc present —
|
|
10
|
+
* a TEXTBOOK: a book, a course with episodes, a coach-authored outline)
|
|
11
|
+
* carries title, kind, contents and their source, sits in the project's
|
|
12
|
+
* study order (`seq`, gapless from 1), and can be routed, covered by
|
|
13
|
+
* chapter, quizzed and photographed; a thin one is a shelf link a station
|
|
14
|
+
* cites.
|
|
15
|
+
* - **Roadmaps are drafted by the coach and adopted by the learner**, one
|
|
16
|
+
* path per textbook (the project carries none — proposal
|
|
17
|
+
* 2026-10-19-library-book-paths). A draft is free to change and a new one
|
|
18
|
+
* replaces the old; adoption initializes the
|
|
19
|
+
* state machine (roots `current`, the rest `locked`), keeps the progress
|
|
20
|
+
* of nodes that survive a re-split, and materializes every acceptance
|
|
21
|
+
* station's rubric as its practice assessment. The adopted path only moves
|
|
22
|
+
* forward: `locked → current → done`, by an assessed quiz (quiz gates),
|
|
23
|
+
* acceptance (acceptance gates) or 「读完」 (read gates).
|
|
24
|
+
* - **Cards dedupe on the write path** (`cards.ts`): an exact key merges,
|
|
25
|
+
* a near match is held back with candidates for the coach to decide.
|
|
26
|
+
* - **Quiz questions carry two axes** — target (marked / asked / missed /
|
|
27
|
+
* topic) and evidence (photo / web / knowledge) — and a station's covers
|
|
28
|
+
* decide whether memory may back a CONTENT question (a material book, a
|
|
29
|
+
* thin link or a bare url among them demands a verbatim quote).
|
|
30
|
+
* - **The assess cascade** is the only cascading write: verdicts land in the
|
|
31
|
+
* ledger keyed by ASSESSMENT, then move the probed cards (mastery and
|
|
32
|
+
* weakness), then the gated node's stats; a node at or above its pass
|
|
33
|
+
* ratio completes and unlocks.
|
|
34
|
+
* - **Photo provenance is a reference, never a copy**, and reading the bytes
|
|
35
|
+
* back is whitelisted to pages the project recorded.
|
|
36
|
+
*
|
|
37
|
+
* @module @khorsheed/dsh-library/service
|
|
38
|
+
*/
|
|
39
|
+
export { LibraryService } from './service/bindings.ts';
|
|
40
|
+
export { LibraryDisposedError, leitnerDueAt } from './service/core.ts';
|
|
41
|
+
export { unlockReady, validateRoadmapShape, coversVerbatimBlocker } from './service/rules.ts';
|
|
42
|
+
//# sourceMappingURL=service.d.ts.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The library service core: owns the store and every rule the data answers
|
|
3
|
+
* to. The Remote face next door is a thin adapter with no logic of its own;
|
|
4
|
+
* the model tools (`tool-definition.ts`) translate these same outcomes into
|
|
5
|
+
* thrown, teaching errors.
|
|
6
|
+
*
|
|
7
|
+
* The rules that live here and nowhere else (v5 — the six-layer model):
|
|
8
|
+
*
|
|
9
|
+
* - **Resources are registered, never uploaded**: a thick one (toc present —
|
|
10
|
+
* a TEXTBOOK: a book, a course with episodes, a coach-authored outline)
|
|
11
|
+
* carries title, kind, contents and their source, sits in the project's
|
|
12
|
+
* study order (`seq`, gapless from 1), and can be routed, covered by
|
|
13
|
+
* chapter, quizzed and photographed; a thin one is a shelf link a station
|
|
14
|
+
* cites.
|
|
15
|
+
* - **Roadmaps are drafted by the coach and adopted by the learner**, one
|
|
16
|
+
* path per textbook (the project carries none — proposal
|
|
17
|
+
* 2026-10-19-library-book-paths). A draft is free to change and a new one
|
|
18
|
+
* replaces the old; adoption initializes the
|
|
19
|
+
* state machine (roots `current`, the rest `locked`), keeps the progress
|
|
20
|
+
* of nodes that survive a re-split, and materializes every acceptance
|
|
21
|
+
* station's rubric as its practice assessment. The adopted path only moves
|
|
22
|
+
* forward: `locked → current → done`, by an assessed quiz (quiz gates),
|
|
23
|
+
* acceptance (acceptance gates) or 「读完」 (read gates).
|
|
24
|
+
* - **Cards dedupe on the write path** (`cards.ts`): an exact key merges,
|
|
25
|
+
* a near match is held back with candidates for the coach to decide.
|
|
26
|
+
* - **Quiz questions carry two axes** — target (marked / asked / missed /
|
|
27
|
+
* topic) and evidence (photo / web / knowledge) — and a station's covers
|
|
28
|
+
* decide whether memory may back a CONTENT question (a material book, a
|
|
29
|
+
* thin link or a bare url among them demands a verbatim quote).
|
|
30
|
+
* - **The assess cascade** is the only cascading write: verdicts land in the
|
|
31
|
+
* ledger keyed by ASSESSMENT, then move the probed cards (mastery and
|
|
32
|
+
* weakness), then the gated node's stats; a node at or above its pass
|
|
33
|
+
* ratio completes and unlocks.
|
|
34
|
+
* - **Photo provenance is a reference, never a copy**, and reading the bytes
|
|
35
|
+
* back is whitelisted to pages the project recorded.
|
|
36
|
+
*
|
|
37
|
+
* @module @khorsheed/dsh-library/service
|
|
38
|
+
*/
|
|
39
|
+
export { LibraryService } from "./service/bindings.js";
|
|
40
|
+
export { LibraryDisposedError, leitnerDueAt } from "./service/core.js";
|
|
41
|
+
export { unlockReady, validateRoadmapShape, coversVerbatimBlocker } from "./service/rules.js";
|