@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.
Files changed (87) hide show
  1. package/CLAUDE.md +2 -2
  2. package/dist/{classroomDB-BgfrVb8d.d.ts → contentSource-BP9hznNV.d.ts} +220 -197
  3. package/dist/{classroomDB-CTOenngH.d.cts → contentSource-DsJadoBU.d.cts} +220 -197
  4. package/dist/core/index.d.cts +80 -6
  5. package/dist/core/index.d.ts +80 -6
  6. package/dist/core/index.js +735 -1560
  7. package/dist/core/index.js.map +1 -1
  8. package/dist/core/index.mjs +708 -1539
  9. package/dist/core/index.mjs.map +1 -1
  10. package/dist/{dataLayerProvider-D6PoCwS6.d.cts → dataLayerProvider-CHYrQ5pB.d.cts} +1 -1
  11. package/dist/{dataLayerProvider-CZxC9GtB.d.ts → dataLayerProvider-MDTxXq2l.d.ts} +1 -1
  12. package/dist/impl/couch/index.d.cts +8 -23
  13. package/dist/impl/couch/index.d.ts +8 -23
  14. package/dist/impl/couch/index.js +723 -1578
  15. package/dist/impl/couch/index.js.map +1 -1
  16. package/dist/impl/couch/index.mjs +692 -1552
  17. package/dist/impl/couch/index.mjs.map +1 -1
  18. package/dist/impl/static/index.d.cts +25 -8
  19. package/dist/impl/static/index.d.ts +25 -8
  20. package/dist/impl/static/index.js +700 -1400
  21. package/dist/impl/static/index.js.map +1 -1
  22. package/dist/impl/static/index.mjs +688 -1393
  23. package/dist/impl/static/index.mjs.map +1 -1
  24. package/dist/{index-D-Fa4Smt.d.cts → index-B_j6u5E4.d.cts} +1 -1
  25. package/dist/{index-CD8BZz2k.d.ts → index-Dj0SEgk3.d.ts} +1 -1
  26. package/dist/index.d.cts +71 -63
  27. package/dist/index.d.ts +71 -63
  28. package/dist/index.js +1162 -1996
  29. package/dist/index.js.map +1 -1
  30. package/dist/index.mjs +1124 -1955
  31. package/dist/index.mjs.map +1 -1
  32. package/dist/pouch/index.js +3 -0
  33. package/dist/pouch/index.js.map +1 -1
  34. package/dist/pouch/index.mjs +3 -0
  35. package/dist/pouch/index.mjs.map +1 -1
  36. package/dist/{types-CzPDLAK6.d.cts → types-Bn0itutr.d.cts} +1 -1
  37. package/dist/{types-CewsN87z.d.ts → types-DQaXnuoc.d.ts} +1 -1
  38. package/dist/{types-legacy-6ettoclI.d.cts → types-legacy-DDY4N-Uq.d.cts} +3 -1
  39. package/dist/{types-legacy-6ettoclI.d.ts → types-legacy-DDY4N-Uq.d.ts} +3 -1
  40. package/dist/util/packer/index.d.cts +3 -3
  41. package/dist/util/packer/index.d.ts +3 -3
  42. package/docs/navigators-architecture.md +115 -17
  43. package/package.json +4 -4
  44. package/src/core/index.ts +1 -0
  45. package/src/core/interfaces/classroomDB.ts +5 -13
  46. package/src/core/interfaces/contentSource.ts +6 -66
  47. package/src/core/interfaces/courseDB.ts +15 -7
  48. package/src/core/interfaces/userDB.ts +32 -0
  49. package/src/core/navigators/Pipeline.ts +136 -52
  50. package/src/core/navigators/PipelineAssembler.ts +1 -1
  51. package/src/core/navigators/defaults.ts +84 -0
  52. package/src/core/navigators/{hierarchyDefinition.ts → filters/hierarchyDefinition.ts} +15 -29
  53. package/src/core/navigators/filters/index.ts +3 -0
  54. package/src/core/navigators/filters/inferredPreferenceStub.ts +107 -0
  55. package/src/core/navigators/{interferenceMitigator.ts → filters/interferenceMitigator.ts} +11 -37
  56. package/src/core/navigators/{relativePriority.ts → filters/relativePriority.ts} +12 -38
  57. package/src/core/navigators/filters/userGoalStub.ts +136 -0
  58. package/src/core/navigators/filters/userTagPreference.ts +217 -0
  59. package/src/core/navigators/{CompositeGenerator.ts → generators/CompositeGenerator.ts} +15 -64
  60. package/src/core/navigators/{elo.ts → generators/elo.ts} +13 -63
  61. package/src/core/navigators/{srs.ts → generators/srs.ts} +11 -40
  62. package/src/core/navigators/generators/types.ts +1 -1
  63. package/src/core/navigators/index.ts +95 -91
  64. package/src/core/types/strategyState.ts +84 -0
  65. package/src/core/types/types-legacy.ts +2 -0
  66. package/src/impl/common/BaseUserDB.ts +74 -7
  67. package/src/impl/couch/adminDB.ts +1 -2
  68. package/src/impl/couch/classroomDB.ts +100 -103
  69. package/src/impl/couch/courseDB.ts +35 -91
  70. package/src/impl/couch/pouchdb-setup.ts +7 -0
  71. package/src/impl/static/StaticDataUnpacker.ts +50 -1
  72. package/src/impl/static/courseDB.ts +87 -37
  73. package/src/study/SessionController.ts +122 -202
  74. package/src/study/SourceMixer.ts +65 -0
  75. package/src/study/TagFilteredContentSource.ts +49 -92
  76. package/src/study/index.ts +1 -0
  77. package/src/study/services/CardHydrationService.ts +165 -81
  78. package/src/util/dataDirectory.ts +1 -1
  79. package/src/util/index.ts +0 -1
  80. package/tests/core/navigators/CompositeGenerator.test.ts +44 -168
  81. package/tests/core/navigators/Pipeline.test.ts +6 -72
  82. package/tests/core/navigators/PipelineAssembler.test.ts +8 -58
  83. package/tests/core/navigators/navigators.test.ts +118 -151
  84. package/docs/todo-pipeline-optimization.md +0 -117
  85. package/docs/todo-strategy-state-storage.md +0 -278
  86. package/src/core/navigators/hardcodedOrder.ts +0 -163
  87. 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
- * Gets new cards that match the tag filter and are not already active for the user.
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 getNewCards(limit?: number): Promise<StudySessionNewItem[]> {
120
+ public async getWeightedCards(limit: number): Promise<WeightedCard[]> {
119
121
  if (!hasActiveFilter(this.filter)) {
120
- logger.warn('[TagFilteredContentSource] getNewCards called with no active filter');
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 newItems: StudySessionNewItem[] = [];
132
+ const newCardWeighted: WeightedCard[] = [];
129
133
  for (const cardId of eligibleCardIds) {
130
134
  if (!activeCardIds.has(cardId)) {
131
- newItems.push({
132
- courseID: this.courseId,
133
- cardID: cardId,
134
- contentSourceType: 'course',
135
- contentSourceID: this.courseId,
136
- status: 'new',
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 (limit !== undefined && newItems.length >= limit) {
152
+ if (newCardWeighted.length >= limit) {
141
153
  break;
142
154
  }
143
155
  }
144
156
 
145
- logger.info(`[TagFilteredContentSource] Found ${newItems.length} new cards matching filter`);
146
- return newItems;
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
- const eligibleCardIds = await this.resolveFilteredCardIds();
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
- return filteredReviews.map((r) => ({
171
- ...r,
172
- courseID: r.courseId,
173
- cardID: r.cardId,
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
- status: 'review' as const,
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 weighted.slice(0, limit);
188
+ return [...reviewWeighted, ...newCardWeighted].slice(0, limit);
232
189
  }
233
190
 
234
191
  /**
@@ -1,3 +1,4 @@
1
1
  export * from './SessionController';
2
+ export * from './SourceMixer';
2
3
  export * from './SpacedRepetition';
3
4
  export * from './TagFilteredContentSource';
@@ -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 a queue of hydrated (ready-to-display) cards.
23
- * Handles pre-fetching card data, caching failed cards, and maintaining optimal buffer size.
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 hydratedQ: ItemQueue<HydratedCard<TView>> = new ItemQueue<HydratedCard<TView>>();
27
- private failedCardCache: Map<string, HydratedCard<TView>> = new Map();
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 selectNextItemToHydrate: () => StudySessionItem | null,
35
- private removeItemFromQueue: (item: StudySessionItem) => void,
36
- private hasAvailableCards: () => boolean
72
+ private getItemsToHydrate: () => StudySessionItem[]
37
73
  ) {}
38
74
 
39
75
  /**
40
- * Get the next hydrated card from the queue.
41
- * @returns Hydrated card or null if none available
76
+ * Get a hydrated card by ID.
77
+ * @returns Hydrated card or null if not in cache
42
78
  */
43
- public dequeueHydratedCard(): HydratedCard<TView> | null {
44
- return this.hydratedQ.dequeue((item) => item.item.cardID);
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
- // Trigger background hydration to maintain cache (async, non-blocking)
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 hydrated card to become available.
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 waitForHydratedCard(): Promise<HydratedCard<TView> | null> {
62
- // If no hydrated card but source cards available, start hydration
63
- if (this.hydratedQ.length === 0 && this.hasAvailableCards()) {
64
- void this.fillHydratedQueue(); // Start hydration in background
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 a card to become available in hydratedQ
68
- while (this.hydratedQ.length === 0 && this.hasAvailableCards()) {
69
- await new Promise((resolve) => setTimeout(resolve, 25)); // Short polling interval
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.dequeueHydratedCard();
138
+ return this.hydratedCards.get(cardId) ?? null;
73
139
  }
74
140
 
75
141
  /**
76
- * Get current hydrated queue length.
142
+ * Get current hydrated cache size.
77
143
  */
78
144
  public get hydratedCount(): number {
79
- return this.hydratedQ.length;
145
+ return this.hydratedCards.size;
80
146
  }
81
147
 
82
148
  /**
83
- * Get current failed card cache size.
149
+ * Get list of currently hydrated card IDs (for debugging).
84
150
  */
85
- public get failedCacheSize(): number {
86
- return this.failedCardCache.size;
151
+ public getHydratedCardIds(): string[] {
152
+ return Array.from(this.hydratedCards.keys());
87
153
  }
88
154
 
89
155
  /**
90
- * Fill the hydrated queue up to BUFFER_SIZE with pre-fetched cards.
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 fillHydratedQueue(): Promise<void> {
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
- while (this.hydratedQ.length < this.BUFFER_SIZE) {
101
- const nextItem = this.selectNextItemToHydrate();
102
- if (!nextItem) {
103
- return; // No more cards to hydrate
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
- // Check cache first for failed cards
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 ${nextItem.cardID}:`, e);
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
- * Cache a failed card for quick re-access.
187
+ * Hydrate a single card and add to cache.
156
188
  */
157
- public cacheFailedCard(card: HydratedCard<TView>): void {
158
- this.failedCardCache.set(card.item.cardID, card);
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
  }
@@ -4,7 +4,7 @@
4
4
  import * as fs from 'fs';
5
5
  import * as path from 'path';
6
6
  import * as os from 'os';
7
- import { logger } from './tuiLogger';
7
+ import { logger } from './logger';
8
8
  import { ENV } from '@db/factory';
9
9
 
10
10
  /**
package/src/util/index.ts CHANGED
@@ -2,4 +2,3 @@ export * from './Loggable';
2
2
  export * from './packer';
3
3
  export * from './migrator';
4
4
  export * from './dataDirectory';
5
- export * from './tuiLogger';