@vue-skuilder/db 0.1.11 → 0.1.12

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 (53) hide show
  1. package/dist/core/index.d.mts +7 -6
  2. package/dist/core/index.d.ts +7 -6
  3. package/dist/core/index.js +146 -37
  4. package/dist/core/index.js.map +1 -1
  5. package/dist/core/index.mjs +146 -37
  6. package/dist/core/index.mjs.map +1 -1
  7. package/dist/{dataLayerProvider-VieuAAkV.d.mts → dataLayerProvider-BiP3kWix.d.mts} +1 -1
  8. package/dist/{dataLayerProvider-juuqUHOP.d.ts → dataLayerProvider-DSdeyRT3.d.ts} +1 -1
  9. package/dist/impl/couch/index.d.mts +3 -3
  10. package/dist/impl/couch/index.d.ts +3 -3
  11. package/dist/impl/couch/index.js +146 -37
  12. package/dist/impl/couch/index.js.map +1 -1
  13. package/dist/impl/couch/index.mjs +146 -37
  14. package/dist/impl/couch/index.mjs.map +1 -1
  15. package/dist/impl/static/index.d.mts +14 -6
  16. package/dist/impl/static/index.d.ts +14 -6
  17. package/dist/impl/static/index.js +147 -39
  18. package/dist/impl/static/index.js.map +1 -1
  19. package/dist/impl/static/index.mjs +147 -39
  20. package/dist/impl/static/index.mjs.map +1 -1
  21. package/dist/{index-DZyxHCcf.d.mts → index-Bmll7Xse.d.mts} +1 -1
  22. package/dist/{index-CWY6yhkV.d.ts → index-CD8BZz2k.d.ts} +1 -1
  23. package/dist/index.d.mts +119 -24
  24. package/dist/index.d.ts +119 -24
  25. package/dist/index.js +785 -261
  26. package/dist/index.js.map +1 -1
  27. package/dist/index.mjs +789 -265
  28. package/dist/index.mjs.map +1 -1
  29. package/dist/{types-DtoI27Xh.d.ts → types-CewsN87z.d.ts} +1 -1
  30. package/dist/{types-Che4wTwA.d.mts → types-Dbp5DaRR.d.mts} +1 -1
  31. package/dist/{types-legacy-B8ahaCbj.d.mts → types-legacy-6ettoclI.d.mts} +13 -2
  32. package/dist/{types-legacy-B8ahaCbj.d.ts → types-legacy-6ettoclI.d.ts} +13 -2
  33. package/dist/{userDB-DJ8HMw83.d.mts → userDB-C4yyAnpp.d.mts} +3 -3
  34. package/dist/{userDB-B7zTQ123.d.ts → userDB-CD6s6ZCp.d.ts} +3 -3
  35. package/dist/util/packer/index.d.mts +3 -3
  36. package/dist/util/packer/index.d.ts +3 -3
  37. package/package.json +3 -3
  38. package/src/core/navigators/hardcodedOrder.ts +64 -0
  39. package/src/core/navigators/index.ts +1 -0
  40. package/src/core/types/contentNavigationStrategy.ts +2 -1
  41. package/src/core/types/types-legacy.ts +2 -2
  42. package/src/impl/common/BaseUserDB.ts +15 -11
  43. package/src/impl/couch/courseDB.ts +74 -27
  44. package/src/impl/couch/updateQueue.ts +8 -3
  45. package/src/impl/static/StaticDataLayerProvider.ts +57 -17
  46. package/src/impl/static/courseDB.ts +17 -12
  47. package/src/impl/static/coursesDB.ts +10 -6
  48. package/src/study/ItemQueue.ts +58 -0
  49. package/src/study/SessionController.ts +132 -178
  50. package/src/study/services/CardHydrationService.ts +153 -0
  51. package/src/study/services/EloService.ts +85 -0
  52. package/src/study/services/ResponseProcessor.ts +224 -0
  53. package/src/study/services/SrsService.ts +44 -0
package/dist/index.mjs CHANGED
@@ -445,7 +445,9 @@ var init_updateQueue = __esm({
445
445
  async applyUpdates(id) {
446
446
  logger.debug(`Applying updates on doc: ${id}`);
447
447
  if (this.inprogressUpdates[id]) {
448
- await this.readDB.info();
448
+ while (this.inprogressUpdates[id]) {
449
+ await new Promise((resolve) => setTimeout(resolve, Math.random() * 50));
450
+ }
449
451
  return this.applyUpdates(id);
450
452
  } else {
451
453
  if (this.pendingUpdates[id] && this.pendingUpdates[id].length > 0) {
@@ -481,6 +483,9 @@ var init_updateQueue = __esm({
481
483
  if (e.name === "conflict" && i < MAX_RETRIES - 1) {
482
484
  logger.warn(`Conflict on update for doc ${id}, retry #${i + 1}`);
483
485
  await new Promise((res) => setTimeout(res, 50 * Math.random()));
486
+ } else if (e.name === "not_found" && i === 0) {
487
+ logger.warn(`Update failed for ${id} - does not exist. Throwing to caller.`);
488
+ throw e;
484
489
  } else {
485
490
  delete this.inprogressUpdates[id];
486
491
  if (this.pendingUpdates[id]) {
@@ -959,12 +964,74 @@ var init_elo = __esm({
959
964
  }
960
965
  });
961
966
 
967
+ // src/core/navigators/hardcodedOrder.ts
968
+ var hardcodedOrder_exports = {};
969
+ __export(hardcodedOrder_exports, {
970
+ default: () => HardcodedOrderNavigator
971
+ });
972
+ var HardcodedOrderNavigator;
973
+ var init_hardcodedOrder = __esm({
974
+ "src/core/navigators/hardcodedOrder.ts"() {
975
+ "use strict";
976
+ init_navigators();
977
+ init_logger();
978
+ HardcodedOrderNavigator = class extends ContentNavigator {
979
+ orderedCardIds = [];
980
+ user;
981
+ course;
982
+ constructor(user, course, strategyData) {
983
+ super();
984
+ this.user = user;
985
+ this.course = course;
986
+ if (strategyData.serializedData) {
987
+ try {
988
+ this.orderedCardIds = JSON.parse(strategyData.serializedData);
989
+ } catch (e) {
990
+ logger.error("Failed to parse serializedData for HardcodedOrderNavigator", e);
991
+ }
992
+ }
993
+ }
994
+ async getPendingReviews() {
995
+ const reviews = await this.user.getPendingReviews(this.course.getCourseID());
996
+ return reviews.map((r) => {
997
+ return {
998
+ ...r,
999
+ contentSourceType: "course",
1000
+ contentSourceID: this.course.getCourseID(),
1001
+ cardID: r.cardId,
1002
+ courseID: r.courseId,
1003
+ reviewID: r._id,
1004
+ status: "review"
1005
+ };
1006
+ });
1007
+ }
1008
+ async getNewCards(limit = 99) {
1009
+ const activeCardIds = (await this.user.getActiveCards()).map((c) => c.cardID);
1010
+ const newCardIds = this.orderedCardIds.filter(
1011
+ (cardId) => !activeCardIds.includes(cardId)
1012
+ );
1013
+ const cardsToReturn = newCardIds.slice(0, limit);
1014
+ return cardsToReturn.map((cardId) => {
1015
+ return {
1016
+ cardID: cardId,
1017
+ courseID: this.course.getCourseID(),
1018
+ contentSourceType: "course",
1019
+ contentSourceID: this.course.getCourseID(),
1020
+ status: "new"
1021
+ };
1022
+ });
1023
+ }
1024
+ };
1025
+ }
1026
+ });
1027
+
962
1028
  // import("./**/*") in src/core/navigators/index.ts
963
1029
  var globImport;
964
1030
  var init_ = __esm({
965
1031
  'import("./**/*") in src/core/navigators/index.ts'() {
966
1032
  globImport = __glob({
967
1033
  "./elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
1034
+ "./hardcodedOrder.ts": () => Promise.resolve().then(() => (init_hardcodedOrder(), hardcodedOrder_exports)),
968
1035
  "./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports))
969
1036
  });
970
1037
  }
@@ -984,6 +1051,7 @@ var init_navigators = __esm({
984
1051
  init_();
985
1052
  Navigators = /* @__PURE__ */ ((Navigators2) => {
986
1053
  Navigators2["ELO"] = "elo";
1054
+ Navigators2["HARDCODED"] = "hardcodedOrder";
987
1055
  return Navigators2;
988
1056
  })(Navigators || {});
989
1057
  ContentNavigator = class {
@@ -1258,6 +1326,23 @@ var init_courseDB = __esm({
1258
1326
  if (!doc.docType || !(doc.docType === "CARD" /* CARD */)) {
1259
1327
  throw new Error(`failed to remove ${id} from course ${this.id}. id does not point to a card`);
1260
1328
  }
1329
+ try {
1330
+ const appliedTags = await this.getAppliedTags(id);
1331
+ const results = await Promise.allSettled(
1332
+ appliedTags.rows.map(async (tagRow) => {
1333
+ const tagId = tagRow.id;
1334
+ await this.removeTagFromCard(id, tagId);
1335
+ })
1336
+ );
1337
+ results.forEach((result, index) => {
1338
+ if (result.status === "rejected") {
1339
+ const tagId = appliedTags.rows[index].id;
1340
+ logger.error(`Failed to remove card ${id} from tag ${tagId}: ${result.reason}`);
1341
+ }
1342
+ });
1343
+ } catch (error) {
1344
+ logger.error(`Error removing card ${id} from tags: ${error}`);
1345
+ }
1261
1346
  return this.db.remove(doc);
1262
1347
  }
1263
1348
  async getCardDisplayableDataIDs(id) {
@@ -1441,23 +1526,9 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
1441
1526
  ////////////////////////////////////
1442
1527
  getNavigationStrategy(id) {
1443
1528
  logger.debug(`[courseDB] Getting navigation strategy: ${id}`);
1444
- const strategy = {
1445
- id: "ELO",
1446
- docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
1447
- name: "ELO",
1448
- description: "ELO-based navigation strategy for ordering content by difficulty",
1449
- implementingClass: "elo" /* ELO */,
1450
- course: this.id,
1451
- serializedData: ""
1452
- // serde is a noop for ELO navigator.
1453
- };
1454
- return Promise.resolve(strategy);
1455
- }
1456
- getAllNavigationStrategies() {
1457
- logger.debug("[courseDB] Returning hard-coded navigation strategies");
1458
- const strategies = [
1459
- {
1460
- id: "ELO",
1529
+ if (id == "") {
1530
+ const strategy = {
1531
+ _id: "NAVIGATION_STRATEGY-ELO",
1461
1532
  docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
1462
1533
  name: "ELO",
1463
1534
  description: "ELO-based navigation strategy for ordering content by difficulty",
@@ -1465,14 +1536,25 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
1465
1536
  course: this.id,
1466
1537
  serializedData: ""
1467
1538
  // serde is a noop for ELO navigator.
1468
- }
1469
- ];
1470
- return Promise.resolve(strategies);
1539
+ };
1540
+ return Promise.resolve(strategy);
1541
+ } else {
1542
+ return this.db.get(id);
1543
+ }
1471
1544
  }
1472
- addNavigationStrategy(data) {
1473
- logger.debug(`[courseDB] Adding navigation strategy: ${data.id}`);
1474
- logger.debug(JSON.stringify(data));
1475
- return Promise.resolve();
1545
+ async getAllNavigationStrategies() {
1546
+ const prefix = DocTypePrefixes["NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */];
1547
+ const result = await this.db.allDocs({
1548
+ startkey: prefix,
1549
+ endkey: `${prefix}\uFFF0`,
1550
+ include_docs: true
1551
+ });
1552
+ return result.rows.map((row) => row.doc);
1553
+ }
1554
+ async addNavigationStrategy(data) {
1555
+ logger.debug(`[courseDB] Adding navigation strategy: ${data._id}`);
1556
+ return this.db.put(data).then(() => {
1557
+ });
1476
1558
  }
1477
1559
  updateNavigationStrategy(id, data) {
1478
1560
  logger.debug(`[courseDB] Updating navigation strategy: ${id}`);
@@ -1480,9 +1562,32 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
1480
1562
  return Promise.resolve();
1481
1563
  }
1482
1564
  async surfaceNavigationStrategy() {
1565
+ try {
1566
+ const config = await this.getCourseConfig();
1567
+ if (config.defaultNavigationStrategyId) {
1568
+ try {
1569
+ const strategy = await this.getNavigationStrategy(config.defaultNavigationStrategyId);
1570
+ if (strategy) {
1571
+ logger.debug(`Surfacing strategy ${strategy.name} from course config`);
1572
+ return strategy;
1573
+ }
1574
+ } catch (e) {
1575
+ logger.warn(
1576
+ // @ts-expect-error tmp: defaultNavigationStrategyId property does not yet exist
1577
+ `Failed to load strategy '${config.defaultNavigationStrategyId}' specified in course config. Falling back to ELO.`,
1578
+ e
1579
+ );
1580
+ }
1581
+ }
1582
+ } catch (e) {
1583
+ logger.warn(
1584
+ "Could not retrieve course config to determine navigation strategy. Falling back to ELO.",
1585
+ e
1586
+ );
1587
+ }
1483
1588
  logger.warn(`Returning hard-coded default ELO navigator`);
1484
1589
  const ret = {
1485
- id: "ELO",
1590
+ _id: "NAVIGATION_STRATEGY-ELO",
1486
1591
  docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
1487
1592
  name: "ELO",
1488
1593
  description: "ELO-based navigation strategy",
@@ -2945,17 +3050,21 @@ Currently logged-in as ${this._username}.`
2945
3050
  } catch (e) {
2946
3051
  const reason = e;
2947
3052
  if (reason.status === 404) {
2948
- const initCardHistory = {
2949
- _id: cardHistoryID,
2950
- cardID: record.cardID,
2951
- courseID: record.courseID,
2952
- records: [record],
2953
- lapses: 0,
2954
- streak: 0,
2955
- bestInterval: 0
2956
- };
2957
- const putResult = await this.writeDB.put(initCardHistory);
2958
- return { ...initCardHistory, _rev: putResult.rev };
3053
+ try {
3054
+ const initCardHistory = {
3055
+ _id: cardHistoryID,
3056
+ cardID: record.cardID,
3057
+ courseID: record.courseID,
3058
+ records: [record],
3059
+ lapses: 0,
3060
+ streak: 0,
3061
+ bestInterval: 0
3062
+ };
3063
+ const putResult = await this.writeDB.put(initCardHistory);
3064
+ return { ...initCardHistory, _rev: putResult.rev };
3065
+ } catch (creationError) {
3066
+ throw new Error(`Failed to create CardHistory for ${cardHistoryID}. Reason: ${creationError}`);
3067
+ }
2959
3068
  } else {
2960
3069
  throw new Error(`putCardRecord failed because of:
2961
3070
  name:${reason.name}
@@ -3735,16 +3844,20 @@ var init_courseDB2 = __esm({
3735
3844
  async updateCardElo(cardId, _elo) {
3736
3845
  return { ok: true, id: cardId, rev: "1-static" };
3737
3846
  }
3738
- async getNewCards(limit) {
3739
- const cardIds = await this.unpacker.queryByElo(1e3, limit || 10);
3740
- return cardIds.map((cardId) => ({
3741
- status: "new",
3742
- qualifiedID: `${this.courseId}-${cardId}`,
3743
- cardID: cardId,
3744
- contentSourceType: "course",
3745
- contentSourceID: this.courseId,
3746
- courseID: this.courseId
3747
- }));
3847
+ async getNewCards(limit = 99) {
3848
+ const activeCards = await this.userDB.getActiveCards();
3849
+ return (await this.getCardsCenteredAtELO({ limit, elo: "user" }, (c) => {
3850
+ if (activeCards.some((ac) => c.cardID === ac.cardID)) {
3851
+ return false;
3852
+ } else {
3853
+ return true;
3854
+ }
3855
+ })).map((c) => {
3856
+ return {
3857
+ ...c,
3858
+ status: "new"
3859
+ };
3860
+ });
3748
3861
  }
3749
3862
  async getCardsCenteredAtELO(options, filter) {
3750
3863
  let targetElo = typeof options.elo === "number" ? options.elo : 1e3;
@@ -3925,7 +4038,7 @@ var init_courseDB2 = __esm({
3925
4038
  // Navigation Strategy Manager implementation
3926
4039
  async getNavigationStrategy(_id) {
3927
4040
  return {
3928
- id: "ELO",
4041
+ _id: "NAVIGATION_STRATEGY-ELO",
3929
4042
  docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
3930
4043
  name: "ELO",
3931
4044
  description: "ELO-based navigation strategy",
@@ -3990,11 +4103,17 @@ var init_coursesDB = __esm({
3990
4103
  this.manifests = manifests;
3991
4104
  }
3992
4105
  async getCourseConfig(courseId) {
3993
- if (!this.manifests[courseId]) {
3994
- logger.warn(`Course ${courseId} not found`);
3995
- return {};
4106
+ const manifest = this.manifests[courseId];
4107
+ if (!manifest) {
4108
+ logger.warn(`Course manifest for ${courseId} not found`);
4109
+ throw new Error(`Course ${courseId} not found`);
4110
+ }
4111
+ if (manifest.courseConfig) {
4112
+ return manifest.courseConfig;
4113
+ } else {
4114
+ logger.warn(`Course config not found in manifest for course ${courseId}`);
4115
+ throw new Error(`Course config not found for course ${courseId}`);
3996
4116
  }
3997
- return {};
3998
4117
  }
3999
4118
  async getCourseList() {
4000
4119
  return Object.keys(this.manifests).map(
@@ -4086,23 +4205,49 @@ var init_StaticDataLayerProvider = __esm({
4086
4205
  config;
4087
4206
  initialized = false;
4088
4207
  courseUnpackers = /* @__PURE__ */ new Map();
4208
+ manifests = {};
4089
4209
  constructor(config) {
4090
4210
  this.config = {
4091
- staticContentPath: config.staticContentPath || "/static-courses",
4092
4211
  localStoragePrefix: config.localStoragePrefix || "skuilder-static",
4093
- manifests: config.manifests || {}
4212
+ rootManifest: config.rootManifest || { dependencies: {} },
4213
+ rootManifestUrl: config.rootManifestUrl || "/"
4094
4214
  };
4095
4215
  }
4216
+ async resolveCourseDependencies() {
4217
+ logger.info("[StaticDataLayerProvider] Starting course dependency resolution...");
4218
+ const rootManifest = this.config.rootManifest;
4219
+ for (const [courseName, courseUrl] of Object.entries(rootManifest.dependencies || {})) {
4220
+ try {
4221
+ logger.debug(`[StaticDataLayerProvider] Resolving dependency: ${courseName} from ${courseUrl}`);
4222
+ const courseManifestUrl = new URL(courseUrl, this.config.rootManifestUrl).href;
4223
+ const courseJsonResponse = await fetch(courseManifestUrl);
4224
+ if (!courseJsonResponse.ok) {
4225
+ throw new Error(`Failed to fetch course manifest for ${courseName}`);
4226
+ }
4227
+ const courseJson = await courseJsonResponse.json();
4228
+ if (courseJson.content && courseJson.content.manifest) {
4229
+ const baseUrl = new URL(".", courseManifestUrl).href;
4230
+ const finalManifestUrl = new URL(courseJson.content.manifest, courseManifestUrl).href;
4231
+ const finalManifestResponse = await fetch(finalManifestUrl);
4232
+ if (!finalManifestResponse.ok) {
4233
+ throw new Error(`Failed to fetch final content manifest for ${courseName} at ${finalManifestUrl}`);
4234
+ }
4235
+ const finalManifest = await finalManifestResponse.json();
4236
+ this.manifests[courseName] = finalManifest;
4237
+ const unpacker = new StaticDataUnpacker(finalManifest, baseUrl);
4238
+ this.courseUnpackers.set(courseName, unpacker);
4239
+ logger.info(`[StaticDataLayerProvider] Successfully resolved and prepared course: ${courseName}`);
4240
+ }
4241
+ } catch (e) {
4242
+ logger.error(`[StaticDataLayerProvider] Failed to resolve dependency ${courseName}:`, e);
4243
+ }
4244
+ }
4245
+ logger.info("[StaticDataLayerProvider] Course dependency resolution complete.");
4246
+ }
4096
4247
  async initialize() {
4097
4248
  if (this.initialized) return;
4098
4249
  logger.info("Initializing static data layer provider");
4099
- for (const [courseId, manifest] of Object.entries(this.config.manifests)) {
4100
- const unpacker = new StaticDataUnpacker(
4101
- manifest,
4102
- `${this.config.staticContentPath}/${courseId}`
4103
- );
4104
- this.courseUnpackers.set(courseId, unpacker);
4105
- }
4250
+ await this.resolveCourseDependencies();
4106
4251
  this.initialized = true;
4107
4252
  }
4108
4253
  async teardown() {
@@ -4116,13 +4261,13 @@ var init_StaticDataLayerProvider = __esm({
4116
4261
  getCourseDB(courseId) {
4117
4262
  const unpacker = this.courseUnpackers.get(courseId);
4118
4263
  if (!unpacker) {
4119
- throw new Error(`Course ${courseId} not found in static data`);
4264
+ throw new Error(`Course ${courseId} not found or failed to initialize in static data layer.`);
4120
4265
  }
4121
- const manifest = this.config.manifests[courseId];
4266
+ const manifest = this.manifests[courseId];
4122
4267
  return new StaticCourseDB(courseId, unpacker, this.getUserDB(), manifest);
4123
4268
  }
4124
4269
  getCoursesDB() {
4125
- return new StaticCoursesDB(this.config.manifests);
4270
+ return new StaticCoursesDB(this.manifests);
4126
4271
  }
4127
4272
  async getClassroomDB(_classId, _type) {
4128
4273
  throw new Error("Classrooms not supported in static mode");
@@ -4428,6 +4573,501 @@ var init_core = __esm({
4428
4573
  init_core();
4429
4574
  init_courseLookupDB();
4430
4575
 
4576
+ // src/study/services/SrsService.ts
4577
+ init_couch();
4578
+ import moment7 from "moment";
4579
+
4580
+ // src/study/SpacedRepetition.ts
4581
+ init_util();
4582
+ init_logger();
4583
+ import moment6 from "moment";
4584
+ var duration = moment6.duration;
4585
+ function newInterval(user, cardHistory) {
4586
+ if (areQuestionRecords(cardHistory)) {
4587
+ return newQuestionInterval(user, cardHistory);
4588
+ } else {
4589
+ return 1e5;
4590
+ }
4591
+ }
4592
+ function newQuestionInterval(user, cardHistory) {
4593
+ const records = cardHistory.records;
4594
+ const currentAttempt = records[records.length - 1];
4595
+ const lastInterval = lastSuccessfulInterval(records);
4596
+ if (lastInterval > cardHistory.bestInterval) {
4597
+ cardHistory.bestInterval = lastInterval;
4598
+ void user.update(cardHistory._id, {
4599
+ bestInterval: lastInterval
4600
+ });
4601
+ }
4602
+ if (currentAttempt.isCorrect) {
4603
+ const skill = Math.min(1, Math.max(0, currentAttempt.performance));
4604
+ logger.debug(`Demontrated skill: ${skill}`);
4605
+ const interval = lastInterval * (0.75 + skill);
4606
+ cardHistory.lapses = getLapses(cardHistory.records);
4607
+ cardHistory.streak = getStreak(cardHistory.records);
4608
+ if (cardHistory.lapses && cardHistory.streak && cardHistory.bestInterval && (cardHistory.lapses >= 0 || cardHistory.streak >= 0)) {
4609
+ const ret = (cardHistory.lapses * interval + cardHistory.streak * cardHistory.bestInterval) / (cardHistory.lapses + cardHistory.streak);
4610
+ logger.debug(`Weighted average interval calculation:
4611
+ (${cardHistory.lapses} * ${interval} + ${cardHistory.streak} * ${cardHistory.bestInterval}) / (${cardHistory.lapses} + ${cardHistory.streak}) = ${ret}`);
4612
+ return ret;
4613
+ } else {
4614
+ return interval;
4615
+ }
4616
+ } else {
4617
+ return 0;
4618
+ }
4619
+ }
4620
+ function lastSuccessfulInterval(cardHistory) {
4621
+ for (let i = cardHistory.length - 1; i >= 1; i--) {
4622
+ if (cardHistory[i].priorAttemps === 0 && cardHistory[i].isCorrect) {
4623
+ const lastInterval = secondsBetween(cardHistory[i - 1].timeStamp, cardHistory[i].timeStamp);
4624
+ const ret = Math.max(lastInterval, 20 * 60 * 60);
4625
+ logger.debug(`Last interval w/ this card was: ${lastInterval}s, returning ${ret}s`);
4626
+ return ret;
4627
+ }
4628
+ }
4629
+ return getInitialInterval(cardHistory);
4630
+ }
4631
+ function getStreak(records) {
4632
+ let streak = 0;
4633
+ let index = records.length - 1;
4634
+ while (index >= 0 && records[index].isCorrect) {
4635
+ index--;
4636
+ streak++;
4637
+ }
4638
+ return streak;
4639
+ }
4640
+ function getLapses(records) {
4641
+ return records.filter((r) => r.isCorrect === false).length;
4642
+ }
4643
+ function getInitialInterval(cardHistory) {
4644
+ logger.warn(`history of length: ${cardHistory.length} ignored!`);
4645
+ return 60 * 60 * 24 * 3;
4646
+ }
4647
+ function secondsBetween(start, end) {
4648
+ start = moment6(start);
4649
+ end = moment6(end);
4650
+ const ret = duration(end.diff(start)).asSeconds();
4651
+ return ret;
4652
+ }
4653
+
4654
+ // src/study/services/SrsService.ts
4655
+ init_logger();
4656
+ var SrsService = class {
4657
+ user;
4658
+ constructor(user) {
4659
+ this.user = user;
4660
+ }
4661
+ /**
4662
+ * Calculates the next review time for a card based on its history and
4663
+ * schedules it in the user's database.
4664
+ * @param history The full history of the card.
4665
+ * @param item The study session item, used to determine if a previous review needs to be cleared.
4666
+ */
4667
+ async scheduleReview(history, item) {
4668
+ const nextInterval = newInterval(this.user, history);
4669
+ const nextReviewTime = moment7.utc().add(nextInterval, "seconds");
4670
+ if (isReview(item)) {
4671
+ logger.info(`[SrsService] Removing previously scheduled review for: ${item.cardID}`);
4672
+ void this.user.removeScheduledCardReview(item.reviewID);
4673
+ }
4674
+ void this.user.scheduleCardReview({
4675
+ user: this.user.getUsername(),
4676
+ course_id: history.courseID,
4677
+ card_id: history.cardID,
4678
+ time: nextReviewTime,
4679
+ scheduledFor: item.contentSourceType,
4680
+ schedulingAgentId: item.contentSourceID
4681
+ });
4682
+ }
4683
+ };
4684
+
4685
+ // src/study/services/EloService.ts
4686
+ init_logger();
4687
+ import { adjustCourseScores, toCourseElo as toCourseElo3 } from "@vue-skuilder/common";
4688
+ var EloService = class {
4689
+ dataLayer;
4690
+ user;
4691
+ constructor(dataLayer, user) {
4692
+ this.dataLayer = dataLayer;
4693
+ this.user = user;
4694
+ }
4695
+ /**
4696
+ * Updates both user and card ELO ratings based on user performance.
4697
+ * @param userScore Score between 0-1 representing user performance
4698
+ * @param course_id Course identifier
4699
+ * @param card_id Card identifier
4700
+ * @param userCourseRegDoc User's course registration document (will be mutated)
4701
+ * @param currentCard Current card session record
4702
+ * @param k Optional K-factor for ELO calculation
4703
+ */
4704
+ async updateUserAndCardElo(userScore, course_id, card_id, userCourseRegDoc, currentCard, k) {
4705
+ if (k) {
4706
+ logger.warn(`k value interpretation not currently implemented`);
4707
+ }
4708
+ const courseDB = this.dataLayer.getCourseDB(currentCard.card.course_id);
4709
+ const userElo = toCourseElo3(userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo);
4710
+ const cardElo = (await courseDB.getCardEloData([currentCard.card.card_id]))[0];
4711
+ if (cardElo && userElo) {
4712
+ const eloUpdate = adjustCourseScores(userElo, cardElo, userScore);
4713
+ userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo = eloUpdate.userElo;
4714
+ const results = await Promise.allSettled([
4715
+ this.user.updateUserElo(course_id, eloUpdate.userElo),
4716
+ courseDB.updateCardElo(card_id, eloUpdate.cardElo)
4717
+ ]);
4718
+ const userEloStatus = results[0].status === "fulfilled";
4719
+ const cardEloStatus = results[1].status === "fulfilled";
4720
+ if (userEloStatus && cardEloStatus) {
4721
+ const user = results[0].value;
4722
+ const card = results[1].value;
4723
+ if (user.ok && card && card.ok) {
4724
+ logger.info(
4725
+ `[EloService] Updated ELOS:
4726
+ User: ${JSON.stringify(eloUpdate.userElo)})
4727
+ Card: ${JSON.stringify(eloUpdate.cardElo)})
4728
+ `
4729
+ );
4730
+ }
4731
+ } else {
4732
+ logger.warn(
4733
+ `[EloService] Partial ELO update:
4734
+ User ELO update: ${userEloStatus ? "SUCCESS" : "FAILED"}
4735
+ Card ELO update: ${cardEloStatus ? "SUCCESS" : "FAILED"}`
4736
+ );
4737
+ if (!userEloStatus && results[0].status === "rejected") {
4738
+ logger.error("[EloService] User ELO update error:", results[0].reason);
4739
+ }
4740
+ if (!cardEloStatus && results[1].status === "rejected") {
4741
+ logger.error("[EloService] Card ELO update error:", results[1].reason);
4742
+ }
4743
+ }
4744
+ }
4745
+ }
4746
+ };
4747
+
4748
+ // src/study/services/ResponseProcessor.ts
4749
+ init_core();
4750
+ init_logger();
4751
+ var ResponseProcessor = class {
4752
+ srsService;
4753
+ eloService;
4754
+ constructor(srsService, eloService) {
4755
+ this.srsService = srsService;
4756
+ this.eloService = eloService;
4757
+ }
4758
+ /**
4759
+ * Processes a user's response to a card, handling SRS scheduling and ELO updates.
4760
+ * @param cardRecord User's response record
4761
+ * @param cardHistory Promise resolving to the card's history
4762
+ * @param studySessionItem Current study session item
4763
+ * @param courseRegistrationDoc User's course registration (for ELO updates)
4764
+ * @param currentCard Current study session record
4765
+ * @param courseId Course identifier
4766
+ * @param cardId Card identifier
4767
+ * @param maxAttemptsPerView Maximum attempts allowed per view
4768
+ * @param maxSessionViews Maximum session views for this card
4769
+ * @param sessionViews Current number of session views
4770
+ * @returns ResponseResult with navigation and UI instructions
4771
+ */
4772
+ async processResponse(cardRecord, cardHistory, studySessionItem, courseRegistrationDoc, currentCard, courseId, cardId, maxAttemptsPerView, maxSessionViews, sessionViews) {
4773
+ if (!isQuestionRecord(cardRecord)) {
4774
+ return {
4775
+ nextCardAction: "dismiss-success",
4776
+ shouldLoadNextCard: true,
4777
+ isCorrect: true,
4778
+ // non-question records are considered "correct"
4779
+ shouldClearFeedbackShadow: true
4780
+ };
4781
+ }
4782
+ const history = await cardHistory;
4783
+ if (cardRecord.isCorrect) {
4784
+ return this.processCorrectResponse(
4785
+ cardRecord,
4786
+ history,
4787
+ studySessionItem,
4788
+ courseRegistrationDoc,
4789
+ currentCard,
4790
+ courseId,
4791
+ cardId
4792
+ );
4793
+ } else {
4794
+ return this.processIncorrectResponse(
4795
+ cardRecord,
4796
+ history,
4797
+ courseRegistrationDoc,
4798
+ currentCard,
4799
+ courseId,
4800
+ cardId,
4801
+ maxAttemptsPerView,
4802
+ maxSessionViews,
4803
+ sessionViews
4804
+ );
4805
+ }
4806
+ }
4807
+ /**
4808
+ * Handles processing for correct responses: SRS scheduling and ELO updates.
4809
+ */
4810
+ processCorrectResponse(cardRecord, history, studySessionItem, courseRegistrationDoc, currentCard, courseId, cardId) {
4811
+ if (cardRecord.priorAttemps === 0) {
4812
+ void this.srsService.scheduleReview(history, studySessionItem);
4813
+ if (history.records.length === 1) {
4814
+ const userScore = 0.5 + cardRecord.performance / 2;
4815
+ void this.eloService.updateUserAndCardElo(
4816
+ userScore,
4817
+ courseId,
4818
+ cardId,
4819
+ courseRegistrationDoc,
4820
+ currentCard
4821
+ );
4822
+ } else {
4823
+ const k = Math.ceil(32 / history.records.length);
4824
+ const userScore = 0.5 + cardRecord.performance / 2;
4825
+ void this.eloService.updateUserAndCardElo(
4826
+ userScore,
4827
+ courseId,
4828
+ cardId,
4829
+ courseRegistrationDoc,
4830
+ currentCard,
4831
+ k
4832
+ );
4833
+ }
4834
+ logger.info(
4835
+ "[ResponseProcessor] Processed correct response with SRS scheduling and ELO update"
4836
+ );
4837
+ return {
4838
+ nextCardAction: "dismiss-success",
4839
+ shouldLoadNextCard: true,
4840
+ isCorrect: true,
4841
+ performanceScore: cardRecord.performance,
4842
+ shouldClearFeedbackShadow: true
4843
+ };
4844
+ } else {
4845
+ logger.info(
4846
+ "[ResponseProcessor] Processed correct response (retry attempt - no scheduling/ELO)"
4847
+ );
4848
+ return {
4849
+ nextCardAction: "marked-failed",
4850
+ shouldLoadNextCard: true,
4851
+ isCorrect: true,
4852
+ performanceScore: cardRecord.performance,
4853
+ shouldClearFeedbackShadow: true
4854
+ };
4855
+ }
4856
+ }
4857
+ /**
4858
+ * Handles processing for incorrect responses: ELO updates only.
4859
+ */
4860
+ processIncorrectResponse(cardRecord, history, courseRegistrationDoc, currentCard, courseId, cardId, maxAttemptsPerView, maxSessionViews, sessionViews) {
4861
+ if (history.records.length !== 1 && cardRecord.priorAttemps === 0) {
4862
+ void this.eloService.updateUserAndCardElo(
4863
+ 0,
4864
+ // Failed response = 0 score
4865
+ courseId,
4866
+ cardId,
4867
+ courseRegistrationDoc,
4868
+ currentCard
4869
+ );
4870
+ logger.info("[ResponseProcessor] Processed incorrect response with ELO update");
4871
+ } else {
4872
+ logger.info("[ResponseProcessor] Processed incorrect response (no ELO update needed)");
4873
+ }
4874
+ if (currentCard.records.length >= maxAttemptsPerView) {
4875
+ if (sessionViews >= maxSessionViews) {
4876
+ void this.eloService.updateUserAndCardElo(
4877
+ 0,
4878
+ courseId,
4879
+ cardId,
4880
+ courseRegistrationDoc,
4881
+ currentCard
4882
+ );
4883
+ return {
4884
+ nextCardAction: "dismiss-failed",
4885
+ shouldLoadNextCard: true,
4886
+ isCorrect: false,
4887
+ shouldClearFeedbackShadow: true
4888
+ };
4889
+ } else {
4890
+ return {
4891
+ nextCardAction: "marked-failed",
4892
+ shouldLoadNextCard: true,
4893
+ isCorrect: false,
4894
+ shouldClearFeedbackShadow: true
4895
+ };
4896
+ }
4897
+ } else {
4898
+ return {
4899
+ nextCardAction: "none",
4900
+ shouldLoadNextCard: false,
4901
+ isCorrect: false,
4902
+ shouldClearFeedbackShadow: true
4903
+ };
4904
+ }
4905
+ }
4906
+ };
4907
+
4908
+ // src/study/services/CardHydrationService.ts
4909
+ init_logger();
4910
+ import {
4911
+ displayableDataToViewData,
4912
+ isCourseElo,
4913
+ toCourseElo as toCourseElo4
4914
+ } from "@vue-skuilder/common";
4915
+
4916
+ // src/study/ItemQueue.ts
4917
+ var ItemQueue = class {
4918
+ q = [];
4919
+ seenCardIds = [];
4920
+ _dequeueCount = 0;
4921
+ get dequeueCount() {
4922
+ return this._dequeueCount;
4923
+ }
4924
+ add(item, cardId) {
4925
+ if (this.seenCardIds.find((d) => d === cardId)) {
4926
+ return;
4927
+ }
4928
+ this.seenCardIds.push(cardId);
4929
+ this.q.push(item);
4930
+ }
4931
+ addAll(items, cardIdExtractor) {
4932
+ items.forEach((i) => this.add(i, cardIdExtractor(i)));
4933
+ }
4934
+ get length() {
4935
+ return this.q.length;
4936
+ }
4937
+ peek(index) {
4938
+ return this.q[index];
4939
+ }
4940
+ dequeue(cardIdExtractor) {
4941
+ if (this.q.length !== 0) {
4942
+ this._dequeueCount++;
4943
+ const item = this.q.splice(0, 1)[0];
4944
+ if (cardIdExtractor) {
4945
+ const cardId = cardIdExtractor(item);
4946
+ const index = this.seenCardIds.indexOf(cardId);
4947
+ if (index > -1) {
4948
+ this.seenCardIds.splice(index, 1);
4949
+ }
4950
+ }
4951
+ return item;
4952
+ } else {
4953
+ return null;
4954
+ }
4955
+ }
4956
+ get toString() {
4957
+ return `${typeof this.q[0]}:
4958
+ ` + this.q.map((i) => ` ${i.courseID}+${i.cardID}: ${i.status}`).join("\n");
4959
+ }
4960
+ };
4961
+
4962
+ // src/study/services/CardHydrationService.ts
4963
+ var CardHydrationService = class {
4964
+ constructor(getViewComponent, getCourseDB3, selectNextItemToHydrate, removeItemFromQueue, hasAvailableCards) {
4965
+ this.getViewComponent = getViewComponent;
4966
+ this.getCourseDB = getCourseDB3;
4967
+ this.selectNextItemToHydrate = selectNextItemToHydrate;
4968
+ this.removeItemFromQueue = removeItemFromQueue;
4969
+ this.hasAvailableCards = hasAvailableCards;
4970
+ }
4971
+ hydratedQ = new ItemQueue();
4972
+ failedCardCache = /* @__PURE__ */ new Map();
4973
+ hydrationInProgress = false;
4974
+ BUFFER_SIZE = 5;
4975
+ /**
4976
+ * Get the next hydrated card from the queue.
4977
+ * @returns Hydrated card or null if none available
4978
+ */
4979
+ dequeueHydratedCard() {
4980
+ return this.hydratedQ.dequeue((item) => item.item.cardID);
4981
+ }
4982
+ /**
4983
+ * Check if hydration should be triggered and start background hydration if needed.
4984
+ */
4985
+ async ensureHydratedCards() {
4986
+ if (this.hydratedQ.length < 3) {
4987
+ void this.fillHydratedQueue();
4988
+ }
4989
+ }
4990
+ /**
4991
+ * Wait for a hydrated card to become available.
4992
+ * @returns Promise that resolves to a hydrated card or null
4993
+ */
4994
+ async waitForHydratedCard() {
4995
+ if (this.hydratedQ.length === 0 && this.hasAvailableCards()) {
4996
+ void this.fillHydratedQueue();
4997
+ }
4998
+ while (this.hydratedQ.length === 0 && this.hasAvailableCards()) {
4999
+ await new Promise((resolve) => setTimeout(resolve, 25));
5000
+ }
5001
+ return this.dequeueHydratedCard();
5002
+ }
5003
+ /**
5004
+ * Get current hydrated queue length.
5005
+ */
5006
+ get hydratedCount() {
5007
+ return this.hydratedQ.length;
5008
+ }
5009
+ /**
5010
+ * Fill the hydrated queue up to BUFFER_SIZE with pre-fetched cards.
5011
+ */
5012
+ async fillHydratedQueue() {
5013
+ if (this.hydrationInProgress) {
5014
+ return;
5015
+ }
5016
+ this.hydrationInProgress = true;
5017
+ try {
5018
+ while (this.hydratedQ.length < this.BUFFER_SIZE) {
5019
+ const nextItem = this.selectNextItemToHydrate();
5020
+ if (!nextItem) {
5021
+ return;
5022
+ }
5023
+ try {
5024
+ if (this.failedCardCache.has(nextItem.cardID)) {
5025
+ const cachedCard = this.failedCardCache.get(nextItem.cardID);
5026
+ this.hydratedQ.add(cachedCard, cachedCard.item.cardID);
5027
+ this.failedCardCache.delete(nextItem.cardID);
5028
+ } else {
5029
+ const courseDB = this.getCourseDB(nextItem.courseID);
5030
+ const cardData = await courseDB.getCourseDoc(nextItem.cardID);
5031
+ if (!isCourseElo(cardData.elo)) {
5032
+ cardData.elo = toCourseElo4(cardData.elo);
5033
+ }
5034
+ const view = this.getViewComponent(cardData.id_view);
5035
+ const dataDocs = await Promise.all(
5036
+ cardData.id_displayable_data.map(
5037
+ (id) => courseDB.getCourseDoc(id, {
5038
+ attachments: true,
5039
+ binary: true
5040
+ })
5041
+ )
5042
+ );
5043
+ const data = dataDocs.map(displayableDataToViewData).reverse();
5044
+ this.hydratedQ.add(
5045
+ {
5046
+ item: nextItem,
5047
+ view,
5048
+ data
5049
+ },
5050
+ nextItem.cardID
5051
+ );
5052
+ }
5053
+ } catch (e) {
5054
+ logger.error(`Error hydrating card ${nextItem.cardID}:`, e);
5055
+ } finally {
5056
+ this.removeItemFromQueue(nextItem);
5057
+ }
5058
+ }
5059
+ } finally {
5060
+ this.hydrationInProgress = false;
5061
+ }
5062
+ }
5063
+ /**
5064
+ * Cache a failed card for quick re-access.
5065
+ */
5066
+ cacheFailedCard(card) {
5067
+ this.failedCardCache.set(card.item.cardID, card);
5068
+ }
5069
+ };
5070
+
4431
5071
  // src/study/SessionController.ts
4432
5072
  init_couch();
4433
5073
 
@@ -5855,65 +6495,27 @@ init_dataDirectory();
5855
6495
  init_tuiLogger();
5856
6496
 
5857
6497
  // src/study/SessionController.ts
5858
- import {
5859
- displayableDataToViewData,
5860
- isCourseElo,
5861
- toCourseElo as toCourseElo3
5862
- } from "@vue-skuilder/common";
5863
6498
  function randomInt(min, max) {
5864
6499
  return Math.floor(Math.random() * (max - min + 1)) + min;
5865
6500
  }
5866
- var ItemQueue = class {
5867
- q = [];
5868
- seenCardIds = [];
5869
- _dequeueCount = 0;
5870
- get dequeueCount() {
5871
- return this._dequeueCount;
5872
- }
5873
- add(item, cardId) {
5874
- if (this.seenCardIds.find((d) => d === cardId)) {
5875
- return;
5876
- }
5877
- this.seenCardIds.push(cardId);
5878
- this.q.push(item);
5879
- }
5880
- addAll(items, cardIdExtractor) {
5881
- items.forEach((i) => this.add(i, cardIdExtractor(i)));
5882
- }
5883
- get length() {
5884
- return this.q.length;
5885
- }
5886
- peek(index) {
5887
- return this.q[index];
5888
- }
5889
- dequeue() {
5890
- if (this.q.length !== 0) {
5891
- this._dequeueCount++;
5892
- return this.q.splice(0, 1)[0];
5893
- } else {
5894
- return null;
5895
- }
5896
- }
5897
- get toString() {
5898
- return `${typeof this.q[0]}:
5899
- ` + this.q.map((i) => ` ${i.courseID}+${i.cardID}: ${i.status}`).join("\n");
5900
- }
5901
- };
5902
6501
  var SessionController = class extends Loggable {
5903
6502
  _className = "SessionController";
6503
+ services;
6504
+ srsService;
6505
+ eloService;
6506
+ hydrationService;
5904
6507
  sources;
5905
- dataLayer;
5906
- getViewComponent;
6508
+ // dataLayer and getViewComponent now injected into CardHydrationService
5907
6509
  _sessionRecord = [];
5908
6510
  set sessionRecord(r) {
5909
6511
  this._sessionRecord = r;
5910
6512
  }
6513
+ // Session card stores
6514
+ _currentCard = null;
5911
6515
  reviewQ = new ItemQueue();
5912
6516
  newQ = new ItemQueue();
5913
6517
  failedQ = new ItemQueue();
5914
- hydratedQ = new ItemQueue();
5915
- _currentCard = null;
5916
- hydration_in_progress = false;
6518
+ // END Session card stores
5917
6519
  startTime;
5918
6520
  endTime;
5919
6521
  _secondsRemaining;
@@ -5933,19 +6535,29 @@ var SessionController = class extends Loggable {
5933
6535
  */
5934
6536
  constructor(sources, time, dataLayer, getViewComponent) {
5935
6537
  super();
6538
+ this.srsService = new SrsService(dataLayer.getUserDB());
6539
+ this.eloService = new EloService(dataLayer, dataLayer.getUserDB());
6540
+ this.hydrationService = new CardHydrationService(
6541
+ getViewComponent,
6542
+ (courseId) => dataLayer.getCourseDB(courseId),
6543
+ () => this._selectNextItemToHydrate(),
6544
+ (item) => this.removeItemFromQueue(item),
6545
+ () => this.hasAvailableCards()
6546
+ );
6547
+ this.services = {
6548
+ response: new ResponseProcessor(this.srsService, this.eloService)
6549
+ };
5936
6550
  this.sources = sources;
5937
6551
  this.startTime = /* @__PURE__ */ new Date();
5938
6552
  this._secondsRemaining = time;
5939
6553
  this.endTime = new Date(this.startTime.valueOf() + 1e3 * this._secondsRemaining);
5940
- this.dataLayer = dataLayer;
5941
- this.getViewComponent = getViewComponent;
5942
6554
  this.log(`Session constructed:
5943
6555
  startTime: ${this.startTime}
5944
6556
  endTime: ${this.endTime}`);
5945
6557
  }
5946
6558
  tick() {
5947
6559
  this._secondsRemaining = Math.floor((this.endTime.valueOf() - Date.now()) / 1e3);
5948
- if (this._secondsRemaining === 0) {
6560
+ if (this._secondsRemaining <= 0) {
5949
6561
  clearInterval(this._intervalHandle);
5950
6562
  }
5951
6563
  }
@@ -5989,7 +6601,7 @@ var SessionController = class extends Loggable {
5989
6601
  } catch (e) {
5990
6602
  this.error("Error preparing study session:", e);
5991
6603
  }
5992
- await this._fillHydratedQueue();
6604
+ await this.hydrationService.ensureHydratedCards();
5993
6605
  this._intervalHandle = setInterval(() => {
5994
6606
  this.tick();
5995
6607
  }, 1e3);
@@ -6050,7 +6662,7 @@ var SessionController = class extends Loggable {
6050
6662
  }
6051
6663
  }
6052
6664
  }
6053
- _selectNextItemToHydrate(action = "dismiss-success") {
6665
+ _selectNextItemToHydrate() {
6054
6666
  const choice = Math.random();
6055
6667
  let newBound = 0.1;
6056
6668
  let reviewBound = 0.75;
@@ -6083,9 +6695,6 @@ var SessionController = class extends Loggable {
6083
6695
  newBound = 0.01;
6084
6696
  reviewBound = 0.1;
6085
6697
  }
6086
- if (this.failedQ.length === 1 && action === "marked-failed") {
6087
- reviewBound = 1;
6088
- }
6089
6698
  if (this.failedQ.length === 0) {
6090
6699
  reviewBound = 1;
6091
6700
  }
@@ -6105,36 +6714,73 @@ var SessionController = class extends Loggable {
6105
6714
  }
6106
6715
  async nextCard(action = "dismiss-success") {
6107
6716
  this.dismissCurrentCard(action);
6108
- let card = this.hydratedQ.dequeue();
6717
+ if (this._secondsRemaining <= 0 && this.failedQ.length === 0) {
6718
+ this._currentCard = null;
6719
+ return null;
6720
+ }
6721
+ let card = this.hydrationService.dequeueHydratedCard();
6109
6722
  if (!card && this.hasAvailableCards()) {
6110
- void this._fillHydratedQueue();
6111
- card = await this.nextHydratedCard();
6723
+ card = await this.hydrationService.waitForHydratedCard();
6112
6724
  }
6113
- if (this.hydratedQ.length < 3) {
6114
- void this._fillHydratedQueue();
6725
+ await this.hydrationService.ensureHydratedCards();
6726
+ if (card) {
6727
+ this._currentCard = card;
6728
+ } else {
6729
+ this._currentCard = null;
6115
6730
  }
6116
6731
  return card;
6117
6732
  }
6733
+ /**
6734
+ * Public API for processing user responses to cards.
6735
+ * @param cardRecord User's response record
6736
+ * @param cardHistory Promise resolving to the card's history
6737
+ * @param courseRegistrationDoc User's course registration document
6738
+ * @param currentCard Current study session record
6739
+ * @param courseId Course identifier
6740
+ * @param cardId Card identifier
6741
+ * @param maxAttemptsPerView Maximum attempts allowed per view
6742
+ * @param maxSessionViews Maximum session views for this card
6743
+ * @param sessionViews Current number of session views
6744
+ * @returns ResponseResult with navigation and UI instructions
6745
+ */
6746
+ async submitResponse(cardRecord, cardHistory, courseRegistrationDoc, currentCard, courseId, cardId, maxAttemptsPerView, maxSessionViews, sessionViews) {
6747
+ const studySessionItem = {
6748
+ ...currentCard.item
6749
+ };
6750
+ return await this.services.response.processResponse(
6751
+ cardRecord,
6752
+ cardHistory,
6753
+ studySessionItem,
6754
+ courseRegistrationDoc,
6755
+ currentCard,
6756
+ courseId,
6757
+ cardId,
6758
+ maxAttemptsPerView,
6759
+ maxSessionViews,
6760
+ sessionViews
6761
+ );
6762
+ }
6118
6763
  dismissCurrentCard(action = "dismiss-success") {
6119
6764
  if (this._currentCard) {
6120
6765
  if (action === "dismiss-success") {
6121
6766
  } else if (action === "marked-failed") {
6767
+ this.hydrationService.cacheFailedCard(this._currentCard);
6122
6768
  let failedItem;
6123
- if (isReview(this._currentCard)) {
6769
+ if (isReview(this._currentCard.item)) {
6124
6770
  failedItem = {
6125
- cardID: this._currentCard.cardID,
6126
- courseID: this._currentCard.courseID,
6127
- contentSourceID: this._currentCard.contentSourceID,
6128
- contentSourceType: this._currentCard.contentSourceType,
6771
+ cardID: this._currentCard.item.cardID,
6772
+ courseID: this._currentCard.item.courseID,
6773
+ contentSourceID: this._currentCard.item.contentSourceID,
6774
+ contentSourceType: this._currentCard.item.contentSourceType,
6129
6775
  status: "failed-review",
6130
- reviewID: this._currentCard.reviewID
6776
+ reviewID: this._currentCard.item.reviewID
6131
6777
  };
6132
6778
  } else {
6133
6779
  failedItem = {
6134
- cardID: this._currentCard.cardID,
6135
- courseID: this._currentCard.courseID,
6136
- contentSourceID: this._currentCard.contentSourceID,
6137
- contentSourceType: this._currentCard.contentSourceType,
6780
+ cardID: this._currentCard.item.cardID,
6781
+ courseID: this._currentCard.item.courseID,
6782
+ contentSourceID: this._currentCard.item.contentSourceID,
6783
+ contentSourceType: this._currentCard.item.contentSourceType,
6138
6784
  status: "failed-new"
6139
6785
  };
6140
6786
  }
@@ -6147,141 +6793,19 @@ var SessionController = class extends Loggable {
6147
6793
  hasAvailableCards() {
6148
6794
  return this.reviewQ.length > 0 || this.newQ.length > 0 || this.failedQ.length > 0;
6149
6795
  }
6150
- async nextHydratedCard() {
6151
- while (this.hydratedQ.length === 0 && this.hasAvailableCards()) {
6152
- await new Promise((resolve) => setTimeout(resolve, 25));
6153
- }
6154
- return this.hydratedQ.dequeue();
6155
- }
6156
- async _fillHydratedQueue() {
6157
- if (this.hydration_in_progress) {
6158
- return;
6159
- }
6160
- const BUFFER_SIZE = 5;
6161
- this.hydration_in_progress = true;
6162
- while (this.hydratedQ.length < BUFFER_SIZE) {
6163
- const nextItem = this._selectNextItemToHydrate();
6164
- if (!nextItem) {
6165
- return;
6166
- }
6167
- try {
6168
- const cardData = await this.dataLayer.getCourseDB(nextItem.courseID).getCourseDoc(nextItem.cardID);
6169
- if (!isCourseElo(cardData.elo)) {
6170
- cardData.elo = toCourseElo3(cardData.elo);
6171
- }
6172
- const view = this.getViewComponent(cardData.id_view);
6173
- const dataDocs = await Promise.all(
6174
- cardData.id_displayable_data.map(
6175
- (id) => this.dataLayer.getCourseDB(nextItem.courseID).getCourseDoc(id, {
6176
- attachments: true,
6177
- binary: true
6178
- })
6179
- )
6180
- );
6181
- const data = dataDocs.map(displayableDataToViewData).reverse();
6182
- this.hydratedQ.add(
6183
- {
6184
- item: nextItem,
6185
- view,
6186
- data
6187
- },
6188
- nextItem.cardID
6189
- );
6190
- if (this.reviewQ.peek(0) === nextItem) {
6191
- this.reviewQ.dequeue();
6192
- } else if (this.newQ.peek(0) === nextItem) {
6193
- this.newQ.dequeue();
6194
- } else {
6195
- this.failedQ.dequeue();
6196
- }
6197
- } catch (e) {
6198
- this.error(`Error hydrating card ${nextItem.cardID}:`, e);
6199
- if (this.reviewQ.peek(0) === nextItem) {
6200
- this.reviewQ.dequeue();
6201
- } else if (this.newQ.peek(0) === nextItem) {
6202
- this.newQ.dequeue();
6203
- } else {
6204
- this.failedQ.dequeue();
6205
- }
6206
- }
6207
- }
6208
- this.hydration_in_progress = false;
6209
- }
6210
- };
6211
-
6212
- // src/study/SpacedRepetition.ts
6213
- init_util();
6214
- init_logger();
6215
- import moment6 from "moment";
6216
- var duration = moment6.duration;
6217
- function newInterval(user, cardHistory) {
6218
- if (areQuestionRecords(cardHistory)) {
6219
- return newQuestionInterval(user, cardHistory);
6220
- } else {
6221
- return 1e5;
6222
- }
6223
- }
6224
- function newQuestionInterval(user, cardHistory) {
6225
- const records = cardHistory.records;
6226
- const currentAttempt = records[records.length - 1];
6227
- const lastInterval = lastSuccessfulInterval(records);
6228
- if (lastInterval > cardHistory.bestInterval) {
6229
- cardHistory.bestInterval = lastInterval;
6230
- void user.update(cardHistory._id, {
6231
- bestInterval: lastInterval
6232
- });
6233
- }
6234
- if (currentAttempt.isCorrect) {
6235
- const skill = Math.min(1, Math.max(0, currentAttempt.performance));
6236
- logger.debug(`Demontrated skill: ${skill}`);
6237
- const interval = lastInterval * (0.75 + skill);
6238
- cardHistory.lapses = getLapses(cardHistory.records);
6239
- cardHistory.streak = getStreak(cardHistory.records);
6240
- if (cardHistory.lapses && cardHistory.streak && cardHistory.bestInterval && (cardHistory.lapses >= 0 || cardHistory.streak >= 0)) {
6241
- const ret = (cardHistory.lapses * interval + cardHistory.streak * cardHistory.bestInterval) / (cardHistory.lapses + cardHistory.streak);
6242
- logger.debug(`Weighted average interval calculation:
6243
- (${cardHistory.lapses} * ${interval} + ${cardHistory.streak} * ${cardHistory.bestInterval}) / (${cardHistory.lapses} + ${cardHistory.streak}) = ${ret}`);
6244
- return ret;
6796
+ /**
6797
+ * Helper method for CardHydrationService to remove items from appropriate queue.
6798
+ */
6799
+ removeItemFromQueue(item) {
6800
+ if (this.reviewQ.peek(0) === item) {
6801
+ this.reviewQ.dequeue((queueItem) => queueItem.cardID);
6802
+ } else if (this.newQ.peek(0) === item) {
6803
+ this.newQ.dequeue((queueItem) => queueItem.cardID);
6245
6804
  } else {
6246
- return interval;
6805
+ this.failedQ.dequeue((queueItem) => queueItem.cardID);
6247
6806
  }
6248
- } else {
6249
- return 0;
6250
6807
  }
6251
- }
6252
- function lastSuccessfulInterval(cardHistory) {
6253
- for (let i = cardHistory.length - 1; i >= 1; i--) {
6254
- if (cardHistory[i].priorAttemps === 0 && cardHistory[i].isCorrect) {
6255
- const lastInterval = secondsBetween(cardHistory[i - 1].timeStamp, cardHistory[i].timeStamp);
6256
- const ret = Math.max(lastInterval, 20 * 60 * 60);
6257
- logger.debug(`Last interval w/ this card was: ${lastInterval}s, returning ${ret}s`);
6258
- return ret;
6259
- }
6260
- }
6261
- return getInitialInterval(cardHistory);
6262
- }
6263
- function getStreak(records) {
6264
- let streak = 0;
6265
- let index = records.length - 1;
6266
- while (index >= 0 && records[index].isCorrect) {
6267
- index--;
6268
- streak++;
6269
- }
6270
- return streak;
6271
- }
6272
- function getLapses(records) {
6273
- return records.filter((r) => r.isCorrect === false).length;
6274
- }
6275
- function getInitialInterval(cardHistory) {
6276
- logger.warn(`history of length: ${cardHistory.length} ignored!`);
6277
- return 60 * 60 * 24 * 3;
6278
- }
6279
- function secondsBetween(start, end) {
6280
- start = moment6(start);
6281
- end = moment6(end);
6282
- const ret = duration(end.diff(start)).asSeconds();
6283
- return ret;
6284
- }
6808
+ };
6285
6809
 
6286
6810
  // src/index.ts
6287
6811
  init_factory();