@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,320 @@
1
+ /**
2
+ * Knowledge cards: the dedupe write path, listing, editing, undo, review scheduling.
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/cards
8
+ */
9
+ import { MAX_BOX, } from "../types.js";
10
+ import { cardKey, cardKeys, cardMatchesQuery, exactMatch, nearMatches, } from "../cards.js";
11
+ import { mintLibraryId } from "../store.js";
12
+ import { refuse, leitnerDueAt, clampWeakness, live } from "./core.js";
13
+ import { LibraryServiceShelf } from "./shelf.js";
14
+ export class LibraryServiceCards extends LibraryServiceShelf {
15
+ /* --------------------------------------------------------------- cards */
16
+ /**
17
+ * Apply card inputs to the project's cards (call inside the project lock).
18
+ * Exact keys merge; near matches are held back unless `mergeNear` (photo
19
+ * words — a plural or a case away is the same word) or the input decides.
20
+ */
21
+ applyCardInputs(projectId, inputs, defaultResourceId, options = {}) {
22
+ const cards = this.store.readCards(projectId);
23
+ const byId = new Map(cards.map(card => [card.id, card]));
24
+ const order = cards.map(card => card.id);
25
+ const added = [];
26
+ const merged = [];
27
+ const pending = [];
28
+ const now = new Date().toISOString();
29
+ for (const [index, input] of inputs.entries()) {
30
+ const keys = [input.term, ...(input.aliases ?? [])].map(cardKey).filter(key => key !== '');
31
+ const pool = [...byId.values()];
32
+ let target;
33
+ if (input.mergeInto !== undefined) {
34
+ target = byId.get(input.mergeInto);
35
+ }
36
+ else if (input.asNew !== true) {
37
+ target = exactMatch(pool, keys);
38
+ if (target === undefined) {
39
+ const near = nearMatches(pool, keys);
40
+ if (near.length > 0) {
41
+ if (options.mergeNear === true) {
42
+ target = near[0];
43
+ }
44
+ else {
45
+ pending.push({ index, term: input.term, candidates: near.map(card => ({ id: card.id, term: card.term, gloss: card.gloss })) });
46
+ continue;
47
+ }
48
+ }
49
+ }
50
+ }
51
+ const resourceId = input.resourceId ?? defaultResourceId;
52
+ const source = {
53
+ type: input.source.type,
54
+ ...(input.source.ref === undefined ? {} : { ref: input.source.ref }),
55
+ ...(input.source.note === undefined || input.source.note.trim() === '' ? {} : { note: input.source.note.trim() }),
56
+ ...(input.weaken === true ? { weakened: true } : {}),
57
+ at: now,
58
+ };
59
+ const context = input.context === undefined || input.context.sentence.trim() === ''
60
+ ? undefined
61
+ : {
62
+ sentence: input.context.sentence.trim(),
63
+ ...(resourceId === undefined ? {} : { resourceId }),
64
+ ...(input.context.pageId === undefined ? {} : { pageId: input.context.pageId }),
65
+ ...(input.context.nodeId === undefined ? {} : { nodeId: input.context.nodeId }),
66
+ };
67
+ if (target !== undefined) {
68
+ const aliases = new Set(target.aliases);
69
+ const ownKeys = new Set(cardKeys(target));
70
+ if (!ownKeys.has(cardKey(input.term)))
71
+ aliases.add(input.term.trim());
72
+ for (const alias of input.aliases ?? [])
73
+ if (!ownKeys.has(cardKey(alias)))
74
+ aliases.add(alias.trim());
75
+ const placeholder = target.gloss === '(待补释义)';
76
+ const next = {
77
+ ...target,
78
+ aliases: [...aliases],
79
+ gloss: placeholder ? input.gloss.trim() : target.gloss,
80
+ ...(target.userGloss === undefined && input.userGloss !== undefined ? { userGloss: input.userGloss.trim() } : {}),
81
+ contexts: context === undefined || target.contexts.some(existing => existing.sentence === context.sentence)
82
+ ? target.contexts
83
+ : [...target.contexts, context],
84
+ ...(target.resourceId === undefined && resourceId !== undefined ? { resourceId } : {}),
85
+ sources: [...target.sources, source],
86
+ weakness: clampWeakness(target.weakness + (input.weaken === true ? 1 : 0)),
87
+ // A card met again is active again — a re-sighting never resets mastery.
88
+ status: target.status === 'deleted' ? 'active' : target.status,
89
+ updatedAt: now,
90
+ };
91
+ byId.set(next.id, next);
92
+ merged.push(next);
93
+ continue;
94
+ }
95
+ const card = {
96
+ id: mintLibraryId('c'),
97
+ kind: input.kind,
98
+ term: input.term.trim(),
99
+ aliases: (input.aliases ?? []).map(alias => alias.trim()).filter(alias => alias !== '' && cardKey(alias) !== cardKey(input.term)),
100
+ gloss: input.gloss.trim(),
101
+ ...(input.userGloss === undefined || input.userGloss.trim() === '' ? {} : { userGloss: input.userGloss.trim() }),
102
+ contexts: context === undefined ? [] : [context],
103
+ ...(resourceId === undefined ? {} : { resourceId }),
104
+ sources: [source],
105
+ weakness: input.weaken === true || input.source.type === 'chat' ? 1 : 0,
106
+ mastery: { box: 0, dueAt: now, streak: 0 },
107
+ status: 'active',
108
+ createdAt: now,
109
+ updatedAt: now,
110
+ };
111
+ byId.set(card.id, card);
112
+ order.push(card.id);
113
+ added.push(card);
114
+ }
115
+ if (added.length > 0 || merged.length > 0) {
116
+ this.store.writeCards(projectId, order.map(cardId => byId.get(cardId)));
117
+ const touched = [...added, ...merged];
118
+ const sourceType = inputs[0]?.source.type;
119
+ this.event(projectId, 'cards.recorded', {
120
+ at: now,
121
+ title: touched.map(card => card.term).join('、').slice(0, 300),
122
+ ids: touched.map(card => card.id),
123
+ ...(sourceType === undefined ? {} : { source: sourceType }),
124
+ ...(sourceType === 'chat' && inputs[0]?.source.ref !== undefined ? { session: inputs[0].source.ref } : {}),
125
+ ...(defaultResourceId === undefined ? {} : { resourceId: defaultResourceId }),
126
+ count: added.length,
127
+ count2: merged.length,
128
+ });
129
+ }
130
+ return { added, merged, pending, at: now };
131
+ }
132
+ /**
133
+ * Record cards (the coach's 「记下」, a manual add). Near-duplicates come back
134
+ * in `pending` with their candidates — resend each with `mergeInto` or `asNew`.
135
+ */
136
+ async writeCards(request) {
137
+ this.assertAlive('writeCards');
138
+ const project = this.readProjectOrRefusal(request.projectId);
139
+ if ('error' in project)
140
+ return project;
141
+ if (request.cards.length === 0) {
142
+ return refuse('invalid', 'cards needs at least one card — each: { kind: word|concept, term, gloss, context?, source: { type: chat|photo|quiz|manual, note? }, weaken? }');
143
+ }
144
+ // Per-card problems are collected and reported at once: a batch with
145
+ // three bad cards earns one refusal listing all three, not three
146
+ // fail-fast round trips.
147
+ const errors = [];
148
+ for (const [index, input] of request.cards.entries()) {
149
+ if (input.kind !== 'word' && input.kind !== 'concept')
150
+ errors.push(`cards[${index}].kind is word | concept`);
151
+ if (input.term.trim() === '' || input.gloss.trim() === '') {
152
+ errors.push(`cards[${index}] needs term and gloss — the gloss is the in-context meaning (a word) or a one-line explanation (a concept)`);
153
+ }
154
+ if (!['photo', 'chat', 'quiz', 'manual'].includes(input.source?.type)) {
155
+ errors.push(`cards[${index}].source.type is chat | photo | quiz | manual — chat: the learner asked / got stuck in conversation`);
156
+ }
157
+ }
158
+ const cards = this.store.readCards(project.value.id);
159
+ for (const [index, input] of request.cards.entries()) {
160
+ if (input.mergeInto !== undefined && !cards.some(card => card.id === input.mergeInto && live(card))) {
161
+ errors.push(`cards[${index}].mergeInto names ${JSON.stringify(input.mergeInto)}, which is not a live card — use an id from the pending candidates`);
162
+ }
163
+ }
164
+ if (errors.length === 1)
165
+ return refuse('invalid', errors[0]);
166
+ if (errors.length > 1) {
167
+ return refuse('invalid', `${errors.length} problems in cards — fix them all, then resend:\n${errors.map(error => `- ${error}`).join('\n')}`);
168
+ }
169
+ if (request.resourceId !== undefined && this.store.readResource(project.value.id, request.resourceId) === undefined) {
170
+ return refuse('not-found', `no resource ${JSON.stringify(request.resourceId)} in this project — omit resource for a project-level card`);
171
+ }
172
+ return await this.mutate(project.value.id, () => {
173
+ const { at: _at, ...result } = this.applyCardInputs(project.value.id, request.cards, request.resourceId);
174
+ return { ok: true, ...result };
175
+ });
176
+ }
177
+ /** Cards, filtered and sorted (the sidebar list, the coach's search). */
178
+ async listCards(request) {
179
+ this.assertAlive('listCards');
180
+ const now = new Date().toISOString();
181
+ let cards = this.store.readCards(request.projectId).filter(live);
182
+ if (request.includeMastered !== true)
183
+ cards = cards.filter(card => card.status === 'active');
184
+ if (request.resourceId !== undefined)
185
+ cards = cards.filter(card => card.resourceId === request.resourceId);
186
+ if (request.nodeId !== undefined)
187
+ cards = cards.filter(card => card.contexts.some(context => context.nodeId === request.nodeId));
188
+ if (request.kind !== undefined)
189
+ cards = cards.filter(card => card.kind === request.kind);
190
+ if (request.source !== undefined)
191
+ cards = cards.filter(card => card.sources.some(source => source.type === request.source));
192
+ if (request.weakMin !== undefined)
193
+ cards = cards.filter(card => card.weakness >= request.weakMin);
194
+ if (request.due === true)
195
+ cards = cards.filter(card => card.mastery.dueAt <= now);
196
+ if (request.query !== undefined && request.query.trim() !== '')
197
+ cards = cards.filter(card => cardMatchesQuery(card, request.query));
198
+ const sort = request.sort ?? 'weak';
199
+ cards.sort((a, b) => sort === 'recent'
200
+ ? b.updatedAt.localeCompare(a.updatedAt)
201
+ : sort === 'due'
202
+ ? a.mastery.dueAt.localeCompare(b.mastery.dueAt)
203
+ : b.weakness - a.weakness || a.mastery.dueAt.localeCompare(b.mastery.dueAt));
204
+ return { cards: cards.slice(0, request.limit ?? 200), total: cards.length };
205
+ }
206
+ /** The cards due for review across projects (or one), weakest first. */
207
+ async dueCards(request) {
208
+ this.assertAlive('dueCards');
209
+ const now = new Date().toISOString();
210
+ const projectIds = request.projectId !== undefined ? [request.projectId] : this.store.readIndex().projects.map(project => project.id);
211
+ const due = [];
212
+ for (const projectId of projectIds) {
213
+ for (const card of this.store.readCards(projectId)) {
214
+ if (card.status === 'active' && card.mastery.dueAt <= now)
215
+ due.push({ ...card, projectId });
216
+ }
217
+ }
218
+ due.sort((a, b) => a.mastery.box - b.mastery.box || b.weakness - a.weakness || a.mastery.dueAt.localeCompare(b.mastery.dueAt));
219
+ return { cards: due.slice(0, request.limit ?? 200) };
220
+ }
221
+ /** Edit a card by hand: its words, or its status (mastered / deleted / active). */
222
+ async editCard(request) {
223
+ this.assertAlive('editCard');
224
+ const project = this.readProjectOrRefusal(request.projectId);
225
+ if ('error' in project)
226
+ return project;
227
+ return await this.mutate(project.value.id, () => {
228
+ const cards = this.store.readCards(project.value.id);
229
+ const card = cards.find(candidate => candidate.id === request.cardId);
230
+ if (card === undefined)
231
+ return refuse('not-found', `no card ${JSON.stringify(request.cardId)}`);
232
+ const patch = request.patch;
233
+ if (patch.term !== undefined && patch.term.trim() === '')
234
+ return refuse('invalid', 'a card keeps a term');
235
+ if (patch.gloss !== undefined && patch.gloss.trim() === '')
236
+ return refuse('invalid', 'a card keeps a gloss');
237
+ const { resourceId: _old, ...rest } = card;
238
+ const next = {
239
+ ...(patch.resourceId === null ? rest : card),
240
+ ...(patch.term === undefined ? {} : { term: patch.term.trim() }),
241
+ ...(patch.gloss === undefined ? {} : { gloss: patch.gloss.trim() }),
242
+ ...(patch.userGloss === undefined ? {} : { userGloss: patch.userGloss.trim() }),
243
+ ...(patch.aliases === undefined ? {} : { aliases: patch.aliases.map(alias => alias.trim()).filter(alias => alias !== '') }),
244
+ ...(patch.status === undefined ? {} : { status: patch.status }),
245
+ ...(typeof patch.resourceId === 'string' ? { resourceId: patch.resourceId } : {}),
246
+ updatedAt: new Date().toISOString(),
247
+ };
248
+ this.store.writeCards(project.value.id, cards.map(candidate => candidate.id === card.id ? next : candidate));
249
+ this.event(project.value.id, 'card.edited', { id: card.id, title: next.term });
250
+ return { ok: true, card: next };
251
+ });
252
+ }
253
+ /**
254
+ * Undo one recording (the toast's 撤销): cards it created are deleted; cards
255
+ * it merged into lose the source it stacked (and the weakness it added).
256
+ */
257
+ async undoCards(request) {
258
+ this.assertAlive('undoCards');
259
+ const project = this.readProjectOrRefusal(request.projectId);
260
+ if ('error' in project)
261
+ return project;
262
+ return await this.mutate(project.value.id, () => {
263
+ const wanted = new Set(request.ids);
264
+ let removed = 0;
265
+ let reverted = 0;
266
+ const cards = this.store.readCards(project.value.id).map(card => {
267
+ if (!wanted.has(card.id))
268
+ return card;
269
+ if (card.createdAt === request.at) {
270
+ removed += 1;
271
+ return { ...card, status: 'deleted', updatedAt: new Date().toISOString() };
272
+ }
273
+ const dropped = card.sources.filter(source => source.at === request.at);
274
+ if (dropped.length === 0)
275
+ return card;
276
+ reverted += 1;
277
+ return {
278
+ ...card,
279
+ sources: card.sources.filter(source => source.at !== request.at),
280
+ weakness: clampWeakness(card.weakness - dropped.filter(source => source.weakened === true).length),
281
+ updatedAt: new Date().toISOString(),
282
+ };
283
+ });
284
+ this.store.writeCards(project.value.id, cards);
285
+ return { ok: true, removed, reverted };
286
+ });
287
+ }
288
+ /**
289
+ * One flashcard review (the daily 复习): only the schedule moves — forgot
290
+ * resets to box 0 (tomorrow), fuzzy keeps the box, recalled climbs one.
291
+ * Reviews never unlock nodes and never touch weakness.
292
+ */
293
+ async reviewCard(request) {
294
+ this.assertAlive('reviewCard');
295
+ if (!['forgot', 'fuzzy', 'recalled'].includes(request.rating))
296
+ return refuse('invalid', 'rating is forgot | fuzzy | recalled');
297
+ const project = this.readProjectOrRefusal(request.projectId);
298
+ if ('error' in project)
299
+ return project;
300
+ return await this.mutate(project.value.id, () => {
301
+ const cards = this.store.readCards(project.value.id);
302
+ const card = cards.find(candidate => candidate.id === request.cardId && live(candidate));
303
+ if (card === undefined)
304
+ return refuse('not-found', `no card ${JSON.stringify(request.cardId)}`);
305
+ const now = new Date();
306
+ const box = request.rating === 'forgot' ? 0 : request.rating === 'fuzzy' ? card.mastery.box : Math.min(card.mastery.box + 1, MAX_BOX);
307
+ const next = {
308
+ ...card,
309
+ mastery: {
310
+ box,
311
+ dueAt: leitnerDueAt(box, now),
312
+ streak: request.rating === 'recalled' ? card.mastery.streak + 1 : 0,
313
+ },
314
+ updatedAt: now.toISOString(),
315
+ };
316
+ this.store.writeCards(project.value.id, cards.map(candidate => candidate.id === card.id ? next : candidate));
317
+ return { ok: true, card: next };
318
+ });
319
+ }
320
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The library service core layer: the store, change listeners, disposal,
3
+ * the write boundary (`mutate`), the event ledger and the shared refusals.
4
+ *
5
+ * The service is ONE class split by domain into a chain of layers, each in
6
+ * its own file and each extending the one before:
7
+ *
8
+ * core → shelf (projects, resources) → cards → roadmap (paths, notes,
9
+ * 读完) → pages → assess (quizzes, attempts) → bindings (bindings, events,
10
+ * stats) = LibraryService
11
+ *
12
+ * A layer may call anything below it. The layering is organization only —
13
+ * `LibraryService` (service.ts re-exports it) is the one public class.
14
+ * Pure rules (shape validation, unlock, the evidence gate) live in
15
+ * `service/rules.ts`.
16
+ *
17
+ * @module @khorsheed/dsh-library/service/core
18
+ */
19
+ import type { Context } from '@deepseek-ai/cordis';
20
+ import { type LibraryAttachmentRef, type LibraryCapabilities, type LibraryCard, type LibraryErrorCode, type LibraryEvent, type LibraryEventType, type LibraryOutcome, type LibraryProject, type LibraryRefusal } from '../types.ts';
21
+ import { LibraryStore } from '../store.ts';
22
+ export declare const PACKAGE_NAME = "@khorsheed/dsh-library";
23
+ /** The attachment store's faces, mirrored structurally (probed, never injected). */
24
+ export interface AttachmentsSlice {
25
+ readImage(ref: LibraryAttachmentRef): Promise<{
26
+ data: Uint8Array;
27
+ ref: {
28
+ mediaType: string;
29
+ };
30
+ }>;
31
+ saveImage?(input: {
32
+ data: Uint8Array;
33
+ mediaType: string;
34
+ }): Promise<LibraryAttachmentRef>;
35
+ }
36
+ /**
37
+ * Thrown by every public verb after {@link LibraryService.dispose} ran (an
38
+ * explicit teardown, or the borrowed Context's own disposal): a coded,
39
+ * package-named refusal, never a silent operation on a dead Context. The
40
+ * disposed instance stays dead — a rebuild over the same state root is a NEW
41
+ * service instance's business (the canvas-novel/sidechat precedent).
42
+ */
43
+ export declare class LibraryDisposedError extends Error {
44
+ readonly code: 'disposed';
45
+ constructor(message: string, code: 'disposed');
46
+ }
47
+ /** A refusal in one line. */
48
+ export declare function refuse(error: LibraryErrorCode, message: string): LibraryRefusal;
49
+ /** Wrap a store failure as a refusal (the service's error channel is values). */
50
+ export declare function storeRefusal(error: unknown): LibraryRefusal;
51
+ /** The Leitner due date for a box: box b waits INTERVALS[min(b, 4)] days. */
52
+ export declare function leitnerDueAt(box: number, from?: Date): string;
53
+ /** Whitespace-normalize a quote before comparing it with a page's text. */
54
+ export declare function normalizeFragment(value: string): string;
55
+ /** Clamp a weakness score into 0..MAX. */
56
+ export declare function clampWeakness(value: number): number;
57
+ /** Live = not deleted. */
58
+ export declare function live(card: LibraryCard): boolean;
59
+ /**
60
+ * The library's service core. Constructing it never throws and never touches
61
+ * the disk: the store probes writability lazily, so a read-only deployment
62
+ * boots and answers `storage-unavailable` on writes. Disposal follows the
63
+ * borrowed Context: an instance mounted dynamically (per state root, say)
64
+ * dies with its mounting Context, and a dead instance refuses every verb
65
+ * coded, reads included.
66
+ */
67
+ export declare class LibraryServiceCore {
68
+ protected readonly ctx: Context;
69
+ /** The store; public for the package's own tests and diagnostics. */
70
+ readonly store: LibraryStore;
71
+ /** Listeners told after every project mutation (the tab's live refresh, the context cache). */
72
+ protected readonly listeners: Set<(projectId: string) => void>;
73
+ /** When this instance last wrote anything (a project created, an index edit — writes that log no event). */
74
+ protected lastChangeAt: string | undefined;
75
+ /** The newest session-made write (a library tool from a session): the client's cue to reveal the sidebar. */
76
+ protected lastSessionWrite: {
77
+ readonly session: string;
78
+ readonly at: string;
79
+ } | undefined;
80
+ /** Set by {@link dispose} (explicit, or the borrowed Context's teardown); every public verb refuses afterwards. */
81
+ protected disposed: boolean;
82
+ constructor(ctx: Context, config?: {
83
+ stateRoot?: string;
84
+ });
85
+ /**
86
+ * Refuse one public verb after disposal: the instance is dead and nothing
87
+ * it could still answer is meant to be consumed — reads included, so a
88
+ * mounting side never keeps consuming a stale instance indefinitely.
89
+ */
90
+ protected assertAlive(verb: string): void;
91
+ /**
92
+ * Tear the instance down (also wired to the borrowed Context's disposal at
93
+ * construction). Idempotent and TERMINAL: the listener set is dropped and
94
+ * afterwards every public verb refuses with {@link LibraryDisposedError};
95
+ * a write that already passed the write boundary may complete (the store's
96
+ * lock/temp-write/atomic-rename keeps the disk side safe), one reaching it
97
+ * after disposal refuses before lock acquisition. A rebuild over the same
98
+ * state root is a NEW instance's business.
99
+ */
100
+ dispose(): void;
101
+ /**
102
+ * What a client may rely on in this composition: storage availability with
103
+ * its reason, and the attachment probe's verdict at call time. A read-only
104
+ * display contract — enforcement stays at the write gates, unchanged.
105
+ */
106
+ capabilities(): Promise<LibraryCapabilities>;
107
+ /** Subscribe to project mutations; returns the unsubscribe. */
108
+ onChange(listener: (projectId: string) => void): () => void;
109
+ protected changed(projectId: string): void;
110
+ /** Run a project mutation under its lock, then notify. */
111
+ protected mutate<T>(projectId: string, fn: () => Promise<LibraryOutcome<T>> | LibraryOutcome<T>): Promise<LibraryOutcome<T>>;
112
+ /** A library tool from this session just wrote (kit.autoBind calls it after every successful write). */
113
+ noteSessionWrite(session: string): void;
114
+ protected event(projectId: string, type: LibraryEventType, fields?: Omit<LibraryEvent, 'type' | 'at'> & {
115
+ at?: string;
116
+ }): void;
117
+ /** The project, or a not-found refusal that teaches where the list lives. */
118
+ protected readProjectOrRefusal(projectId: string): {
119
+ value: LibraryProject;
120
+ } | (LibraryRefusal & {
121
+ error: LibraryErrorCode;
122
+ });
123
+ }
124
+ //# sourceMappingURL=core.d.ts.map
@@ -0,0 +1,169 @@
1
+ import { LEITNER_INTERVAL_DAYS, libraryErrorMessage, MAX_WEAKNESS, } from "../types.js";
2
+ import { LibraryStore, LibraryStoreError } from "../store.js";
3
+ export const PACKAGE_NAME = '@khorsheed/dsh-library';
4
+ /**
5
+ * Thrown by every public verb after {@link LibraryService.dispose} ran (an
6
+ * explicit teardown, or the borrowed Context's own disposal): a coded,
7
+ * package-named refusal, never a silent operation on a dead Context. The
8
+ * disposed instance stays dead — a rebuild over the same state root is a NEW
9
+ * service instance's business (the canvas-novel/sidechat precedent).
10
+ */
11
+ export class LibraryDisposedError extends Error {
12
+ code;
13
+ constructor(message, code) {
14
+ super(message);
15
+ this.code = code;
16
+ this.name = 'LibraryDisposedError';
17
+ }
18
+ }
19
+ /** A refusal in one line. */
20
+ export function refuse(error, message) {
21
+ return { ok: false, error, message };
22
+ }
23
+ /** Wrap a store failure as a refusal (the service's error channel is values). */
24
+ export function storeRefusal(error) {
25
+ if (error instanceof LibraryStoreError) {
26
+ const code = error.kind === 'corrupt' ? 'corrupt' : error.kind === 'unavailable' ? 'storage-unavailable' : 'io';
27
+ return refuse(code, error.message);
28
+ }
29
+ return refuse('io', `library: ${libraryErrorMessage(error)}`);
30
+ }
31
+ const DAY_MS = 24 * 60 * 60 * 1000;
32
+ /** The Leitner due date for a box: box b waits INTERVALS[min(b, 4)] days. */
33
+ export function leitnerDueAt(box, from = new Date()) {
34
+ const days = LEITNER_INTERVAL_DAYS[Math.min(Math.max(box, 0), LEITNER_INTERVAL_DAYS.length - 1)];
35
+ return new Date(from.getTime() + days * DAY_MS).toISOString();
36
+ }
37
+ /** Whitespace-normalize a quote before comparing it with a page's text. */
38
+ export function normalizeFragment(value) {
39
+ return value.normalize('NFKC').replace(/\s+/g, ' ').trim().toLowerCase();
40
+ }
41
+ /** Clamp a weakness score into 0..MAX. */
42
+ export function clampWeakness(value) {
43
+ return Math.min(Math.max(value, 0), MAX_WEAKNESS);
44
+ }
45
+ /** Live = not deleted. */
46
+ export function live(card) {
47
+ return card.status !== 'deleted';
48
+ }
49
+ /**
50
+ * The library's service core. Constructing it never throws and never touches
51
+ * the disk: the store probes writability lazily, so a read-only deployment
52
+ * boots and answers `storage-unavailable` on writes. Disposal follows the
53
+ * borrowed Context: an instance mounted dynamically (per state root, say)
54
+ * dies with its mounting Context, and a dead instance refuses every verb
55
+ * coded, reads included.
56
+ */
57
+ export class LibraryServiceCore {
58
+ ctx;
59
+ /** The store; public for the package's own tests and diagnostics. */
60
+ store;
61
+ /** Listeners told after every project mutation (the tab's live refresh, the context cache). */
62
+ listeners = new Set();
63
+ /** When this instance last wrote anything (a project created, an index edit — writes that log no event). */
64
+ lastChangeAt;
65
+ /** The newest session-made write (a library tool from a session): the client's cue to reveal the sidebar. */
66
+ lastSessionWrite;
67
+ /** Set by {@link dispose} (explicit, or the borrowed Context's teardown); every public verb refuses afterwards. */
68
+ disposed = false;
69
+ constructor(ctx, config = {}) {
70
+ this.ctx = ctx;
71
+ this.store = new LibraryStore(config);
72
+ // The borrowed Context's disposal IS this instance's disposal: an
73
+ // instance mounted per state root dies with its mounting Context instead
74
+ // of answering verbs off a dead scope.
75
+ ctx.effect(() => () => { this.dispose(); }, 'library: dispose on context teardown');
76
+ }
77
+ /**
78
+ * Refuse one public verb after disposal: the instance is dead and nothing
79
+ * it could still answer is meant to be consumed — reads included, so a
80
+ * mounting side never keeps consuming a stale instance indefinitely.
81
+ */
82
+ assertAlive(verb) {
83
+ if (this.disposed)
84
+ 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 === undefined ? {} : { storageUnavailableReason: this.store.unavailableReason }),
110
+ hasAttachments: this.ctx.get('attachments') !== undefined,
111
+ };
112
+ }
113
+ /** Subscribe to project mutations; returns the unsubscribe. */
114
+ onChange(listener) {
115
+ this.assertAlive('onChange');
116
+ this.listeners.add(listener);
117
+ return () => { this.listeners.delete(listener); };
118
+ }
119
+ changed(projectId) {
120
+ this.lastChangeAt = new Date().toISOString();
121
+ for (const listener of this.listeners) {
122
+ try {
123
+ listener(projectId);
124
+ }
125
+ catch {
126
+ // A listener's failure is its own.
127
+ }
128
+ }
129
+ }
130
+ /** Run a project mutation under its lock, then notify. */
131
+ async mutate(projectId, fn) {
132
+ // The write boundary: a disposal that landed since the entry guard dies
133
+ // here, BEFORE the lock is taken — never queuing onto a lock a rebuilt
134
+ // instance on the same root now owns (kept outside the try, so the coded
135
+ // refusal propagates as itself rather than folding into a store refusal).
136
+ this.assertAlive('mutate');
137
+ try {
138
+ const outcome = await this.store.withProjectLock(projectId, fn);
139
+ if (outcome.ok)
140
+ this.changed(projectId);
141
+ return outcome;
142
+ }
143
+ catch (error) {
144
+ return storeRefusal(error);
145
+ }
146
+ }
147
+ /** A library tool from this session just wrote (kit.autoBind calls it after every successful write). */
148
+ noteSessionWrite(session) {
149
+ this.lastSessionWrite = { session, at: new Date().toISOString() };
150
+ }
151
+ event(projectId, type, fields = {}) {
152
+ this.store.appendEvents(projectId, [{ at: fields.at ?? new Date().toISOString(), type, ...fields }]);
153
+ }
154
+ /* -------------------------------------------------------------- helpers */
155
+ /** The project, or a not-found refusal that teaches where the list lives. */
156
+ readProjectOrRefusal(projectId) {
157
+ let project;
158
+ try {
159
+ project = this.store.readProject(projectId);
160
+ }
161
+ catch (error) {
162
+ return storeRefusal(error);
163
+ }
164
+ if (project === undefined) {
165
+ return refuse('not-found', `no project ${JSON.stringify(projectId)} — library_read · projects lists every project; create one with library_project`);
166
+ }
167
+ return { value: project };
168
+ }
169
+ }
@@ -0,0 +1,60 @@
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 { type LibraryAttachmentRef, type LibraryCardWriteResult, type LibraryMark, type LibraryOutcome, type LibraryPage } from '../types.ts';
10
+ import { LibraryServiceRoadmap } from './roadmap.ts';
11
+ export declare class LibraryServicePages extends LibraryServiceRoadmap {
12
+ /**
13
+ * Record one photographed page and card every mark at once (「一键全收」).
14
+ * Uncertain marks are kept on the page but never carded — the learner
15
+ * decides them. Each card's gloss is the mark's recognized gloss, else the
16
+ * learner's handwriting, else a placeholder the learner can fill in.
17
+ */
18
+ recordPage(request: {
19
+ projectId: string;
20
+ resourceId: string;
21
+ nodeId?: string;
22
+ label?: string;
23
+ attachment: LibraryAttachmentRef;
24
+ text: string;
25
+ marks: readonly LibraryMark[];
26
+ }): Promise<LibraryOutcome<{
27
+ page: LibraryPage;
28
+ cards: LibraryCardWriteResult;
29
+ uncertain: string[];
30
+ }>>;
31
+ /** One resource's pages, optionally one node's. */
32
+ listPages(request: {
33
+ projectId: string;
34
+ resourceId: string;
35
+ nodeId?: string;
36
+ }): Promise<{
37
+ pages: LibraryPage[];
38
+ }>;
39
+ /** Find one page anywhere in the project. */
40
+ protected findPage(projectId: string, pageId: string): LibraryPage | undefined;
41
+ /**
42
+ * One page's photo, as base64 — whitelisted: the page must be one this
43
+ * project recorded, so the Remote never reads an arbitrary attachment.
44
+ */
45
+ readPagePhoto(request: {
46
+ projectId: string;
47
+ pageId: string;
48
+ }): Promise<LibraryOutcome<{
49
+ data: string;
50
+ mediaType: string;
51
+ }>>;
52
+ /** Save a browser-uploaded photo into the host attachment store (the sidebar's 拍照). */
53
+ savePhoto(request: {
54
+ data: string;
55
+ mediaType: string;
56
+ }): Promise<LibraryOutcome<{
57
+ attachment: LibraryAttachmentRef;
58
+ }>>;
59
+ }
60
+ //# sourceMappingURL=pages.d.ts.map