@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.js CHANGED
@@ -468,7 +468,9 @@ var init_updateQueue = __esm({
468
468
  async applyUpdates(id) {
469
469
  logger.debug(`Applying updates on doc: ${id}`);
470
470
  if (this.inprogressUpdates[id]) {
471
- await this.readDB.info();
471
+ while (this.inprogressUpdates[id]) {
472
+ await new Promise((resolve) => setTimeout(resolve, Math.random() * 50));
473
+ }
472
474
  return this.applyUpdates(id);
473
475
  } else {
474
476
  if (this.pendingUpdates[id] && this.pendingUpdates[id].length > 0) {
@@ -504,6 +506,9 @@ var init_updateQueue = __esm({
504
506
  if (e.name === "conflict" && i < MAX_RETRIES - 1) {
505
507
  logger.warn(`Conflict on update for doc ${id}, retry #${i + 1}`);
506
508
  await new Promise((res) => setTimeout(res, 50 * Math.random()));
509
+ } else if (e.name === "not_found" && i === 0) {
510
+ logger.warn(`Update failed for ${id} - does not exist. Throwing to caller.`);
511
+ throw e;
507
512
  } else {
508
513
  delete this.inprogressUpdates[id];
509
514
  if (this.pendingUpdates[id]) {
@@ -982,12 +987,74 @@ var init_elo = __esm({
982
987
  }
983
988
  });
984
989
 
990
+ // src/core/navigators/hardcodedOrder.ts
991
+ var hardcodedOrder_exports = {};
992
+ __export(hardcodedOrder_exports, {
993
+ default: () => HardcodedOrderNavigator
994
+ });
995
+ var HardcodedOrderNavigator;
996
+ var init_hardcodedOrder = __esm({
997
+ "src/core/navigators/hardcodedOrder.ts"() {
998
+ "use strict";
999
+ init_navigators();
1000
+ init_logger();
1001
+ HardcodedOrderNavigator = class extends ContentNavigator {
1002
+ orderedCardIds = [];
1003
+ user;
1004
+ course;
1005
+ constructor(user, course, strategyData) {
1006
+ super();
1007
+ this.user = user;
1008
+ this.course = course;
1009
+ if (strategyData.serializedData) {
1010
+ try {
1011
+ this.orderedCardIds = JSON.parse(strategyData.serializedData);
1012
+ } catch (e) {
1013
+ logger.error("Failed to parse serializedData for HardcodedOrderNavigator", e);
1014
+ }
1015
+ }
1016
+ }
1017
+ async getPendingReviews() {
1018
+ const reviews = await this.user.getPendingReviews(this.course.getCourseID());
1019
+ return reviews.map((r) => {
1020
+ return {
1021
+ ...r,
1022
+ contentSourceType: "course",
1023
+ contentSourceID: this.course.getCourseID(),
1024
+ cardID: r.cardId,
1025
+ courseID: r.courseId,
1026
+ reviewID: r._id,
1027
+ status: "review"
1028
+ };
1029
+ });
1030
+ }
1031
+ async getNewCards(limit = 99) {
1032
+ const activeCardIds = (await this.user.getActiveCards()).map((c) => c.cardID);
1033
+ const newCardIds = this.orderedCardIds.filter(
1034
+ (cardId) => !activeCardIds.includes(cardId)
1035
+ );
1036
+ const cardsToReturn = newCardIds.slice(0, limit);
1037
+ return cardsToReturn.map((cardId) => {
1038
+ return {
1039
+ cardID: cardId,
1040
+ courseID: this.course.getCourseID(),
1041
+ contentSourceType: "course",
1042
+ contentSourceID: this.course.getCourseID(),
1043
+ status: "new"
1044
+ };
1045
+ });
1046
+ }
1047
+ };
1048
+ }
1049
+ });
1050
+
985
1051
  // import("./**/*") in src/core/navigators/index.ts
986
1052
  var globImport;
987
1053
  var init_ = __esm({
988
1054
  'import("./**/*") in src/core/navigators/index.ts'() {
989
1055
  globImport = __glob({
990
1056
  "./elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
1057
+ "./hardcodedOrder.ts": () => Promise.resolve().then(() => (init_hardcodedOrder(), hardcodedOrder_exports)),
991
1058
  "./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports))
992
1059
  });
993
1060
  }
@@ -1007,6 +1074,7 @@ var init_navigators = __esm({
1007
1074
  init_();
1008
1075
  Navigators = /* @__PURE__ */ ((Navigators2) => {
1009
1076
  Navigators2["ELO"] = "elo";
1077
+ Navigators2["HARDCODED"] = "hardcodedOrder";
1010
1078
  return Navigators2;
1011
1079
  })(Navigators || {});
1012
1080
  ContentNavigator = class {
@@ -1276,6 +1344,23 @@ var init_courseDB = __esm({
1276
1344
  if (!doc.docType || !(doc.docType === "CARD" /* CARD */)) {
1277
1345
  throw new Error(`failed to remove ${id} from course ${this.id}. id does not point to a card`);
1278
1346
  }
1347
+ try {
1348
+ const appliedTags = await this.getAppliedTags(id);
1349
+ const results = await Promise.allSettled(
1350
+ appliedTags.rows.map(async (tagRow) => {
1351
+ const tagId = tagRow.id;
1352
+ await this.removeTagFromCard(id, tagId);
1353
+ })
1354
+ );
1355
+ results.forEach((result, index) => {
1356
+ if (result.status === "rejected") {
1357
+ const tagId = appliedTags.rows[index].id;
1358
+ logger.error(`Failed to remove card ${id} from tag ${tagId}: ${result.reason}`);
1359
+ }
1360
+ });
1361
+ } catch (error) {
1362
+ logger.error(`Error removing card ${id} from tags: ${error}`);
1363
+ }
1279
1364
  return this.db.remove(doc);
1280
1365
  }
1281
1366
  async getCardDisplayableDataIDs(id) {
@@ -1459,23 +1544,9 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
1459
1544
  ////////////////////////////////////
1460
1545
  getNavigationStrategy(id) {
1461
1546
  logger.debug(`[courseDB] Getting navigation strategy: ${id}`);
1462
- const strategy = {
1463
- id: "ELO",
1464
- docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
1465
- name: "ELO",
1466
- description: "ELO-based navigation strategy for ordering content by difficulty",
1467
- implementingClass: "elo" /* ELO */,
1468
- course: this.id,
1469
- serializedData: ""
1470
- // serde is a noop for ELO navigator.
1471
- };
1472
- return Promise.resolve(strategy);
1473
- }
1474
- getAllNavigationStrategies() {
1475
- logger.debug("[courseDB] Returning hard-coded navigation strategies");
1476
- const strategies = [
1477
- {
1478
- id: "ELO",
1547
+ if (id == "") {
1548
+ const strategy = {
1549
+ _id: "NAVIGATION_STRATEGY-ELO",
1479
1550
  docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
1480
1551
  name: "ELO",
1481
1552
  description: "ELO-based navigation strategy for ordering content by difficulty",
@@ -1483,14 +1554,25 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
1483
1554
  course: this.id,
1484
1555
  serializedData: ""
1485
1556
  // serde is a noop for ELO navigator.
1486
- }
1487
- ];
1488
- return Promise.resolve(strategies);
1557
+ };
1558
+ return Promise.resolve(strategy);
1559
+ } else {
1560
+ return this.db.get(id);
1561
+ }
1489
1562
  }
1490
- addNavigationStrategy(data) {
1491
- logger.debug(`[courseDB] Adding navigation strategy: ${data.id}`);
1492
- logger.debug(JSON.stringify(data));
1493
- return Promise.resolve();
1563
+ async getAllNavigationStrategies() {
1564
+ const prefix = DocTypePrefixes["NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */];
1565
+ const result = await this.db.allDocs({
1566
+ startkey: prefix,
1567
+ endkey: `${prefix}\uFFF0`,
1568
+ include_docs: true
1569
+ });
1570
+ return result.rows.map((row) => row.doc);
1571
+ }
1572
+ async addNavigationStrategy(data) {
1573
+ logger.debug(`[courseDB] Adding navigation strategy: ${data._id}`);
1574
+ return this.db.put(data).then(() => {
1575
+ });
1494
1576
  }
1495
1577
  updateNavigationStrategy(id, data) {
1496
1578
  logger.debug(`[courseDB] Updating navigation strategy: ${id}`);
@@ -1498,9 +1580,32 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
1498
1580
  return Promise.resolve();
1499
1581
  }
1500
1582
  async surfaceNavigationStrategy() {
1583
+ try {
1584
+ const config = await this.getCourseConfig();
1585
+ if (config.defaultNavigationStrategyId) {
1586
+ try {
1587
+ const strategy = await this.getNavigationStrategy(config.defaultNavigationStrategyId);
1588
+ if (strategy) {
1589
+ logger.debug(`Surfacing strategy ${strategy.name} from course config`);
1590
+ return strategy;
1591
+ }
1592
+ } catch (e) {
1593
+ logger.warn(
1594
+ // @ts-expect-error tmp: defaultNavigationStrategyId property does not yet exist
1595
+ `Failed to load strategy '${config.defaultNavigationStrategyId}' specified in course config. Falling back to ELO.`,
1596
+ e
1597
+ );
1598
+ }
1599
+ }
1600
+ } catch (e) {
1601
+ logger.warn(
1602
+ "Could not retrieve course config to determine navigation strategy. Falling back to ELO.",
1603
+ e
1604
+ );
1605
+ }
1501
1606
  logger.warn(`Returning hard-coded default ELO navigator`);
1502
1607
  const ret = {
1503
- id: "ELO",
1608
+ _id: "NAVIGATION_STRATEGY-ELO",
1504
1609
  docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
1505
1610
  name: "ELO",
1506
1611
  description: "ELO-based navigation strategy",
@@ -2964,17 +3069,21 @@ Currently logged-in as ${this._username}.`
2964
3069
  } catch (e) {
2965
3070
  const reason = e;
2966
3071
  if (reason.status === 404) {
2967
- const initCardHistory = {
2968
- _id: cardHistoryID,
2969
- cardID: record.cardID,
2970
- courseID: record.courseID,
2971
- records: [record],
2972
- lapses: 0,
2973
- streak: 0,
2974
- bestInterval: 0
2975
- };
2976
- const putResult = await this.writeDB.put(initCardHistory);
2977
- return { ...initCardHistory, _rev: putResult.rev };
3072
+ try {
3073
+ const initCardHistory = {
3074
+ _id: cardHistoryID,
3075
+ cardID: record.cardID,
3076
+ courseID: record.courseID,
3077
+ records: [record],
3078
+ lapses: 0,
3079
+ streak: 0,
3080
+ bestInterval: 0
3081
+ };
3082
+ const putResult = await this.writeDB.put(initCardHistory);
3083
+ return { ...initCardHistory, _rev: putResult.rev };
3084
+ } catch (creationError) {
3085
+ throw new Error(`Failed to create CardHistory for ${cardHistoryID}. Reason: ${creationError}`);
3086
+ }
2978
3087
  } else {
2979
3088
  throw new Error(`putCardRecord failed because of:
2980
3089
  name:${reason.name}
@@ -3754,16 +3863,20 @@ var init_courseDB2 = __esm({
3754
3863
  async updateCardElo(cardId, _elo) {
3755
3864
  return { ok: true, id: cardId, rev: "1-static" };
3756
3865
  }
3757
- async getNewCards(limit) {
3758
- const cardIds = await this.unpacker.queryByElo(1e3, limit || 10);
3759
- return cardIds.map((cardId) => ({
3760
- status: "new",
3761
- qualifiedID: `${this.courseId}-${cardId}`,
3762
- cardID: cardId,
3763
- contentSourceType: "course",
3764
- contentSourceID: this.courseId,
3765
- courseID: this.courseId
3766
- }));
3866
+ async getNewCards(limit = 99) {
3867
+ const activeCards = await this.userDB.getActiveCards();
3868
+ return (await this.getCardsCenteredAtELO({ limit, elo: "user" }, (c) => {
3869
+ if (activeCards.some((ac) => c.cardID === ac.cardID)) {
3870
+ return false;
3871
+ } else {
3872
+ return true;
3873
+ }
3874
+ })).map((c) => {
3875
+ return {
3876
+ ...c,
3877
+ status: "new"
3878
+ };
3879
+ });
3767
3880
  }
3768
3881
  async getCardsCenteredAtELO(options, filter) {
3769
3882
  let targetElo = typeof options.elo === "number" ? options.elo : 1e3;
@@ -3944,7 +4057,7 @@ var init_courseDB2 = __esm({
3944
4057
  // Navigation Strategy Manager implementation
3945
4058
  async getNavigationStrategy(_id) {
3946
4059
  return {
3947
- id: "ELO",
4060
+ _id: "NAVIGATION_STRATEGY-ELO",
3948
4061
  docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
3949
4062
  name: "ELO",
3950
4063
  description: "ELO-based navigation strategy",
@@ -4009,11 +4122,17 @@ var init_coursesDB = __esm({
4009
4122
  this.manifests = manifests;
4010
4123
  }
4011
4124
  async getCourseConfig(courseId) {
4012
- if (!this.manifests[courseId]) {
4013
- logger.warn(`Course ${courseId} not found`);
4014
- return {};
4125
+ const manifest = this.manifests[courseId];
4126
+ if (!manifest) {
4127
+ logger.warn(`Course manifest for ${courseId} not found`);
4128
+ throw new Error(`Course ${courseId} not found`);
4129
+ }
4130
+ if (manifest.courseConfig) {
4131
+ return manifest.courseConfig;
4132
+ } else {
4133
+ logger.warn(`Course config not found in manifest for course ${courseId}`);
4134
+ throw new Error(`Course config not found for course ${courseId}`);
4015
4135
  }
4016
- return {};
4017
4136
  }
4018
4137
  async getCourseList() {
4019
4138
  return Object.keys(this.manifests).map(
@@ -4105,23 +4224,49 @@ var init_StaticDataLayerProvider = __esm({
4105
4224
  config;
4106
4225
  initialized = false;
4107
4226
  courseUnpackers = /* @__PURE__ */ new Map();
4227
+ manifests = {};
4108
4228
  constructor(config) {
4109
4229
  this.config = {
4110
- staticContentPath: config.staticContentPath || "/static-courses",
4111
4230
  localStoragePrefix: config.localStoragePrefix || "skuilder-static",
4112
- manifests: config.manifests || {}
4231
+ rootManifest: config.rootManifest || { dependencies: {} },
4232
+ rootManifestUrl: config.rootManifestUrl || "/"
4113
4233
  };
4114
4234
  }
4235
+ async resolveCourseDependencies() {
4236
+ logger.info("[StaticDataLayerProvider] Starting course dependency resolution...");
4237
+ const rootManifest = this.config.rootManifest;
4238
+ for (const [courseName, courseUrl] of Object.entries(rootManifest.dependencies || {})) {
4239
+ try {
4240
+ logger.debug(`[StaticDataLayerProvider] Resolving dependency: ${courseName} from ${courseUrl}`);
4241
+ const courseManifestUrl = new URL(courseUrl, this.config.rootManifestUrl).href;
4242
+ const courseJsonResponse = await fetch(courseManifestUrl);
4243
+ if (!courseJsonResponse.ok) {
4244
+ throw new Error(`Failed to fetch course manifest for ${courseName}`);
4245
+ }
4246
+ const courseJson = await courseJsonResponse.json();
4247
+ if (courseJson.content && courseJson.content.manifest) {
4248
+ const baseUrl = new URL(".", courseManifestUrl).href;
4249
+ const finalManifestUrl = new URL(courseJson.content.manifest, courseManifestUrl).href;
4250
+ const finalManifestResponse = await fetch(finalManifestUrl);
4251
+ if (!finalManifestResponse.ok) {
4252
+ throw new Error(`Failed to fetch final content manifest for ${courseName} at ${finalManifestUrl}`);
4253
+ }
4254
+ const finalManifest = await finalManifestResponse.json();
4255
+ this.manifests[courseName] = finalManifest;
4256
+ const unpacker = new StaticDataUnpacker(finalManifest, baseUrl);
4257
+ this.courseUnpackers.set(courseName, unpacker);
4258
+ logger.info(`[StaticDataLayerProvider] Successfully resolved and prepared course: ${courseName}`);
4259
+ }
4260
+ } catch (e) {
4261
+ logger.error(`[StaticDataLayerProvider] Failed to resolve dependency ${courseName}:`, e);
4262
+ }
4263
+ }
4264
+ logger.info("[StaticDataLayerProvider] Course dependency resolution complete.");
4265
+ }
4115
4266
  async initialize() {
4116
4267
  if (this.initialized) return;
4117
4268
  logger.info("Initializing static data layer provider");
4118
- for (const [courseId, manifest] of Object.entries(this.config.manifests)) {
4119
- const unpacker = new StaticDataUnpacker(
4120
- manifest,
4121
- `${this.config.staticContentPath}/${courseId}`
4122
- );
4123
- this.courseUnpackers.set(courseId, unpacker);
4124
- }
4269
+ await this.resolveCourseDependencies();
4125
4270
  this.initialized = true;
4126
4271
  }
4127
4272
  async teardown() {
@@ -4135,13 +4280,13 @@ var init_StaticDataLayerProvider = __esm({
4135
4280
  getCourseDB(courseId) {
4136
4281
  const unpacker = this.courseUnpackers.get(courseId);
4137
4282
  if (!unpacker) {
4138
- throw new Error(`Course ${courseId} not found in static data`);
4283
+ throw new Error(`Course ${courseId} not found or failed to initialize in static data layer.`);
4139
4284
  }
4140
- const manifest = this.config.manifests[courseId];
4285
+ const manifest = this.manifests[courseId];
4141
4286
  return new StaticCourseDB(courseId, unpacker, this.getUserDB(), manifest);
4142
4287
  }
4143
4288
  getCoursesDB() {
4144
- return new StaticCoursesDB(this.config.manifests);
4289
+ return new StaticCoursesDB(this.manifests);
4145
4290
  }
4146
4291
  async getClassroomDB(_classId, _type) {
4147
4292
  throw new Error("Classrooms not supported in static mode");
@@ -4490,6 +4635,497 @@ module.exports = __toCommonJS(index_exports);
4490
4635
  init_core();
4491
4636
  init_courseLookupDB();
4492
4637
 
4638
+ // src/study/services/SrsService.ts
4639
+ var import_moment7 = __toESM(require("moment"));
4640
+ init_couch();
4641
+
4642
+ // src/study/SpacedRepetition.ts
4643
+ init_util();
4644
+ var import_moment6 = __toESM(require("moment"));
4645
+ init_logger();
4646
+ var duration = import_moment6.default.duration;
4647
+ function newInterval(user, cardHistory) {
4648
+ if (areQuestionRecords(cardHistory)) {
4649
+ return newQuestionInterval(user, cardHistory);
4650
+ } else {
4651
+ return 1e5;
4652
+ }
4653
+ }
4654
+ function newQuestionInterval(user, cardHistory) {
4655
+ const records = cardHistory.records;
4656
+ const currentAttempt = records[records.length - 1];
4657
+ const lastInterval = lastSuccessfulInterval(records);
4658
+ if (lastInterval > cardHistory.bestInterval) {
4659
+ cardHistory.bestInterval = lastInterval;
4660
+ void user.update(cardHistory._id, {
4661
+ bestInterval: lastInterval
4662
+ });
4663
+ }
4664
+ if (currentAttempt.isCorrect) {
4665
+ const skill = Math.min(1, Math.max(0, currentAttempt.performance));
4666
+ logger.debug(`Demontrated skill: ${skill}`);
4667
+ const interval = lastInterval * (0.75 + skill);
4668
+ cardHistory.lapses = getLapses(cardHistory.records);
4669
+ cardHistory.streak = getStreak(cardHistory.records);
4670
+ if (cardHistory.lapses && cardHistory.streak && cardHistory.bestInterval && (cardHistory.lapses >= 0 || cardHistory.streak >= 0)) {
4671
+ const ret = (cardHistory.lapses * interval + cardHistory.streak * cardHistory.bestInterval) / (cardHistory.lapses + cardHistory.streak);
4672
+ logger.debug(`Weighted average interval calculation:
4673
+ (${cardHistory.lapses} * ${interval} + ${cardHistory.streak} * ${cardHistory.bestInterval}) / (${cardHistory.lapses} + ${cardHistory.streak}) = ${ret}`);
4674
+ return ret;
4675
+ } else {
4676
+ return interval;
4677
+ }
4678
+ } else {
4679
+ return 0;
4680
+ }
4681
+ }
4682
+ function lastSuccessfulInterval(cardHistory) {
4683
+ for (let i = cardHistory.length - 1; i >= 1; i--) {
4684
+ if (cardHistory[i].priorAttemps === 0 && cardHistory[i].isCorrect) {
4685
+ const lastInterval = secondsBetween(cardHistory[i - 1].timeStamp, cardHistory[i].timeStamp);
4686
+ const ret = Math.max(lastInterval, 20 * 60 * 60);
4687
+ logger.debug(`Last interval w/ this card was: ${lastInterval}s, returning ${ret}s`);
4688
+ return ret;
4689
+ }
4690
+ }
4691
+ return getInitialInterval(cardHistory);
4692
+ }
4693
+ function getStreak(records) {
4694
+ let streak = 0;
4695
+ let index = records.length - 1;
4696
+ while (index >= 0 && records[index].isCorrect) {
4697
+ index--;
4698
+ streak++;
4699
+ }
4700
+ return streak;
4701
+ }
4702
+ function getLapses(records) {
4703
+ return records.filter((r) => r.isCorrect === false).length;
4704
+ }
4705
+ function getInitialInterval(cardHistory) {
4706
+ logger.warn(`history of length: ${cardHistory.length} ignored!`);
4707
+ return 60 * 60 * 24 * 3;
4708
+ }
4709
+ function secondsBetween(start, end) {
4710
+ start = (0, import_moment6.default)(start);
4711
+ end = (0, import_moment6.default)(end);
4712
+ const ret = duration(end.diff(start)).asSeconds();
4713
+ return ret;
4714
+ }
4715
+
4716
+ // src/study/services/SrsService.ts
4717
+ init_logger();
4718
+ var SrsService = class {
4719
+ user;
4720
+ constructor(user) {
4721
+ this.user = user;
4722
+ }
4723
+ /**
4724
+ * Calculates the next review time for a card based on its history and
4725
+ * schedules it in the user's database.
4726
+ * @param history The full history of the card.
4727
+ * @param item The study session item, used to determine if a previous review needs to be cleared.
4728
+ */
4729
+ async scheduleReview(history, item) {
4730
+ const nextInterval = newInterval(this.user, history);
4731
+ const nextReviewTime = import_moment7.default.utc().add(nextInterval, "seconds");
4732
+ if (isReview(item)) {
4733
+ logger.info(`[SrsService] Removing previously scheduled review for: ${item.cardID}`);
4734
+ void this.user.removeScheduledCardReview(item.reviewID);
4735
+ }
4736
+ void this.user.scheduleCardReview({
4737
+ user: this.user.getUsername(),
4738
+ course_id: history.courseID,
4739
+ card_id: history.cardID,
4740
+ time: nextReviewTime,
4741
+ scheduledFor: item.contentSourceType,
4742
+ schedulingAgentId: item.contentSourceID
4743
+ });
4744
+ }
4745
+ };
4746
+
4747
+ // src/study/services/EloService.ts
4748
+ var import_common15 = require("@vue-skuilder/common");
4749
+ init_logger();
4750
+ var EloService = class {
4751
+ dataLayer;
4752
+ user;
4753
+ constructor(dataLayer, user) {
4754
+ this.dataLayer = dataLayer;
4755
+ this.user = user;
4756
+ }
4757
+ /**
4758
+ * Updates both user and card ELO ratings based on user performance.
4759
+ * @param userScore Score between 0-1 representing user performance
4760
+ * @param course_id Course identifier
4761
+ * @param card_id Card identifier
4762
+ * @param userCourseRegDoc User's course registration document (will be mutated)
4763
+ * @param currentCard Current card session record
4764
+ * @param k Optional K-factor for ELO calculation
4765
+ */
4766
+ async updateUserAndCardElo(userScore, course_id, card_id, userCourseRegDoc, currentCard, k) {
4767
+ if (k) {
4768
+ logger.warn(`k value interpretation not currently implemented`);
4769
+ }
4770
+ const courseDB = this.dataLayer.getCourseDB(currentCard.card.course_id);
4771
+ const userElo = (0, import_common15.toCourseElo)(userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo);
4772
+ const cardElo = (await courseDB.getCardEloData([currentCard.card.card_id]))[0];
4773
+ if (cardElo && userElo) {
4774
+ const eloUpdate = (0, import_common15.adjustCourseScores)(userElo, cardElo, userScore);
4775
+ userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo = eloUpdate.userElo;
4776
+ const results = await Promise.allSettled([
4777
+ this.user.updateUserElo(course_id, eloUpdate.userElo),
4778
+ courseDB.updateCardElo(card_id, eloUpdate.cardElo)
4779
+ ]);
4780
+ const userEloStatus = results[0].status === "fulfilled";
4781
+ const cardEloStatus = results[1].status === "fulfilled";
4782
+ if (userEloStatus && cardEloStatus) {
4783
+ const user = results[0].value;
4784
+ const card = results[1].value;
4785
+ if (user.ok && card && card.ok) {
4786
+ logger.info(
4787
+ `[EloService] Updated ELOS:
4788
+ User: ${JSON.stringify(eloUpdate.userElo)})
4789
+ Card: ${JSON.stringify(eloUpdate.cardElo)})
4790
+ `
4791
+ );
4792
+ }
4793
+ } else {
4794
+ logger.warn(
4795
+ `[EloService] Partial ELO update:
4796
+ User ELO update: ${userEloStatus ? "SUCCESS" : "FAILED"}
4797
+ Card ELO update: ${cardEloStatus ? "SUCCESS" : "FAILED"}`
4798
+ );
4799
+ if (!userEloStatus && results[0].status === "rejected") {
4800
+ logger.error("[EloService] User ELO update error:", results[0].reason);
4801
+ }
4802
+ if (!cardEloStatus && results[1].status === "rejected") {
4803
+ logger.error("[EloService] Card ELO update error:", results[1].reason);
4804
+ }
4805
+ }
4806
+ }
4807
+ }
4808
+ };
4809
+
4810
+ // src/study/services/ResponseProcessor.ts
4811
+ init_core();
4812
+ init_logger();
4813
+ var ResponseProcessor = class {
4814
+ srsService;
4815
+ eloService;
4816
+ constructor(srsService, eloService) {
4817
+ this.srsService = srsService;
4818
+ this.eloService = eloService;
4819
+ }
4820
+ /**
4821
+ * Processes a user's response to a card, handling SRS scheduling and ELO updates.
4822
+ * @param cardRecord User's response record
4823
+ * @param cardHistory Promise resolving to the card's history
4824
+ * @param studySessionItem Current study session item
4825
+ * @param courseRegistrationDoc User's course registration (for ELO updates)
4826
+ * @param currentCard Current study session record
4827
+ * @param courseId Course identifier
4828
+ * @param cardId Card identifier
4829
+ * @param maxAttemptsPerView Maximum attempts allowed per view
4830
+ * @param maxSessionViews Maximum session views for this card
4831
+ * @param sessionViews Current number of session views
4832
+ * @returns ResponseResult with navigation and UI instructions
4833
+ */
4834
+ async processResponse(cardRecord, cardHistory, studySessionItem, courseRegistrationDoc, currentCard, courseId, cardId, maxAttemptsPerView, maxSessionViews, sessionViews) {
4835
+ if (!isQuestionRecord(cardRecord)) {
4836
+ return {
4837
+ nextCardAction: "dismiss-success",
4838
+ shouldLoadNextCard: true,
4839
+ isCorrect: true,
4840
+ // non-question records are considered "correct"
4841
+ shouldClearFeedbackShadow: true
4842
+ };
4843
+ }
4844
+ const history = await cardHistory;
4845
+ if (cardRecord.isCorrect) {
4846
+ return this.processCorrectResponse(
4847
+ cardRecord,
4848
+ history,
4849
+ studySessionItem,
4850
+ courseRegistrationDoc,
4851
+ currentCard,
4852
+ courseId,
4853
+ cardId
4854
+ );
4855
+ } else {
4856
+ return this.processIncorrectResponse(
4857
+ cardRecord,
4858
+ history,
4859
+ courseRegistrationDoc,
4860
+ currentCard,
4861
+ courseId,
4862
+ cardId,
4863
+ maxAttemptsPerView,
4864
+ maxSessionViews,
4865
+ sessionViews
4866
+ );
4867
+ }
4868
+ }
4869
+ /**
4870
+ * Handles processing for correct responses: SRS scheduling and ELO updates.
4871
+ */
4872
+ processCorrectResponse(cardRecord, history, studySessionItem, courseRegistrationDoc, currentCard, courseId, cardId) {
4873
+ if (cardRecord.priorAttemps === 0) {
4874
+ void this.srsService.scheduleReview(history, studySessionItem);
4875
+ if (history.records.length === 1) {
4876
+ const userScore = 0.5 + cardRecord.performance / 2;
4877
+ void this.eloService.updateUserAndCardElo(
4878
+ userScore,
4879
+ courseId,
4880
+ cardId,
4881
+ courseRegistrationDoc,
4882
+ currentCard
4883
+ );
4884
+ } else {
4885
+ const k = Math.ceil(32 / history.records.length);
4886
+ const userScore = 0.5 + cardRecord.performance / 2;
4887
+ void this.eloService.updateUserAndCardElo(
4888
+ userScore,
4889
+ courseId,
4890
+ cardId,
4891
+ courseRegistrationDoc,
4892
+ currentCard,
4893
+ k
4894
+ );
4895
+ }
4896
+ logger.info(
4897
+ "[ResponseProcessor] Processed correct response with SRS scheduling and ELO update"
4898
+ );
4899
+ return {
4900
+ nextCardAction: "dismiss-success",
4901
+ shouldLoadNextCard: true,
4902
+ isCorrect: true,
4903
+ performanceScore: cardRecord.performance,
4904
+ shouldClearFeedbackShadow: true
4905
+ };
4906
+ } else {
4907
+ logger.info(
4908
+ "[ResponseProcessor] Processed correct response (retry attempt - no scheduling/ELO)"
4909
+ );
4910
+ return {
4911
+ nextCardAction: "marked-failed",
4912
+ shouldLoadNextCard: true,
4913
+ isCorrect: true,
4914
+ performanceScore: cardRecord.performance,
4915
+ shouldClearFeedbackShadow: true
4916
+ };
4917
+ }
4918
+ }
4919
+ /**
4920
+ * Handles processing for incorrect responses: ELO updates only.
4921
+ */
4922
+ processIncorrectResponse(cardRecord, history, courseRegistrationDoc, currentCard, courseId, cardId, maxAttemptsPerView, maxSessionViews, sessionViews) {
4923
+ if (history.records.length !== 1 && cardRecord.priorAttemps === 0) {
4924
+ void this.eloService.updateUserAndCardElo(
4925
+ 0,
4926
+ // Failed response = 0 score
4927
+ courseId,
4928
+ cardId,
4929
+ courseRegistrationDoc,
4930
+ currentCard
4931
+ );
4932
+ logger.info("[ResponseProcessor] Processed incorrect response with ELO update");
4933
+ } else {
4934
+ logger.info("[ResponseProcessor] Processed incorrect response (no ELO update needed)");
4935
+ }
4936
+ if (currentCard.records.length >= maxAttemptsPerView) {
4937
+ if (sessionViews >= maxSessionViews) {
4938
+ void this.eloService.updateUserAndCardElo(
4939
+ 0,
4940
+ courseId,
4941
+ cardId,
4942
+ courseRegistrationDoc,
4943
+ currentCard
4944
+ );
4945
+ return {
4946
+ nextCardAction: "dismiss-failed",
4947
+ shouldLoadNextCard: true,
4948
+ isCorrect: false,
4949
+ shouldClearFeedbackShadow: true
4950
+ };
4951
+ } else {
4952
+ return {
4953
+ nextCardAction: "marked-failed",
4954
+ shouldLoadNextCard: true,
4955
+ isCorrect: false,
4956
+ shouldClearFeedbackShadow: true
4957
+ };
4958
+ }
4959
+ } else {
4960
+ return {
4961
+ nextCardAction: "none",
4962
+ shouldLoadNextCard: false,
4963
+ isCorrect: false,
4964
+ shouldClearFeedbackShadow: true
4965
+ };
4966
+ }
4967
+ }
4968
+ };
4969
+
4970
+ // src/study/services/CardHydrationService.ts
4971
+ var import_common16 = require("@vue-skuilder/common");
4972
+ init_logger();
4973
+
4974
+ // src/study/ItemQueue.ts
4975
+ var ItemQueue = class {
4976
+ q = [];
4977
+ seenCardIds = [];
4978
+ _dequeueCount = 0;
4979
+ get dequeueCount() {
4980
+ return this._dequeueCount;
4981
+ }
4982
+ add(item, cardId) {
4983
+ if (this.seenCardIds.find((d) => d === cardId)) {
4984
+ return;
4985
+ }
4986
+ this.seenCardIds.push(cardId);
4987
+ this.q.push(item);
4988
+ }
4989
+ addAll(items, cardIdExtractor) {
4990
+ items.forEach((i) => this.add(i, cardIdExtractor(i)));
4991
+ }
4992
+ get length() {
4993
+ return this.q.length;
4994
+ }
4995
+ peek(index) {
4996
+ return this.q[index];
4997
+ }
4998
+ dequeue(cardIdExtractor) {
4999
+ if (this.q.length !== 0) {
5000
+ this._dequeueCount++;
5001
+ const item = this.q.splice(0, 1)[0];
5002
+ if (cardIdExtractor) {
5003
+ const cardId = cardIdExtractor(item);
5004
+ const index = this.seenCardIds.indexOf(cardId);
5005
+ if (index > -1) {
5006
+ this.seenCardIds.splice(index, 1);
5007
+ }
5008
+ }
5009
+ return item;
5010
+ } else {
5011
+ return null;
5012
+ }
5013
+ }
5014
+ get toString() {
5015
+ return `${typeof this.q[0]}:
5016
+ ` + this.q.map((i) => ` ${i.courseID}+${i.cardID}: ${i.status}`).join("\n");
5017
+ }
5018
+ };
5019
+
5020
+ // src/study/services/CardHydrationService.ts
5021
+ var CardHydrationService = class {
5022
+ constructor(getViewComponent, getCourseDB3, selectNextItemToHydrate, removeItemFromQueue, hasAvailableCards) {
5023
+ this.getViewComponent = getViewComponent;
5024
+ this.getCourseDB = getCourseDB3;
5025
+ this.selectNextItemToHydrate = selectNextItemToHydrate;
5026
+ this.removeItemFromQueue = removeItemFromQueue;
5027
+ this.hasAvailableCards = hasAvailableCards;
5028
+ }
5029
+ hydratedQ = new ItemQueue();
5030
+ failedCardCache = /* @__PURE__ */ new Map();
5031
+ hydrationInProgress = false;
5032
+ BUFFER_SIZE = 5;
5033
+ /**
5034
+ * Get the next hydrated card from the queue.
5035
+ * @returns Hydrated card or null if none available
5036
+ */
5037
+ dequeueHydratedCard() {
5038
+ return this.hydratedQ.dequeue((item) => item.item.cardID);
5039
+ }
5040
+ /**
5041
+ * Check if hydration should be triggered and start background hydration if needed.
5042
+ */
5043
+ async ensureHydratedCards() {
5044
+ if (this.hydratedQ.length < 3) {
5045
+ void this.fillHydratedQueue();
5046
+ }
5047
+ }
5048
+ /**
5049
+ * Wait for a hydrated card to become available.
5050
+ * @returns Promise that resolves to a hydrated card or null
5051
+ */
5052
+ async waitForHydratedCard() {
5053
+ if (this.hydratedQ.length === 0 && this.hasAvailableCards()) {
5054
+ void this.fillHydratedQueue();
5055
+ }
5056
+ while (this.hydratedQ.length === 0 && this.hasAvailableCards()) {
5057
+ await new Promise((resolve) => setTimeout(resolve, 25));
5058
+ }
5059
+ return this.dequeueHydratedCard();
5060
+ }
5061
+ /**
5062
+ * Get current hydrated queue length.
5063
+ */
5064
+ get hydratedCount() {
5065
+ return this.hydratedQ.length;
5066
+ }
5067
+ /**
5068
+ * Fill the hydrated queue up to BUFFER_SIZE with pre-fetched cards.
5069
+ */
5070
+ async fillHydratedQueue() {
5071
+ if (this.hydrationInProgress) {
5072
+ return;
5073
+ }
5074
+ this.hydrationInProgress = true;
5075
+ try {
5076
+ while (this.hydratedQ.length < this.BUFFER_SIZE) {
5077
+ const nextItem = this.selectNextItemToHydrate();
5078
+ if (!nextItem) {
5079
+ return;
5080
+ }
5081
+ try {
5082
+ if (this.failedCardCache.has(nextItem.cardID)) {
5083
+ const cachedCard = this.failedCardCache.get(nextItem.cardID);
5084
+ this.hydratedQ.add(cachedCard, cachedCard.item.cardID);
5085
+ this.failedCardCache.delete(nextItem.cardID);
5086
+ } else {
5087
+ const courseDB = this.getCourseDB(nextItem.courseID);
5088
+ const cardData = await courseDB.getCourseDoc(nextItem.cardID);
5089
+ if (!(0, import_common16.isCourseElo)(cardData.elo)) {
5090
+ cardData.elo = (0, import_common16.toCourseElo)(cardData.elo);
5091
+ }
5092
+ const view = this.getViewComponent(cardData.id_view);
5093
+ const dataDocs = await Promise.all(
5094
+ cardData.id_displayable_data.map(
5095
+ (id) => courseDB.getCourseDoc(id, {
5096
+ attachments: true,
5097
+ binary: true
5098
+ })
5099
+ )
5100
+ );
5101
+ const data = dataDocs.map(import_common16.displayableDataToViewData).reverse();
5102
+ this.hydratedQ.add(
5103
+ {
5104
+ item: nextItem,
5105
+ view,
5106
+ data
5107
+ },
5108
+ nextItem.cardID
5109
+ );
5110
+ }
5111
+ } catch (e) {
5112
+ logger.error(`Error hydrating card ${nextItem.cardID}:`, e);
5113
+ } finally {
5114
+ this.removeItemFromQueue(nextItem);
5115
+ }
5116
+ }
5117
+ } finally {
5118
+ this.hydrationInProgress = false;
5119
+ }
5120
+ }
5121
+ /**
5122
+ * Cache a failed card for quick re-access.
5123
+ */
5124
+ cacheFailedCard(card) {
5125
+ this.failedCardCache.set(card.item.cardID, card);
5126
+ }
5127
+ };
5128
+
4493
5129
  // src/study/SessionController.ts
4494
5130
  init_couch();
4495
5131
 
@@ -5917,61 +6553,27 @@ init_dataDirectory();
5917
6553
  init_tuiLogger();
5918
6554
 
5919
6555
  // src/study/SessionController.ts
5920
- var import_common15 = require("@vue-skuilder/common");
5921
6556
  function randomInt(min, max) {
5922
6557
  return Math.floor(Math.random() * (max - min + 1)) + min;
5923
6558
  }
5924
- var ItemQueue = class {
5925
- q = [];
5926
- seenCardIds = [];
5927
- _dequeueCount = 0;
5928
- get dequeueCount() {
5929
- return this._dequeueCount;
5930
- }
5931
- add(item, cardId) {
5932
- if (this.seenCardIds.find((d) => d === cardId)) {
5933
- return;
5934
- }
5935
- this.seenCardIds.push(cardId);
5936
- this.q.push(item);
5937
- }
5938
- addAll(items, cardIdExtractor) {
5939
- items.forEach((i) => this.add(i, cardIdExtractor(i)));
5940
- }
5941
- get length() {
5942
- return this.q.length;
5943
- }
5944
- peek(index) {
5945
- return this.q[index];
5946
- }
5947
- dequeue() {
5948
- if (this.q.length !== 0) {
5949
- this._dequeueCount++;
5950
- return this.q.splice(0, 1)[0];
5951
- } else {
5952
- return null;
5953
- }
5954
- }
5955
- get toString() {
5956
- return `${typeof this.q[0]}:
5957
- ` + this.q.map((i) => ` ${i.courseID}+${i.cardID}: ${i.status}`).join("\n");
5958
- }
5959
- };
5960
6559
  var SessionController = class extends Loggable {
5961
6560
  _className = "SessionController";
6561
+ services;
6562
+ srsService;
6563
+ eloService;
6564
+ hydrationService;
5962
6565
  sources;
5963
- dataLayer;
5964
- getViewComponent;
6566
+ // dataLayer and getViewComponent now injected into CardHydrationService
5965
6567
  _sessionRecord = [];
5966
6568
  set sessionRecord(r) {
5967
6569
  this._sessionRecord = r;
5968
6570
  }
6571
+ // Session card stores
6572
+ _currentCard = null;
5969
6573
  reviewQ = new ItemQueue();
5970
6574
  newQ = new ItemQueue();
5971
6575
  failedQ = new ItemQueue();
5972
- hydratedQ = new ItemQueue();
5973
- _currentCard = null;
5974
- hydration_in_progress = false;
6576
+ // END Session card stores
5975
6577
  startTime;
5976
6578
  endTime;
5977
6579
  _secondsRemaining;
@@ -5991,19 +6593,29 @@ var SessionController = class extends Loggable {
5991
6593
  */
5992
6594
  constructor(sources, time, dataLayer, getViewComponent) {
5993
6595
  super();
6596
+ this.srsService = new SrsService(dataLayer.getUserDB());
6597
+ this.eloService = new EloService(dataLayer, dataLayer.getUserDB());
6598
+ this.hydrationService = new CardHydrationService(
6599
+ getViewComponent,
6600
+ (courseId) => dataLayer.getCourseDB(courseId),
6601
+ () => this._selectNextItemToHydrate(),
6602
+ (item) => this.removeItemFromQueue(item),
6603
+ () => this.hasAvailableCards()
6604
+ );
6605
+ this.services = {
6606
+ response: new ResponseProcessor(this.srsService, this.eloService)
6607
+ };
5994
6608
  this.sources = sources;
5995
6609
  this.startTime = /* @__PURE__ */ new Date();
5996
6610
  this._secondsRemaining = time;
5997
6611
  this.endTime = new Date(this.startTime.valueOf() + 1e3 * this._secondsRemaining);
5998
- this.dataLayer = dataLayer;
5999
- this.getViewComponent = getViewComponent;
6000
6612
  this.log(`Session constructed:
6001
6613
  startTime: ${this.startTime}
6002
6614
  endTime: ${this.endTime}`);
6003
6615
  }
6004
6616
  tick() {
6005
6617
  this._secondsRemaining = Math.floor((this.endTime.valueOf() - Date.now()) / 1e3);
6006
- if (this._secondsRemaining === 0) {
6618
+ if (this._secondsRemaining <= 0) {
6007
6619
  clearInterval(this._intervalHandle);
6008
6620
  }
6009
6621
  }
@@ -6047,7 +6659,7 @@ var SessionController = class extends Loggable {
6047
6659
  } catch (e) {
6048
6660
  this.error("Error preparing study session:", e);
6049
6661
  }
6050
- await this._fillHydratedQueue();
6662
+ await this.hydrationService.ensureHydratedCards();
6051
6663
  this._intervalHandle = setInterval(() => {
6052
6664
  this.tick();
6053
6665
  }, 1e3);
@@ -6108,7 +6720,7 @@ var SessionController = class extends Loggable {
6108
6720
  }
6109
6721
  }
6110
6722
  }
6111
- _selectNextItemToHydrate(action = "dismiss-success") {
6723
+ _selectNextItemToHydrate() {
6112
6724
  const choice = Math.random();
6113
6725
  let newBound = 0.1;
6114
6726
  let reviewBound = 0.75;
@@ -6141,9 +6753,6 @@ var SessionController = class extends Loggable {
6141
6753
  newBound = 0.01;
6142
6754
  reviewBound = 0.1;
6143
6755
  }
6144
- if (this.failedQ.length === 1 && action === "marked-failed") {
6145
- reviewBound = 1;
6146
- }
6147
6756
  if (this.failedQ.length === 0) {
6148
6757
  reviewBound = 1;
6149
6758
  }
@@ -6163,36 +6772,73 @@ var SessionController = class extends Loggable {
6163
6772
  }
6164
6773
  async nextCard(action = "dismiss-success") {
6165
6774
  this.dismissCurrentCard(action);
6166
- let card = this.hydratedQ.dequeue();
6775
+ if (this._secondsRemaining <= 0 && this.failedQ.length === 0) {
6776
+ this._currentCard = null;
6777
+ return null;
6778
+ }
6779
+ let card = this.hydrationService.dequeueHydratedCard();
6167
6780
  if (!card && this.hasAvailableCards()) {
6168
- void this._fillHydratedQueue();
6169
- card = await this.nextHydratedCard();
6781
+ card = await this.hydrationService.waitForHydratedCard();
6170
6782
  }
6171
- if (this.hydratedQ.length < 3) {
6172
- void this._fillHydratedQueue();
6783
+ await this.hydrationService.ensureHydratedCards();
6784
+ if (card) {
6785
+ this._currentCard = card;
6786
+ } else {
6787
+ this._currentCard = null;
6173
6788
  }
6174
6789
  return card;
6175
6790
  }
6791
+ /**
6792
+ * Public API for processing user responses to cards.
6793
+ * @param cardRecord User's response record
6794
+ * @param cardHistory Promise resolving to the card's history
6795
+ * @param courseRegistrationDoc User's course registration document
6796
+ * @param currentCard Current study session record
6797
+ * @param courseId Course identifier
6798
+ * @param cardId Card identifier
6799
+ * @param maxAttemptsPerView Maximum attempts allowed per view
6800
+ * @param maxSessionViews Maximum session views for this card
6801
+ * @param sessionViews Current number of session views
6802
+ * @returns ResponseResult with navigation and UI instructions
6803
+ */
6804
+ async submitResponse(cardRecord, cardHistory, courseRegistrationDoc, currentCard, courseId, cardId, maxAttemptsPerView, maxSessionViews, sessionViews) {
6805
+ const studySessionItem = {
6806
+ ...currentCard.item
6807
+ };
6808
+ return await this.services.response.processResponse(
6809
+ cardRecord,
6810
+ cardHistory,
6811
+ studySessionItem,
6812
+ courseRegistrationDoc,
6813
+ currentCard,
6814
+ courseId,
6815
+ cardId,
6816
+ maxAttemptsPerView,
6817
+ maxSessionViews,
6818
+ sessionViews
6819
+ );
6820
+ }
6176
6821
  dismissCurrentCard(action = "dismiss-success") {
6177
6822
  if (this._currentCard) {
6178
6823
  if (action === "dismiss-success") {
6179
6824
  } else if (action === "marked-failed") {
6825
+ this.hydrationService.cacheFailedCard(this._currentCard);
6180
6826
  let failedItem;
6181
- if (isReview(this._currentCard)) {
6827
+ if (isReview(this._currentCard.item)) {
6182
6828
  failedItem = {
6183
- cardID: this._currentCard.cardID,
6184
- courseID: this._currentCard.courseID,
6185
- contentSourceID: this._currentCard.contentSourceID,
6186
- contentSourceType: this._currentCard.contentSourceType,
6829
+ cardID: this._currentCard.item.cardID,
6830
+ courseID: this._currentCard.item.courseID,
6831
+ contentSourceID: this._currentCard.item.contentSourceID,
6832
+ contentSourceType: this._currentCard.item.contentSourceType,
6187
6833
  status: "failed-review",
6188
- reviewID: this._currentCard.reviewID
6834
+ reviewID: this._currentCard.item.reviewID
6189
6835
  };
6190
6836
  } else {
6191
6837
  failedItem = {
6192
- cardID: this._currentCard.cardID,
6193
- courseID: this._currentCard.courseID,
6194
- contentSourceID: this._currentCard.contentSourceID,
6195
- contentSourceType: this._currentCard.contentSourceType,
6838
+ cardID: this._currentCard.item.cardID,
6839
+ courseID: this._currentCard.item.courseID,
6840
+ contentSourceID: this._currentCard.item.contentSourceID,
6841
+ contentSourceType: this._currentCard.item.contentSourceType,
6196
6842
  status: "failed-new"
6197
6843
  };
6198
6844
  }
@@ -6205,141 +6851,19 @@ var SessionController = class extends Loggable {
6205
6851
  hasAvailableCards() {
6206
6852
  return this.reviewQ.length > 0 || this.newQ.length > 0 || this.failedQ.length > 0;
6207
6853
  }
6208
- async nextHydratedCard() {
6209
- while (this.hydratedQ.length === 0 && this.hasAvailableCards()) {
6210
- await new Promise((resolve) => setTimeout(resolve, 25));
6211
- }
6212
- return this.hydratedQ.dequeue();
6213
- }
6214
- async _fillHydratedQueue() {
6215
- if (this.hydration_in_progress) {
6216
- return;
6217
- }
6218
- const BUFFER_SIZE = 5;
6219
- this.hydration_in_progress = true;
6220
- while (this.hydratedQ.length < BUFFER_SIZE) {
6221
- const nextItem = this._selectNextItemToHydrate();
6222
- if (!nextItem) {
6223
- return;
6224
- }
6225
- try {
6226
- const cardData = await this.dataLayer.getCourseDB(nextItem.courseID).getCourseDoc(nextItem.cardID);
6227
- if (!(0, import_common15.isCourseElo)(cardData.elo)) {
6228
- cardData.elo = (0, import_common15.toCourseElo)(cardData.elo);
6229
- }
6230
- const view = this.getViewComponent(cardData.id_view);
6231
- const dataDocs = await Promise.all(
6232
- cardData.id_displayable_data.map(
6233
- (id) => this.dataLayer.getCourseDB(nextItem.courseID).getCourseDoc(id, {
6234
- attachments: true,
6235
- binary: true
6236
- })
6237
- )
6238
- );
6239
- const data = dataDocs.map(import_common15.displayableDataToViewData).reverse();
6240
- this.hydratedQ.add(
6241
- {
6242
- item: nextItem,
6243
- view,
6244
- data
6245
- },
6246
- nextItem.cardID
6247
- );
6248
- if (this.reviewQ.peek(0) === nextItem) {
6249
- this.reviewQ.dequeue();
6250
- } else if (this.newQ.peek(0) === nextItem) {
6251
- this.newQ.dequeue();
6252
- } else {
6253
- this.failedQ.dequeue();
6254
- }
6255
- } catch (e) {
6256
- this.error(`Error hydrating card ${nextItem.cardID}:`, e);
6257
- if (this.reviewQ.peek(0) === nextItem) {
6258
- this.reviewQ.dequeue();
6259
- } else if (this.newQ.peek(0) === nextItem) {
6260
- this.newQ.dequeue();
6261
- } else {
6262
- this.failedQ.dequeue();
6263
- }
6264
- }
6265
- }
6266
- this.hydration_in_progress = false;
6267
- }
6268
- };
6269
-
6270
- // src/study/SpacedRepetition.ts
6271
- init_util();
6272
- var import_moment6 = __toESM(require("moment"));
6273
- init_logger();
6274
- var duration = import_moment6.default.duration;
6275
- function newInterval(user, cardHistory) {
6276
- if (areQuestionRecords(cardHistory)) {
6277
- return newQuestionInterval(user, cardHistory);
6278
- } else {
6279
- return 1e5;
6280
- }
6281
- }
6282
- function newQuestionInterval(user, cardHistory) {
6283
- const records = cardHistory.records;
6284
- const currentAttempt = records[records.length - 1];
6285
- const lastInterval = lastSuccessfulInterval(records);
6286
- if (lastInterval > cardHistory.bestInterval) {
6287
- cardHistory.bestInterval = lastInterval;
6288
- void user.update(cardHistory._id, {
6289
- bestInterval: lastInterval
6290
- });
6291
- }
6292
- if (currentAttempt.isCorrect) {
6293
- const skill = Math.min(1, Math.max(0, currentAttempt.performance));
6294
- logger.debug(`Demontrated skill: ${skill}`);
6295
- const interval = lastInterval * (0.75 + skill);
6296
- cardHistory.lapses = getLapses(cardHistory.records);
6297
- cardHistory.streak = getStreak(cardHistory.records);
6298
- if (cardHistory.lapses && cardHistory.streak && cardHistory.bestInterval && (cardHistory.lapses >= 0 || cardHistory.streak >= 0)) {
6299
- const ret = (cardHistory.lapses * interval + cardHistory.streak * cardHistory.bestInterval) / (cardHistory.lapses + cardHistory.streak);
6300
- logger.debug(`Weighted average interval calculation:
6301
- (${cardHistory.lapses} * ${interval} + ${cardHistory.streak} * ${cardHistory.bestInterval}) / (${cardHistory.lapses} + ${cardHistory.streak}) = ${ret}`);
6302
- return ret;
6854
+ /**
6855
+ * Helper method for CardHydrationService to remove items from appropriate queue.
6856
+ */
6857
+ removeItemFromQueue(item) {
6858
+ if (this.reviewQ.peek(0) === item) {
6859
+ this.reviewQ.dequeue((queueItem) => queueItem.cardID);
6860
+ } else if (this.newQ.peek(0) === item) {
6861
+ this.newQ.dequeue((queueItem) => queueItem.cardID);
6303
6862
  } else {
6304
- return interval;
6863
+ this.failedQ.dequeue((queueItem) => queueItem.cardID);
6305
6864
  }
6306
- } else {
6307
- return 0;
6308
6865
  }
6309
- }
6310
- function lastSuccessfulInterval(cardHistory) {
6311
- for (let i = cardHistory.length - 1; i >= 1; i--) {
6312
- if (cardHistory[i].priorAttemps === 0 && cardHistory[i].isCorrect) {
6313
- const lastInterval = secondsBetween(cardHistory[i - 1].timeStamp, cardHistory[i].timeStamp);
6314
- const ret = Math.max(lastInterval, 20 * 60 * 60);
6315
- logger.debug(`Last interval w/ this card was: ${lastInterval}s, returning ${ret}s`);
6316
- return ret;
6317
- }
6318
- }
6319
- return getInitialInterval(cardHistory);
6320
- }
6321
- function getStreak(records) {
6322
- let streak = 0;
6323
- let index = records.length - 1;
6324
- while (index >= 0 && records[index].isCorrect) {
6325
- index--;
6326
- streak++;
6327
- }
6328
- return streak;
6329
- }
6330
- function getLapses(records) {
6331
- return records.filter((r) => r.isCorrect === false).length;
6332
- }
6333
- function getInitialInterval(cardHistory) {
6334
- logger.warn(`history of length: ${cardHistory.length} ignored!`);
6335
- return 60 * 60 * 24 * 3;
6336
- }
6337
- function secondsBetween(start, end) {
6338
- start = (0, import_moment6.default)(start);
6339
- end = (0, import_moment6.default)(end);
6340
- const ret = duration(end.diff(start)).asSeconds();
6341
- return ret;
6342
- }
6866
+ };
6343
6867
 
6344
6868
  // src/index.ts
6345
6869
  init_factory();