Adds a full flashcards system: FSRS-based scheduling, cloze/basic card types, CSV import/export, media storage, and study UI, wired into navigation and the dashboard alongside existing question flows.
117 lines
4.5 KiB
C#
117 lines
4.5 KiB
C#
using FSRS.Core.Enums;
|
|
using FSRS.Core.Models;
|
|
using FSRS.Core.Services;
|
|
using QuestionsApi.Models;
|
|
|
|
namespace QuestionsApi.Services.Flashcards;
|
|
|
|
public sealed class FlashcardScheduleResult
|
|
{
|
|
public string State { get; init; } = FlashcardScheduleStates.Learning;
|
|
public int? Step { get; init; }
|
|
public double Stability { get; init; }
|
|
public double Difficulty { get; init; }
|
|
public DateTime DueUtc { get; init; }
|
|
public DateTime LastReviewUtc { get; init; }
|
|
}
|
|
|
|
public interface IFlashcardScheduler
|
|
{
|
|
FlashcardScheduleResult Review(FlashcardSchedule schedule, string rating, DateTime reviewedUtc, int durationSeconds);
|
|
List<FlashcardRatingOption> PreviewRatingOptions(FlashcardSchedule schedule, DateTime nowUtc);
|
|
}
|
|
|
|
// Wraps FSRS.Core 1.0.7 behind an internal interface, pinned to the repo's fixed defaults
|
|
// (90% desired retention, 36,500 day max interval, no per-user parameter optimization).
|
|
// Fuzzing is disabled so scheduling is deterministic: reproducible for tests/support, and
|
|
// so a review's outcome depends only on stored state, never on wall-clock randomness.
|
|
public sealed class FsrsFlashcardScheduler : IFlashcardScheduler
|
|
{
|
|
public const string AlgorithmVersion = "fsrs-core-1.0.7";
|
|
public const double DesiredRetention = 0.90;
|
|
public const int MaximumIntervalDays = 36_500;
|
|
|
|
private readonly Scheduler _scheduler = new(
|
|
desiredRetention: DesiredRetention,
|
|
maximumInterval: MaximumIntervalDays,
|
|
enableFuzzing: false);
|
|
|
|
public FlashcardScheduleResult Review(FlashcardSchedule schedule, string rating, DateTime reviewedUtc, int durationSeconds)
|
|
{
|
|
var card = ToCard(schedule);
|
|
var (updated, _) = _scheduler.ReviewCard(card, ParseRating(rating), reviewedUtc, Math.Max(0, durationSeconds) * 1000);
|
|
return FromCard(updated);
|
|
}
|
|
|
|
public List<FlashcardRatingOption> PreviewRatingOptions(FlashcardSchedule schedule, DateTime nowUtc)
|
|
{
|
|
var options = new List<FlashcardRatingOption>();
|
|
foreach (var rating in FlashcardRatings.All)
|
|
{
|
|
var card = ToCard(schedule);
|
|
var (updated, _) = _scheduler.ReviewCard(card, ParseRating(rating), nowUtc);
|
|
options.Add(new FlashcardRatingOption
|
|
{
|
|
Rating = rating,
|
|
DueUtc = updated.Due,
|
|
IntervalLabel = FormatInterval(updated.Due - nowUtc)
|
|
});
|
|
}
|
|
return options;
|
|
}
|
|
|
|
private static Card ToCard(FlashcardSchedule schedule) =>
|
|
schedule.LastReviewUtc is null
|
|
? new Card()
|
|
: new Card(
|
|
state: ParseState(schedule.State),
|
|
step: schedule.Step,
|
|
stability: schedule.Stability,
|
|
difficulty: schedule.Difficulty,
|
|
due: schedule.DueUtc,
|
|
lastReview: schedule.LastReviewUtc);
|
|
|
|
private static FlashcardScheduleResult FromCard(Card card) => new()
|
|
{
|
|
State = FormatState(card.State),
|
|
Step = card.Step,
|
|
Stability = card.Stability ?? 0,
|
|
Difficulty = card.Difficulty ?? 0,
|
|
DueUtc = card.Due,
|
|
LastReviewUtc = card.LastReview ?? DateTime.UtcNow
|
|
};
|
|
|
|
private static Rating ParseRating(string rating) => rating switch
|
|
{
|
|
FlashcardRatings.Again => Rating.Again,
|
|
FlashcardRatings.Hard => Rating.Hard,
|
|
FlashcardRatings.Good => Rating.Good,
|
|
FlashcardRatings.Easy => Rating.Easy,
|
|
_ => throw new ArgumentOutOfRangeException(nameof(rating), rating, "unknown flashcard rating")
|
|
};
|
|
|
|
private static State ParseState(string state) => state switch
|
|
{
|
|
FlashcardScheduleStates.Review => State.Review,
|
|
FlashcardScheduleStates.Relearning => State.Relearning,
|
|
_ => State.Learning
|
|
};
|
|
|
|
private static string FormatState(State state) => state switch
|
|
{
|
|
State.Review => FlashcardScheduleStates.Review,
|
|
State.Relearning => FlashcardScheduleStates.Relearning,
|
|
_ => FlashcardScheduleStates.Learning
|
|
};
|
|
|
|
public static string FormatInterval(TimeSpan span)
|
|
{
|
|
if (span.TotalMinutes < 1) return "<1m";
|
|
if (span.TotalHours < 1) return $"{(int)Math.Round(span.TotalMinutes)}m";
|
|
if (span.TotalDays < 1) return $"{(int)Math.Round(span.TotalHours)}h";
|
|
if (span.TotalDays < 30) return $"{(int)Math.Round(span.TotalDays)}d";
|
|
if (span.TotalDays < 365) return $"{Math.Round(span.TotalDays / 30, 1)}mo";
|
|
return $"{Math.Round(span.TotalDays / 365, 1)}y";
|
|
}
|
|
}
|