mirror of
https://github.com/mntno/obsidian-come-through.git
synced 2026-07-22 05:47:56 +00:00
344 lines
11 KiB
TypeScript
344 lines
11 KiB
TypeScript
import { CardIDDataTuple, DataStore, StatisticsData } from 'DataStore';
|
|
import { Env } from 'env';
|
|
import { FullID } from 'FullID';
|
|
import { Card, createEmptyCard, default_enable_fuzz, default_enable_short_term, default_learning_steps, default_maximum_interval, default_relearning_steps, default_request_retention, fsrs, FSRS, generatorParameters, Grade, Rating, State, TypeConvert } from 'ts-fsrs';
|
|
import { isString, toIsoStringOrNull } from 'TypeAssistant';
|
|
|
|
type DataItem = {
|
|
id: FullID;
|
|
card: Card;
|
|
}
|
|
|
|
export type FsrsSchedulerConfig = {
|
|
enableFuzz: boolean;
|
|
}
|
|
|
|
export class Scheduler {
|
|
|
|
private static readonly DEFAULT_CONFIG: FsrsSchedulerConfig = {
|
|
enableFuzz: default_enable_fuzz,
|
|
};
|
|
|
|
private readonly data: DataStore;
|
|
private fsrs: FSRS;
|
|
|
|
public constructor(data: DataStore, config: FsrsSchedulerConfig = Scheduler.DEFAULT_CONFIG) {
|
|
this.data = data;
|
|
this.configure(config);
|
|
}
|
|
|
|
private static createFsrs(config: FsrsSchedulerConfig) {
|
|
Env.assert(config);
|
|
config = config ?? Scheduler.DEFAULT_CONFIG;
|
|
|
|
const params = generatorParameters({
|
|
request_retention: default_request_retention,
|
|
enable_fuzz: config.enableFuzz,
|
|
enable_short_term: default_enable_short_term,
|
|
maximum_interval: default_maximum_interval,
|
|
learning_steps: default_learning_steps,
|
|
relearning_steps: default_relearning_steps,
|
|
// w: default_w,
|
|
});
|
|
|
|
return fsrs(params);
|
|
}
|
|
|
|
public configure(config: FsrsSchedulerConfig) {
|
|
this.fsrs = Scheduler.createFsrs(config);
|
|
}
|
|
|
|
public createItem() {
|
|
return Scheduler.asStatistics(createEmptyCard(new Date()));
|
|
}
|
|
|
|
/**
|
|
* @param id ID of the item to rate.
|
|
* @param grade The grade to rate.
|
|
* @param scheduleDate The date for the scheduling. Omit to schedule at the time of call (recommended).
|
|
*/
|
|
public rateItem(id: FullID, grade: Grade, scheduleDate?: Date) {
|
|
this.data.editCard(id, (editor) => {
|
|
const recordLogItem = this.fsrs.next(Scheduler.asCard(editor.data.s), scheduleDate ?? new Date(), grade);
|
|
Scheduler.setStatistics(editor.data.s, recordLogItem.card);
|
|
//this.data.log.unshift(recordLogItem.log);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
public previewNextItem(data: StatisticsData, reviewDate?: Date) {
|
|
return this.fsrs.repeat(Scheduler.asCard(data), reviewDate ?? new Date());
|
|
}
|
|
|
|
/**
|
|
* Get the next review item from {@link cards}.
|
|
*
|
|
* Not necessarily deterministic.
|
|
*
|
|
* @param cards
|
|
* @param reviewDate
|
|
* @returns `null` if there is noting to review at {@link reviewDate}.
|
|
*/
|
|
public getNextItem(cards: CardIDDataTuple[], reviewDate: Date): {
|
|
id: FullID;
|
|
statistics: StatisticsData;
|
|
} | null {
|
|
|
|
const items = cards.map<DataItem>(i => ({
|
|
id: i.id,
|
|
card: Scheduler.asCard(i.data.s)
|
|
}));
|
|
|
|
const nextItem = items
|
|
.sort(Scheduler.Comparer.sortDueDateAsc)
|
|
.filter(i => this.isCardDue(i.card, reviewDate)).first();
|
|
|
|
return nextItem ? { id: nextItem.id, statistics: Scheduler.asStatistics(nextItem.card) } : null;
|
|
}
|
|
|
|
//#region
|
|
|
|
/**
|
|
*
|
|
* @param lastState
|
|
* @param groupedByStateSortedByDueDate Cards with {@link State.Relearning} are expected to be merged with {@link State.Learning}.
|
|
* @returns If {@link State.Learning} is returned, cards in {@link State.Relearning} state may also be used.
|
|
*/
|
|
private getNextStateToUse(date: Date, lastState: State, groupedByStateSortedByDueDate: Record<State, {
|
|
id: FullID;
|
|
card: Card;
|
|
}[]>): State {
|
|
|
|
const hasNewItems = groupedByStateSortedByDueDate[State.New].length > 0;
|
|
const hasLearningItems = groupedByStateSortedByDueDate[State.Learning].length > 0;
|
|
const hasRelearningItems = groupedByStateSortedByDueDate[State.Relearning].length > 0;
|
|
const hasReviewItems = groupedByStateSortedByDueDate[State.Review].length > 0;
|
|
|
|
if (hasRelearningItems)
|
|
throw new Error("Internal Error");
|
|
|
|
let nextState = State.New;
|
|
|
|
switch (lastState) {
|
|
case State.New:
|
|
if (hasLearningItems)
|
|
nextState = State.Learning;
|
|
else if (hasReviewItems)
|
|
nextState = State.Review;
|
|
break;
|
|
|
|
case State.Learning:
|
|
case State.Relearning:
|
|
if (hasReviewItems)
|
|
nextState = State.Review;
|
|
else if (hasNewItems)
|
|
nextState = State.New;
|
|
else
|
|
nextState = State.Learning;
|
|
break;
|
|
|
|
case State.Review:
|
|
if (hasLearningItems)
|
|
nextState = State.Learning;
|
|
else if (hasNewItems)
|
|
nextState = State.New;
|
|
else
|
|
nextState = State.Review;
|
|
break;
|
|
}
|
|
|
|
// If next state is learning/relearning and there are cards in those states due, go ahead.
|
|
// The first card in the array is expected to be due next.
|
|
if (nextState === State.Learning && hasLearningItems && this.isCardDue(groupedByStateSortedByDueDate[State.Learning][0].card, date)) {
|
|
return State.Learning;
|
|
}
|
|
else {
|
|
if (!hasNewItems)
|
|
return State.Review
|
|
else if (!hasReviewItems)
|
|
return State.New
|
|
else
|
|
return Math.random() > 0.5 ? State.Review : State.New
|
|
}
|
|
}
|
|
|
|
private groupItemsByState(items: DataItem[], groupRelearningAsLearning: boolean, excludeItem?: DataItem) {
|
|
|
|
const grouped: Record<State, DataItem[]> = {
|
|
[State.New]: [],
|
|
[State.Learning]: [],
|
|
[State.Relearning]: [],
|
|
[State.Review]: [],
|
|
};
|
|
|
|
for (const item of items) {
|
|
if (excludeItem && excludeItem.id.isEqual(item.id))
|
|
continue;
|
|
if (groupRelearningAsLearning && item.card.state === State.Relearning)
|
|
grouped[State.Learning].push(item);
|
|
else
|
|
grouped[item.card.state as State].push(item);
|
|
}
|
|
|
|
return grouped;
|
|
}
|
|
|
|
private asCard(id: FullID) {
|
|
return Scheduler.asCard(this.data.getCard(id, true)!.s);
|
|
}
|
|
|
|
//#endregion
|
|
|
|
private nextState(rating: Rating, card?: Card) {
|
|
const nextMemoryState = this.fsrs.next_state(
|
|
card ? { stability: card.stability, difficulty: card.difficulty } : null,
|
|
card ? card.elapsed_days : 0,
|
|
rating,
|
|
);
|
|
}
|
|
|
|
private previewNextItemByID(id: FullID, reviewDate?: Date) {
|
|
return this.fsrs.repeat(this.asCard(id), reviewDate ?? new Date());
|
|
}
|
|
|
|
//#region
|
|
|
|
/**
|
|
* @param card
|
|
* @param date The {@link Date} to compare against. If later than {@link card}, then the latter is due.
|
|
* @returns
|
|
*/
|
|
public isCardDue(card: Card, date: Date) {
|
|
return Scheduler.isDateLater(card.due, date);
|
|
}
|
|
|
|
public isStatisticsDue(data: StatisticsData, compareDate: Date = new Date()) {
|
|
return Scheduler.isDateLater(TypeConvert.time(data.due), compareDate);
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param date
|
|
* @param compareDate
|
|
* @returns `true` if {@link compareDate} is later than {@link date}.
|
|
*/
|
|
public static isDateLater(date: Date | string, compareDate: Date = new Date()): boolean {
|
|
if (typeof date === 'object' && date instanceof Date) {
|
|
return compareDate.getTime() - date.getTime() > 0 ? true : false;
|
|
} else {
|
|
return this.isDateLater(TypeConvert.time(date), compareDate);
|
|
}
|
|
}
|
|
|
|
public retrievability(statistics: StatisticsData, date?: Date | string) {
|
|
return this.fsrs.get_retrievability(Scheduler.asCard(statistics), date ?? new Date(), false);
|
|
}
|
|
|
|
//#endregion
|
|
|
|
private static Comparer = class {
|
|
|
|
/**
|
|
* Sort by last reviewed.
|
|
*
|
|
* Sort by the date an item was latest reviewed descending,
|
|
* i.e., the item with the latest last review date will be sorted first.
|
|
*
|
|
* Items without a last review date will be sorted last as they have not been reviewed.
|
|
*/
|
|
public static sortByLastReviewDateDesc(a: DataItem, b: DataItem): number {
|
|
|
|
if (b.card.last_review && a.card.last_review)
|
|
return TypeConvert.time(b.card.last_review).getTime() - TypeConvert.time(a.card.last_review).getTime();
|
|
|
|
if (b.card.last_review !== undefined && a.card.last_review === undefined)
|
|
return 1;
|
|
|
|
if (b.card.last_review === undefined && a.card.last_review !== undefined)
|
|
return -1;
|
|
|
|
return 0;
|
|
}
|
|
|
|
public static sortDueDateAsc(a: DataItem, b: DataItem): number {
|
|
return TypeConvert.time(a.card.due).getTime() - TypeConvert.time(b.card.due).getTime();
|
|
}
|
|
|
|
public static sortByRetrievability(fsrs: FSRS, date: Date, a: DataItem, b: DataItem) {
|
|
const ar = fsrs.get_retrievability(a.card, date, false);
|
|
const br = fsrs.get_retrievability(b.card, date, false);
|
|
if (ar < br) return -1;
|
|
if (ar > br) return 1;
|
|
return 0;
|
|
}
|
|
|
|
public static newest(a: DataItem, b: DataItem): number {
|
|
|
|
if (a.card.state == State.New && b.card.state == State.New)
|
|
return Scheduler.Comparer.sortDueDateAsc(a, b);
|
|
if (a.card.state == State.New && b.card.state != State.New)
|
|
return 1;
|
|
if (a.card.state != State.New && b.card.state == State.New)
|
|
return -1
|
|
|
|
const aIsLearningOrRelearning = a.card.state == State.Learning || a.card.state == State.Relearning;
|
|
const bIsLearningOrRelearning = b.card.state == State.Learning || b.card.state == State.Relearning;
|
|
if (aIsLearningOrRelearning && bIsLearningOrRelearning)
|
|
return Scheduler.Comparer.sortDueDateAsc(a, b);
|
|
if (aIsLearningOrRelearning && !bIsLearningOrRelearning)
|
|
return 1;
|
|
if (!aIsLearningOrRelearning && bIsLearningOrRelearning)
|
|
return -1;
|
|
|
|
return Scheduler.Comparer.sortDueDateAsc(a, b);
|
|
}
|
|
}
|
|
|
|
private static asCard(s: StatisticsData): Card {
|
|
return {
|
|
due: TypeConvert.time(s.due),
|
|
stability: s.s,
|
|
difficulty: s.d,
|
|
// TODO: Delete in ts-fsrs 6. Value not used.
|
|
elapsed_days: 0,
|
|
scheduled_days: s.sd,
|
|
learning_steps: s.ls,
|
|
reps: s.r,
|
|
lapses: s.l,
|
|
state: s.st as State,
|
|
// Check if is `string` rather than if `null`: Type changed from `string | undefined` to `string | null` in 0.5.1, so there may still be `undefined` values around. `TypeConvert.time` only supports `Date`, `string`, `number`: else throws.
|
|
last_review: isString(s.lr) ? TypeConvert.time(s.lr) : undefined,
|
|
};
|
|
}
|
|
|
|
private static asStatistics(card: Card): StatisticsData {
|
|
return {
|
|
due: card.due.toISOString(),
|
|
s: card.stability,
|
|
d: card.difficulty,
|
|
sd: card.scheduled_days,
|
|
ls: card.learning_steps,
|
|
r: card.reps,
|
|
l: card.lapses,
|
|
st: card.state,
|
|
lr: toIsoStringOrNull(card.last_review),
|
|
} satisfies StatisticsData;
|
|
}
|
|
|
|
/**
|
|
* Updates the properties of a {@link StatisticsData} object with the values from a {@link Card} object.
|
|
* @param s The {@link StatisticsData} object to update.
|
|
* @param card The {@link Card} object to source the new values from.
|
|
*/
|
|
private static setStatistics(s: StatisticsData, card: Card) {
|
|
s.due = card.due.toISOString();
|
|
s.s = card.stability;
|
|
s.d = card.difficulty;
|
|
s.sd = card.scheduled_days;
|
|
s.ls = card.learning_steps;
|
|
s.r = card.reps;
|
|
s.l = card.lapses;
|
|
s.st = card.state;
|
|
s.lr = toIsoStringOrNull(card.last_review);
|
|
}
|
|
}
|