@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.
Files changed (130) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.en.md +49 -0
  3. package/README.i18n.yaml +6 -0
  4. package/README.md +48 -2
  5. package/cordis.patch.yml +17 -0
  6. package/lib/client.js +66334 -0
  7. package/lib/index.js +3318 -0
  8. package/lib/profiles-CFrPQdfO.js +1692 -0
  9. package/lib/tool.js +1597 -0
  10. package/lib/typert.host.d.ts +3 -0
  11. package/lib/typert.host.js +3811 -0
  12. package/lib/typert.remote-client.d.ts +137 -0
  13. package/lib/typert.remote-client.js +3077 -0
  14. package/lib/types/cards.d.ts +41 -0
  15. package/lib/types/cards.js +111 -0
  16. package/lib/types/client/LibraryPane.d.ts +26 -0
  17. package/lib/types/client/LibraryPane.js +129 -0
  18. package/lib/types/client/bind/BindChip.d.ts +33 -0
  19. package/lib/types/client/bind/BindChip.js +102 -0
  20. package/lib/types/client/contract.d.ts +167 -0
  21. package/lib/types/client/contract.js +1 -0
  22. package/lib/types/client/covers.d.ts +36 -0
  23. package/lib/types/client/covers.js +66 -0
  24. package/lib/types/client/definition.d.ts +27 -0
  25. package/lib/types/client/definition.js +32 -0
  26. package/lib/types/client/icons-local.d.ts +41 -0
  27. package/lib/types/client/icons-local.js +25 -0
  28. package/lib/types/client/icons.d.ts +27 -0
  29. package/lib/types/client/icons.js +11 -0
  30. package/lib/types/client/index.d.ts +35 -0
  31. package/lib/types/client/index.js +231 -0
  32. package/lib/types/client/judge.d.ts +70 -0
  33. package/lib/types/client/judge.js +262 -0
  34. package/lib/types/client/locales.d.ts +553 -0
  35. package/lib/types/client/locales.js +1076 -0
  36. package/lib/types/client/look.d.ts +63 -0
  37. package/lib/types/client/look.js +64 -0
  38. package/lib/types/client/math.d.ts +24 -0
  39. package/lib/types/client/math.js +47 -0
  40. package/lib/types/client/mistakes.d.ts +21 -0
  41. package/lib/types/client/mistakes.js +28 -0
  42. package/lib/types/client/preset-visibility.d.ts +109 -0
  43. package/lib/types/client/preset-visibility.js +123 -0
  44. package/lib/types/client/quiz-drafts.d.ts +29 -0
  45. package/lib/types/client/quiz-drafts.js +50 -0
  46. package/lib/types/client/reveal.d.ts +21 -0
  47. package/lib/types/client/reveal.js +31 -0
  48. package/lib/types/client/store.d.ts +111 -0
  49. package/lib/types/client/store.js +48 -0
  50. package/lib/types/client/titles.d.ts +17 -0
  51. package/lib/types/client/titles.js +23 -0
  52. package/lib/types/client/ui.d.ts +96 -0
  53. package/lib/types/client/ui.js +124 -0
  54. package/lib/types/client/views/book.d.ts +57 -0
  55. package/lib/types/client/views/book.js +164 -0
  56. package/lib/types/client/views/cards.d.ts +17 -0
  57. package/lib/types/client/views/cards.js +53 -0
  58. package/lib/types/client/views/home.d.ts +26 -0
  59. package/lib/types/client/views/home.js +263 -0
  60. package/lib/types/client/views/lesson.d.ts +42 -0
  61. package/lib/types/client/views/lesson.js +146 -0
  62. package/lib/types/client/views/path.d.ts +32 -0
  63. package/lib/types/client/views/path.js +64 -0
  64. package/lib/types/client/views/quiz.d.ts +42 -0
  65. package/lib/types/client/views/quiz.js +366 -0
  66. package/lib/types/client/views/station-quiz.d.ts +43 -0
  67. package/lib/types/client/views/station-quiz.js +203 -0
  68. package/lib/types/client/views/station.d.ts +18 -0
  69. package/lib/types/client/views/station.js +142 -0
  70. package/lib/types/image-refs.d.ts +35 -0
  71. package/lib/types/image-refs.js +71 -0
  72. package/lib/types/index.d.ts +51 -0
  73. package/lib/types/index.js +31 -0
  74. package/lib/types/mastery.d.ts +81 -0
  75. package/lib/types/mastery.js +110 -0
  76. package/lib/types/profiles.d.ts +31 -0
  77. package/lib/types/profiles.js +25 -0
  78. package/lib/types/remote.d.ts +355 -0
  79. package/lib/types/remote.js +332 -0
  80. package/lib/types/service/assess.d.ts +144 -0
  81. package/lib/types/service/assess.js +773 -0
  82. package/lib/types/service/bindings.d.ts +92 -0
  83. package/lib/types/service/bindings.js +198 -0
  84. package/lib/types/service/cards.d.ts +85 -0
  85. package/lib/types/service/cards.js +320 -0
  86. package/lib/types/service/core.d.ts +124 -0
  87. package/lib/types/service/core.js +169 -0
  88. package/lib/types/service/pages.d.ts +60 -0
  89. package/lib/types/service/pages.js +125 -0
  90. package/lib/types/service/roadmap.d.ts +99 -0
  91. package/lib/types/service/roadmap.js +284 -0
  92. package/lib/types/service/rules.d.ts +63 -0
  93. package/lib/types/service/rules.js +245 -0
  94. package/lib/types/service/shelf.d.ts +136 -0
  95. package/lib/types/service/shelf.js +422 -0
  96. package/lib/types/service.d.ts +42 -0
  97. package/lib/types/service.js +41 -0
  98. package/lib/types/store.d.ts +257 -0
  99. package/lib/types/store.js +1376 -0
  100. package/lib/types/tool-definition.d.ts +49 -0
  101. package/lib/types/tool-definition.js +49 -0
  102. package/lib/types/tool.d.ts +71 -0
  103. package/lib/types/tool.js +174 -0
  104. package/lib/types/tools/assess.d.ts +11 -0
  105. package/lib/types/tools/assess.js +82 -0
  106. package/lib/types/tools/binding.d.ts +11 -0
  107. package/lib/types/tools/binding.js +46 -0
  108. package/lib/types/tools/cards.d.ts +11 -0
  109. package/lib/types/tools/cards.js +100 -0
  110. package/lib/types/tools/kit.d.ts +215 -0
  111. package/lib/types/tools/kit.js +281 -0
  112. package/lib/types/tools/notes.d.ts +11 -0
  113. package/lib/types/tools/notes.js +40 -0
  114. package/lib/types/tools/page.d.ts +11 -0
  115. package/lib/types/tools/page.js +59 -0
  116. package/lib/types/tools/project.d.ts +11 -0
  117. package/lib/types/tools/project.js +67 -0
  118. package/lib/types/tools/quiz.d.ts +11 -0
  119. package/lib/types/tools/quiz.js +74 -0
  120. package/lib/types/tools/read.d.ts +11 -0
  121. package/lib/types/tools/read.js +127 -0
  122. package/lib/types/tools/resource.d.ts +11 -0
  123. package/lib/types/tools/resource.js +147 -0
  124. package/lib/types/tools/roadmap.d.ts +11 -0
  125. package/lib/types/tools/roadmap.js +102 -0
  126. package/lib/types/types.d.ts +752 -0
  127. package/lib/types/types.js +159 -0
  128. package/locale/en.json +6 -0
  129. package/locale/zh.json +6 -0
  130. package/package.json +135 -3
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Photographed pages: recording (marks carded), listing, photo reads and saves.
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/pages
8
+ */
9
+ import { isThickResource, libraryErrorMessage, } from "../types.js";
10
+ import { mintLibraryId } from "../store.js";
11
+ import { refuse } from "./core.js";
12
+ import { profileOf } from "../profiles.js";
13
+ import { LibraryServiceRoadmap } from "./roadmap.js";
14
+ export class LibraryServicePages extends LibraryServiceRoadmap {
15
+ /* --------------------------------------------------------------- pages */
16
+ /**
17
+ * Record one photographed page and card every mark at once (「一键全收」).
18
+ * Uncertain marks are kept on the page but never carded — the learner
19
+ * decides them. Each card's gloss is the mark's recognized gloss, else the
20
+ * learner's handwriting, else a placeholder the learner can fill in.
21
+ */
22
+ async recordPage(request) {
23
+ this.assertAlive('recordPage');
24
+ const project = this.readProjectOrRefusal(request.projectId);
25
+ if ('error' in project)
26
+ return project;
27
+ const resource = this.store.readResource(project.value.id, request.resourceId);
28
+ if (resource === undefined)
29
+ return refuse('not-found', `no resource ${JSON.stringify(request.resourceId)} in this project — a page belongs to a resource`);
30
+ if (!isThickResource(resource))
31
+ return refuse('invalid', `《${resource.title}》 is a thin link — only a thick resource (one with a contents) has pages to photograph`);
32
+ const ref = request.attachment;
33
+ if (typeof ref?.attachmentId !== 'string' || typeof ref.mediaType !== 'string' || typeof ref.bytes !== 'number' || typeof ref.width !== 'number' || typeof ref.height !== 'number') {
34
+ 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');
35
+ }
36
+ if (request.text.trim() === '') {
37
+ 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');
38
+ }
39
+ const marks = request.marks.filter(mark => mark.text.trim() !== '');
40
+ const pageId = mintLibraryId('pg');
41
+ const nodeId = request.nodeId;
42
+ if (nodeId !== undefined) {
43
+ const roadmap = this.store.readRoadmap(project.value.id, resource.id);
44
+ if (roadmap !== undefined && !roadmap.nodes.some(node => node.id === nodeId)) {
45
+ return refuse('not-found', `no node ${JSON.stringify(nodeId)} on this resource's path — omit nodeId or read the roadmap`);
46
+ }
47
+ }
48
+ const kind = profileOf(project.value.template).markKind;
49
+ const inputs = marks.filter(mark => mark.uncertain !== true).map(mark => ({
50
+ kind,
51
+ term: mark.text,
52
+ gloss: mark.gloss ?? mark.handNote ?? '(待补释义)',
53
+ ...(mark.handNote === undefined ? {} : { userGloss: mark.handNote }),
54
+ ...(mark.sentence === undefined ? {} : { context: { sentence: mark.sentence, pageId, ...(nodeId === undefined ? {} : { nodeId }) } }),
55
+ source: { type: 'photo', ref: pageId, ...(request.label === undefined ? {} : { note: request.label }) },
56
+ resourceId: resource.id,
57
+ }));
58
+ return await this.mutate(project.value.id, () => {
59
+ const cards = this.applyCardInputs(project.value.id, inputs, resource.id, { mergeNear: kind === 'word' });
60
+ const page = {
61
+ id: pageId,
62
+ resourceId: resource.id,
63
+ ...(nodeId === undefined ? {} : { nodeId }),
64
+ ...(request.label === undefined || request.label.trim() === '' ? {} : { label: request.label.trim() }),
65
+ attachment: { attachmentId: ref.attachmentId, mediaType: ref.mediaType, bytes: ref.bytes, width: ref.width, height: ref.height },
66
+ text: request.text,
67
+ marks,
68
+ cardIds: [...cards.added, ...cards.merged].map(card => card.id),
69
+ at: new Date().toISOString(),
70
+ };
71
+ this.store.appendPage(project.value.id, page);
72
+ this.event(project.value.id, 'page.recorded', { id: page.id, title: page.label ?? resource.title, resourceId: resource.id, count: cards.added.length, count2: cards.merged.length });
73
+ return { ok: true, page, cards, uncertain: marks.filter(mark => mark.uncertain === true).map(mark => mark.text) };
74
+ });
75
+ }
76
+ /** One resource's pages, optionally one node's. */
77
+ async listPages(request) {
78
+ this.assertAlive('listPages');
79
+ const pages = this.store.readPages(request.projectId, request.resourceId);
80
+ return { pages: request.nodeId === undefined ? pages : pages.filter(page => page.nodeId === request.nodeId) };
81
+ }
82
+ /** Find one page anywhere in the project. */
83
+ findPage(projectId, pageId) {
84
+ for (const resource of this.store.listResources(projectId)) {
85
+ const page = this.store.readPages(projectId, resource.id).find(candidate => candidate.id === pageId);
86
+ if (page !== undefined)
87
+ return page;
88
+ }
89
+ return undefined;
90
+ }
91
+ /**
92
+ * One page's photo, as base64 — whitelisted: the page must be one this
93
+ * project recorded, so the Remote never reads an arbitrary attachment.
94
+ */
95
+ async readPagePhoto(request) {
96
+ this.assertAlive('readPagePhoto');
97
+ const page = this.findPage(request.projectId, request.pageId);
98
+ if (page === undefined)
99
+ return refuse('not-found', `no page ${JSON.stringify(request.pageId)} in this project`);
100
+ const attachments = this.ctx.get('attachments');
101
+ if (attachments === undefined)
102
+ return refuse('unavailable', 'this deployment mounts no attachment store — the photo cannot be read back here');
103
+ try {
104
+ const stored = await attachments.readImage(page.attachment);
105
+ return { ok: true, data: Buffer.from(stored.data).toString('base64'), mediaType: stored.ref.mediaType };
106
+ }
107
+ catch (error) {
108
+ return refuse('io', `the photo could not be read — ${libraryErrorMessage(error)}`);
109
+ }
110
+ }
111
+ /** Save a browser-uploaded photo into the host attachment store (the sidebar's 拍照). */
112
+ async savePhoto(request) {
113
+ this.assertAlive('savePhoto');
114
+ const attachments = this.ctx.get('attachments');
115
+ if (attachments?.saveImage === undefined)
116
+ return refuse('unavailable', 'this deployment mounts no attachment store — photos cannot be saved here');
117
+ try {
118
+ const attachment = await attachments.saveImage({ data: Buffer.from(request.data, 'base64'), mediaType: request.mediaType });
119
+ return { ok: true, attachment };
120
+ }
121
+ catch (error) {
122
+ return refuse('invalid', `the photo was refused — ${libraryErrorMessage(error)}`);
123
+ }
124
+ }
125
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Paths: drafts, adoption (with practice rubrics materialized), discard, station notes, 读完.
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/roadmap
8
+ */
9
+ import { type LibraryAcceptanceInput, type LibraryOutcome, type LibraryNodeCover, type LibraryNodeNotes, type LibraryPoint, type LibraryRoadmap, type RoadmapNode } from '../types.ts';
10
+ import { LibraryServiceCards } from './cards.ts';
11
+ export declare class LibraryServiceRoadmap extends LibraryServiceCards {
12
+ /** One textbook's adopted roadmap and its pending draft. */
13
+ getRoadmap(request: {
14
+ projectId: string;
15
+ resourceId: string;
16
+ }): Promise<{
17
+ roadmap?: LibraryRoadmap;
18
+ draft?: LibraryRoadmap;
19
+ }>;
20
+ /**
21
+ * Write the coach's draft of one textbook's path; it replaces the
22
+ * textbook's previous draft. Shape rules: unique ids, a gate, at least one
23
+ * cover per node, references that exist, locators inside the resource's
24
+ * contents, dependencies pointing backwards. States are not the coach's to
25
+ * set — adoption initializes them.
26
+ */
27
+ draftRoadmap(request: {
28
+ projectId: string;
29
+ resourceId: string;
30
+ nodes: readonly {
31
+ id: string;
32
+ title: string;
33
+ gate: RoadmapNode['gate'];
34
+ goals?: readonly string[] | string;
35
+ covers?: readonly LibraryNodeCover[];
36
+ dependsOn?: readonly string[];
37
+ acceptance?: readonly LibraryAcceptanceInput[];
38
+ /** Quiz-gated stations only: the knowledge points this station tests (3-6) — stable slug ids, reviewed on the draft card. */
39
+ points?: readonly LibraryPoint[];
40
+ }[];
41
+ passRatio?: number;
42
+ note?: string;
43
+ }): Promise<LibraryOutcome<{
44
+ draft: LibraryRoadmap;
45
+ }>>;
46
+ /**
47
+ * The learner's 采用: the draft becomes the path. Nodes whose deps are
48
+ * all done start `current`; a node that survives a re-split (same id) keeps
49
+ * its state, its quiz stats and its 讲义, so re-planning never erases
50
+ * progress. Every acceptance station's rubric materializes as its practice
51
+ * assessment (unchanged lines reuse the live one — its history stays put;
52
+ * changed lines supersede it).
53
+ */
54
+ adoptRoadmap(request: {
55
+ projectId: string;
56
+ resourceId: string;
57
+ }): Promise<LibraryOutcome<{
58
+ roadmap: LibraryRoadmap;
59
+ }>>;
60
+ /**
61
+ * Give every acceptance node a practice assessment keyed to it: an
62
+ * unchanged rubric reuses the node's live assessment (its attempts stay
63
+ * attached); a changed rubric supersedes it (the old snapshot keeps the old
64
+ * history). Call inside the project lock.
65
+ */
66
+ protected materializePracticeAssessments(projectId: string, nodes: readonly RoadmapNode[], at: string): void;
67
+ /** Drop a textbook's draft without adopting it. */
68
+ discardDraft(request: {
69
+ projectId: string;
70
+ resourceId: string;
71
+ }): Promise<LibraryOutcome<{
72
+ resourceId: string;
73
+ }>>;
74
+ /**
75
+ * Write (or rewrite) a station's 讲义 — the coach's distilled teaching,
76
+ * persisted on the adopted path's node. Replanning keeps it when the
77
+ * station's id survives; every write bumps `revisions`.
78
+ */
79
+ /** 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. */
80
+ private notesMiss;
81
+ putNotes(request: {
82
+ projectId: string;
83
+ resourceId?: string;
84
+ nodeId: string;
85
+ text: string;
86
+ }): Promise<LibraryOutcome<{
87
+ notes: LibraryNodeNotes;
88
+ }>>;
89
+ /** 「读完」: a current read node completes and unlocks what it gates. */
90
+ markNodeRead(request: {
91
+ projectId: string;
92
+ resourceId?: string;
93
+ nodeId: string;
94
+ }): Promise<LibraryOutcome<{
95
+ roadmap: LibraryRoadmap;
96
+ unlocked: string[];
97
+ }>>;
98
+ }
99
+ //# sourceMappingURL=roadmap.d.ts.map
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Paths: drafts, adoption (with practice rubrics materialized), discard, station notes, 读完.
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/roadmap
8
+ */
9
+ import { isLibraryId, acceptanceLines, isThickResource, } from "../types.js";
10
+ import { mintLibraryId } from "../store.js";
11
+ import { refuse } from "./core.js";
12
+ import { lessonProblems, titleOf, unlockReady, validateRoadmapShape } from "./rules.js";
13
+ import { LibraryServiceCards } from "./cards.js";
14
+ export class LibraryServiceRoadmap extends LibraryServiceCards {
15
+ /* ------------------------------------------------------------- roadmap */
16
+ /** One textbook's adopted roadmap and its pending draft. */
17
+ async getRoadmap(request) {
18
+ this.assertAlive('getRoadmap');
19
+ const roadmap = this.store.readRoadmap(request.projectId, request.resourceId);
20
+ const draft = this.store.readRoadmap(request.projectId, request.resourceId, 'draft');
21
+ return { ...(roadmap === undefined ? {} : { roadmap }), ...(draft === undefined ? {} : { draft }) };
22
+ }
23
+ /**
24
+ * Write the coach's draft of one textbook's path; it replaces the
25
+ * textbook's previous draft. Shape rules: unique ids, a gate, at least one
26
+ * cover per node, references that exist, locators inside the resource's
27
+ * contents, dependencies pointing backwards. States are not the coach's to
28
+ * set — adoption initializes them.
29
+ */
30
+ async draftRoadmap(request) {
31
+ this.assertAlive('draftRoadmap');
32
+ const project = this.readProjectOrRefusal(request.projectId);
33
+ if ('error' in project)
34
+ return project;
35
+ const textbooks = this.store.listResources(project.value.id).filter(isThickResource);
36
+ const shelf = textbooks.length === 0
37
+ ? '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)'
38
+ : `this project's textbooks: ${textbooks.map(textbook => `${textbook.seq ?? '-'}. ${textbook.id} 《${textbook.title}》`).join('; ')}`;
39
+ if (typeof request.resourceId !== 'string' || request.resourceId.trim() === '') {
40
+ return refuse('invalid', `a path belongs to one textbook — pass resource; ${shelf}`);
41
+ }
42
+ const primary = isLibraryId(request.resourceId) ? this.store.readResource(project.value.id, request.resourceId) : undefined;
43
+ if (primary === undefined) {
44
+ return refuse('not-found', `no resource ${JSON.stringify(request.resourceId)} in project ${JSON.stringify(project.value.name)} — ${shelf}`);
45
+ }
46
+ if (!isThickResource(primary)) {
47
+ 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}`);
48
+ }
49
+ if (request.nodes.length === 0) {
50
+ 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: [...]');
51
+ }
52
+ if (request.passRatio !== undefined && !(request.passRatio > 0 && request.passRatio <= 1)) {
53
+ return refuse('invalid', 'passRatio is a fraction in (0, 1] — 0.8 means 8 of 10 right');
54
+ }
55
+ for (const node of request.nodes) {
56
+ if (node.points === undefined || node.points.length === 0)
57
+ continue;
58
+ if (node.gate !== 'quiz')
59
+ return refuse('invalid', `${node.title} is a ${node.gate} node — points (考点) belong to quiz-gated stations only`);
60
+ const seen = new Set();
61
+ for (const point of node.points) {
62
+ if (!isLibraryId(point.id))
63
+ 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`);
64
+ if (typeof point.title !== 'string' || point.title.trim() === '')
65
+ return refuse('invalid', `point ${point.id} on ${node.title} needs a title — the point as the learner reads it (「极限的定义」)`);
66
+ if (seen.has(point.id))
67
+ return refuse('invalid', `duplicate point id ${point.id} on ${node.title} — reference the existing one`);
68
+ seen.add(point.id);
69
+ }
70
+ }
71
+ const nodes = request.nodes.map(node => ({
72
+ id: node.id,
73
+ title: node.title,
74
+ gate: node.gate,
75
+ // A model writes one goal as a bare string as often as a list.
76
+ goals: typeof node.goals === 'string' ? [node.goals] : (node.goals ?? []).filter(goal => typeof goal === 'string'),
77
+ covers: node.covers ?? [],
78
+ dependsOn: node.dependsOn ?? [],
79
+ state: 'locked',
80
+ ...(node.points === undefined || node.points.length === 0 ? {} : { points: node.points.map(point => ({ id: point.id, title: point.title.trim() })) }),
81
+ ...(Array.isArray(node.acceptance) ? {
82
+ acceptance: node.acceptance.flatMap((entry) => {
83
+ if (typeof entry === 'string')
84
+ return entry.trim() === '' ? [] : [entry];
85
+ if (typeof entry?.line !== 'string' || entry.line.trim() === '')
86
+ return [];
87
+ return [{ line: entry.line.trim(), ...(typeof entry.ref === 'string' && entry.ref.trim() !== '' ? { ref: entry.ref.trim() } : {}) }];
88
+ }),
89
+ } : {}),
90
+ }));
91
+ const shapeError = validateRoadmapShape(nodes, {
92
+ resourceId: primary.id,
93
+ resource: candidate => this.store.readResource(project.value.id, candidate),
94
+ hasAssessment: candidate => this.store.readAssessment(project.value.id, candidate) !== undefined,
95
+ });
96
+ if (shapeError !== undefined)
97
+ return refuse('invalid', shapeError);
98
+ return await this.mutate(project.value.id, () => {
99
+ const draft = {
100
+ primaryResourceId: primary.id,
101
+ status: 'draft',
102
+ nodes,
103
+ ...(request.passRatio === undefined ? {} : { passRatio: request.passRatio }),
104
+ ...(request.note === undefined || request.note.trim() === '' ? {} : { note: request.note.trim() }),
105
+ generatedAt: new Date().toISOString(),
106
+ };
107
+ this.store.writeRoadmap(project.value.id, draft);
108
+ this.event(project.value.id, 'roadmap.drafted', { id: primary.id, title: primary.title, resourceId: primary.id, count: nodes.length });
109
+ return { ok: true, draft: draft };
110
+ });
111
+ }
112
+ /**
113
+ * The learner's 采用: the draft becomes the path. Nodes whose deps are
114
+ * all done start `current`; a node that survives a re-split (same id) keeps
115
+ * its state, its quiz stats and its 讲义, so re-planning never erases
116
+ * progress. Every acceptance station's rubric materializes as its practice
117
+ * assessment (unchanged lines reuse the live one — its history stays put;
118
+ * changed lines supersede it).
119
+ */
120
+ async adoptRoadmap(request) {
121
+ this.assertAlive('adoptRoadmap');
122
+ const project = this.readProjectOrRefusal(request.projectId);
123
+ if ('error' in project)
124
+ return project;
125
+ return await this.mutate(project.value.id, () => {
126
+ const draft = this.store.readRoadmap(project.value.id, request.resourceId, 'draft');
127
+ if (draft === undefined)
128
+ return refuse('not-found', 'no draft to adopt here — ask the coach to draft a roadmap first');
129
+ const prior = new Map((this.store.readRoadmap(project.value.id, request.resourceId)?.nodes ?? []).map(node => [node.id, node]));
130
+ const nodes = [];
131
+ for (const node of draft.nodes) {
132
+ const kept = prior.get(node.id);
133
+ // No station is locked (user decision 10-26, D6): the path is a recommended
134
+ // order; any station can be studied and quizzed, and passing records it.
135
+ const state = kept?.state === 'done' ? 'done' : 'current';
136
+ nodes.push({
137
+ ...node,
138
+ state,
139
+ ...(node.gate === 'quiz' ? { quizStats: kept?.quizStats ?? { attempts: 0 } } : {}),
140
+ // A re-plan that keeps the station's id also keeps its 讲义.
141
+ ...(kept?.notes !== undefined ? { notes: kept.notes } : {}),
142
+ });
143
+ }
144
+ const now = new Date().toISOString();
145
+ const roadmap = { ...draft, primaryResourceId: request.resourceId, status: 'adopted', nodes, adoptedAt: now };
146
+ this.store.writeRoadmap(project.value.id, roadmap);
147
+ this.store.removeDraft(project.value.id, request.resourceId);
148
+ // Materialize each acceptance station's rubric as its practice assessment.
149
+ this.materializePracticeAssessments(project.value.id, nodes, now);
150
+ const primary = this.store.readResource(project.value.id, request.resourceId);
151
+ this.event(project.value.id, 'roadmap.adopted', {
152
+ at: now,
153
+ id: request.resourceId,
154
+ title: primary?.title ?? request.resourceId,
155
+ resourceId: request.resourceId,
156
+ count: nodes.length,
157
+ });
158
+ return { ok: true, roadmap };
159
+ });
160
+ }
161
+ /**
162
+ * Give every acceptance node a practice assessment keyed to it: an
163
+ * unchanged rubric reuses the node's live assessment (its attempts stay
164
+ * attached); a changed rubric supersedes it (the old snapshot keeps the old
165
+ * history). Call inside the project lock.
166
+ */
167
+ materializePracticeAssessments(projectId, nodes, at) {
168
+ const assessments = this.store.listAssessments(projectId);
169
+ for (const node of nodes) {
170
+ if (node.gate !== 'acceptance')
171
+ continue;
172
+ const rubric = acceptanceLines(node);
173
+ if (rubric.length === 0)
174
+ continue;
175
+ const liveAssessment = assessments.find(assessment => assessment.type === 'practice' && assessment.scope.nodeId === node.id && assessment.supersededBy === undefined);
176
+ const sameRubric = liveAssessment !== undefined
177
+ && (liveAssessment.rubric ?? []).length === rubric.length
178
+ && rubric.every((line, index) => liveAssessment.rubric?.[index]?.line === line.line && liveAssessment.rubric?.[index]?.ref === line.ref);
179
+ if (sameRubric)
180
+ continue;
181
+ const assessment = {
182
+ id: mintLibraryId('q'),
183
+ type: 'practice',
184
+ scope: { nodeId: node.id },
185
+ rubric,
186
+ createdAt: at,
187
+ };
188
+ this.store.writeAssessment(projectId, assessment);
189
+ if (liveAssessment !== undefined) {
190
+ this.store.writeAssessment(projectId, { ...liveAssessment, supersededBy: assessment.id });
191
+ }
192
+ }
193
+ }
194
+ /** Drop a textbook's draft without adopting it. */
195
+ async discardDraft(request) {
196
+ this.assertAlive('discardDraft');
197
+ const project = this.readProjectOrRefusal(request.projectId);
198
+ if ('error' in project)
199
+ return project;
200
+ return await this.mutate(project.value.id, () => {
201
+ this.store.removeDraft(project.value.id, request.resourceId);
202
+ return { ok: true, resourceId: request.resourceId };
203
+ });
204
+ }
205
+ /**
206
+ * Write (or rewrite) a station's 讲义 — the coach's distilled teaching,
207
+ * persisted on the adopted path's node. Replanning keeps it when the
208
+ * station's id survives; every write bumps `revisions`.
209
+ */
210
+ /** 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. */
211
+ notesMiss(projectId, resourceId, nodeId) {
212
+ const book = resourceId === undefined ? undefined : this.store.readResource(projectId, resourceId);
213
+ const named = book === undefined ? 'this textbook' : `《${book.title}》 (${book.id})`;
214
+ const roadmap = this.store.readRoadmap(projectId, resourceId);
215
+ if (roadmap === undefined) {
216
+ const draft = this.store.readRoadmap(projectId, resourceId, 'draft');
217
+ return refuse('not-found', draft === undefined
218
+ ? `${named} has no path yet — draft one (library_roadmap); 讲义 land on an adopted path's stations`
219
+ : `${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 讲义`);
220
+ }
221
+ const elsewhere = this.store.listResources(projectId)
222
+ .filter(resource => resource.id !== roadmap.primaryResourceId)
223
+ .find(resource => this.store.readRoadmap(projectId, resource.id)?.nodes.some(candidate => candidate.id === nodeId) === true);
224
+ if (elsewhere !== undefined)
225
+ return refuse('not-found', `station ${JSON.stringify(nodeId)} is on 《${elsewhere.title}》's path — pass resource ${elsewhere.id}`);
226
+ return refuse('not-found', `no station ${JSON.stringify(nodeId)} on ${named}'s path — its stations: ${roadmap.nodes.map(candidate => candidate.id).join(', ')}`);
227
+ }
228
+ async putNotes(request) {
229
+ this.assertAlive('putNotes');
230
+ const project = this.readProjectOrRefusal(request.projectId);
231
+ if ('error' in project)
232
+ return project;
233
+ const text = request.text.trim();
234
+ if (text === '')
235
+ return refuse('invalid', 'notes need text — the distilled teaching for this station (markdown): the explanation, the points the learner got stuck on, the pitfalls');
236
+ return await this.mutate(project.value.id, () => {
237
+ const roadmap = this.store.readRoadmap(project.value.id, request.resourceId);
238
+ const node = roadmap?.nodes.find(candidate => candidate.id === request.nodeId);
239
+ if (roadmap === undefined || node === undefined)
240
+ return this.notesMiss(project.value.id, request.resourceId, request.nodeId);
241
+ const book = roadmap.primaryResourceId === undefined ? undefined : this.store.readResource(project.value.id, roadmap.primaryResourceId);
242
+ // Every textbook's 讲义 are the lesson itself (10-26): the per-point check applies to all.
243
+ const lesson = lessonProblems(text, node, book !== undefined);
244
+ if (lesson !== undefined)
245
+ return refuse('invalid', lesson);
246
+ // A rewrite keeps the version it replaces (notes/<resource>/<node>.<n>.md).
247
+ if (node.notes !== undefined && node.notes.text !== '' && roadmap.primaryResourceId !== undefined) {
248
+ this.store.archiveNotes(project.value.id, roadmap.primaryResourceId, node.id, node.notes.revisions, node.notes.text);
249
+ }
250
+ const notes = { text, updatedAt: new Date().toISOString(), revisions: (node.notes?.revisions ?? 0) + 1 };
251
+ const next = { ...roadmap, nodes: roadmap.nodes.map(candidate => candidate.id === node.id ? { ...candidate, notes } : candidate) };
252
+ this.store.writeRoadmap(project.value.id, next);
253
+ this.event(project.value.id, 'node.noted', { id: node.id, title: node.title, ...(request.resourceId === undefined ? {} : { resourceId: request.resourceId }), count: notes.revisions });
254
+ return { ok: true, notes };
255
+ });
256
+ }
257
+ /** 「读完」: a current read node completes and unlocks what it gates. */
258
+ async markNodeRead(request) {
259
+ this.assertAlive('markNodeRead');
260
+ const project = this.readProjectOrRefusal(request.projectId);
261
+ if ('error' in project)
262
+ return project;
263
+ return await this.mutate(project.value.id, () => {
264
+ const roadmap = this.store.readRoadmap(project.value.id, request.resourceId);
265
+ const node = roadmap?.nodes.find(candidate => candidate.id === request.nodeId);
266
+ if (roadmap === undefined || node === undefined)
267
+ return refuse('not-found', `no node ${JSON.stringify(request.nodeId)} on the adopted path`);
268
+ if (node.gate !== 'read')
269
+ 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)`);
270
+ if (node.state !== 'current')
271
+ return refuse('state-machine', `${node.title} is ${node.state} — only a current node can be marked read`);
272
+ const now = new Date().toISOString();
273
+ const completed = roadmap.nodes.map(candidate => candidate.id === node.id ? { ...candidate, state: 'done' } : candidate);
274
+ const { nodes, unlocked } = unlockReady(completed);
275
+ const next = { ...roadmap, nodes };
276
+ this.store.writeRoadmap(project.value.id, next);
277
+ this.store.appendEvents(project.value.id, [
278
+ { at: now, type: 'node.completed', id: node.id, title: node.title, ...(request.resourceId === undefined ? {} : { resourceId: request.resourceId }) },
279
+ ...unlocked.map(nodeId => ({ at: now, type: 'node.unlocked', id: nodeId, title: titleOf(nodes, nodeId), ...(request.resourceId === undefined ? {} : { resourceId: request.resourceId }) })),
280
+ ]);
281
+ return { ok: true, roadmap: next, unlocked };
282
+ });
283
+ }
284
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The library's pure rules: roadmap shape validation, the unlock pass, the
3
+ * covers' evidence gate, and two small helpers. No store, no context — the
4
+ * service layers and the tools call them.
5
+ *
6
+ * @module @khorsheed/dsh-library/service/rules
7
+ */
8
+ import { type LibraryResource, type LibraryNodeCover, type RoadmapNode } from '../types.ts';
9
+ /** A node's title, for event rows. */
10
+ export declare function titleOf(nodes: readonly RoadmapNode[], nodeId: string): string;
11
+ /**
12
+ * The option an answer key names — a letter in range, a lettered option, the
13
+ * option text, else a one-way containment — or -1. The sidebar's judge
14
+ * (`client/judge.ts`'s choiceIndex) resolves the same spellings; a question
15
+ * this resolves to -1 is judged wrong no matter what the learner picks, so
16
+ * the write gate refuses it.
17
+ */
18
+ export declare function choiceOptionIndex(options: readonly string[], answer: string): number;
19
+ /** Move every locked node whose deps are all done to current. */
20
+ export declare function unlockReady(nodes: readonly RoadmapNode[]): {
21
+ nodes: RoadmapNode[];
22
+ unlocked: string[];
23
+ };
24
+ /** A lesson's size in knowledge points (a quiz station's points). */
25
+ export declare const STATION_MIN_POINTS = 3;
26
+ export declare const STATION_MAX_POINTS = 5;
27
+ /**
28
+ * Structural validation of a roadmap's nodes; returns the teaching error or
29
+ * undefined. `scope.resourceId` marks a resource-level draft: the special case
30
+ * whose every node covers that one resource only. Every per-node problem is
31
+ * collected and reported at once — a draft with five mistakes earns ONE
32
+ * refusal listing all five, not five fail-fast round trips (each retry
33
+ * resends the whole nodes payload).
34
+ */
35
+ export declare function validateRoadmapShape(nodes: readonly RoadmapNode[], scope: {
36
+ /** The textbook whose path this is (a cover of it is the station's main source; others ride along). */
37
+ resourceId: string;
38
+ /** Resolve a resource cover (existence + contents length). */
39
+ resource: (resourceId: string) => LibraryResource | undefined;
40
+ /** Whether an assessment exists (an acceptance line's ref). */
41
+ hasAssessment: (assessmentId: string) => boolean;
42
+ }): string | undefined;
43
+ /**
44
+ * Why a node's covers forbid a memory-backed content question, or undefined
45
+ * when they allow one: only knowledge books and plain coached teaching (topic)
46
+ * may be quizzed from domain knowledge; a material book, a thin link or a
47
+ * bare url demands a verbatim quote. When the station covers its own
48
+ * textbook (`primaryId`), that cover alone decides — a side reference (a
49
+ * video, an article) never turns a knowledge chapter into a quote-only one.
50
+ * The evidence-gate's node-level half (a resource-scoped quiz without a node
51
+ * keeps the resource rule).
52
+ */
53
+ export declare function coversVerbatimBlocker(covers: readonly LibraryNodeCover[], resource: (resourceId: string) => LibraryResource | undefined, primaryId?: string): string | undefined;
54
+ /** The least a point's 讲义 section says (characters, markup and blanks aside). */
55
+ export declare const LESSON_SECTION_MIN = 120;
56
+ /**
57
+ * What keeps a 讲义 from reading as a lesson, or undefined: change logs and
58
+ * internal words never reach the learner; on a textbook you authored (the 讲义
59
+ * ARE its text) every knowledge point gets its own section — a heading naming
60
+ * the point, then enough to learn it from.
61
+ */
62
+ export declare function lessonProblems(text: string, node: RoadmapNode, authored: boolean): string | undefined;
63
+ //# sourceMappingURL=rules.d.ts.map