@vue-skuilder/db 0.1.18 → 0.1.21
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/CLAUDE.md +2 -2
- package/dist/{classroomDB-BgfrVb8d.d.ts → contentSource-BP9hznNV.d.ts} +220 -197
- package/dist/{classroomDB-CTOenngH.d.cts → contentSource-DsJadoBU.d.cts} +220 -197
- package/dist/core/index.d.cts +80 -6
- package/dist/core/index.d.ts +80 -6
- package/dist/core/index.js +735 -1560
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +708 -1539
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-D6PoCwS6.d.cts → dataLayerProvider-CHYrQ5pB.d.cts} +1 -1
- package/dist/{dataLayerProvider-CZxC9GtB.d.ts → dataLayerProvider-MDTxXq2l.d.ts} +1 -1
- package/dist/impl/couch/index.d.cts +8 -23
- package/dist/impl/couch/index.d.ts +8 -23
- package/dist/impl/couch/index.js +723 -1578
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +692 -1552
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.cts +25 -8
- package/dist/impl/static/index.d.ts +25 -8
- package/dist/impl/static/index.js +700 -1400
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +688 -1393
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/{index-D-Fa4Smt.d.cts → index-B_j6u5E4.d.cts} +1 -1
- package/dist/{index-CD8BZz2k.d.ts → index-Dj0SEgk3.d.ts} +1 -1
- package/dist/index.d.cts +71 -63
- package/dist/index.d.ts +71 -63
- package/dist/index.js +1162 -1996
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1124 -1955
- package/dist/index.mjs.map +1 -1
- package/dist/pouch/index.js +3 -0
- package/dist/pouch/index.js.map +1 -1
- package/dist/pouch/index.mjs +3 -0
- package/dist/pouch/index.mjs.map +1 -1
- package/dist/{types-CzPDLAK6.d.cts → types-Bn0itutr.d.cts} +1 -1
- package/dist/{types-CewsN87z.d.ts → types-DQaXnuoc.d.ts} +1 -1
- package/dist/{types-legacy-6ettoclI.d.cts → types-legacy-DDY4N-Uq.d.cts} +3 -1
- package/dist/{types-legacy-6ettoclI.d.ts → types-legacy-DDY4N-Uq.d.ts} +3 -1
- package/dist/util/packer/index.d.cts +3 -3
- package/dist/util/packer/index.d.ts +3 -3
- package/docs/navigators-architecture.md +115 -17
- package/package.json +4 -4
- package/src/core/index.ts +1 -0
- package/src/core/interfaces/classroomDB.ts +5 -13
- package/src/core/interfaces/contentSource.ts +6 -66
- package/src/core/interfaces/courseDB.ts +15 -7
- package/src/core/interfaces/userDB.ts +32 -0
- package/src/core/navigators/Pipeline.ts +136 -52
- package/src/core/navigators/PipelineAssembler.ts +1 -1
- package/src/core/navigators/defaults.ts +84 -0
- package/src/core/navigators/{hierarchyDefinition.ts → filters/hierarchyDefinition.ts} +15 -29
- package/src/core/navigators/filters/index.ts +3 -0
- package/src/core/navigators/filters/inferredPreferenceStub.ts +107 -0
- package/src/core/navigators/{interferenceMitigator.ts → filters/interferenceMitigator.ts} +11 -37
- package/src/core/navigators/{relativePriority.ts → filters/relativePriority.ts} +12 -38
- package/src/core/navigators/filters/userGoalStub.ts +136 -0
- package/src/core/navigators/filters/userTagPreference.ts +217 -0
- package/src/core/navigators/{CompositeGenerator.ts → generators/CompositeGenerator.ts} +15 -64
- package/src/core/navigators/{elo.ts → generators/elo.ts} +13 -63
- package/src/core/navigators/{srs.ts → generators/srs.ts} +11 -40
- package/src/core/navigators/generators/types.ts +1 -1
- package/src/core/navigators/index.ts +95 -91
- package/src/core/types/strategyState.ts +84 -0
- package/src/core/types/types-legacy.ts +2 -0
- package/src/impl/common/BaseUserDB.ts +74 -7
- package/src/impl/couch/adminDB.ts +1 -2
- package/src/impl/couch/classroomDB.ts +100 -103
- package/src/impl/couch/courseDB.ts +35 -91
- package/src/impl/couch/pouchdb-setup.ts +7 -0
- package/src/impl/static/StaticDataUnpacker.ts +50 -1
- package/src/impl/static/courseDB.ts +87 -37
- package/src/study/SessionController.ts +122 -202
- package/src/study/SourceMixer.ts +65 -0
- package/src/study/TagFilteredContentSource.ts +49 -92
- package/src/study/index.ts +1 -0
- package/src/study/services/CardHydrationService.ts +165 -81
- package/src/util/dataDirectory.ts +1 -1
- package/src/util/index.ts +0 -1
- package/tests/core/navigators/CompositeGenerator.test.ts +44 -168
- package/tests/core/navigators/Pipeline.test.ts +6 -72
- package/tests/core/navigators/PipelineAssembler.test.ts +8 -58
- package/tests/core/navigators/navigators.test.ts +118 -151
- package/docs/todo-pipeline-optimization.md +0 -117
- package/docs/todo-strategy-state-storage.md +0 -278
- package/src/core/navigators/hardcodedOrder.ts +0 -163
- package/src/util/tuiLogger.ts +0 -139
|
@@ -1,11 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
StudyContentSource,
|
|
3
|
-
StudySessionNewItem,
|
|
4
|
-
StudySessionReviewItem,
|
|
5
|
-
} from '@db/core/interfaces/contentSource';
|
|
1
|
+
import { StudyContentSource } from '@db/core/interfaces/contentSource';
|
|
6
2
|
import { WeightedCard } from '@db/core/navigators';
|
|
7
3
|
import { UserDBInterface } from '@db/core';
|
|
8
|
-
import { ScheduledCard } from '@db/core/types/user';
|
|
9
4
|
import { TagFilter, hasActiveFilter } from '@vue-skuilder/common';
|
|
10
5
|
import { getTag } from '../impl/couch/courseDB';
|
|
11
6
|
import { logger } from '@db/util/logger';
|
|
@@ -113,122 +108,84 @@ export class TagFilteredContentSource implements StudyContentSource {
|
|
|
113
108
|
}
|
|
114
109
|
|
|
115
110
|
/**
|
|
116
|
-
*
|
|
111
|
+
* Get cards with suitability scores for presentation.
|
|
112
|
+
*
|
|
113
|
+
* Filters cards by tag inclusion/exclusion and assigns score=1.0 to all.
|
|
114
|
+
* TagFilteredContentSource does not currently support pluggable navigation
|
|
115
|
+
* strategies - it returns flat-scored candidates.
|
|
116
|
+
*
|
|
117
|
+
* @param limit - Maximum number of cards to return
|
|
118
|
+
* @returns Cards sorted by score descending (all scores = 1.0)
|
|
117
119
|
*/
|
|
118
|
-
public async
|
|
120
|
+
public async getWeightedCards(limit: number): Promise<WeightedCard[]> {
|
|
119
121
|
if (!hasActiveFilter(this.filter)) {
|
|
120
|
-
logger.warn('[TagFilteredContentSource]
|
|
122
|
+
logger.warn('[TagFilteredContentSource] getWeightedCards called with no active filter');
|
|
121
123
|
return [];
|
|
122
124
|
}
|
|
123
125
|
|
|
124
126
|
const eligibleCardIds = await this.resolveFilteredCardIds();
|
|
127
|
+
|
|
128
|
+
// Get new cards: eligible cards that are not already active
|
|
125
129
|
const activeCards = await this.user.getActiveCards();
|
|
126
130
|
const activeCardIds = new Set(activeCards.map((c) => c.cardID));
|
|
127
131
|
|
|
128
|
-
const
|
|
132
|
+
const newCardWeighted: WeightedCard[] = [];
|
|
129
133
|
for (const cardId of eligibleCardIds) {
|
|
130
134
|
if (!activeCardIds.has(cardId)) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
135
|
+
newCardWeighted.push({
|
|
136
|
+
cardId,
|
|
137
|
+
courseId: this.courseId,
|
|
138
|
+
score: 1.0,
|
|
139
|
+
provenance: [
|
|
140
|
+
{
|
|
141
|
+
strategy: 'tagFilter',
|
|
142
|
+
strategyName: 'Tag Filter',
|
|
143
|
+
strategyId: 'TAG_FILTER',
|
|
144
|
+
action: 'generated' as const,
|
|
145
|
+
score: 1.0,
|
|
146
|
+
reason: `Tag-filtered new card (tags: ${this.filter.include.join(', ')})`,
|
|
147
|
+
},
|
|
148
|
+
],
|
|
137
149
|
});
|
|
138
150
|
}
|
|
139
151
|
|
|
140
|
-
if (
|
|
152
|
+
if (newCardWeighted.length >= limit) {
|
|
141
153
|
break;
|
|
142
154
|
}
|
|
143
155
|
}
|
|
144
156
|
|
|
145
|
-
logger.info(
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Gets pending reviews, filtered to only include cards that match the tag filter.
|
|
151
|
-
*/
|
|
152
|
-
public async getPendingReviews(): Promise<(StudySessionReviewItem & ScheduledCard)[]> {
|
|
153
|
-
if (!hasActiveFilter(this.filter)) {
|
|
154
|
-
logger.warn('[TagFilteredContentSource] getPendingReviews called with no active filter');
|
|
155
|
-
return [];
|
|
156
|
-
}
|
|
157
|
+
logger.info(
|
|
158
|
+
`[TagFilteredContentSource] Found ${newCardWeighted.length} new cards matching filter`
|
|
159
|
+
);
|
|
157
160
|
|
|
158
|
-
|
|
161
|
+
// Get pending reviews: reviews for cards in the eligible set
|
|
159
162
|
const allReviews = await this.user.getPendingReviews(this.courseId);
|
|
160
|
-
|
|
161
|
-
const filteredReviews = allReviews.filter((review) => {
|
|
162
|
-
return eligibleCardIds.has(review.cardId);
|
|
163
|
-
});
|
|
163
|
+
const filteredReviews = allReviews.filter((review) => eligibleCardIds.has(review.cardId));
|
|
164
164
|
|
|
165
165
|
logger.info(
|
|
166
166
|
`[TagFilteredContentSource] Found ${filteredReviews.length} pending reviews matching filter ` +
|
|
167
167
|
`(of ${allReviews.length} total)`
|
|
168
168
|
);
|
|
169
169
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
contentSourceType: 'course' as const,
|
|
175
|
-
contentSourceID: this.courseId,
|
|
170
|
+
const reviewWeighted: WeightedCard[] = filteredReviews.map((r) => ({
|
|
171
|
+
cardId: r.cardId,
|
|
172
|
+
courseId: r.courseId,
|
|
173
|
+
score: 1.0,
|
|
176
174
|
reviewID: r._id,
|
|
177
|
-
|
|
175
|
+
provenance: [
|
|
176
|
+
{
|
|
177
|
+
strategy: 'tagFilter',
|
|
178
|
+
strategyName: 'Tag Filter',
|
|
179
|
+
strategyId: 'TAG_FILTER',
|
|
180
|
+
action: 'generated' as const,
|
|
181
|
+
score: 1.0,
|
|
182
|
+
reason: `Tag-filtered review (tags: ${this.filter.include.join(', ')})`,
|
|
183
|
+
},
|
|
184
|
+
],
|
|
178
185
|
}));
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Get cards with suitability scores for presentation.
|
|
183
|
-
*
|
|
184
|
-
* This implementation wraps the legacy getNewCards/getPendingReviews methods,
|
|
185
|
-
* assigning score=1.0 to all cards. TagFilteredContentSource does not currently
|
|
186
|
-
* support pluggable navigation strategies - it returns flat-scored candidates.
|
|
187
|
-
*
|
|
188
|
-
* @param limit - Maximum number of cards to return
|
|
189
|
-
* @returns Cards sorted by score descending (all scores = 1.0)
|
|
190
|
-
*/
|
|
191
|
-
public async getWeightedCards(limit: number): Promise<WeightedCard[]> {
|
|
192
|
-
const [newCards, reviews] = await Promise.all([
|
|
193
|
-
this.getNewCards(limit),
|
|
194
|
-
this.getPendingReviews(),
|
|
195
|
-
]);
|
|
196
|
-
|
|
197
|
-
const weighted: WeightedCard[] = [
|
|
198
|
-
...reviews.map((r) => ({
|
|
199
|
-
cardId: r.cardID,
|
|
200
|
-
courseId: r.courseID,
|
|
201
|
-
score: 1.0,
|
|
202
|
-
provenance: [
|
|
203
|
-
{
|
|
204
|
-
strategy: 'tagFilter',
|
|
205
|
-
strategyName: 'Tag Filter',
|
|
206
|
-
strategyId: 'TAG_FILTER',
|
|
207
|
-
action: 'generated' as const,
|
|
208
|
-
score: 1.0,
|
|
209
|
-
reason: `Tag-filtered review (tags: ${this.filter.include.join(', ')})`,
|
|
210
|
-
},
|
|
211
|
-
],
|
|
212
|
-
})),
|
|
213
|
-
...newCards.map((c) => ({
|
|
214
|
-
cardId: c.cardID,
|
|
215
|
-
courseId: c.courseID,
|
|
216
|
-
score: 1.0,
|
|
217
|
-
provenance: [
|
|
218
|
-
{
|
|
219
|
-
strategy: 'tagFilter',
|
|
220
|
-
strategyName: 'Tag Filter',
|
|
221
|
-
strategyId: 'TAG_FILTER',
|
|
222
|
-
action: 'generated' as const,
|
|
223
|
-
score: 1.0,
|
|
224
|
-
reason: `Tag-filtered new card (tags: ${this.filter.include.join(', ')})`,
|
|
225
|
-
},
|
|
226
|
-
],
|
|
227
|
-
})),
|
|
228
|
-
];
|
|
229
186
|
|
|
230
187
|
// Reviews first, then new cards; respect limit
|
|
231
|
-
return
|
|
188
|
+
return [...reviewWeighted, ...newCardWeighted].slice(0, limit);
|
|
232
189
|
}
|
|
233
190
|
|
|
234
191
|
/**
|
package/src/study/index.ts
CHANGED
|
@@ -7,89 +7,156 @@ import {
|
|
|
7
7
|
} from '@vue-skuilder/common';
|
|
8
8
|
import { StudySessionItem } from '@db/impl/couch';
|
|
9
9
|
import { logger } from '@db/util/logger';
|
|
10
|
-
import { ItemQueue } from '../ItemQueue';
|
|
11
10
|
import { CourseDBInterface } from '@db/core';
|
|
12
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Extract audio URLs from arbitrary field data using heuristic pattern matching.
|
|
14
|
+
* This is a "worse is better" approach - catches obvious URLs, silently ignores edge cases.
|
|
15
|
+
*/
|
|
16
|
+
function parseAudioURIs(data: unknown): string[] {
|
|
17
|
+
if (typeof data !== 'string') return [];
|
|
18
|
+
|
|
19
|
+
// Match URLs ending in common audio extensions
|
|
20
|
+
const audioPattern = /https?:\/\/[^\s"'<>]+\.(wav|mp3|ogg|m4a|aac|webm)/gi;
|
|
21
|
+
return data.match(audioPattern) ?? [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Prefetch an audio file by loading it into browser cache.
|
|
26
|
+
* Resolves when the audio is ready to play (or on error, to avoid blocking).
|
|
27
|
+
*/
|
|
28
|
+
function prefetchAudio(url: string): Promise<void> {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
const audio = new Audio();
|
|
31
|
+
audio.preload = 'auto';
|
|
32
|
+
|
|
33
|
+
const cleanup = () => {
|
|
34
|
+
audio.oncanplaythrough = null;
|
|
35
|
+
audio.onerror = null;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
audio.oncanplaythrough = () => {
|
|
39
|
+
cleanup();
|
|
40
|
+
resolve();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
audio.onerror = () => {
|
|
44
|
+
cleanup();
|
|
45
|
+
logger.warn(`[CardHydrationService] Failed to prefetch audio: ${url}`);
|
|
46
|
+
resolve(); // Don't block hydration on failed prefetch
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
audio.src = url;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
13
53
|
export interface HydratedCard<TView = unknown> {
|
|
14
54
|
item: StudySessionItem;
|
|
15
55
|
view: TView;
|
|
16
56
|
data: any[];
|
|
17
57
|
}
|
|
18
58
|
|
|
19
|
-
// ItemQueue now imported from separate file
|
|
20
|
-
|
|
21
59
|
/**
|
|
22
|
-
* Service responsible for managing
|
|
23
|
-
*
|
|
60
|
+
* Service responsible for managing hydrated (ready-to-display) cards.
|
|
61
|
+
* Uses a Map-based cache for direct ID lookup - no ordering assumptions.
|
|
62
|
+
* SessionController owns all ordering decisions.
|
|
24
63
|
*/
|
|
25
64
|
export class CardHydrationService<TView = unknown> {
|
|
26
|
-
private
|
|
27
|
-
private
|
|
65
|
+
private hydratedCards: Map<string, HydratedCard<TView>> = new Map();
|
|
66
|
+
private hydrationInFlight: Set<string> = new Set();
|
|
28
67
|
private hydrationInProgress: boolean = false;
|
|
29
|
-
private readonly BUFFER_SIZE = 5;
|
|
30
68
|
|
|
31
69
|
constructor(
|
|
32
70
|
private getViewComponent: (viewId: string) => TView,
|
|
33
71
|
private getCourseDB: (courseId: string) => CourseDBInterface,
|
|
34
|
-
private
|
|
35
|
-
private removeItemFromQueue: (item: StudySessionItem) => void,
|
|
36
|
-
private hasAvailableCards: () => boolean
|
|
72
|
+
private getItemsToHydrate: () => StudySessionItem[]
|
|
37
73
|
) {}
|
|
38
74
|
|
|
39
75
|
/**
|
|
40
|
-
* Get
|
|
41
|
-
* @returns Hydrated card or null if
|
|
76
|
+
* Get a hydrated card by ID.
|
|
77
|
+
* @returns Hydrated card or null if not in cache
|
|
42
78
|
*/
|
|
43
|
-
public
|
|
44
|
-
return this.
|
|
79
|
+
public getHydratedCard(cardId: string): HydratedCard<TView> | null {
|
|
80
|
+
return this.hydratedCards.get(cardId) ?? null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Check if a card is hydrated.
|
|
85
|
+
*/
|
|
86
|
+
public hasHydratedCard(cardId: string): boolean {
|
|
87
|
+
return this.hydratedCards.has(cardId);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Remove a card from the cache (call on successful dismiss to free memory).
|
|
92
|
+
*/
|
|
93
|
+
public removeCard(cardId: string): void {
|
|
94
|
+
this.hydratedCards.delete(cardId);
|
|
45
95
|
}
|
|
46
96
|
|
|
47
97
|
/**
|
|
48
98
|
* Check if hydration should be triggered and start background hydration if needed.
|
|
49
99
|
*/
|
|
50
100
|
public async ensureHydratedCards(): Promise<void> {
|
|
51
|
-
|
|
52
|
-
if (this.hydratedQ.length < 3) {
|
|
53
|
-
void this.fillHydratedQueue();
|
|
54
|
-
}
|
|
101
|
+
void this.fillHydratedCards();
|
|
55
102
|
}
|
|
56
103
|
|
|
57
104
|
/**
|
|
58
|
-
* Wait for a
|
|
105
|
+
* Wait for a specific card to become hydrated.
|
|
59
106
|
* @returns Promise that resolves to a hydrated card or null
|
|
60
107
|
*/
|
|
61
|
-
public async
|
|
62
|
-
// If
|
|
63
|
-
if (this.
|
|
64
|
-
|
|
108
|
+
public async waitForCard(cardId: string): Promise<HydratedCard<TView> | null> {
|
|
109
|
+
// If already hydrated, return immediately
|
|
110
|
+
if (this.hydratedCards.has(cardId)) {
|
|
111
|
+
return this.hydratedCards.get(cardId)!;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Start hydration if not already in progress
|
|
115
|
+
if (!this.hydrationInProgress) {
|
|
116
|
+
void this.fillHydratedCards();
|
|
65
117
|
}
|
|
66
118
|
|
|
67
|
-
// Wait for
|
|
68
|
-
|
|
69
|
-
|
|
119
|
+
// Wait for the specific card to become available
|
|
120
|
+
const maxWaitMs = 10000; // 10 second timeout
|
|
121
|
+
const pollIntervalMs = 25;
|
|
122
|
+
let elapsed = 0;
|
|
123
|
+
|
|
124
|
+
while (elapsed < maxWaitMs) {
|
|
125
|
+
if (this.hydratedCards.has(cardId)) {
|
|
126
|
+
return this.hydratedCards.get(cardId)!;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// If the card is not in flight and not hydrated, it may have failed
|
|
130
|
+
if (!this.hydrationInFlight.has(cardId) && !this.hydrationInProgress) {
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
135
|
+
elapsed += pollIntervalMs;
|
|
70
136
|
}
|
|
71
137
|
|
|
72
|
-
return this.
|
|
138
|
+
return this.hydratedCards.get(cardId) ?? null;
|
|
73
139
|
}
|
|
74
140
|
|
|
75
141
|
/**
|
|
76
|
-
* Get current hydrated
|
|
142
|
+
* Get current hydrated cache size.
|
|
77
143
|
*/
|
|
78
144
|
public get hydratedCount(): number {
|
|
79
|
-
return this.
|
|
145
|
+
return this.hydratedCards.size;
|
|
80
146
|
}
|
|
81
147
|
|
|
82
148
|
/**
|
|
83
|
-
* Get
|
|
149
|
+
* Get list of currently hydrated card IDs (for debugging).
|
|
84
150
|
*/
|
|
85
|
-
public
|
|
86
|
-
return this.
|
|
151
|
+
public getHydratedCardIds(): string[] {
|
|
152
|
+
return Array.from(this.hydratedCards.keys());
|
|
87
153
|
}
|
|
88
154
|
|
|
89
155
|
/**
|
|
90
|
-
* Fill the hydrated
|
|
156
|
+
* Fill the hydrated cache by hydrating items from getItemsToHydrate().
|
|
157
|
+
* This is a pure cache-warming operation - no queue mutation.
|
|
91
158
|
*/
|
|
92
|
-
private async
|
|
159
|
+
private async fillHydratedCards(): Promise<void> {
|
|
93
160
|
if (this.hydrationInProgress) {
|
|
94
161
|
return; // Prevent concurrent hydration
|
|
95
162
|
}
|
|
@@ -97,53 +164,18 @@ export class CardHydrationService<TView = unknown> {
|
|
|
97
164
|
this.hydrationInProgress = true;
|
|
98
165
|
|
|
99
166
|
try {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
167
|
+
const itemsToHydrate = this.getItemsToHydrate();
|
|
168
|
+
|
|
169
|
+
for (const item of itemsToHydrate) {
|
|
170
|
+
// Skip if already hydrated or in flight
|
|
171
|
+
if (this.hydratedCards.has(item.cardID) || this.hydrationInFlight.has(item.cardID)) {
|
|
172
|
+
continue;
|
|
104
173
|
}
|
|
105
174
|
|
|
106
175
|
try {
|
|
107
|
-
|
|
108
|
-
if (this.failedCardCache.has(nextItem.cardID)) {
|
|
109
|
-
const cachedCard = this.failedCardCache.get(nextItem.cardID)!;
|
|
110
|
-
this.hydratedQ.add(cachedCard, cachedCard.item.cardID);
|
|
111
|
-
this.failedCardCache.delete(nextItem.cardID);
|
|
112
|
-
} else {
|
|
113
|
-
// Hydrate new card using original logic pattern
|
|
114
|
-
const courseDB = this.getCourseDB(nextItem.courseID);
|
|
115
|
-
const cardData = await courseDB.getCourseDoc<CardData>(nextItem.cardID);
|
|
116
|
-
|
|
117
|
-
if (!isCourseElo(cardData.elo)) {
|
|
118
|
-
cardData.elo = toCourseElo(cardData.elo);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const view = this.getViewComponent(cardData.id_view);
|
|
122
|
-
const dataDocs = await Promise.all(
|
|
123
|
-
cardData.id_displayable_data.map((id: string) =>
|
|
124
|
-
courseDB.getCourseDoc<DisplayableData>(id, {
|
|
125
|
-
attachments: true,
|
|
126
|
-
binary: true,
|
|
127
|
-
})
|
|
128
|
-
)
|
|
129
|
-
);
|
|
130
|
-
|
|
131
|
-
const data = dataDocs.map(displayableDataToViewData).reverse();
|
|
132
|
-
|
|
133
|
-
this.hydratedQ.add(
|
|
134
|
-
{
|
|
135
|
-
item: nextItem,
|
|
136
|
-
view,
|
|
137
|
-
data,
|
|
138
|
-
},
|
|
139
|
-
nextItem.cardID
|
|
140
|
-
);
|
|
141
|
-
}
|
|
176
|
+
await this.hydrateCard(item);
|
|
142
177
|
} catch (e) {
|
|
143
|
-
logger.error(`Error hydrating card ${
|
|
144
|
-
} finally {
|
|
145
|
-
// Remove the item from the original queue, regardless of success/failure/cache
|
|
146
|
-
this.removeItemFromQueue(nextItem);
|
|
178
|
+
logger.error(`[CardHydrationService] Error hydrating card ${item.cardID}:`, e);
|
|
147
179
|
}
|
|
148
180
|
}
|
|
149
181
|
} finally {
|
|
@@ -152,9 +184,61 @@ export class CardHydrationService<TView = unknown> {
|
|
|
152
184
|
}
|
|
153
185
|
|
|
154
186
|
/**
|
|
155
|
-
*
|
|
187
|
+
* Hydrate a single card and add to cache.
|
|
156
188
|
*/
|
|
157
|
-
|
|
158
|
-
this.
|
|
189
|
+
private async hydrateCard(item: StudySessionItem): Promise<void> {
|
|
190
|
+
if (this.hydratedCards.has(item.cardID) || this.hydrationInFlight.has(item.cardID)) {
|
|
191
|
+
return; // Already hydrated or in progress
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
this.hydrationInFlight.add(item.cardID);
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const courseDB = this.getCourseDB(item.courseID);
|
|
198
|
+
const cardData = await courseDB.getCourseDoc<CardData>(item.cardID);
|
|
199
|
+
|
|
200
|
+
if (!isCourseElo(cardData.elo)) {
|
|
201
|
+
cardData.elo = toCourseElo(cardData.elo);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const view = this.getViewComponent(cardData.id_view);
|
|
205
|
+
const dataDocs = await Promise.all(
|
|
206
|
+
cardData.id_displayable_data.map((id: string) =>
|
|
207
|
+
courseDB.getCourseDoc<DisplayableData>(id, {
|
|
208
|
+
attachments: true,
|
|
209
|
+
binary: true,
|
|
210
|
+
})
|
|
211
|
+
)
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// Extract audio URLs from all data fields and prefetch them
|
|
215
|
+
const audioToPrefetch: string[] = [];
|
|
216
|
+
dataDocs.forEach((dd) => {
|
|
217
|
+
dd.data.forEach((f) => {
|
|
218
|
+
audioToPrefetch.push(...parseAudioURIs(f.data));
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Dedupe and prefetch, waiting for browser cache to be ready
|
|
223
|
+
const uniqueAudioUrls = [...new Set(audioToPrefetch)];
|
|
224
|
+
if (uniqueAudioUrls.length > 0) {
|
|
225
|
+
logger.debug(
|
|
226
|
+
`[CardHydrationService] Prefetching ${uniqueAudioUrls.length} audio files for card ${item.cardID}`
|
|
227
|
+
);
|
|
228
|
+
await Promise.allSettled(uniqueAudioUrls.map(prefetchAudio));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const data = dataDocs.map(displayableDataToViewData).reverse();
|
|
232
|
+
|
|
233
|
+
this.hydratedCards.set(item.cardID, {
|
|
234
|
+
item,
|
|
235
|
+
view,
|
|
236
|
+
data,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
logger.debug(`[CardHydrationService] Hydrated card ${item.cardID}`);
|
|
240
|
+
} finally {
|
|
241
|
+
this.hydrationInFlight.delete(item.cardID);
|
|
242
|
+
}
|
|
159
243
|
}
|
|
160
244
|
}
|
package/src/util/index.ts
CHANGED