From 48a8c8361def3dc1a2bc72715661aecfc8faaf80 Mon Sep 17 00:00:00 2001 From: hz Date: Tue, 21 Jul 2026 15:08:43 -0500 Subject: [PATCH] Add flashcards feature with spaced repetition scheduling 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. --- .../FlashcardClozeParserTests.cs | 94 ++++ api/QuestionsApi.Tests/FlashcardCsvTests.cs | 75 ++++ .../FlashcardImportExportModelTests.cs | 92 ++++ .../FlashcardSchedulerTests.cs | 152 +++++++ .../FlashcardSchedulingTests.cs | 41 ++ .../Controllers/FlashcardsController.cs | 395 ++++++++++++++++ api/QuestionsApi/Models/DashboardModels.cs | 3 + api/QuestionsApi/Models/FlashcardModels.cs | 377 ++++++++++++++++ api/QuestionsApi/Program.cs | 9 + api/QuestionsApi/QuestionsApi.csproj | 1 + api/QuestionsApi/Services/DashboardStore.cs | 25 +- .../Flashcards/FlashcardClozeParser.cs | 57 +++ .../Services/Flashcards/FlashcardCsv.cs | 64 +++ .../Services/Flashcards/FlashcardDeckStore.cs | 128 ++++++ .../FlashcardImportExportService.cs | 393 ++++++++++++++++ .../Flashcards/FlashcardMediaStore.cs | 41 ++ .../Services/Flashcards/FlashcardNoteStore.cs | 187 ++++++++ .../Flashcards/FlashcardScheduleStore.cs | 322 +++++++++++++ .../Services/Flashcards/FlashcardScheduler.cs | 116 +++++ .../Flashcards/FlashcardScheduling.cs | 14 + .../Flashcards/FlashcardSettingsStore.cs | 36 ++ .../Flashcards/FlashcardStudyService.cs | 112 +++++ api/QuestionsApi/Services/UserProfileStore.cs | 4 +- docs/FLASHCARDS.md | 83 ++++ docs/PROFILE_ACCOUNT_OPERATIONS.md | 4 + docs/UAT_SMOKE_TESTS.md | 2 + project.godot | 2 + scripts/autoload/flashcard_service.gd | 375 ++++++++++++++++ scripts/ui/dashboard_screen.gd | 4 + scripts/ui/flashcard_cloze.gd | 41 ++ scripts/ui/flashcard_prefill_dialog.gd | 178 ++++++++ scripts/ui/flashcards_hub.gd | 422 ++++++++++++++++++ scripts/ui/flashcards_study.gd | 318 +++++++++++++ scripts/ui/main.gd | 15 +- scripts/ui/navigation_routes.gd | 2 + scripts/ui/question_player.gd | 34 +- tests/flashcard_cloze_test.gd | 32 ++ tests/flashcard_navigation_test.gd | 23 + tests/flashcard_outbox_test.gd | 51 +++ tests/flashcard_study_render_test.gd | 59 +++ tests/smoke/uat-smoke.spec.mjs | 83 ++++ 41 files changed, 4458 insertions(+), 8 deletions(-) create mode 100644 api/QuestionsApi.Tests/FlashcardClozeParserTests.cs create mode 100644 api/QuestionsApi.Tests/FlashcardCsvTests.cs create mode 100644 api/QuestionsApi.Tests/FlashcardImportExportModelTests.cs create mode 100644 api/QuestionsApi.Tests/FlashcardSchedulerTests.cs create mode 100644 api/QuestionsApi.Tests/FlashcardSchedulingTests.cs create mode 100644 api/QuestionsApi/Controllers/FlashcardsController.cs create mode 100644 api/QuestionsApi/Models/FlashcardModels.cs create mode 100644 api/QuestionsApi/Services/Flashcards/FlashcardClozeParser.cs create mode 100644 api/QuestionsApi/Services/Flashcards/FlashcardCsv.cs create mode 100644 api/QuestionsApi/Services/Flashcards/FlashcardDeckStore.cs create mode 100644 api/QuestionsApi/Services/Flashcards/FlashcardImportExportService.cs create mode 100644 api/QuestionsApi/Services/Flashcards/FlashcardMediaStore.cs create mode 100644 api/QuestionsApi/Services/Flashcards/FlashcardNoteStore.cs create mode 100644 api/QuestionsApi/Services/Flashcards/FlashcardScheduleStore.cs create mode 100644 api/QuestionsApi/Services/Flashcards/FlashcardScheduler.cs create mode 100644 api/QuestionsApi/Services/Flashcards/FlashcardScheduling.cs create mode 100644 api/QuestionsApi/Services/Flashcards/FlashcardSettingsStore.cs create mode 100644 api/QuestionsApi/Services/Flashcards/FlashcardStudyService.cs create mode 100644 docs/FLASHCARDS.md create mode 100644 scripts/autoload/flashcard_service.gd create mode 100644 scripts/ui/flashcard_cloze.gd create mode 100644 scripts/ui/flashcard_prefill_dialog.gd create mode 100644 scripts/ui/flashcards_hub.gd create mode 100644 scripts/ui/flashcards_study.gd create mode 100644 tests/flashcard_cloze_test.gd create mode 100644 tests/flashcard_navigation_test.gd create mode 100644 tests/flashcard_outbox_test.gd create mode 100644 tests/flashcard_study_render_test.gd diff --git a/api/QuestionsApi.Tests/FlashcardClozeParserTests.cs b/api/QuestionsApi.Tests/FlashcardClozeParserTests.cs new file mode 100644 index 0000000..fed70a2 --- /dev/null +++ b/api/QuestionsApi.Tests/FlashcardClozeParserTests.cs @@ -0,0 +1,94 @@ +using QuestionsApi.Services.Flashcards; +using Xunit; + +namespace QuestionsApi.Tests; + +public sealed class FlashcardClozeParserTests +{ + [Fact] + public void Parse_ExtractsAnswerAndHint() + { + var deletions = FlashcardClozeParser.Parse("The capital of France is {{c1::Paris::country capital}}."); + + var deletion = Assert.Single(deletions); + Assert.Equal(1, deletion.Number); + Assert.Equal("Paris", deletion.Answer); + Assert.Equal("country capital", deletion.Hint); + } + + [Fact] + public void Parse_WithoutHint_LeavesHintNull() + { + var deletions = FlashcardClozeParser.Parse("{{c1::Paris}}"); + + Assert.Null(Assert.Single(deletions).Hint); + } + + [Fact] + public void DistinctClozeNumbers_DeduplicatesRepeatedNumbersAndSorts() + { + var text = "{{c2::b}} some text {{c1::a}} more {{c1::a-again}} {{c3::c}}"; + + var numbers = FlashcardClozeParser.DistinctClozeNumbers(text); + + Assert.Equal([1, 2, 3], numbers); + } + + [Theory] + [InlineData("no cloze markers here")] + [InlineData("{{c0::invalid, number must be >= 1}}")] + [InlineData("{{cX::not a number}}")] + [InlineData("{{c1::}}")] + public void HasValidCloze_IsFalse_ForMalformedOrMissingMarkers(string text) + { + Assert.False(FlashcardClozeParser.HasValidCloze(text)); + } + + [Fact] + public void HasValidCloze_IsTrue_WhenAtLeastOneValidMarkerExists() + { + Assert.True(FlashcardClozeParser.HasValidCloze("prefix {{c1::answer}} suffix")); + } + + [Fact] + public void RenderFront_HidesTargetNumber_RevealsOthers() + { + var text = "{{c1::Paris}} is the capital of {{c2::France}}"; + + var front1 = FlashcardClozeParser.RenderFront(text, 1); + var front2 = FlashcardClozeParser.RenderFront(text, 2); + + Assert.Equal("[...] is the capital of France", front1); + Assert.Equal("Paris is the capital of [...]", front2); + } + + [Fact] + public void RenderFront_UsesHintWhenPresent() + { + var text = "{{c1::Paris::capital city}} is in France"; + + var front = FlashcardClozeParser.RenderFront(text, 1); + + Assert.Equal("[capital city] is in France", front); + } + + [Fact] + public void RenderBack_RevealsAllAnswers() + { + var text = "{{c1::Paris}} is the capital of {{c2::France::hint}}"; + + var back = FlashcardClozeParser.RenderBack(text, 1); + + Assert.Equal("Paris is the capital of France", back); + } + + [Fact] + public void Parse_SupportsMultilineClozeContent() + { + var text = "Line one\n{{c1::multi\nline\nanswer}}\nLine two"; + + var deletion = Assert.Single(FlashcardClozeParser.Parse(text)); + + Assert.Equal("multi\nline\nanswer", deletion.Answer); + } +} diff --git a/api/QuestionsApi.Tests/FlashcardCsvTests.cs b/api/QuestionsApi.Tests/FlashcardCsvTests.cs new file mode 100644 index 0000000..92e3724 --- /dev/null +++ b/api/QuestionsApi.Tests/FlashcardCsvTests.cs @@ -0,0 +1,75 @@ +using QuestionsApi.Services.Flashcards; +using Xunit; + +namespace QuestionsApi.Tests; + +public sealed class FlashcardCsvTests +{ + [Fact] + public void ParseRows_HandlesQuotedMultilineFieldsAndEmbeddedCommas() + { + var csv = "stable_id,deck,type,prompt,answer,extra,tags,source_question_external_id\n" + + "abc-1,Cardio,basic,\"What is the first line,\nsecond line?\",\"Answer, with comma\",,tag1;tag2,q-1\n"; + + var rows = FlashcardCsv.ParseRows(csv); + + Assert.Equal(2, rows.Count); + var data = rows[1]; + Assert.Equal("abc-1", data[0]); + Assert.Equal("What is the first line,\nsecond line?", data[3]); + Assert.Equal("Answer, with comma", data[4]); + Assert.Equal("tag1;tag2", data[6]); + } + + [Fact] + public void ParseRows_HandlesEscapedDoubleQuotes() + { + var csv = "a,b\n\"she said \"\"hi\"\"\",plain\n"; + + var rows = FlashcardCsv.ParseRows(csv); + + Assert.Equal("she said \"hi\"", rows[1][0]); + Assert.Equal("plain", rows[1][1]); + } + + [Fact] + public void ParseRows_SupportsUnicodeContent() + { + var csv = "a,b\néè中文,\U0001F600\n"; + + var rows = FlashcardCsv.ParseRows(csv); + + Assert.Equal("éè中文", rows[1][0]); + Assert.Equal("😀", rows[1][1]); + } + + [Fact] + public void ParseRows_HandlesFileWithoutTrailingNewline() + { + var csv = "a,b\n1,2"; + + var rows = FlashcardCsv.ParseRows(csv); + + Assert.Equal(2, rows.Count); + Assert.Equal(["1", "2"], rows[1]); + } + + [Fact] + public void WriteRow_QuotesFieldsContainingSpecialCharacters() + { + var row = FlashcardCsv.WriteRow(["plain", "has,comma", "has\"quote", "has\nnewline"]); + + Assert.Equal("plain,\"has,comma\",\"has\"\"quote\",\"has\nnewline\"", row); + } + + [Fact] + public void WriteThenParse_RoundTrips() + { + var original = new[] { "id-1", "Deck, Name", "basic", "prompt with \"quotes\" and\nnewline", "answer", "", "a;b", "q-1" }; + var line = FlashcardCsv.WriteRow(original); + + var rows = FlashcardCsv.ParseRows("header\n" + line + "\n"); + + Assert.Equal(original, rows[1]); + } +} diff --git a/api/QuestionsApi.Tests/FlashcardImportExportModelTests.cs b/api/QuestionsApi.Tests/FlashcardImportExportModelTests.cs new file mode 100644 index 0000000..648406f --- /dev/null +++ b/api/QuestionsApi.Tests/FlashcardImportExportModelTests.cs @@ -0,0 +1,92 @@ +using System.Text.Json; +using QuestionsApi.Models; +using QuestionsApi.Services.Flashcards; +using Xunit; + +namespace QuestionsApi.Tests; + +public sealed class FlashcardImportExportModelTests +{ + [Fact] + public void JsonExport_RoundTrips_NotesCardsAndReviewHistory() + { + var export = new FlashcardJsonExport + { + SchemaVersion = 1, + Settings = new FlashcardSettings { UserId = "user-1", NewCardsPerDay = 20, ReviewsPerDay = 200 }, + Decks = [new FlashcardDeck { Id = "deck-1", UserId = "user-1", Name = "Cardio", IsDefault = true }], + Notes = + [ + new FlashcardJsonExportNote + { + Note = new FlashcardNote + { + Id = "note-1", UserId = "user-1", DeckId = "deck-1", StableId = "stable-1", + Type = FlashcardTypes.Cloze, Prompt = "{{c1::Paris}} is the capital of France", + Tags = ["geography"] + }, + Cards = + [ + new FlashcardSchedule + { + Id = "card-1", UserId = "user-1", NoteId = "note-1", Variant = 1, + State = FlashcardScheduleStates.Review, Stability = 12.3, Difficulty = 4.5, + DueUtc = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc) + } + ], + ReviewLogs = + [ + new FlashcardReviewLog + { + Id = "log-1", UserId = "user-1", CardId = "card-1", Rating = FlashcardRatings.Good, + ReviewedUtc = new DateTime(2025, 12, 31, 0, 0, 0, DateTimeKind.Utc) + } + ], + Images = [new FlashcardJsonExportImage { AssetId = "asset-1", FileName = "x.png", ContentType = "image/png", Base64Bytes = Convert.ToBase64String([1, 2, 3]) }] + } + ] + }; + + var json = JsonSerializer.Serialize(export); + var roundTripped = JsonSerializer.Deserialize(json); + + Assert.NotNull(roundTripped); + var note = Assert.Single(roundTripped!.Notes); + Assert.Equal("stable-1", note.Note.StableId); + Assert.Equal(FlashcardTypes.Cloze, note.Note.Type); + var card = Assert.Single(note.Cards); + Assert.Equal(1, card.Variant); + Assert.Equal(12.3, card.Stability); + var log = Assert.Single(note.ReviewLogs); + Assert.Equal(FlashcardRatings.Good, log.Rating); + var image = Assert.Single(note.Images); + Assert.Equal([1, 2, 3], Convert.FromBase64String(image.Base64Bytes)); + } + + [Fact] + public void Fingerprint_IsStable_ForSameTypePromptAndAnswer_IgnoringSurroundingWhitespace() + { + var a = FlashcardNoteStore.Fingerprint(FlashcardTypes.Basic, " What is 2+2? ", "4"); + var b = FlashcardNoteStore.Fingerprint(FlashcardTypes.Basic, "What is 2+2?", "4"); + + Assert.Equal(a, b); + } + + [Fact] + public void Fingerprint_DiffersWhenAnswerDiffers() + { + var a = FlashcardNoteStore.Fingerprint(FlashcardTypes.Basic, "What is 2+2?", "4"); + var b = FlashcardNoteStore.Fingerprint(FlashcardTypes.Basic, "What is 2+2?", "five"); + + Assert.NotEqual(a, b); + } + + [Fact] + public void Fingerprint_DiffersByType_ForSamePromptAndAnswer() + { + var basic = FlashcardNoteStore.Fingerprint(FlashcardTypes.Basic, "text", "text"); + var cloze = FlashcardNoteStore.Fingerprint(FlashcardTypes.Cloze, "text", "text"); + + Assert.NotEqual(basic, cloze); + } +} diff --git a/api/QuestionsApi.Tests/FlashcardSchedulerTests.cs b/api/QuestionsApi.Tests/FlashcardSchedulerTests.cs new file mode 100644 index 0000000..aa2130d --- /dev/null +++ b/api/QuestionsApi.Tests/FlashcardSchedulerTests.cs @@ -0,0 +1,152 @@ +using FSRS.Core.Constants; +using QuestionsApi.Models; +using QuestionsApi.Services.Flashcards; +using Xunit; + +namespace QuestionsApi.Tests; + +// Validates FsrsFlashcardScheduler against the official FSRS formulas (not just against the +// library's own outputs), using the exact weights FSRS.Core 1.0.7 ships via FsrsConstants so +// this keeps validating the formula even if the shipped defaults are ever bumped upstream. +// +// S0(rating) = w[(int)rating - 1] (initial stability) +// D0(rating) = clamp(w4 - (e^(w5*((int)rating-1)) - 1), 1, 10) (initial difficulty) +// I(r, S) = round(S) when desired_retention == the FSRS reference retention (0.9), because +// the interval formula collapses to the stability itself at that specific retention. +public sealed class FlashcardSchedulerTests +{ + private static readonly double[] W = FsrsConstants.DefaultParameters; + private static readonly DateTime Start = new(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + private static FlashcardSchedule NewSchedule() => new() + { + UserId = "user-1", + NoteId = "note-1", + State = FlashcardScheduleStates.New, + DueUtc = Start, + LastReviewUtc = null + }; + + [Theory] + [InlineData(FlashcardRatings.Again, 0)] + [InlineData(FlashcardRatings.Hard, 1)] + [InlineData(FlashcardRatings.Good, 2)] + [InlineData(FlashcardRatings.Easy, 3)] + public void InitialStability_MatchesFsrsFormula(string rating, int weightIndex) + { + var scheduler = new FsrsFlashcardScheduler(); + var result = scheduler.Review(NewSchedule(), rating, Start, 0); + + Assert.Equal(W[weightIndex], result.Stability, precision: 6); + } + + [Theory] + [InlineData(FlashcardRatings.Again, 1)] + [InlineData(FlashcardRatings.Hard, 2)] + [InlineData(FlashcardRatings.Good, 3)] + [InlineData(FlashcardRatings.Easy, 4)] + public void InitialDifficulty_MatchesFsrsFormula(string rating, int fsrsRatingValue) + { + var scheduler = new FsrsFlashcardScheduler(); + var result = scheduler.Review(NewSchedule(), rating, Start, 0); + + var expected = Math.Clamp(W[4] - (Math.Exp(W[5] * (fsrsRatingValue - 1)) - 1), 1, 10); + Assert.Equal(expected, result.Difficulty, precision: 6); + } + + [Fact] + public void Interval_EqualsRoundedNewStability_AtPinnedDesiredRetention() + { + // At desired_retention == 0.9 (the reference retention baked into the FSRS interval + // formula), I(0.9, S) reduces to S regardless of the decay parameter - this holds for + // any state, so it's a strong end-to-end check that both the stability update and the + // interval formula are wired correctly, without hand-deriving decay/factor constants. + var scheduler = new FsrsFlashcardScheduler(); + var schedule = NewSchedule(); + var t = Start; + foreach (var rating in new[] { FlashcardRatings.Good, FlashcardRatings.Good, FlashcardRatings.Good }) + { + var result = scheduler.Review(schedule, rating, t, 0); + schedule = ToSchedule(schedule, result); + t = result.DueUtc; + } + + var final = scheduler.Review(schedule, FlashcardRatings.Good, t, 0); + var intervalDays = (final.DueUtc - t).TotalDays; + Assert.Equal(Math.Round(final.Stability, MidpointRounding.AwayFromZero), Math.Round(intervalDays), precision: 6); + } + + [Fact] + public void MaximumInterval_NeverExceeds36500Days() + { + var scheduler = new FsrsFlashcardScheduler(); + var schedule = NewSchedule(); + schedule.State = FlashcardScheduleStates.Review; + schedule.Stability = 1_000_000; + schedule.Difficulty = 5; + schedule.DueUtc = Start; + schedule.LastReviewUtc = Start.AddDays(-1); + + var result = scheduler.Review(schedule, FlashcardRatings.Good, Start, 0); + + Assert.True((result.DueUtc - Start).TotalDays <= FsrsFlashcardScheduler.MaximumIntervalDays); + } + + [Fact] + public void Again_MovesToRelearning_AndReducesStabilityBelowPriorReview() + { + var scheduler = new FsrsFlashcardScheduler(); + var schedule = NewSchedule(); + var t = Start; + FlashcardScheduleResult result = null!; + foreach (var rating in new[] { FlashcardRatings.Good, FlashcardRatings.Good }) + { + result = scheduler.Review(schedule, rating, t, 0); + schedule = ToSchedule(schedule, result); + t = result.DueUtc; + } + var stabilityBeforeLapse = schedule.Stability; + + var lapsed = scheduler.Review(schedule, FlashcardRatings.Again, t, 0); + + Assert.Equal(FlashcardScheduleStates.Relearning, lapsed.State); + Assert.True(lapsed.Stability < stabilityBeforeLapse); + } + + [Fact] + public void PreviewRatingOptions_ReturnsAllFourRatings_WithNonDecreasingDueDates() + { + var scheduler = new FsrsFlashcardScheduler(); + var schedule = NewSchedule(); + schedule.State = FlashcardScheduleStates.Review; + schedule.Stability = 10; + schedule.Difficulty = 5; + schedule.LastReviewUtc = Start.AddDays(-10); + schedule.DueUtc = Start; + + var options = scheduler.PreviewRatingOptions(schedule, Start); + + Assert.Equal(FlashcardRatings.All.Length, options.Count); + Assert.Equal(FlashcardRatings.All.ToHashSet(), options.Select(o => o.Rating).ToHashSet()); + var again = options.Single(o => o.Rating == FlashcardRatings.Again).DueUtc; + var hard = options.Single(o => o.Rating == FlashcardRatings.Hard).DueUtc; + var good = options.Single(o => o.Rating == FlashcardRatings.Good).DueUtc; + var easy = options.Single(o => o.Rating == FlashcardRatings.Easy).DueUtc; + Assert.True(again <= hard); + Assert.True(hard <= good); + Assert.True(good <= easy); + } + + private static FlashcardSchedule ToSchedule(FlashcardSchedule prior, FlashcardScheduleResult result) => new() + { + UserId = prior.UserId, + NoteId = prior.NoteId, + Variant = prior.Variant, + State = result.State, + Step = result.Step, + Stability = result.Stability, + Difficulty = result.Difficulty, + DueUtc = result.DueUtc, + LastReviewUtc = result.LastReviewUtc + }; +} diff --git a/api/QuestionsApi.Tests/FlashcardSchedulingTests.cs b/api/QuestionsApi.Tests/FlashcardSchedulingTests.cs new file mode 100644 index 0000000..bae9ab8 --- /dev/null +++ b/api/QuestionsApi.Tests/FlashcardSchedulingTests.cs @@ -0,0 +1,41 @@ +using QuestionsApi.Services.Flashcards; +using Xunit; + +namespace QuestionsApi.Tests; + +public sealed class FlashcardSchedulingTests +{ + [Fact] + public void NextStudyDayStart_IsMidnightOfTheFollowingDay() + { + var studyDayStart = new DateTime(2026, 3, 5, 0, 0, 0, DateTimeKind.Utc); + + var next = FlashcardScheduling.NextStudyDayStart(studyDayStart); + + Assert.Equal(new DateTime(2026, 3, 6, 0, 0, 0, DateTimeKind.Utc), next); + } + + [Fact] + public void NextStudyDayStart_TruncatesTimeOfDayBeforeAdvancing() + { + // A session that started mid-day still buries siblings until the *next calendar day*, + // not exactly 24h later. + var studyDayStart = new DateTime(2026, 3, 5, 14, 30, 0, DateTimeKind.Utc); + + var next = FlashcardScheduling.NextStudyDayStart(studyDayStart); + + Assert.Equal(new DateTime(2026, 3, 6, 0, 0, 0, DateTimeKind.Utc), next); + } + + [Theory] + [InlineData(20, null, 0, 20)] + [InlineData(20, null, 15, 5)] + [InlineData(20, null, 25, 0)] + [InlineData(20, 5, 0, 5)] + [InlineData(20, 0, 0, 0)] + public void ResolveDailyBudget_DeckOverrideWinsOverGlobal_ClampedAtZero( + int globalLimit, int? deckOverride, int consumedToday, int expected) + { + Assert.Equal(expected, FlashcardScheduling.ResolveDailyBudget(globalLimit, deckOverride, consumedToday)); + } +} diff --git a/api/QuestionsApi/Controllers/FlashcardsController.cs b/api/QuestionsApi/Controllers/FlashcardsController.cs new file mode 100644 index 0000000..c944f5d --- /dev/null +++ b/api/QuestionsApi/Controllers/FlashcardsController.cs @@ -0,0 +1,395 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using QuestionsApi.Models; +using QuestionsApi.Services.Flashcards; +using System.Security.Claims; +using System.Text; + +namespace QuestionsApi.Controllers; + +[ApiController] +[Route("api/flashcards")] +[Authorize(Roles = "USER,SUPER")] +public sealed class FlashcardsController( + FlashcardDeckStore decks, + FlashcardNoteStore notes, + FlashcardScheduleStore schedules, + FlashcardSettingsStore settings, + FlashcardMediaStore media, + FlashcardStudyService study, + FlashcardImportExportService importExport) : ControllerBase +{ + [HttpGet("decks")] + public async Task ListDecks() + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + await decks.GetOrCreateDefaultDeckAsync(userId); + var now = DateTime.UtcNow; + var summaries = await study.GetDeckSummariesAsync(userId, now, now.Date); + return Ok(summaries); + } + + [HttpPost("decks")] + public async Task CreateDeck([FromBody] UpsertFlashcardDeckRequest request) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest("name required"); + var result = await decks.UpsertAsync(userId, request, null); + if (!result.Succeeded) return Conflict(result.Current); + return Ok(result.Deck); + } + + [HttpPut("decks/{deckId}")] + public async Task UpdateDeck(string deckId, [FromBody] UpsertFlashcardDeckRequest request) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest("name required"); + var result = await decks.UpsertAsync(userId, request, deckId); + if (!result.Succeeded) return Conflict(result.Current); + return Ok(result.Deck); + } + + [HttpPost("decks/{deckId}/archive")] + public async Task ArchiveDeck(string deckId) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + var result = await decks.ArchiveAsync(userId, deckId, true); + if (!result.Succeeded) return Conflict("cannot archive the default deck, or deck not found"); + return Ok(result.Deck); + } + + [HttpPost("decks/{deckId}/restore")] + public async Task RestoreDeck(string deckId) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + var result = await decks.ArchiveAsync(userId, deckId, false); + if (!result.Succeeded) return NotFound(); + return Ok(result.Deck); + } + + [HttpPost("notes/search")] + public async Task SearchNotes([FromBody] FlashcardLibraryQuery query) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + var (items, total) = await notes.SearchAsync(userId, query); + return Ok(new FlashcardLibraryResponse { Items = items, Total = total }); + } + + [HttpGet("notes/{noteId}")] + public async Task GetNote(string noteId) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + var note = await notes.GetAsync(userId, noteId); + if (note is null) return NotFound(); + return Ok(note); + } + + [HttpPost("notes")] + public async Task CreateNote([FromBody] UpsertFlashcardNoteRequest request) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + if (string.IsNullOrWhiteSpace(request.Prompt)) return BadRequest("prompt required"); + if (string.IsNullOrWhiteSpace(request.DeckId)) + request.DeckId = (await decks.GetOrCreateDefaultDeckAsync(userId)).Id!; + + try + { + var result = await notes.UpsertAsync(userId, request, null); + if (!result.Succeeded) return Conflict(result.Current); + return Ok(result.Note); + } + catch (FlashcardValidationException exception) + { + return BadRequest(exception.Message); + } + } + + [HttpPut("notes/{noteId}")] + public async Task UpdateNote(string noteId, [FromBody] UpsertFlashcardNoteRequest request) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + if (string.IsNullOrWhiteSpace(request.Prompt)) return BadRequest("prompt required"); + + try + { + var result = await notes.UpsertAsync(userId, request, noteId); + if (!result.Succeeded) return Conflict(result.Current); + return Ok(result.Note); + } + catch (FlashcardValidationException exception) + { + return BadRequest(exception.Message); + } + } + + [HttpPost("notes/move")] + public async Task MoveNotes([FromBody] FlashcardMoveRequest request) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + if (string.IsNullOrWhiteSpace(request.TargetDeckId)) return BadRequest("targetDeckId required"); + var moved = await notes.MoveAsync(userId, request.NoteIds, request.TargetDeckId); + return Ok(new { moved }); + } + + [HttpPost("notes/archive")] + public async Task ArchiveNotes([FromBody] FlashcardBulkNoteIdsRequest request) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + var results = new List(); + foreach (var noteId in request.NoteIds) + { + var result = await notes.ArchiveAsync(userId, noteId, true); + if (result.Note is not null) results.Add(result.Note); + } + return Ok(results); + } + + [HttpPost("notes/restore")] + public async Task RestoreNotes([FromBody] FlashcardBulkNoteIdsRequest request) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + var results = new List(); + foreach (var noteId in request.NoteIds) + { + var result = await notes.ArchiveAsync(userId, noteId, false); + if (result.Note is not null) results.Add(result.Note); + } + return Ok(results); + } + + [HttpPost("cards/suspend")] + public async Task SuspendCards([FromBody] FlashcardSuspendRequest request) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + var changed = await schedules.SetSuspendedAsync(userId, request.CardIds, request.Suspended); + return Ok(new { changed }); + } + + [HttpPost("cards/bury")] + public async Task BuryCards([FromBody] FlashcardBuryRequest request) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + var until = request.UntilUtc == default ? DateTime.UtcNow.Date.AddDays(1) : request.UntilUtc; + var changed = await schedules.BuryAsync(userId, request.CardIds, until); + return Ok(new { changed }); + } + + [HttpPost("study/next")] + public async Task NextCard([FromBody] FlashcardStudyRequest request) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + var response = await study.GetNextCardAsync(userId, request); + return Ok(response); + } + + [HttpGet("counts")] + public async Task GetCounts() + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + var now = DateTime.UtcNow; + var counts = await study.GetDashboardCountsAsync(userId, now, now.Date.AddDays(-6)); + return Ok(counts); + } + + [HttpGet("settings")] + public async Task GetSettings() + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + return Ok(await settings.GetAsync(userId)); + } + + [HttpPut("settings")] + public async Task UpdateSettings([FromBody] UpdateFlashcardSettingsRequest request) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + return Ok(await settings.UpdateAsync(userId, request)); + } + + [HttpPost("reviews")] + public async Task SubmitReviews([FromBody] FlashcardBatchReviewRequest request) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + + var results = new List(); + foreach (var review in request.Reviews) + { + if (!Guid.TryParse(review.ClientReviewId, out _)) return BadRequest("clientReviewId must be a UUID"); + if (!FlashcardRatings.All.Contains(review.Rating)) return BadRequest("rating must be again/hard/good/easy"); + + var outcome = await schedules.SubmitReviewAsync(userId, review); + switch (outcome.Kind) + { + case FlashcardReviewOutcomeKind.NotFound: + return NotFound($"card {review.CardId} not found"); + case FlashcardReviewOutcomeKind.Conflict: + return Conflict(new { cardId = review.CardId, current = outcome.Schedule }); + case FlashcardReviewOutcomeKind.Duplicate: + results.Add(new FlashcardReviewResponse { Card = outcome.Schedule!, Duplicate = true }); + break; + default: + results.Add(new FlashcardReviewResponse + { + Card = outcome.Schedule!, + RatingOptions = schedules.PreviewRatingOptions(outcome.Schedule!, DateTime.UtcNow) + }); + break; + } + } + return Ok(new FlashcardBatchReviewResponse { Results = results }); + } + + [HttpPost("reviews/undo")] + public async Task UndoReview([FromBody] FlashcardUndoRequest request) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + var outcome = await schedules.UndoAsync(userId, request.ReviewLogId); + return outcome.Kind switch + { + FlashcardReviewOutcomeKind.NotFound => NotFound(), + FlashcardReviewOutcomeKind.Conflict => Conflict(outcome.Schedule), + _ => Ok(outcome.Schedule) + }; + } + + [HttpPost("media")] + [RequestSizeLimit(5_000_000)] + public async Task UploadMedia(IFormFile file) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + if (file is null || file.Length == 0) return BadRequest("file required"); + if (file.Length > 5_000_000) return BadRequest("file must be 5 MB or smaller"); + + var contentType = file.ContentType.Trim().ToLowerInvariant(); + var allowedTypes = new HashSet { "image/png", "image/jpeg", "image/webp" }; + if (!allowedTypes.Contains(contentType)) return BadRequest("file must be a png, jpeg, or webp image"); + + await using var stream = file.OpenReadStream(); + using var memory = new MemoryStream(); + await stream.CopyToAsync(memory); + + var asset = await media.CreateAsync(userId, file.FileName, contentType, memory.ToArray()); + return Ok(new { id = asset.Id ?? "", url = $"/api/flashcards/media/{asset.Id}" }); + } + + [HttpGet("media/{id}")] + public async Task GetMedia(string id) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + var asset = await media.GetAsync(userId, id.Trim()); + if (asset is null) return NotFound(); + return File(asset.Bytes, asset.ContentType); + } + + [HttpGet("export/csv")] + public async Task ExportCsv() + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + var (csv, omissions) = await importExport.ExportCsvAsync(userId); + Response.Headers.Append("X-Flashcard-Export-Omissions", string.Join(';', omissions.Select(v => v.Kind))); + return File(Encoding.UTF8.GetBytes(csv), "text/csv", "flashcards.csv"); + } + + [HttpGet("export/json")] + public async Task ExportJson() + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + var export = await importExport.ExportJsonAsync(userId); + return Ok(export); + } + + [HttpPost("import/csv/preview")] + [RequestSizeLimit(25 * 1024 * 1024)] + public async Task PreviewCsvImport(IFormFile file) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + if (file is null || file.Length == 0) return BadRequest("file required"); + try + { + FlashcardImportExportService.ValidateSize(file.Length); + using var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8); + var text = await reader.ReadToEndAsync(); + return Ok(await importExport.PreviewCsvAsync(userId, text)); + } + catch (FlashcardValidationException exception) + { + return BadRequest(exception.Message); + } + } + + [HttpPost("import/json/preview")] + [RequestSizeLimit(25 * 1024 * 1024)] + public async Task PreviewJsonImport(IFormFile file) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + if (file is null || file.Length == 0) return BadRequest("file required"); + try + { + FlashcardImportExportService.ValidateSize(file.Length); + using var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8); + var text = await reader.ReadToEndAsync(); + return Ok(await importExport.PreviewJsonAsync(userId, text)); + } + catch (FlashcardValidationException exception) + { + return BadRequest(exception.Message); + } + } + + [HttpPost("import/commit")] + public async Task CommitImport([FromBody] FlashcardImportCommitRequest request) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + try + { + return Ok(await importExport.CommitAsync(userId, request.ImportToken)); + } + catch (FlashcardValidationException exception) + { + return BadRequest(exception.Message); + } + } + + private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirstValue(ClaimTypes.Name) + ?? User.Identity?.Name + ?? ""; +} + +public sealed class FlashcardSuspendRequest +{ + public List CardIds { get; set; } = []; + public bool Suspended { get; set; } = true; +} + +public sealed class FlashcardBuryRequest +{ + public List CardIds { get; set; } = []; + public DateTime UntilUtc { get; set; } +} diff --git a/api/QuestionsApi/Models/DashboardModels.cs b/api/QuestionsApi/Models/DashboardModels.cs index a6bad14..37968fa 100644 --- a/api/QuestionsApi/Models/DashboardModels.cs +++ b/api/QuestionsApi/Models/DashboardModels.cs @@ -16,6 +16,9 @@ public sealed class DashboardResponse public int MissedQuestionCount { get; set; } public int BookmarkCount { get; set; } public int NoteCount { get; set; } + public int FlashcardsDue { get; set; } + public int FlashcardsNewAvailable { get; set; } + public int FlashcardsReviewedThisWeek { get; set; } public List PerformanceTrend { get; set; } = []; public List Systems { get; set; } = []; public List WeeklyActivity { get; set; } = []; diff --git a/api/QuestionsApi/Models/FlashcardModels.cs b/api/QuestionsApi/Models/FlashcardModels.cs new file mode 100644 index 0000000..8d90909 --- /dev/null +++ b/api/QuestionsApi/Models/FlashcardModels.cs @@ -0,0 +1,377 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace QuestionsApi.Models; + +public static class FlashcardTypes +{ + public const string Basic = "basic"; + public const string Cloze = "cloze"; + public static readonly HashSet All = [Basic, Cloze]; +} + +public static class FlashcardRatings +{ + public const string Again = "again"; + public const string Hard = "hard"; + public const string Good = "good"; + public const string Easy = "easy"; + public static readonly string[] All = [Again, Hard, Good, Easy]; +} + +public static class FlashcardScheduleStates +{ + public const string New = "new"; + public const string Learning = "learning"; + public const string Review = "review"; + public const string Relearning = "relearning"; +} + +public sealed class FlashcardDeck +{ + [BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; } + public string UserId { get; set; } = ""; + public string StableId { get; set; } = Guid.NewGuid().ToString(); + public string Name { get; set; } = ""; + public string NormalizedName { get; set; } = ""; + public string Description { get; set; } = ""; + public bool IsDefault { get; set; } + public int? NewCardsPerDay { get; set; } + public int? ReviewsPerDay { get; set; } + public double? DesiredRetention { get; set; } + public long Revision { get; set; } = 1; + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; + public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; + public DateTime? ArchivedUtc { get; set; } +} + +public sealed class FlashcardImageReference +{ + public string Kind { get; set; } = "owned"; + public string AssetId { get; set; } = ""; +} + +public sealed class FlashcardNote +{ + [BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; } + public string UserId { get; set; } = ""; + public string StableId { get; set; } = Guid.NewGuid().ToString(); + public string DeckId { get; set; } = ""; + public string Type { get; set; } = FlashcardTypes.Basic; + public string Prompt { get; set; } = ""; + public string Answer { get; set; } = ""; + public string Extra { get; set; } = ""; + public List Tags { get; set; } = []; + public List Images { get; set; } = []; + public string SourceQuestionExternalId { get; set; } = ""; + public string ContentFingerprint { get; set; } = ""; + public long Revision { get; set; } = 1; + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; + public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; + public DateTime? ArchivedUtc { get; set; } +} + +public sealed class FlashcardSchedule +{ + [BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; } + public string UserId { get; set; } = ""; + public string NoteId { get; set; } = ""; + public int Variant { get; set; } + public string State { get; set; } = FlashcardScheduleStates.New; + public int? Step { get; set; } + public double? Stability { get; set; } + public double? Difficulty { get; set; } + public DateTime DueUtc { get; set; } = DateTime.UtcNow; + public DateTime? LastReviewUtc { get; set; } + public DateTime? BuriedUntilUtc { get; set; } + public bool IsSuspended { get; set; } + public long Revision { get; set; } = 1; + public string AlgorithmVersion { get; set; } = "fsrs-core-1.0.7"; + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; +} + +public sealed class FlashcardReviewLog +{ + [BsonId] public string Id { get; set; } = ""; + public string UserId { get; set; } = ""; + public string CardId { get; set; } = ""; + public string Rating { get; set; } = ""; + public DateTime ReviewedUtc { get; set; } + public int DurationSeconds { get; set; } + public FlashcardScheduleSnapshot Before { get; set; } = new(); + public FlashcardScheduleSnapshot After { get; set; } = new(); + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; + public DateTime? UndoneUtc { get; set; } +} + +public sealed class FlashcardScheduleSnapshot +{ + public string State { get; set; } = FlashcardScheduleStates.New; + public int? Step { get; set; } + public double? Stability { get; set; } + public double? Difficulty { get; set; } + public DateTime DueUtc { get; set; } + public DateTime? LastReviewUtc { get; set; } + public long Revision { get; set; } +} + +public sealed class FlashcardMediaAsset +{ + [BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; } + public string UserId { get; set; } = ""; + public string FileName { get; set; } = ""; + public string ContentType { get; set; } = ""; + public byte[] Bytes { get; set; } = []; + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; +} + +public sealed class FlashcardSettings +{ + [BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; } + public string UserId { get; set; } = ""; + public int NewCardsPerDay { get; set; } = 20; + public int ReviewsPerDay { get; set; } = 200; + public double DesiredRetention { get; set; } = 0.90; + public long Revision { get; set; } = 1; + public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; +} + +public sealed class UpsertFlashcardDeckRequest +{ + public string Name { get; set; } = ""; + public string Description { get; set; } = ""; + public int? NewCardsPerDay { get; set; } + public int? ReviewsPerDay { get; set; } + public double? DesiredRetention { get; set; } + public long? ExpectedRevision { get; set; } +} + +public sealed class UpsertFlashcardNoteRequest +{ + public string DeckId { get; set; } = ""; + public string StableId { get; set; } = ""; + public string Type { get; set; } = FlashcardTypes.Basic; + public string Prompt { get; set; } = ""; + public string Answer { get; set; } = ""; + public string Extra { get; set; } = ""; + public List Tags { get; set; } = []; + public List Images { get; set; } = []; + public string SourceQuestionExternalId { get; set; } = ""; + public long? ExpectedRevision { get; set; } +} + +public sealed class UpdateFlashcardSettingsRequest +{ + public int NewCardsPerDay { get; set; } = 20; + public int ReviewsPerDay { get; set; } = 200; + public double DesiredRetention { get; set; } = 0.90; + public long? ExpectedRevision { get; set; } +} + +public sealed class FlashcardDeckSummary +{ + public FlashcardDeck Deck { get; set; } = new(); + public int Total { get; set; } + public int Due { get; set; } + public int New { get; set; } + public int Suspended { get; set; } +} + +public sealed class FlashcardLibraryResponse +{ + public List Items { get; set; } = []; + public int Total { get; set; } +} + +public sealed class FlashcardLibraryQuery +{ + public string? DeckId { get; set; } + public string? Tag { get; set; } + public string? Type { get; set; } + public string? State { get; set; } + public string? Search { get; set; } + public int Page { get; set; } = 1; + public int PageSize { get; set; } = 50; +} + +public sealed class FlashcardStudyRequest +{ + public string DeckId { get; set; } = ""; + public List ExcludeCardIds { get; set; } = []; + public DateTime StudyDayStartUtc { get; set; } = DateTime.UtcNow.Date; +} + +public sealed class FlashcardBulkNoteIdsRequest +{ + public List NoteIds { get; set; } = []; +} + +public sealed class FlashcardMoveRequest +{ + public List NoteIds { get; set; } = []; + public string TargetDeckId { get; set; } = ""; +} + +public sealed class FlashcardBatchReviewRequest +{ + public List Reviews { get; set; } = []; +} + +public sealed class FlashcardBatchReviewResponse +{ + public List Results { get; set; } = []; +} + +public sealed class FlashcardUndoRequest +{ + public string ReviewLogId { get; set; } = ""; +} + +public sealed class FlashcardRatingOption +{ + public string Rating { get; set; } = ""; + public string IntervalLabel { get; set; } = ""; + public DateTime DueUtc { get; set; } +} + +public sealed class FlashcardStudyCardResponse +{ + public FlashcardNote? Note { get; set; } + public FlashcardSchedule? Card { get; set; } + public List RatingOptions { get; set; } = []; + public int DueRemaining { get; set; } + public int NewRemaining { get; set; } +} + +public sealed class FlashcardReviewRequest +{ + public string ClientReviewId { get; set; } = ""; + public string CardId { get; set; } = ""; + public string Rating { get; set; } = ""; + public long ExpectedRevision { get; set; } + public DateTime ReviewedUtc { get; set; } + public int DurationSeconds { get; set; } + public DateTime StudyDayStartUtc { get; set; } = DateTime.UtcNow.Date; +} + +public sealed class FlashcardReviewResponse +{ + public FlashcardSchedule Card { get; set; } = new(); + public List RatingOptions { get; set; } = []; + public bool Duplicate { get; set; } +} + +public sealed class FlashcardDashboardCounts +{ + public int Due { get; set; } + public int NewAvailable { get; set; } + public int ReviewedThisWeek { get; set; } +} + +public static class FlashcardImportFormats +{ + public const string Csv = "csv"; + public const string Json = "json"; +} + +public static class FlashcardImportRowActions +{ + public const string Create = "create"; + public const string Update = "update"; + public const string Skip = "skip"; + public const string Error = "error"; +} + +public sealed class FlashcardImportPendingRecord +{ + public int RowNumber { get; set; } + public string Action { get; set; } = FlashcardImportRowActions.Create; + public string? Error { get; set; } + public string StableId { get; set; } = ""; + public string DeckName { get; set; } = ""; + public string Type { get; set; } = FlashcardTypes.Basic; + public string Prompt { get; set; } = ""; + public string Answer { get; set; } = ""; + public string Extra { get; set; } = ""; + public List Tags { get; set; } = []; + public string SourceQuestionExternalId { get; set; } = ""; + public List Cards { get; set; } = []; + public List ReviewLogs { get; set; } = []; +} + +public sealed class FlashcardImportPreview +{ + [BsonId] public string Id { get; set; } = ""; + public string UserId { get; set; } = ""; + public string Format { get; set; } = FlashcardImportFormats.Csv; + public List Records { get; set; } = []; + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; + public DateTime ExpiresUtc { get; set; } = DateTime.UtcNow.AddHours(1); +} + +public sealed class FlashcardImportPreviewRow +{ + public int RowNumber { get; set; } + public string Action { get; set; } = FlashcardImportRowActions.Create; + public string? Error { get; set; } + public string StableId { get; set; } = ""; + public string Deck { get; set; } = ""; + public string Type { get; set; } = FlashcardTypes.Basic; + public string Prompt { get; set; } = ""; + public string Answer { get; set; } = ""; +} + +public sealed class FlashcardImportPreviewResponse +{ + public string ImportToken { get; set; } = ""; + public int CreateCount { get; set; } + public int UpdateCount { get; set; } + public int SkipCount { get; set; } + public int ErrorCount { get; set; } + public List Rows { get; set; } = []; +} + +public sealed class FlashcardImportCommitRequest +{ + public string ImportToken { get; set; } = ""; +} + +public sealed class FlashcardImportCommitResponse +{ + public int Created { get; set; } + public int Updated { get; set; } + public int Skipped { get; set; } + public int Errored { get; set; } + public List Errors { get; set; } = []; +} + +public sealed class FlashcardExportOmission +{ + public string Kind { get; set; } = ""; + public string Reason { get; set; } = ""; +} + +public sealed class FlashcardJsonExport +{ + public int SchemaVersion { get; set; } = 1; + public DateTime ExportedUtc { get; set; } = DateTime.UtcNow; + public FlashcardSettings Settings { get; set; } = new(); + public List Decks { get; set; } = []; + public List Notes { get; set; } = []; +} + +public sealed class FlashcardJsonExportNote +{ + public FlashcardNote Note { get; set; } = new(); + public List Cards { get; set; } = []; + public List ReviewLogs { get; set; } = []; + public List Images { get; set; } = []; +} + +public sealed class FlashcardJsonExportImage +{ + public string AssetId { get; set; } = ""; + public string FileName { get; set; } = ""; + public string ContentType { get; set; } = ""; + public string Base64Bytes { get; set; } = ""; +} diff --git a/api/QuestionsApi/Program.cs b/api/QuestionsApi/Program.cs index 3b472ea..c5b71e0 100644 --- a/api/QuestionsApi/Program.cs +++ b/api/QuestionsApi/Program.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.HttpOverrides; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; using QuestionsApi.Services; +using QuestionsApi.Services.Flashcards; using MongoDB.Bson; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; @@ -43,6 +44,14 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/api/QuestionsApi/QuestionsApi.csproj b/api/QuestionsApi/QuestionsApi.csproj index d627762..0a6aa82 100644 --- a/api/QuestionsApi/QuestionsApi.csproj +++ b/api/QuestionsApi/QuestionsApi.csproj @@ -8,6 +8,7 @@ + diff --git a/api/QuestionsApi/Services/DashboardStore.cs b/api/QuestionsApi/Services/DashboardStore.cs index 2631977..7557636 100644 --- a/api/QuestionsApi/Services/DashboardStore.cs +++ b/api/QuestionsApi/Services/DashboardStore.cs @@ -1,5 +1,6 @@ using MongoDB.Driver; using QuestionsApi.Models; +using QuestionsApi.Services.Flashcards; namespace QuestionsApi.Services; @@ -15,8 +16,15 @@ public sealed class DashboardStore private readonly IMongoCollection _notes; private readonly QuestionStore _questions; private readonly ReviewQueueStore _reviewQueue; + private readonly FlashcardStudyService _flashcards; + private readonly FlashcardScheduleStore _flashcardSchedules; - public DashboardStore(MongoDatabaseProvider mongo, QuestionStore questions, ReviewQueueStore reviewQueue) + public DashboardStore( + MongoDatabaseProvider mongo, + QuestionStore questions, + ReviewQueueStore reviewQueue, + FlashcardStudyService flashcards, + FlashcardScheduleStore flashcardSchedules) { _attempts = mongo.Database.GetCollection("QuestionAttempts"); _ratings = mongo.Database.GetCollection("UserRatings"); @@ -28,6 +36,8 @@ public sealed class DashboardStore _notes = mongo.Database.GetCollection("UserNotepads"); _questions = questions; _reviewQueue = reviewQueue; + _flashcards = flashcards; + _flashcardSchedules = flashcardSchedules; } public async Task GetAsync(string userId, DateTime nowUtc) @@ -45,8 +55,10 @@ public sealed class DashboardStore var bookmarkCountTask = _bookmarks.CountDocumentsAsync(value => value.UserId == userId); var notepadTask = _notes.Find(value => value.UserId == userId).FirstOrDefaultAsync(); var reviewAttemptsTask = _reviewQueue.GetReviewsSinceAsync(userId, weekStart); + var flashcardCountsTask = _flashcards.GetDashboardCountsAsync(userId, nowUtc, weekStart); + var flashcardReviewsTask = _flashcardSchedules.GetReviewsSinceAsync(userId, weekStart); await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask, completedCcsCountTask, - simulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask); + simulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask, flashcardCountsTask, flashcardReviewsTask); var attempts = attemptsTask.Result; var answeredAttempts = attempts.Where(value => !value.IsSkip).ToList(); @@ -71,7 +83,10 @@ public sealed class DashboardStore QuestionsThisWeek = answeredAttempts.Count(value => value.CreatedUtc >= weekStart), BookmarkCount = checked((int)bookmarkCountTask.Result), NoteCount = CountNotes(notepadTask.Result?.Content), - MissedQuestionCount = checked((int)pendingReviewCount) + MissedQuestionCount = checked((int)pendingReviewCount), + FlashcardsDue = flashcardCountsTask.Result.Due, + FlashcardsNewAvailable = flashcardCountsTask.Result.NewAvailable, + FlashcardsReviewedThisWeek = flashcardCountsTask.Result.ReviewedThisWeek }; response.PerformanceTrend = Enumerable.Range(0, 30).Select(offset => @@ -115,12 +130,14 @@ public sealed class DashboardStore + dailyCcs.Sum(CcsDurationSeconds) + dailySimulations.Sum(value => value.Responses.Sum(item => Math.Max(0, item.ElapsedSeconds))) + reviewAttemptsTask.Result.Where(value => value.CreatedUtc.Date == date).Sum(value => value.DurationSeconds) + + flashcardReviewsTask.Result.Where(value => value.ReviewedUtc.Date == date).Sum(value => value.DurationSeconds) }; }).ToList(); response.StudyTimeSecondsThisWeek = response.WeeklyActivity.Sum(value => value.StudyTimeSeconds); var activeDates = attempts.Select(value => value.CreatedUtc.Date) .Concat(completedCcs.Select(value => value.CompletedUtc!.Value.Date)) - .Concat(simulations.Where(value => value.CompletedUtc.HasValue).Select(value => value.CompletedUtc!.Value.Date)); + .Concat(simulations.Where(value => value.CompletedUtc.HasValue).Select(value => value.CompletedUtc!.Value.Date)) + .Concat(flashcardReviewsTask.Result.Select(value => value.ReviewedUtc.Date)); response.CurrentStreak = CalculateCurrentStreak(activeDates, today); response.RecentActivity = await BuildRecentActivityAsync(answeredAttempts, questionMap, completedCcs, simulations); return response; diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardClozeParser.cs b/api/QuestionsApi/Services/Flashcards/FlashcardClozeParser.cs new file mode 100644 index 0000000..6549a0f --- /dev/null +++ b/api/QuestionsApi/Services/Flashcards/FlashcardClozeParser.cs @@ -0,0 +1,57 @@ +using System.Text.RegularExpressions; + +namespace QuestionsApi.Services.Flashcards; + +public sealed record ClozeDeletion(int Number, string Answer, string? Hint); + +// {{c1::answer}} or {{c1::answer::hint}}. Numbers need not be contiguous or start at 1; +// each distinct number becomes its own independently scheduled card (a "sibling"). +public static partial class FlashcardClozeParser +{ + [GeneratedRegex(@"\{\{c(\d+)::((?:(?!\}\}).)*)\}\}", RegexOptions.Singleline)] + private static partial Regex ClozeRegex(); + + public static List Parse(string text) + { + if (string.IsNullOrEmpty(text)) return []; + var results = new List(); + foreach (Match match in ClozeRegex().Matches(text)) + { + if (!int.TryParse(match.Groups[1].Value, out var number) || number < 1) + continue; + var inner = match.Groups[2].Value; + var parts = inner.Split("::", 2); + var answer = parts[0].Trim(); + var hint = parts.Length > 1 ? parts[1].Trim() : null; + if (answer.Length == 0) continue; + results.Add(new ClozeDeletion(number, answer, string.IsNullOrEmpty(hint) ? null : hint)); + } + return results; + } + + public static List DistinctClozeNumbers(string text) => + Parse(text).Select(v => v.Number).Distinct().OrderBy(v => v).ToList(); + + public static bool HasValidCloze(string text) => DistinctClozeNumbers(text).Count > 0; + + // Renders the front for a given cloze number: that number's answer is hidden (replaced + // by its hint or "..."), all other cloze numbers are revealed as their answer text. + public static string RenderFront(string text, int clozeNumber) => + ClozeRegex().Replace(text, match => + { + if (!int.TryParse(match.Groups[1].Value, out var number)) return match.Value; + var inner = match.Groups[2].Value; + var parts = inner.Split("::", 2); + if (number != clozeNumber) return parts[0].Trim(); + var hint = parts.Length > 1 ? parts[1].Trim() : ""; + return hint.Length > 0 ? $"[{hint}]" : "[...]"; + }); + + public static string RenderBack(string text, int clozeNumber) => + ClozeRegex().Replace(text, match => + { + var inner = match.Groups[2].Value; + var parts = inner.Split("::", 2); + return parts[0].Trim(); + }); +} diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardCsv.cs b/api/QuestionsApi/Services/Flashcards/FlashcardCsv.cs new file mode 100644 index 0000000..6f6a54b --- /dev/null +++ b/api/QuestionsApi/Services/Flashcards/FlashcardCsv.cs @@ -0,0 +1,64 @@ +using System.Text; + +namespace QuestionsApi.Services.Flashcards; + +// Minimal RFC 4180 CSV reader/writer (quoted fields, embedded commas/newlines, "" escaping). +// UTF-8, text-only, matches the flashcard CSV contract exactly. +public static class FlashcardCsv +{ + public static readonly string[] Headers = + ["stable_id", "deck", "type", "prompt", "answer", "extra", "tags", "source_question_external_id"]; + + public static List ParseRows(string text) + { + var rows = new List(); + var field = new StringBuilder(); + var row = new List(); + var inQuotes = false; + var i = 0; + var n = text.Length; + + while (i < n) + { + var c = text[i]; + if (inQuotes) + { + if (c == '"') + { + if (i + 1 < n && text[i + 1] == '"') { field.Append('"'); i += 2; continue; } + inQuotes = false; i++; continue; + } + field.Append(c); i++; continue; + } + + switch (c) + { + case '"': + inQuotes = true; i++; break; + case ',': + row.Add(field.ToString()); field.Clear(); i++; break; + case '\r': + i++; break; + case '\n': + row.Add(field.ToString()); field.Clear(); + rows.Add(row.ToArray()); row = []; + i++; break; + default: + field.Append(c); i++; break; + } + } + if (field.Length > 0 || row.Count > 0) + { + row.Add(field.ToString()); + rows.Add(row.ToArray()); + } + return rows; + } + + public static string WriteRow(IEnumerable fields) => string.Join(",", fields.Select(Escape)); + + private static string Escape(string field) => + field.IndexOfAny(['"', ',', '\n', '\r']) >= 0 + ? "\"" + field.Replace("\"", "\"\"") + "\"" + : field; +} diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardDeckStore.cs b/api/QuestionsApi/Services/Flashcards/FlashcardDeckStore.cs new file mode 100644 index 0000000..4141c92 --- /dev/null +++ b/api/QuestionsApi/Services/Flashcards/FlashcardDeckStore.cs @@ -0,0 +1,128 @@ +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services.Flashcards; + +public sealed record FlashcardDeckUpdateResult(FlashcardDeck? Deck, FlashcardDeck? Current) +{ + public bool Succeeded => Deck is not null; +} + +public sealed class FlashcardDeckStore +{ + private readonly IMongoCollection _decks; + + public FlashcardDeckStore(MongoDatabaseProvider mongo) + { + _decks = mongo.Database.GetCollection("FlashcardDecks"); + _decks.Indexes.CreateMany([ + new CreateIndexModel( + Builders.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.NormalizedName), + new CreateIndexOptions { Unique = true, Name = "flashcard_deck_unique_user_name" }), + new CreateIndexModel( + Builders.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.StableId), + new CreateIndexOptions { Unique = true, Name = "flashcard_deck_unique_user_stable" }) + ]); + } + + public static string Normalize(string name) => name.Trim().ToLowerInvariant(); + + public Task> ListAsync(string userId) => + _decks.Find(v => v.UserId == userId && v.ArchivedUtc == null).SortBy(v => v.Name).ToListAsync(); + + public async Task GetAsync(string userId, string deckId) => + await _decks.Find(v => v.UserId == userId && v.Id == deckId).FirstOrDefaultAsync(); + + public async Task GetOrCreateDefaultDeckAsync(string userId) + { + var existing = await _decks.Find(v => v.UserId == userId && v.IsDefault).FirstOrDefaultAsync(); + if (existing is not null) return existing; + + var deck = new FlashcardDeck + { + UserId = userId, + Name = "Default", + NormalizedName = Normalize("Default"), + IsDefault = true + }; + try + { + await _decks.InsertOneAsync(deck); + return deck; + } + catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + return await _decks.Find(v => v.UserId == userId && v.IsDefault).FirstOrDefaultAsync() + ?? throw new InvalidOperationException("default deck race with no winner"); + } + } + + public async Task UpsertAsync(string userId, UpsertFlashcardDeckRequest request, string? deckId) + { + var now = DateTime.UtcNow; + var normalized = Normalize(request.Name); + + if (string.IsNullOrWhiteSpace(deckId)) + { + var created = new FlashcardDeck + { + UserId = userId, + Name = request.Name.Trim(), + NormalizedName = normalized, + Description = request.Description.Trim(), + NewCardsPerDay = request.NewCardsPerDay, + ReviewsPerDay = request.ReviewsPerDay, + DesiredRetention = request.DesiredRetention, + CreatedUtc = now, + UpdatedUtc = now + }; + try + { + await _decks.InsertOneAsync(created); + return new FlashcardDeckUpdateResult(created, null); + } + catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + return new FlashcardDeckUpdateResult(null, await GetByNameAsync(userId, normalized)); + } + } + + var expectedRevision = request.ExpectedRevision ?? 0; + var updated = await _decks.FindOneAndUpdateAsync( + v => v.UserId == userId && v.Id == deckId && v.Revision == expectedRevision, + Builders.Update + .Set(v => v.Name, request.Name.Trim()) + .Set(v => v.NormalizedName, normalized) + .Set(v => v.Description, request.Description.Trim()) + .Set(v => v.NewCardsPerDay, request.NewCardsPerDay) + .Set(v => v.ReviewsPerDay, request.ReviewsPerDay) + .Set(v => v.DesiredRetention, request.DesiredRetention) + .Set(v => v.UpdatedUtc, now) + .Inc(v => v.Revision, 1), + new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); + + return updated is not null + ? new FlashcardDeckUpdateResult(updated, null) + : new FlashcardDeckUpdateResult(null, await GetAsync(userId, deckId)); + } + + public async Task GetByNameAsync(string userId, string normalizedName) => + await _decks.Find(v => v.UserId == userId && v.NormalizedName == normalizedName).FirstOrDefaultAsync(); + + public async Task ArchiveAsync(string userId, string deckId, bool archived) + { + var deck = await GetAsync(userId, deckId); + if (deck is not null && deck.IsDefault) return new FlashcardDeckUpdateResult(null, deck); + + var updated = await _decks.FindOneAndUpdateAsync( + v => v.UserId == userId && v.Id == deckId && !v.IsDefault, + Builders.Update + .Set(v => v.ArchivedUtc, archived ? DateTime.UtcNow : null) + .Set(v => v.UpdatedUtc, DateTime.UtcNow) + .Inc(v => v.Revision, 1), + new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); + return updated is not null + ? new FlashcardDeckUpdateResult(updated, null) + : new FlashcardDeckUpdateResult(null, deck); + } +} diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardImportExportService.cs b/api/QuestionsApi/Services/Flashcards/FlashcardImportExportService.cs new file mode 100644 index 0000000..b776e2c --- /dev/null +++ b/api/QuestionsApi/Services/Flashcards/FlashcardImportExportService.cs @@ -0,0 +1,393 @@ +using System.Text; +using System.Text.Json; +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services.Flashcards; + +public sealed class FlashcardImportExportService +{ + private const int MaxNotesPerImport = 10_000; + private const long MaxImportBytes = 25 * 1024 * 1024; + + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + + private readonly FlashcardDeckStore _decks; + private readonly FlashcardNoteStore _notes; + private readonly FlashcardScheduleStore _schedules; + private readonly FlashcardMediaStore _media; + private readonly FlashcardSettingsStore _settings; + private readonly IMongoCollection _previews; + + public FlashcardImportExportService( + MongoDatabaseProvider mongo, + FlashcardDeckStore decks, + FlashcardNoteStore notes, + FlashcardScheduleStore schedules, + FlashcardMediaStore media, + FlashcardSettingsStore settings) + { + _decks = decks; + _notes = notes; + _schedules = schedules; + _media = media; + _settings = settings; + _previews = mongo.Database.GetCollection("FlashcardImportPreviews"); + _previews.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(v => v.ExpiresUtc), + new CreateIndexOptions { ExpireAfter = TimeSpan.Zero, Name = "flashcard_import_preview_ttl" })); + } + + public static void ValidateSize(long byteLength) + { + if (byteLength > MaxImportBytes) + throw new FlashcardValidationException("import file must be 25 MB or smaller"); + } + + public async Task<(string Csv, List Omissions)> ExportCsvAsync(string userId) + { + var deckList = await _decks.ListAsync(userId); + var deckNames = deckList.ToDictionary(v => v.Id!, v => v.Name, StringComparer.Ordinal); + var allNotes = await _notes.ListAllActiveAsync(userId); + + var sb = new StringBuilder(); + sb.Append(FlashcardCsv.WriteRow(FlashcardCsv.Headers)).Append('\n'); + foreach (var note in allNotes.OrderBy(v => v.CreatedUtc)) + { + var deckName = deckNames.GetValueOrDefault(note.DeckId, "Default"); + sb.Append(FlashcardCsv.WriteRow([ + note.StableId, deckName, note.Type, note.Prompt, note.Answer, note.Extra, + string.Join(';', note.Tags), note.SourceQuestionExternalId + ])).Append('\n'); + } + + var omissions = new List + { + new() { Kind = "schedule", Reason = "CSV export omits FSRS scheduling state" }, + new() { Kind = "history", Reason = "CSV export omits review history" } + }; + if (allNotes.Any(v => v.Images.Count > 0)) + omissions.Insert(0, new FlashcardExportOmission { Kind = "images", Reason = "CSV export omits attached images" }); + return (sb.ToString(), omissions); + } + + public async Task ExportJsonAsync(string userId) + { + var export = new FlashcardJsonExport + { + Settings = await _settings.GetAsync(userId), + Decks = await _decks.ListAsync(userId) + }; + + foreach (var note in await _notes.ListAllActiveAsync(userId)) + { + var cards = await _schedules.GetByNoteIdAsync(userId, note.Id!); + var logs = new List(); + foreach (var card in cards) + logs.AddRange(await _schedules.GetReviewLogsForCardAsync(userId, card.Id!)); + + var images = new List(); + foreach (var image in note.Images) + { + var asset = await _media.GetAsync(userId, image.AssetId); + if (asset is null) continue; + images.Add(new FlashcardJsonExportImage + { + AssetId = asset.Id!, + FileName = asset.FileName, + ContentType = asset.ContentType, + Base64Bytes = Convert.ToBase64String(asset.Bytes) + }); + } + + export.Notes.Add(new FlashcardJsonExportNote { Note = note, Cards = cards, ReviewLogs = logs, Images = images }); + } + return export; + } + + public async Task PreviewCsvAsync(string userId, string csvText) + { + var rows = FlashcardCsv.ParseRows(csvText); + if (rows.Count == 0) throw new FlashcardValidationException("CSV file is empty"); + if (!rows[0].Select(v => v.Trim()).SequenceEqual(FlashcardCsv.Headers, StringComparer.OrdinalIgnoreCase)) + throw new FlashcardValidationException($"CSV header must be: {string.Join(",", FlashcardCsv.Headers)}"); + if (rows.Count - 1 > MaxNotesPerImport) + throw new FlashcardValidationException($"import limited to {MaxNotesPerImport:N0} notes"); + + var existingNotes = await _notes.ListAllActiveAsync(userId); + var byStableId = existingNotes.Where(v => v.StableId.Length > 0) + .ToDictionary(v => v.StableId, StringComparer.Ordinal); + var fingerprints = existingNotes.Select(v => v.ContentFingerprint).ToHashSet(StringComparer.Ordinal); + + var records = new List(); + for (var r = 1; r < rows.Count; r++) + { + var cols = rows[r]; + var record = new FlashcardImportPendingRecord { RowNumber = r + 1 }; + if (cols.Length != FlashcardCsv.Headers.Length) + { + record.Action = FlashcardImportRowActions.Error; + record.Error = $"expected {FlashcardCsv.Headers.Length} columns, found {cols.Length}"; + records.Add(record); + continue; + } + + var stableId = cols[0].Trim(); + var deckName = cols[1].Trim(); + var type = cols[2].Trim().ToLowerInvariant(); + var prompt = cols[3]; + var answer = cols[4]; + var extra = cols[5]; + var tags = cols[6].Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + var sourceQuestionId = cols[7].Trim(); + + record.StableId = stableId; + record.DeckName = deckName.Length == 0 ? "Default" : deckName; + record.Type = type; + record.Prompt = prompt; + record.Answer = answer; + record.Extra = extra; + record.Tags = tags; + record.SourceQuestionExternalId = sourceQuestionId; + + if (type != FlashcardTypes.Basic && type != FlashcardTypes.Cloze) + { + record.Action = FlashcardImportRowActions.Error; + record.Error = "type must be 'basic' or 'cloze'"; + } + else if (prompt.Trim().Length == 0) + { + record.Action = FlashcardImportRowActions.Error; + record.Error = "prompt is required"; + } + else if (type == FlashcardTypes.Cloze && !FlashcardClozeParser.HasValidCloze(prompt)) + { + record.Action = FlashcardImportRowActions.Error; + record.Error = "cloze prompt must contain at least one {{cN::answer}} marker"; + } + else + { + var fingerprint = FlashcardNoteStore.Fingerprint(type, prompt, answer); + if (stableId.Length > 0 && byStableId.TryGetValue(stableId, out var existing)) + { + record.Action = existing.ContentFingerprint == fingerprint + ? FlashcardImportRowActions.Skip + : FlashcardImportRowActions.Update; + } + else if (fingerprints.Contains(fingerprint)) + { + record.Action = FlashcardImportRowActions.Skip; + } + else + { + record.Action = FlashcardImportRowActions.Create; + if (stableId.Length == 0) record.StableId = Guid.NewGuid().ToString(); + } + } + records.Add(record); + } + + return await SavePreviewAsync(userId, FlashcardImportFormats.Csv, records); + } + + public async Task PreviewJsonAsync(string userId, string jsonText) + { + FlashcardJsonExport parsed; + try + { + parsed = JsonSerializer.Deserialize(jsonText, JsonOptions) + ?? throw new FlashcardValidationException("empty JSON document"); + } + catch (JsonException exception) + { + throw new FlashcardValidationException($"invalid JSON: {exception.Message}"); + } + + if (parsed.Notes.Count > MaxNotesPerImport) + throw new FlashcardValidationException($"import limited to {MaxNotesPerImport:N0} notes"); + + var existingNotes = await _notes.ListAllActiveAsync(userId); + var byStableId = existingNotes.Where(v => v.StableId.Length > 0) + .ToDictionary(v => v.StableId, StringComparer.Ordinal); + + var records = new List(); + var rowNumber = 0; + foreach (var item in parsed.Notes) + { + rowNumber++; + var note = item.Note; + var stableId = string.IsNullOrWhiteSpace(note.StableId) ? Guid.NewGuid().ToString() : note.StableId; + var type = FlashcardTypes.All.Contains(note.Type) ? note.Type : FlashcardTypes.Basic; + var record = new FlashcardImportPendingRecord + { + RowNumber = rowNumber, + StableId = stableId, + DeckName = parsed.Decks.FirstOrDefault(d => d.Id == note.DeckId)?.Name ?? "Default", + Type = type, + Prompt = note.Prompt, + Answer = note.Answer, + Extra = note.Extra, + Tags = note.Tags, + SourceQuestionExternalId = note.SourceQuestionExternalId, + Cards = item.Cards, + ReviewLogs = item.ReviewLogs + }; + + record.Action = type == FlashcardTypes.Cloze && !FlashcardClozeParser.HasValidCloze(note.Prompt) + ? FlashcardImportRowActions.Error + : byStableId.ContainsKey(stableId) ? FlashcardImportRowActions.Update : FlashcardImportRowActions.Create; + if (record.Action == FlashcardImportRowActions.Error) + record.Error = "cloze prompt must contain at least one {{cN::answer}} marker"; + + records.Add(record); + } + + return await SavePreviewAsync(userId, FlashcardImportFormats.Json, records); + } + + public async Task CommitAsync(string userId, string importToken) + { + var preview = await _previews.Find(v => v.Id == importToken && v.UserId == userId).FirstOrDefaultAsync(); + if (preview is null) throw new FlashcardValidationException("import preview not found or expired; re-run preview"); + + var deckCache = new Dictionary(StringComparer.OrdinalIgnoreCase); + var response = new FlashcardImportCommitResponse(); + + foreach (var record in preview.Records) + { + if (record.Action == FlashcardImportRowActions.Error) + { + response.Errored++; + response.Errors.Add(ToRow(record)); + continue; + } + if (record.Action == FlashcardImportRowActions.Skip) + { + response.Skipped++; + continue; + } + + var deckId = await ResolveDeckIdAsync(userId, record.DeckName, deckCache); + var existing = await _notes.GetByStableIdAsync(userId, record.StableId); + var request = new UpsertFlashcardNoteRequest + { + DeckId = deckId, + StableId = record.StableId, + Type = record.Type, + Prompt = record.Prompt, + Answer = record.Answer, + Extra = record.Extra, + Tags = record.Tags, + SourceQuestionExternalId = record.SourceQuestionExternalId, + ExpectedRevision = existing?.Revision + }; + + FlashcardNoteUpdateResult result; + try + { + result = await _notes.UpsertAsync(userId, request, existing?.Id); + } + catch (FlashcardValidationException exception) + { + response.Errored++; + response.Errors.Add(new FlashcardImportPreviewRow + { + RowNumber = record.RowNumber, Action = FlashcardImportRowActions.Error, + Error = exception.Message, StableId = record.StableId + }); + continue; + } + + if (!result.Succeeded) + { + response.Errored++; + response.Errors.Add(new FlashcardImportPreviewRow + { + RowNumber = record.RowNumber, Action = FlashcardImportRowActions.Error, + Error = "note changed since preview; re-run import", StableId = record.StableId + }); + continue; + } + + if (record.Action == FlashcardImportRowActions.Create) response.Created++; else response.Updated++; + + foreach (var card in record.Cards) + await _schedules.ImportScheduleStateAsync(userId, result.Note!.Id!, card); + foreach (var log in record.ReviewLogs) + { + var targetCard = (await _schedules.GetByNoteIdAsync(userId, result.Note!.Id!)) + .FirstOrDefault(v => v.Variant == record.Cards.FirstOrDefault(c => c.Id == log.CardId)?.Variant); + if (targetCard is not null) + await _schedules.ImportReviewLogAsync(userId, targetCard.Id!, log); + } + } + + await _previews.DeleteOneAsync(v => v.Id == importToken); + return response; + } + + private async Task SavePreviewAsync( + string userId, string format, List records) + { + var preview = new FlashcardImportPreview + { + Id = Guid.NewGuid().ToString(), + UserId = userId, + Format = format, + Records = records + }; + await _previews.InsertOneAsync(preview); + + return new FlashcardImportPreviewResponse + { + ImportToken = preview.Id, + CreateCount = records.Count(v => v.Action == FlashcardImportRowActions.Create), + UpdateCount = records.Count(v => v.Action == FlashcardImportRowActions.Update), + SkipCount = records.Count(v => v.Action == FlashcardImportRowActions.Skip), + ErrorCount = records.Count(v => v.Action == FlashcardImportRowActions.Error), + Rows = records.Select(ToRow).ToList() + }; + } + + private static FlashcardImportPreviewRow ToRow(FlashcardImportPendingRecord record) => new() + { + RowNumber = record.RowNumber, + Action = record.Action, + Error = record.Error, + StableId = record.StableId, + Deck = record.DeckName, + Type = record.Type, + Prompt = Truncate(record.Prompt), + Answer = Truncate(record.Answer) + }; + + private static string Truncate(string value) => value.Length > 200 ? value[..200] + "…" : value; + + private async Task ResolveDeckIdAsync(string userId, string deckName, Dictionary cache) + { + var key = deckName.Trim(); + if (key.Length == 0) key = "Default"; + if (cache.TryGetValue(key, out var cached)) return cached; + + if (string.Equals(key, "Default", StringComparison.OrdinalIgnoreCase)) + { + var defaultDeck = await _decks.GetOrCreateDefaultDeckAsync(userId); + cache[key] = defaultDeck.Id!; + return defaultDeck.Id!; + } + + var normalized = FlashcardDeckStore.Normalize(key); + var existing = await _decks.GetByNameAsync(userId, normalized); + if (existing is not null) + { + cache[key] = existing.Id!; + return existing.Id!; + } + + var created = await _decks.UpsertAsync(userId, new UpsertFlashcardDeckRequest { Name = key }, null); + var deckId = created.Deck?.Id ?? (await _decks.GetByNameAsync(userId, normalized))?.Id + ?? throw new InvalidOperationException("deck resolution failed during import"); + cache[key] = deckId; + return deckId; + } +} diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardMediaStore.cs b/api/QuestionsApi/Services/Flashcards/FlashcardMediaStore.cs new file mode 100644 index 0000000..c54f9fc --- /dev/null +++ b/api/QuestionsApi/Services/Flashcards/FlashcardMediaStore.cs @@ -0,0 +1,41 @@ +using MongoDB.Bson; +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services.Flashcards; + +public sealed class FlashcardMediaStore +{ + private readonly IMongoCollection _assets; + + public FlashcardMediaStore(MongoDatabaseProvider mongo) + { + _assets = mongo.Database.GetCollection("FlashcardMediaAssets"); + _assets.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(v => v.UserId), + new CreateIndexOptions { Name = "flashcard_media_user" })); + } + + public async Task CreateAsync(string userId, string fileName, string contentType, byte[] bytes) + { + var asset = new FlashcardMediaAsset + { + UserId = userId, + FileName = fileName, + ContentType = contentType, + Bytes = bytes, + CreatedUtc = DateTime.UtcNow + }; + await _assets.InsertOneAsync(asset); + return asset; + } + + public async Task GetAsync(string userId, string id) + { + if (!ObjectId.TryParse(id, out _)) return null; + return await _assets.Find(v => v.UserId == userId && v.Id == id).FirstOrDefaultAsync(); + } + + public Task DeleteAsync(string userId, string id) => + _assets.DeleteOneAsync(v => v.UserId == userId && v.Id == id).ContinueWith(t => t.Result.DeletedCount); +} diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardNoteStore.cs b/api/QuestionsApi/Services/Flashcards/FlashcardNoteStore.cs new file mode 100644 index 0000000..2a15dc3 --- /dev/null +++ b/api/QuestionsApi/Services/Flashcards/FlashcardNoteStore.cs @@ -0,0 +1,187 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services.Flashcards; + +public sealed record FlashcardNoteUpdateResult(FlashcardNote? Note, FlashcardNote? Current) +{ + public bool Succeeded => Note is not null; +} + +public sealed class FlashcardNoteStore +{ + private readonly IMongoCollection _notes; + private readonly FlashcardScheduleStore _schedules; + + public FlashcardNoteStore(MongoDatabaseProvider mongo, FlashcardScheduleStore schedules) + { + _notes = mongo.Database.GetCollection("FlashcardNotes"); + _schedules = schedules; + _notes.Indexes.CreateMany([ + new CreateIndexModel( + Builders.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.StableId), + new CreateIndexOptions { Unique = true, Name = "flashcard_note_unique_user_stable" }), + new CreateIndexModel( + Builders.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.DeckId), + new CreateIndexOptions { Name = "flashcard_note_user_deck" }), + new CreateIndexModel( + Builders.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.SourceQuestionExternalId), + new CreateIndexOptions { Name = "flashcard_note_user_source_question" }), + new CreateIndexModel( + Builders.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.ContentFingerprint), + new CreateIndexOptions { Name = "flashcard_note_user_fingerprint" }) + ]); + } + + public static string Fingerprint(string type, string prompt, string answer) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{type}{prompt.Trim()}{answer.Trim()}"))); + + public async Task GetAsync(string userId, string noteId) => + await _notes.Find(v => v.UserId == userId && v.Id == noteId).FirstOrDefaultAsync(); + + public async Task GetByStableIdAsync(string userId, string stableId) => + await _notes.Find(v => v.UserId == userId && v.StableId == stableId).FirstOrDefaultAsync(); + + public Task> ListByDeckAsync(string userId, string deckId) => + _notes.Find(v => v.UserId == userId && v.DeckId == deckId && v.ArchivedUtc == null).ToListAsync(); + + public Task> ListAllActiveAsync(string userId) => + _notes.Find(v => v.UserId == userId && v.ArchivedUtc == null).ToListAsync(); + + public async Task<(List Items, int Total)> SearchAsync(string userId, FlashcardLibraryQuery query) + { + var filter = Builders.Filter.Eq(v => v.UserId, userId); + if (query.State != FlashcardArchiveFilter.IncludeArchived) + filter &= Builders.Filter.Eq(v => v.ArchivedUtc, null); + if (!string.IsNullOrWhiteSpace(query.DeckId)) + filter &= Builders.Filter.Eq(v => v.DeckId, query.DeckId); + if (!string.IsNullOrWhiteSpace(query.Type)) + filter &= Builders.Filter.Eq(v => v.Type, query.Type); + if (!string.IsNullOrWhiteSpace(query.Tag)) + filter &= Builders.Filter.AnyEq(v => v.Tags, query.Tag); + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var pattern = new MongoDB.Bson.BsonRegularExpression(Regex.Escape(query.Search), "i"); + filter &= Builders.Filter.Or( + Builders.Filter.Regex(v => v.Prompt, pattern), + Builders.Filter.Regex(v => v.Answer, pattern), + Builders.Filter.Regex(v => v.Extra, pattern)); + } + + var total = checked((int)await _notes.CountDocumentsAsync(filter)); + var page = Math.Max(1, query.Page); + var pageSize = Math.Clamp(query.PageSize, 1, 200); + var items = await _notes.Find(filter) + .SortByDescending(v => v.UpdatedUtc) + .Skip((page - 1) * pageSize) + .Limit(pageSize) + .ToListAsync(); + return (items, total); + } + + public async Task UpsertAsync(string userId, UpsertFlashcardNoteRequest request, string? noteId) + { + var type = FlashcardTypes.All.Contains(request.Type) ? request.Type : FlashcardTypes.Basic; + if (type == FlashcardTypes.Cloze && !FlashcardClozeParser.HasValidCloze(request.Prompt)) + throw new FlashcardValidationException("cloze note must contain at least one {{cN::answer}} marker"); + + var now = DateTime.UtcNow; + var fingerprint = Fingerprint(type, request.Prompt, request.Answer); + + FlashcardNote? saved; + if (string.IsNullOrWhiteSpace(noteId)) + { + var created = new FlashcardNote + { + UserId = userId, + StableId = string.IsNullOrWhiteSpace(request.StableId) ? Guid.NewGuid().ToString() : request.StableId, + DeckId = request.DeckId, + Type = type, + Prompt = request.Prompt, + Answer = request.Answer, + Extra = request.Extra, + Tags = NormalizeTags(request.Tags), + Images = request.Images, + SourceQuestionExternalId = request.SourceQuestionExternalId, + ContentFingerprint = fingerprint, + CreatedUtc = now, + UpdatedUtc = now + }; + try + { + await _notes.InsertOneAsync(created); + saved = created; + } + catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + return new FlashcardNoteUpdateResult(null, await GetByStableIdAsync(userId, created.StableId)); + } + } + else + { + var expectedRevision = request.ExpectedRevision ?? 0; + var updated = await _notes.FindOneAndUpdateAsync( + v => v.UserId == userId && v.Id == noteId && v.Revision == expectedRevision, + Builders.Update + .Set(v => v.DeckId, request.DeckId) + .Set(v => v.Type, type) + .Set(v => v.Prompt, request.Prompt) + .Set(v => v.Answer, request.Answer) + .Set(v => v.Extra, request.Extra) + .Set(v => v.Tags, NormalizeTags(request.Tags)) + .Set(v => v.Images, request.Images) + .Set(v => v.SourceQuestionExternalId, request.SourceQuestionExternalId) + .Set(v => v.ContentFingerprint, fingerprint) + .Set(v => v.UpdatedUtc, now) + .Inc(v => v.Revision, 1), + new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); + if (updated is null) + return new FlashcardNoteUpdateResult(null, await GetAsync(userId, noteId)); + saved = updated; + } + + await _schedules.SyncVariantsAsync(saved); + return new FlashcardNoteUpdateResult(saved, null); + } + + public async Task ArchiveAsync(string userId, string noteId, bool archived) + { + var updated = await _notes.FindOneAndUpdateAsync( + v => v.UserId == userId && v.Id == noteId, + Builders.Update + .Set(v => v.ArchivedUtc, archived ? DateTime.UtcNow : null) + .Set(v => v.UpdatedUtc, DateTime.UtcNow) + .Inc(v => v.Revision, 1), + new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); + return updated is not null + ? new FlashcardNoteUpdateResult(updated, null) + : new FlashcardNoteUpdateResult(null, await GetAsync(userId, noteId)); + } + + public async Task MoveAsync(string userId, IReadOnlyCollection noteIds, string targetDeckId) + { + var result = await _notes.UpdateManyAsync( + v => v.UserId == userId && noteIds.Contains(v.Id!), + Builders.Update.Set(v => v.DeckId, targetDeckId).Set(v => v.UpdatedUtc, DateTime.UtcNow).Inc(v => v.Revision, 1)); + return result.ModifiedCount; + } + + public Task CountInDeckAsync(string userId, string deckId) => + _notes.CountDocumentsAsync(v => v.UserId == userId && v.DeckId == deckId && v.ArchivedUtc == null); + + private static List NormalizeTags(List tags) => tags + .Select(v => v.Trim()) + .Where(v => v.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); +} + +public sealed class FlashcardValidationException(string message) : Exception(message); + +public static class FlashcardArchiveFilter +{ + public const string IncludeArchived = "archived"; +} diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardScheduleStore.cs b/api/QuestionsApi/Services/Flashcards/FlashcardScheduleStore.cs new file mode 100644 index 0000000..012f070 --- /dev/null +++ b/api/QuestionsApi/Services/Flashcards/FlashcardScheduleStore.cs @@ -0,0 +1,322 @@ +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services.Flashcards; + +public enum FlashcardReviewOutcomeKind { Success, Duplicate, Conflict, NotFound } + +public sealed class FlashcardReviewOutcome +{ + public FlashcardReviewOutcomeKind Kind { get; init; } + public FlashcardSchedule? Schedule { get; init; } + public static FlashcardReviewOutcome NotFound() => new() { Kind = FlashcardReviewOutcomeKind.NotFound }; + public static FlashcardReviewOutcome Conflict(FlashcardSchedule? current) => new() { Kind = FlashcardReviewOutcomeKind.Conflict, Schedule = current }; + public static FlashcardReviewOutcome Duplicate(FlashcardSchedule? current) => new() { Kind = FlashcardReviewOutcomeKind.Duplicate, Schedule = current }; + public static FlashcardReviewOutcome Success(FlashcardSchedule schedule) => new() { Kind = FlashcardReviewOutcomeKind.Success, Schedule = schedule }; +} + +public sealed class FlashcardScheduleStore +{ + private readonly IMongoCollection _schedules; + private readonly IMongoCollection _reviewLogs; + private readonly IFlashcardScheduler _scheduler; + + public FlashcardScheduleStore(MongoDatabaseProvider mongo, IFlashcardScheduler scheduler) + { + _schedules = mongo.Database.GetCollection("FlashcardSchedules"); + _reviewLogs = mongo.Database.GetCollection("FlashcardReviewLogs"); + _scheduler = scheduler; + + _schedules.Indexes.CreateMany([ + new CreateIndexModel( + Builders.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.NoteId).Ascending(v => v.Variant), + new CreateIndexOptions { Unique = true, Name = "flashcard_schedule_unique_user_note_variant" }), + new CreateIndexModel( + Builders.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.State).Ascending(v => v.DueUtc), + new CreateIndexOptions { Name = "flashcard_schedule_user_state_due" }) + ]); + _reviewLogs.Indexes.CreateMany([ + new CreateIndexModel( + Builders.IndexKeys.Ascending(v => v.UserId).Descending(v => v.ReviewedUtc), + new CreateIndexOptions { Name = "flashcard_review_log_user_reviewed" }), + new CreateIndexModel( + Builders.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.CardId), + new CreateIndexOptions { Name = "flashcard_review_log_user_card" }) + ]); + } + + // Basic notes always have exactly one card (variant 0). Cloze notes get one independently + // scheduled card per distinct {{cN::...}} number found in the prompt. Existing variants are + // never removed on re-sync (even if a cloze number disappears from edited text) so a note + // edit never discards review history. + public async Task SyncVariantsAsync(FlashcardNote note) + { + var desiredVariants = note.Type == FlashcardTypes.Cloze + ? FlashcardClozeParser.DistinctClozeNumbers(note.Prompt) + : [0]; + if (desiredVariants.Count == 0) return; + + var existing = await GetByNoteIdAsync(note.UserId, note.Id!); + var existingVariants = existing.Select(v => v.Variant).ToHashSet(); + var now = DateTime.UtcNow; + foreach (var variant in desiredVariants.Where(v => !existingVariants.Contains(v))) + { + var schedule = new FlashcardSchedule + { + UserId = note.UserId, + NoteId = note.Id!, + Variant = variant, + State = FlashcardScheduleStates.New, + DueUtc = now, + CreatedUtc = now + }; + try + { + await _schedules.InsertOneAsync(schedule); + } + catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + // Concurrent save already created this variant. + } + } + } + + public async Task GetAsync(string userId, string cardId) => + await _schedules.Find(v => v.UserId == userId && v.Id == cardId).FirstOrDefaultAsync(); + + public Task> GetByNoteIdAsync(string userId, string noteId) => + _schedules.Find(v => v.UserId == userId && v.NoteId == noteId).ToListAsync(); + + public async Task> GetDueAsync( + string userId, IReadOnlyCollection noteIds, DateTime nowUtc, DateTime studyDayStartUtc, int limit) + { + if (noteIds.Count == 0 || limit <= 0) return []; + return await _schedules.Find(v => v.UserId == userId + && noteIds.Contains(v.NoteId) + && v.State != FlashcardScheduleStates.New + && !v.IsSuspended + && (v.BuriedUntilUtc == null || v.BuriedUntilUtc <= studyDayStartUtc) + && v.DueUtc <= nowUtc) + .SortBy(v => v.DueUtc) + .Limit(limit) + .ToListAsync(); + } + + public async Task> GetNewAsync( + string userId, IReadOnlyCollection noteIds, DateTime studyDayStartUtc, int limit) + { + if (noteIds.Count == 0 || limit <= 0) return []; + return await _schedules.Find(v => v.UserId == userId + && noteIds.Contains(v.NoteId) + && v.State == FlashcardScheduleStates.New + && !v.IsSuspended + && (v.BuriedUntilUtc == null || v.BuriedUntilUtc <= studyDayStartUtc)) + .SortBy(v => v.CreatedUtc) + .Limit(limit) + .ToListAsync(); + } + + public Task CountDueAsync(string userId, IReadOnlyCollection noteIds, DateTime nowUtc, DateTime studyDayStartUtc) => + noteIds.Count == 0 + ? Task.FromResult(0L) + : _schedules.CountDocumentsAsync(v => v.UserId == userId + && noteIds.Contains(v.NoteId) + && v.State != FlashcardScheduleStates.New + && !v.IsSuspended + && (v.BuriedUntilUtc == null || v.BuriedUntilUtc <= studyDayStartUtc) + && v.DueUtc <= nowUtc); + + public Task CountNewAsync(string userId, IReadOnlyCollection noteIds, DateTime studyDayStartUtc) => + noteIds.Count == 0 + ? Task.FromResult(0L) + : _schedules.CountDocumentsAsync(v => v.UserId == userId + && noteIds.Contains(v.NoteId) + && v.State == FlashcardScheduleStates.New + && !v.IsSuspended + && (v.BuriedUntilUtc == null || v.BuriedUntilUtc <= studyDayStartUtc)); + + public Task CountReviewsSinceAsync(string userId, DateTime sinceUtc) => + _reviewLogs.CountDocumentsAsync(v => v.UserId == userId && v.ReviewedUtc >= sinceUtc && v.UndoneUtc == null); + + public async Task CountNewIntroducedSinceAsync(string userId, DateTime sinceUtc) => + checked((int)await _reviewLogs.CountDocumentsAsync(v => v.UserId == userId + && v.ReviewedUtc >= sinceUtc && v.UndoneUtc == null && v.Before.State == FlashcardScheduleStates.New)); + + public Task> GetReviewsSinceAsync(string userId, DateTime sinceUtc) => + _reviewLogs.Find(v => v.UserId == userId && v.ReviewedUtc >= sinceUtc).SortBy(v => v.ReviewedUtc).ToListAsync(); + + public Task> GetReviewLogsForCardAsync(string userId, string cardId) => + _reviewLogs.Find(v => v.UserId == userId && v.CardId == cardId).SortBy(v => v.ReviewedUtc).ToListAsync(); + + // Used only by JSON import: restores a previously-exported card's FSRS state verbatim + // (not a normal review) so importing preserves history instead of re-deriving it. + public Task ImportScheduleStateAsync(string userId, string noteId, FlashcardSchedule imported) => + _schedules.UpdateOneAsync( + v => v.UserId == userId && v.NoteId == noteId && v.Variant == imported.Variant, + Builders.Update + .Set(v => v.State, imported.State) + .Set(v => v.Step, imported.Step) + .Set(v => v.Stability, imported.Stability) + .Set(v => v.Difficulty, imported.Difficulty) + .Set(v => v.DueUtc, imported.DueUtc) + .Set(v => v.LastReviewUtc, imported.LastReviewUtc) + .Set(v => v.IsSuspended, imported.IsSuspended) + .Set(v => v.BuriedUntilUtc, imported.BuriedUntilUtc) + .Inc(v => v.Revision, 1)); + + public async Task ImportReviewLogAsync(string userId, string cardId, FlashcardReviewLog imported) + { + var log = new FlashcardReviewLog + { + Id = string.IsNullOrWhiteSpace(imported.Id) ? Guid.NewGuid().ToString() : imported.Id, + UserId = userId, + CardId = cardId, + Rating = imported.Rating, + ReviewedUtc = imported.ReviewedUtc, + DurationSeconds = imported.DurationSeconds, + Before = imported.Before, + After = imported.After, + CreatedUtc = imported.CreatedUtc, + UndoneUtc = imported.UndoneUtc + }; + try + { + await _reviewLogs.InsertOneAsync(log); + } + catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + // Already imported (re-running the same import commit). + } + } + + public List PreviewRatingOptions(FlashcardSchedule schedule, DateTime nowUtc) => + _scheduler.PreviewRatingOptions(schedule, nowUtc); + + public async Task SubmitReviewAsync(string userId, FlashcardReviewRequest request) + { + var schedule = await GetAsync(userId, request.CardId); + if (schedule is null) return FlashcardReviewOutcome.NotFound(); + + if (schedule.Revision != request.ExpectedRevision) + { + var priorLog = await _reviewLogs.Find(v => v.Id == request.ClientReviewId && v.UserId == userId).FirstOrDefaultAsync(); + return priorLog is not null && priorLog.CardId == request.CardId + ? FlashcardReviewOutcome.Duplicate(schedule) + : FlashcardReviewOutcome.Conflict(schedule); + } + + var before = Snapshot(schedule); + var result = _scheduler.Review(schedule, request.Rating, request.ReviewedUtc, request.DurationSeconds); + + var updated = await _schedules.FindOneAndUpdateAsync( + v => v.UserId == userId && v.Id == request.CardId && v.Revision == request.ExpectedRevision, + Builders.Update + .Set(v => v.State, result.State) + .Set(v => v.Step, result.Step) + .Set(v => v.Stability, result.Stability) + .Set(v => v.Difficulty, result.Difficulty) + .Set(v => v.DueUtc, result.DueUtc) + .Set(v => v.LastReviewUtc, result.LastReviewUtc) + .Set(v => v.AlgorithmVersion, FsrsFlashcardScheduler.AlgorithmVersion) + .Inc(v => v.Revision, 1), + new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); + + if (updated is null) + { + var raceLog = await _reviewLogs.Find(v => v.Id == request.ClientReviewId && v.UserId == userId).FirstOrDefaultAsync(); + var current = await GetAsync(userId, request.CardId); + return raceLog is not null && raceLog.CardId == request.CardId + ? FlashcardReviewOutcome.Duplicate(current) + : FlashcardReviewOutcome.Conflict(current); + } + + var log = new FlashcardReviewLog + { + Id = request.ClientReviewId, + UserId = userId, + CardId = request.CardId, + Rating = request.Rating, + ReviewedUtc = request.ReviewedUtc, + DurationSeconds = Math.Clamp(request.DurationSeconds, 0, 86_400), + Before = before, + After = Snapshot(updated), + CreatedUtc = DateTime.UtcNow + }; + try + { + await _reviewLogs.InsertOneAsync(log); + } + catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + // A retried duplicate request already recorded this review; the schedule update + // above only applies once because it matched the pre-review revision exactly. + } + + // Bury cloze siblings (basic notes have exactly one variant, so this is a no-op for them). + var buriedUntil = FlashcardScheduling.NextStudyDayStart(request.StudyDayStartUtc); + await _schedules.UpdateManyAsync( + v => v.UserId == userId && v.NoteId == updated.NoteId && v.Id != updated.Id, + Builders.Update.Set(v => v.BuriedUntilUtc, buriedUntil)); + + return FlashcardReviewOutcome.Success(updated); + } + + public async Task UndoAsync(string userId, string reviewLogId) + { + var log = await _reviewLogs.Find(v => v.Id == reviewLogId && v.UserId == userId && v.UndoneUtc == null).FirstOrDefaultAsync(); + if (log is null) return FlashcardReviewOutcome.NotFound(); + + var current = await GetAsync(userId, log.CardId); + if (current is null) return FlashcardReviewOutcome.NotFound(); + if (current.Revision != log.After.Revision) + return FlashcardReviewOutcome.Conflict(current); + + var restored = await _schedules.FindOneAndUpdateAsync( + v => v.UserId == userId && v.Id == log.CardId && v.Revision == log.After.Revision, + Builders.Update + .Set(v => v.State, log.Before.State) + .Set(v => v.Step, log.Before.Step) + .Set(v => v.Stability, log.Before.Stability) + .Set(v => v.Difficulty, log.Before.Difficulty) + .Set(v => v.DueUtc, log.Before.DueUtc) + .Set(v => v.LastReviewUtc, log.Before.LastReviewUtc) + .Inc(v => v.Revision, 1), + new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); + + if (restored is null) return FlashcardReviewOutcome.Conflict(await GetAsync(userId, log.CardId)); + + await _reviewLogs.UpdateOneAsync( + v => v.Id == reviewLogId, + Builders.Update.Set(v => v.UndoneUtc, DateTime.UtcNow)); + return FlashcardReviewOutcome.Success(restored); + } + + public async Task SetSuspendedAsync(string userId, IReadOnlyCollection cardIds, bool suspended) + { + if (cardIds.Count == 0) return 0; + var result = await _schedules.UpdateManyAsync( + v => v.UserId == userId && cardIds.Contains(v.Id!), + Builders.Update.Set(v => v.IsSuspended, suspended).Inc(v => v.Revision, 1)); + return result.ModifiedCount; + } + + public async Task BuryAsync(string userId, IReadOnlyCollection cardIds, DateTime untilUtc) + { + if (cardIds.Count == 0) return 0; + var result = await _schedules.UpdateManyAsync( + v => v.UserId == userId && cardIds.Contains(v.Id!), + Builders.Update.Set(v => v.BuriedUntilUtc, untilUtc).Inc(v => v.Revision, 1)); + return result.ModifiedCount; + } + + private static FlashcardScheduleSnapshot Snapshot(FlashcardSchedule schedule) => new() + { + State = schedule.State, + Step = schedule.Step, + Stability = schedule.Stability, + Difficulty = schedule.Difficulty, + DueUtc = schedule.DueUtc, + LastReviewUtc = schedule.LastReviewUtc, + Revision = schedule.Revision + }; +} diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardScheduler.cs b/api/QuestionsApi/Services/Flashcards/FlashcardScheduler.cs new file mode 100644 index 0000000..67c3508 --- /dev/null +++ b/api/QuestionsApi/Services/Flashcards/FlashcardScheduler.cs @@ -0,0 +1,116 @@ +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 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 PreviewRatingOptions(FlashcardSchedule schedule, DateTime nowUtc) + { + var options = new List(); + 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"; + } +} diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardScheduling.cs b/api/QuestionsApi/Services/Flashcards/FlashcardScheduling.cs new file mode 100644 index 0000000..c471998 --- /dev/null +++ b/api/QuestionsApi/Services/Flashcards/FlashcardScheduling.cs @@ -0,0 +1,14 @@ +namespace QuestionsApi.Services.Flashcards; + +// Small pure helpers pulled out of FlashcardStudyService/FlashcardScheduleStore so the +// daily-limit and sibling-bury rules can be unit tested without a live Mongo instance. +public static class FlashcardScheduling +{ + // A card reviewed on a given local study day buries its siblings until the next local day. + public static DateTime NextStudyDayStart(DateTime studyDayStartUtc) => studyDayStartUtc.Date.AddDays(1); + + // Effective per-day budget: deck override wins over the global default, minus what's + // already been consumed on the client's current local study day. + public static int ResolveDailyBudget(int globalLimit, int? deckOverride, int consumedToday) => + Math.Max(0, (deckOverride ?? globalLimit) - consumedToday); +} diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardSettingsStore.cs b/api/QuestionsApi/Services/Flashcards/FlashcardSettingsStore.cs new file mode 100644 index 0000000..f7a6830 --- /dev/null +++ b/api/QuestionsApi/Services/Flashcards/FlashcardSettingsStore.cs @@ -0,0 +1,36 @@ +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services.Flashcards; + +public sealed class FlashcardSettingsStore +{ + private readonly IMongoCollection _settings; + + public FlashcardSettingsStore(MongoDatabaseProvider mongo) + { + _settings = mongo.Database.GetCollection("FlashcardSettings"); + _settings.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(v => v.UserId), + new CreateIndexOptions { Unique = true, Name = "flashcard_settings_unique_user" })); + } + + public async Task GetAsync(string userId) => + await _settings.Find(v => v.UserId == userId).FirstOrDefaultAsync() + ?? new FlashcardSettings { UserId = userId }; + + public async Task UpdateAsync(string userId, UpdateFlashcardSettingsRequest request) + { + var now = DateTime.UtcNow; + return await _settings.FindOneAndUpdateAsync( + v => v.UserId == userId, + Builders.Update + .Set(v => v.NewCardsPerDay, Math.Clamp(request.NewCardsPerDay, 0, 2000)) + .Set(v => v.ReviewsPerDay, Math.Clamp(request.ReviewsPerDay, 0, 5000)) + .Set(v => v.DesiredRetention, FsrsFlashcardScheduler.DesiredRetention) + .Set(v => v.UpdatedUtc, now) + .Inc(v => v.Revision, 1) + .SetOnInsert(v => v.UserId, userId), + new FindOneAndUpdateOptions { IsUpsert = true, ReturnDocument = ReturnDocument.After }); + } +} diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardStudyService.cs b/api/QuestionsApi/Services/Flashcards/FlashcardStudyService.cs new file mode 100644 index 0000000..089839c --- /dev/null +++ b/api/QuestionsApi/Services/Flashcards/FlashcardStudyService.cs @@ -0,0 +1,112 @@ +using QuestionsApi.Models; + +namespace QuestionsApi.Services.Flashcards; + +// Composes decks/notes/schedules/settings to answer "what's next" and daily-count questions. +// Kept separate from the individual stores to avoid a circular dependency (notes need to +// sync schedule variants; schedules must not need to know about notes/decks). +public sealed class FlashcardStudyService( + FlashcardDeckStore decks, + FlashcardNoteStore notes, + FlashcardScheduleStore schedules, + FlashcardSettingsStore settingsStore) +{ + public async Task GetNextCardAsync(string userId, FlashcardStudyRequest request) + { + var now = DateTime.UtcNow; + var studyDayStart = request.StudyDayStartUtc.Date; + var noteIds = await ResolveNoteIdsAsync(userId, request.DeckId); + var (newBudget, reviewBudget) = await ResolveBudgetsAsync(userId, request.DeckId, studyDayStart); + var exclude = request.ExcludeCardIds.Where(v => !string.IsNullOrWhiteSpace(v)).ToHashSet(StringComparer.Ordinal); + + // Overdue/due cards first (oldest due first), then new cards. + FlashcardSchedule? card = null; + if (reviewBudget > 0) + { + var due = await schedules.GetDueAsync(userId, noteIds, now, studyDayStart, reviewBudget + exclude.Count + 1); + card = due.FirstOrDefault(v => !exclude.Contains(v.Id!)); + } + if (card is null && newBudget > 0) + { + var fresh = await schedules.GetNewAsync(userId, noteIds, studyDayStart, newBudget + exclude.Count + 1); + card = fresh.FirstOrDefault(v => !exclude.Contains(v.Id!)); + } + + var dueRemaining = checked((int)await schedules.CountDueAsync(userId, noteIds, now, studyDayStart)); + var newRemaining = checked((int)await schedules.CountNewAsync(userId, noteIds, studyDayStart)); + var response = new FlashcardStudyCardResponse + { + DueRemaining = Math.Min(dueRemaining, reviewBudget), + NewRemaining = Math.Min(newRemaining, newBudget) + }; + if (card is null) return response; + + var note = await notes.GetAsync(userId, card.NoteId); + if (note is null) return response; + + response.Note = note; + response.Card = card; + response.RatingOptions = schedules.PreviewRatingOptions(card, now); + return response; + } + + public async Task GetDashboardCountsAsync(string userId, DateTime nowUtc, DateTime weekStartUtc) + { + var studyDayStart = nowUtc.Date; + var noteIds = await ResolveNoteIdsAsync(userId, ""); + var (newBudget, _) = await ResolveBudgetsAsync(userId, "", studyDayStart); + var due = checked((int)await schedules.CountDueAsync(userId, noteIds, nowUtc, studyDayStart)); + var newAvailable = Math.Min(checked((int)await schedules.CountNewAsync(userId, noteIds, studyDayStart)), newBudget); + var reviewedThisWeek = checked((int)await schedules.CountReviewsSinceAsync(userId, weekStartUtc)); + return new FlashcardDashboardCounts { Due = due, NewAvailable = newAvailable, ReviewedThisWeek = reviewedThisWeek }; + } + + public async Task> GetDeckSummariesAsync(string userId, DateTime nowUtc, DateTime studyDayStart) + { + var deckList = await decks.ListAsync(userId); + var result = new List(); + foreach (var deck in deckList) + { + var deckNoteIds = (await notes.ListByDeckAsync(userId, deck.Id!)).Select(v => v.Id!).ToList(); + var due = checked((int)await schedules.CountDueAsync(userId, deckNoteIds, nowUtc, studyDayStart)); + var fresh = checked((int)await schedules.CountNewAsync(userId, deckNoteIds, studyDayStart)); + result.Add(new FlashcardDeckSummary + { + Deck = deck, + Total = deckNoteIds.Count, + Due = due, + New = fresh + }); + } + return result; + } + + private async Task> ResolveNoteIdsAsync(string userId, string deckId) + { + var activeNotes = string.IsNullOrWhiteSpace(deckId) + ? await notes.ListAllActiveAsync(userId) + : await notes.ListByDeckAsync(userId, deckId); + return activeNotes.Select(v => v.Id!).ToList(); + } + + private async Task<(int NewBudget, int ReviewBudget)> ResolveBudgetsAsync(string userId, string deckId, DateTime studyDayStart) + { + var settings = await settingsStore.GetAsync(userId); + var newLimit = settings.NewCardsPerDay; + var reviewLimit = settings.ReviewsPerDay; + if (!string.IsNullOrWhiteSpace(deckId)) + { + var deck = await decks.GetAsync(userId, deckId); + if (deck is not null) + { + newLimit = deck.NewCardsPerDay ?? newLimit; + reviewLimit = deck.ReviewsPerDay ?? reviewLimit; + } + } + var reviewedToday = checked((int)await schedules.CountReviewsSinceAsync(userId, studyDayStart)); + var newIntroducedToday = await schedules.CountNewIntroducedSinceAsync(userId, studyDayStart); + return ( + FlashcardScheduling.ResolveDailyBudget(newLimit, null, newIntroducedToday), + FlashcardScheduling.ResolveDailyBudget(reviewLimit, null, reviewedToday)); + } +} diff --git a/api/QuestionsApi/Services/UserProfileStore.cs b/api/QuestionsApi/Services/UserProfileStore.cs index e798da5..7076882 100644 --- a/api/QuestionsApi/Services/UserProfileStore.cs +++ b/api/QuestionsApi/Services/UserProfileStore.cs @@ -16,7 +16,9 @@ public sealed class UserProfileStore "ReviewQueueItems", "QuestionReviewAttempts", "ReviewQueueBackfillStates", "CcsAttempts", "CcsUserRatings", "CcsRatedCaseResults", "ExamSimulations", "Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles", - "ExternalIdentities", "ExternalAuthTransactions" + "ExternalIdentities", "ExternalAuthTransactions", + "FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs", + "FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews" ]; public UserProfileStore(MongoDatabaseProvider mongo) diff --git a/docs/FLASHCARDS.md b/docs/FLASHCARDS.md new file mode 100644 index 0000000..09f71d9 --- /dev/null +++ b/docs/FLASHCARDS.md @@ -0,0 +1,83 @@ +# Flashcards + +A separate spaced-repetition system alongside the missed-question review queue. Users author +basic and cloze flashcards (or generate a basic card from a resolved question), organize them +in flat decks, and study them on an FSRS schedule. + +## Scheduling + +Reviews are scored by [FSRS.Core](https://www.nuget.org/packages/FSRS.Core) 1.0.7, wrapped +behind `IFlashcardScheduler` (`api/QuestionsApi/Services/Flashcards/FlashcardScheduler.cs`) so +the rest of the API never touches the library directly. Pinned, non-configurable defaults: + +- Desired retention: 90% +- Maximum interval: 36,500 days +- Interval fuzzing: disabled, so a review's outcome is a deterministic function of stored state + (needed for idempotent replay from the offline outbox, and for golden-vector testing) +- Algorithm/parameter version is recorded per card (`FlashcardSchedule.AlgorithmVersion`) so a + future FSRS upgrade or personalized-parameter feature can be introduced without losing the + ability to tell which cards were scheduled under which version + +There is no per-user parameter optimization yet; `FlashcardScheduleStore` stores complete +before/after snapshots on every `FlashcardReviewLog` entry specifically so that a future +optimizer can be trained from full history without a data migration. + +`api/QuestionsApi.Tests/FlashcardSchedulerTests.cs` validates the wrapper against the official +FSRS formulas (initial stability/difficulty per rating, and the interval-equals-stability +identity that holds at exactly 90% desired retention) computed from the library's own shipped +default parameters, rather than against hand-copied constants - this stays correct even if +FSRS.Core's defaults are retrained upstream. + +## Data model + +Six user-owned Mongo collections, all indexed on `(UserId, ...)`: + +- `FlashcardDecks` - flat (no nesting); every user has exactly one `IsDefault` deck, created + lazily on first use. +- `FlashcardNotes` - authored content (`Prompt`/`Answer`/`Extra`/`Tags`/`Images`). Cloze notes + store their `{{cN::answer::hint}}` markup in `Prompt`. +- `FlashcardSchedules` - one row per generated card, keyed by `(NoteId, Variant)`. Basic notes + always have variant `0`; cloze notes get one row per distinct cloze number found in the + prompt. Kept in a separate collection from notes so editing a note's content never discards + its review history. +- `FlashcardReviewLogs` - immutable, keyed by the client-generated review UUID for idempotent + submission; carries full before/after schedule snapshots. +- `FlashcardMediaAssets` - user-uploaded images (PNG/JPEG/WebP, 5 MB/4-per-note limits). +- `FlashcardSettings` - one row per user (global new/review daily limits). + +Attaching a question's own image to a flashcard does not copy bytes: `FlashcardImageReference` +has a `Kind` (`"question"` vs `"owned"`) so the client can resolve `"question"` references +against the existing `/api/questions/media/{id}` endpoint instead of duplicating the asset. + +Sibling cloze cards are buried (`FlashcardSchedule.BuriedUntilUtc`) until the next local study +day whenever any one sibling is reviewed. Daily new/review limits reset at the same boundary. +That boundary is supplied by the client as `studyDayStartUtc` on study/review requests (the +local calendar day at session start, expressed as a UTC instant) rather than computed +server-side, per the spec's client-local-day requirement. + +## API + +All endpoints live under `/api/flashcards` (`FlashcardsController.cs`) and require `USER`/`SUPER` +auth. Deck/note mutations use the same optimistic-concurrency (`ExpectedRevision` + 409 Conflict) +pattern as the rest of the API. Review submission is idempotent on `ClientReviewId`; undo only +allows reverting the single most recent review on a card (it refuses if the card's revision has +moved since). + +## Import/export + +CSV is UTF-8, text-only, headers `stable_id,deck,type,prompt,answer,extra,tags, +source_question_external_id` (tags semicolon-separated); it omits images, schedule state, and +review history and reports those omissions via the `X-Flashcard-Export-Omissions` response +header. App JSON is full-fidelity (decks, settings, notes, schedules, review logs, base64 +images) and merges on import by the note's stable UUID. Both formats go through a +create/update/skip/error preview (`FlashcardImportPreviews`, TTL-expired after an hour) before +`import/commit` applies it; commit never silently drops a row - everything is created, updated, +skipped as an exact-content duplicate, or reported as a row-level error. Limits: 25 MB and +10,000 notes per import. + +## Deployment + +No new secrets. `FlashcardsController`/stores register the same way as the rest of the API +(`Program.cs`, `AddSingleton`). The Godot client resolves its API base URL from +`step3/flashcards_api_url` in `project.godot`, following the same same-origin-on-web resolution +as every other autoload service (`api_endpoint_config.gd`). diff --git a/docs/PROFILE_ACCOUNT_OPERATIONS.md b/docs/PROFILE_ACCOUNT_OPERATIONS.md index ca4beaf..2b92932 100644 --- a/docs/PROFILE_ACCOUNT_OPERATIONS.md +++ b/docs/PROFILE_ACCOUNT_OPERATIONS.md @@ -28,3 +28,7 @@ must initiate recovery with their exact username. Exports are versioned JSON files containing account metadata and user-owned study records. Password hashes, refresh tokens, global question content, and moderation data owned by other users are excluded. + +Flashcard decks, notes, generated cards, review logs, media, and settings are user-owned +collections (see [FLASHCARDS.md](FLASHCARDS.md)) and are included in export/purge the same way +as the rest of a learner's study data. diff --git a/docs/UAT_SMOKE_TESTS.md b/docs/UAT_SMOKE_TESTS.md index 6787d99..a159a76 100644 --- a/docs/UAT_SMOKE_TESTS.md +++ b/docs/UAT_SMOKE_TESTS.md @@ -10,6 +10,8 @@ It checks: - registration, login, refresh, authenticated profile, dashboard, ratings, review queue, and randomized question retrieval; - random CCS attempt creation and abandonment; +- flashcard deck/card creation, studying and grading a card, confirming it + drops out of the due queue, and a CSV export/import round trip; - public web/legal assets, completion of the Godot loader, and a visible, non-uniform canvas without browser or critical asset errors. diff --git a/project.godot b/project.godot index 1ae990d..2630382 100644 --- a/project.godot +++ b/project.godot @@ -35,6 +35,7 @@ NotesService="*res://scripts/autoload/notes_service.gd" BookmarkService="*res://scripts/autoload/bookmark_service.gd" ReviewQueueService="*res://scripts/autoload/review_queue_service.gd" DashboardService="*res://scripts/autoload/dashboard_service.gd" +FlashcardService="*res://scripts/autoload/flashcard_service.gd" AccountProfileService="*res://scripts/autoload/account_profile_service.gd" DonationService="*res://scripts/autoload/donation_service.gd" @@ -67,4 +68,5 @@ dashboard_api_url="https://us3q.ranaze.com/api/dashboard" profile_api_url="https://us3q.ranaze.com/api/profile" donations_api_url="https://us3q.ranaze.com/api/donations" review_queue_api_url="https://us3q.ranaze.com/api/review-queue" +flashcards_api_url="https://us3q.ranaze.com/api/flashcards" build_revision="development" diff --git a/scripts/autoload/flashcard_service.gd b/scripts/autoload/flashcard_service.gd new file mode 100644 index 0000000..445bbca --- /dev/null +++ b/scripts/autoload/flashcard_service.gd @@ -0,0 +1,375 @@ +extends Node + +# Flashcard autoload: study-session networking (next card, review submission) plus best-effort +# CRUD for decks/notes/settings used by the library screens. +# +# Review submission is the one path that must survive a dropped connection mid-session: graded +# reviews are queued to a durable per-account outbox (mirrors ReviewQueueService) and retried +# until the server confirms them. Everything else (deck/note CRUD, counts) is a plain +# best-effort request - if it fails, the caller shows an error and the user retries manually. + +signal decks_changed(decks: Array) +signal settings_changed(settings: Dictionary) +signal counts_changed(counts: Dictionary) +signal next_card_loaded(card: Dictionary) +signal next_card_failed(message: String) +signal review_synced(card_id: String, updated_card: Dictionary) +signal review_paused(message: String) +signal review_resumed +signal notes_loaded(response: Dictionary) +signal note_saved(note: Dictionary) +signal request_failed(tag: String, message: String) + +const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") +const DEFAULT_API_URL := "https://us3q.ranaze.com/api/flashcards" +const OUTBOX_PATH := "user://pending_flashcard_reviews.json" + +var api_url := DEFAULT_API_URL +var next_request: HTTPRequest +var review_request: HTTPRequest +var misc_request: HTTPRequest +var retry_timer: Timer +var loading_next := false +var submitting_review := false +var misc_busy := false +var misc_queue: Array = [] +var outbox: Array = [] +var active_account_key := "" +var is_paused := false + + +func _ready() -> void: + api_url = EndpointConfig.resolve( + str(ProjectSettings.get_setting("step3/flashcards_api_url", DEFAULT_API_URL)), + "/api/flashcards") + next_request = _make_request(_on_next_completed) + review_request = _make_request(_on_review_completed) + misc_request = _make_request(_on_misc_completed) + retry_timer = Timer.new() + retry_timer.one_shot = true + retry_timer.wait_time = 5.0 + retry_timer.timeout.connect(_process_outbox) + add_child(retry_timer) + _load_outbox() + AuthState.auth_changed.connect(_on_auth_changed) + _on_auth_changed() + + +func _make_request(callback: Callable) -> HTTPRequest: + var request := HTTPRequest.new() + request.timeout = 25.0 + request.request_completed.connect(callback) + add_child(request) + return request + + +func _headers() -> PackedStringArray: + return PackedStringArray([ + "Content-Type: application/json", + "Authorization: Bearer %s" % AuthState.access_token + ]) + + +func current_study_day_start_utc() -> String: + # The local calendar day at session start, expressed as a UTC instant, per the spec's + # "daily limits are based on the client's local study day recorded at session start." + var local := Time.get_datetime_dict_from_system(false) + var utc_offset_minutes: int = Time.get_time_zone_from_system().get("bias", 0) + var local_midnight_unix: int = Time.get_unix_time_from_datetime_dict({ + "year": local.year, "month": local.month, "day": local.day, + "hour": 0, "minute": 0, "second": 0 + }) + var utc_unix: int = local_midnight_unix + utc_offset_minutes * 60 + return "%sZ" % Time.get_datetime_string_from_unix_time(utc_unix, true) + + +# ---------- Next card / study session ---------- + +func request_next_card(deck_id: String, exclude_ids: Array, study_day_start_utc: String) -> void: + if loading_next or not AuthState.can_send_authenticated_request(): + next_card_failed.emit("Login is required to study flashcards.") + return + loading_next = true + var payload := { + "deckId": deck_id, + "excludeCardIds": exclude_ids, + "studyDayStartUtc": study_day_start_utc + } + var error := next_request.request( + "%s/study/next" % api_url, _headers(), HTTPClient.METHOD_POST, JSON.stringify(payload)) + if error != OK: + loading_next = false + next_card_failed.emit("The next flashcard could not be loaded. Check your connection and try again.") + + +func _on_next_completed(result: int, response_code: int, _headers_value: PackedStringArray, body: PackedByteArray) -> void: + loading_next = false + if response_code == 401: + AuthState.handle_unauthorized(AuthState.access_token) + return + if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + next_card_failed.emit("The next flashcard could not be loaded. Check your connection and try again.") + return + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + if typeof(parsed) != TYPE_DICTIONARY: + next_card_failed.emit("The flashcard service returned an invalid response.") + return + next_card_loaded.emit(parsed) + + +# ---------- Review submission (offline-safe outbox) ---------- + +func submit_review(card_id: String, rating: String, expected_revision: int, duration_seconds: int, study_day_start_utc: String) -> String: + var client_review_id := _new_uuid() + var payload := { + "clientReviewId": client_review_id, + "cardId": card_id, + "rating": rating, + "expectedRevision": expected_revision, + "reviewedUtc": "%sZ" % Time.get_datetime_string_from_system(true), + "durationSeconds": clampi(duration_seconds, 0, 86400), + "studyDayStartUtc": study_day_start_utc + } + outbox.append({"account": _account_key(), "payload": payload, "retry_count": 0}) + _save_outbox() + _process_outbox() + return client_review_id + + +func has_pending_reviews() -> bool: + return _next_account_item_index() >= 0 + + +func _process_outbox() -> void: + if submitting_review or not AuthState.can_send_authenticated_request(): + return + var index := _next_account_item_index() + if index < 0: + if is_paused: + is_paused = false + review_resumed.emit() + return + var item: Dictionary = outbox[index] + item["active"] = true + outbox[index] = item + submitting_review = true + var error := review_request.request( + "%s/reviews" % api_url, _headers(), HTTPClient.METHOD_POST, + JSON.stringify({"reviews": [item.get("payload", {})]})) + if error != OK: + submitting_review = false + item.erase("active") + item["retry_count"] = int(item.get("retry_count", 0)) + 1 + outbox[index] = item + _save_outbox() + _mark_paused() + retry_timer.start() + + +func _on_review_completed(result: int, response_code: int, _headers_value: PackedStringArray, body: PackedByteArray) -> void: + submitting_review = false + var index := _active_item_index() + if index < 0: + return + var item: Dictionary = outbox[index] + item.erase("active") + var card_id := str(item.get("payload", {}).get("cardId", "")) + if response_code == 401: + outbox[index] = item + _save_outbox() + AuthState.handle_unauthorized(AuthState.access_token) + return + if result == HTTPRequest.RESULT_SUCCESS and response_code >= 200 and response_code < 300: + outbox.remove_at(index) + _save_outbox() + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + if typeof(parsed) == TYPE_DICTIONARY: + var results: Array = parsed.get("results", []) + if results.size() > 0: + review_synced.emit(card_id, (results[0] as Dictionary).get("card", {})) + _process_outbox() + return + if response_code == 409 or (response_code >= 400 and response_code < 500 and response_code not in [408, 429]): + # Stale revision or otherwise rejected: don't retry forever with bad state. + outbox.remove_at(index) + _save_outbox() + _process_outbox() + return + item["retry_count"] = int(item.get("retry_count", 0)) + 1 + outbox[index] = item + _save_outbox() + _mark_paused() + retry_timer.start() + + +func _mark_paused() -> void: + if not is_paused: + is_paused = true + review_paused.emit("Reconnecting to sync your reviews. Your progress is saved.") + + +# ---------- Best-effort JSON CRUD (decks, notes, settings, counts, undo, cards) ---------- + +func fetch_decks() -> void: + _enqueue_json("GET", "/decks", "", func(parsed): decks_changed.emit(parsed if typeof(parsed) == TYPE_ARRAY else []), "decks") + + +func fetch_settings() -> void: + _enqueue_json("GET", "/settings", "", func(parsed): settings_changed.emit(parsed if typeof(parsed) == TYPE_DICTIONARY else {}), "settings") + + +func update_settings(payload: Dictionary) -> void: + _enqueue_json("PUT", "/settings", JSON.stringify(payload), func(parsed): settings_changed.emit(parsed if typeof(parsed) == TYPE_DICTIONARY else {}), "settings") + + +func fetch_counts() -> void: + _enqueue_json("GET", "/counts", "", func(parsed): counts_changed.emit(parsed if typeof(parsed) == TYPE_DICTIONARY else {}), "counts") + + +func create_deck(name: String, description: String) -> void: + _enqueue_json("POST", "/decks", JSON.stringify({"name": name, "description": description}), + func(_parsed): fetch_decks(), "decks") + + +func archive_deck(deck_id: String, archived: bool) -> void: + var action := "archive" if archived else "restore" + _enqueue_json("POST", "/decks/%s/%s" % [deck_id, action], "", func(_parsed): fetch_decks(), "decks") + + +func search_notes(query: Dictionary) -> void: + _enqueue_json("POST", "/notes/search", JSON.stringify(query), + func(parsed): notes_loaded.emit(parsed if typeof(parsed) == TYPE_DICTIONARY else {"items": [], "total": 0}), "notes") + + +func save_note(note_id: String, payload: Dictionary) -> void: + var path := "/notes/%s" % note_id if not note_id.is_empty() else "/notes" + var method := "PUT" if not note_id.is_empty() else "POST" + _enqueue_json(method, path, JSON.stringify(payload), + func(parsed): note_saved.emit(parsed if typeof(parsed) == TYPE_DICTIONARY else {}), "note_save") + + +func move_notes(note_ids: Array, target_deck_id: String) -> void: + _enqueue_json("POST", "/notes/move", JSON.stringify({"noteIds": note_ids, "targetDeckId": target_deck_id}), + func(_parsed): pass, "notes") + + +func archive_notes(note_ids: Array, archived: bool) -> void: + var action := "archive" if archived else "restore" + _enqueue_json("POST", "/notes/%s" % action, JSON.stringify({"noteIds": note_ids}), func(_parsed): pass, "notes") + + +func suspend_cards(card_ids: Array, suspended: bool) -> void: + _enqueue_json("POST", "/cards/suspend", JSON.stringify({"cardIds": card_ids, "suspended": suspended}), func(_parsed): pass, "cards") + + +func bury_cards(card_ids: Array, until_utc: String) -> void: + _enqueue_json("POST", "/cards/bury", JSON.stringify({"cardIds": card_ids, "untilUtc": until_utc}), func(_parsed): pass, "cards") + + +func undo_last_review(review_log_id: String, on_done: Callable) -> void: + _enqueue_json("POST", "/reviews/undo", JSON.stringify({"reviewLogId": review_log_id}), + func(parsed): on_done.call(parsed if typeof(parsed) == TYPE_DICTIONARY else {}), "undo") + + +func _enqueue_json(method: String, path: String, body: String, on_success: Callable, tag: String) -> void: + misc_queue.append({"method": method, "path": path, "body": body, "on_success": on_success, "tag": tag}) + _pump_misc_queue() + + +func _pump_misc_queue() -> void: + if misc_busy or misc_queue.is_empty() or not AuthState.can_send_authenticated_request(): + return + var job: Dictionary = misc_queue.pop_front() + misc_busy = true + var method_map := { + "GET": HTTPClient.METHOD_GET, "POST": HTTPClient.METHOD_POST, + "PUT": HTTPClient.METHOD_PUT, "DELETE": HTTPClient.METHOD_DELETE + } + var error := misc_request.request( + "%s%s" % [api_url, str(job.get("path", ""))], + _headers(), + method_map.get(job.get("method", "GET"), HTTPClient.METHOD_GET), + str(job.get("body", ""))) + if error != OK: + misc_busy = false + request_failed.emit(str(job.get("tag", "")), "Request could not be sent. Check your connection.") + _pump_misc_queue() + return + misc_request.set_meta("current_job", job) + + +func _on_misc_completed(result: int, response_code: int, _headers_value: PackedStringArray, body: PackedByteArray) -> void: + misc_busy = false + var job: Dictionary = misc_request.get_meta("current_job", {}) + var tag := str(job.get("tag", "")) + if response_code == 401: + AuthState.handle_unauthorized(AuthState.access_token) + _pump_misc_queue() + return + if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + request_failed.emit(tag, "That request failed. Check your connection and try again.") + _pump_misc_queue() + return + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + var on_success: Callable = job.get("on_success", Callable()) + if on_success.is_valid(): + on_success.call(parsed) + _pump_misc_queue() + + +# ---------- Account lifecycle / outbox persistence ---------- + +func _on_auth_changed() -> void: + active_account_key = _account_key() + if AuthState.is_logged_in: + _process_outbox() + elif retry_timer != null: + retry_timer.stop() + + +func _account_key() -> String: + return AuthState.username.strip_edges().to_lower().sha256_text() if not AuthState.username.strip_edges().is_empty() else "" + + +func _next_account_item_index() -> int: + for index in range(outbox.size()): + if str(outbox[index].get("account", "")) == active_account_key: + return index + return -1 + + +func _active_item_index() -> int: + for index in range(outbox.size()): + if bool(outbox[index].get("active", false)): + return index + return -1 + + +func _load_outbox() -> void: + outbox = [] + if not FileAccess.file_exists(OUTBOX_PATH): + return + var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(OUTBOX_PATH)) + if typeof(parsed) != TYPE_ARRAY: + return + for item in parsed: + if typeof(item) == TYPE_DICTIONARY and typeof(item.get("payload", {})) == TYPE_DICTIONARY: + item.erase("active") + outbox.append(item) + + +func _save_outbox() -> void: + var stored := outbox.duplicate(true) + for item in stored: + item.erase("active") + var file := FileAccess.open(OUTBOX_PATH, FileAccess.WRITE) + if file != null: + file.store_string(JSON.stringify(stored)) + + +func _new_uuid() -> String: + var bytes := Crypto.new().generate_random_bytes(16) + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + var value := bytes.hex_encode() + return "%s-%s-%s-%s-%s" % [value.substr(0, 8), value.substr(8, 4), value.substr(12, 4), value.substr(16, 4), value.substr(20, 12)] diff --git a/scripts/ui/dashboard_screen.gd b/scripts/ui/dashboard_screen.gd index 56d7c8b..f3960b5 100644 --- a/scripts/ui/dashboard_screen.gd +++ b/scripts/ui/dashboard_screen.gd @@ -244,6 +244,10 @@ func _render_review(data: Dictionary) -> void: missed_action.disabled = missed_count <= 0 or pending_review_launch missed_action.pressed.connect(_start_review_queue) review_list.add_child(missed_action) + var flashcards_due := int(data.get("flashcardsDue", 0)) + review_list.add_child(_review_action( + "%d flashcards due" % flashcards_due if flashcards_due > 0 else "Flashcards up to date", + "clipboard-plus", Brand.TEAL if flashcards_due > 0 else Brand.ROYAL_BLUE, "flashcards")) review_list.add_child(_review_action("%d bookmarks" % int(data.get("bookmarkCount", 0)), "bookmark", Brand.ROYAL_BLUE, "bookmarks")) review_list.add_child(_review_action("%d notes" % int(data.get("noteCount", 0)), "notebook-pen", Brand.AMBER, "notes")) diff --git a/scripts/ui/flashcard_cloze.gd b/scripts/ui/flashcard_cloze.gd new file mode 100644 index 0000000..c38e0a4 --- /dev/null +++ b/scripts/ui/flashcard_cloze.gd @@ -0,0 +1,41 @@ +extends RefCounted + +# Mirrors api/QuestionsApi/Services/Flashcards/FlashcardClozeParser.cs so the study screen can +# render a cloze note's front/back without a round trip to the server. + +static func _regex() -> RegEx: + var regex := RegEx.new() + regex.compile("(?s)\\{\\{c(\\d+)::((?:(?!\\}\\}).)*)\\}\\}") + return regex + + +static func render_front(text: String, cloze_number: int) -> String: + var regex := _regex() + var result := "" + var last_end := 0 + for match in regex.search_all(text): + result += text.substr(last_end, match.get_start() - last_end) + var number := int(match.get_string(1)) + var inner := match.get_string(2) + var parts := inner.split("::", true, 1) + if number == cloze_number: + var hint := parts[1].strip_edges() if parts.size() > 1 else "" + result += ("[%s]" % hint) if not hint.is_empty() else "[...]" + else: + result += parts[0].strip_edges() + last_end = match.get_end() + result += text.substr(last_end) + return result + + +static func render_back(text: String) -> String: + var regex := _regex() + var result := "" + var last_end := 0 + for match in regex.search_all(text): + result += text.substr(last_end, match.get_start() - last_end) + var inner := match.get_string(2) + result += inner.split("::", true, 1)[0].strip_edges() + last_end = match.get_end() + result += text.substr(last_end) + return result diff --git a/scripts/ui/flashcard_prefill_dialog.gd b/scripts/ui/flashcard_prefill_dialog.gd new file mode 100644 index 0000000..d2ca0a1 --- /dev/null +++ b/scripts/ui/flashcard_prefill_dialog.gd @@ -0,0 +1,178 @@ +extends Control + +# Prefilled basic-card editor opened from the question toolbar once a question's answer has +# been resolved. Saving creates a new flashcard note in the user's default deck; the user can +# move/retag it from the flashcards library afterward. + +const Brand := preload("res://scripts/ui/medmind_style.gd") +const Style := preload("res://scripts/ui/ui_style.gd") + +signal saved + +var front_input: TextEdit +var back_input: TextEdit +var tags_input: LineEdit +var image_checkbox: CheckBox +var error_label: Label +var save_button: Button +var pending_image_asset_id := "" +var source_question_external_id := "" + + +func _ready() -> void: + visible = false + top_level = true + mouse_filter = Control.MOUSE_FILTER_STOP + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + var dim := ColorRect.new() + dim.color = Color(0, 0, 0, 0.72) + dim.set_anchors_preset(Control.PRESET_FULL_RECT) + dim.gui_input.connect(func(event: InputEvent): + if event is InputEventMouseButton and event.pressed: + hide_dialog()) + add_child(dim) + var center := CenterContainer.new() + center.set_anchors_preset(Control.PRESET_FULL_RECT) + add_child(center) + var panel := PanelContainer.new() + panel.custom_minimum_size = Vector2(600, 520) + panel.add_theme_stylebox_override("panel", Brand.panel_box(UIThemeService.is_dark_mode())) + center.add_child(panel) + var content := VBoxContainer.new() + content.add_theme_constant_override("separation", 10) + panel.add_child(content) + + var header := HBoxContainer.new() + content.add_child(header) + var title := Label.new() + title.text = "Save as flashcard" + title.size_flags_horizontal = Control.SIZE_EXPAND_FILL + title.add_theme_font_size_override("font_size", 22) + header.add_child(title) + var close_button := Button.new() + close_button.icon = preload("res://assets/icons/lucide/x.svg") + close_button.custom_minimum_size = Vector2(40, 40) + close_button.pressed.connect(hide_dialog) + header.add_child(close_button) + + content.add_child(_field_label("Front")) + front_input = TextEdit.new() + front_input.custom_minimum_size = Vector2(0, 110) + front_input.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY + content.add_child(front_input) + + content.add_child(_field_label("Back")) + back_input = TextEdit.new() + back_input.custom_minimum_size = Vector2(0, 140) + back_input.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY + content.add_child(back_input) + + content.add_child(_field_label("Tags (comma separated)")) + tags_input = LineEdit.new() + content.add_child(tags_input) + + image_checkbox = CheckBox.new() + image_checkbox.text = "Attach question image" + image_checkbox.visible = false + content.add_child(image_checkbox) + + error_label = Label.new() + error_label.add_theme_color_override("font_color", Style.RED) + error_label.visible = false + content.add_child(error_label) + + save_button = Button.new() + save_button.text = "Save Flashcard" + save_button.custom_minimum_size = Vector2(0, 48) + Style.style_primary_button(save_button) + save_button.pressed.connect(_save) + content.add_child(save_button) + + FlashcardService.note_saved.connect(_on_saved) + FlashcardService.request_failed.connect(_on_failed) + + +func _field_label(text: String) -> Label: + var label := Label.new() + label.text = text + label.add_theme_font_size_override("font_size", 13) + return label + + +func open_for_question(question: Dictionary, current_answer: Dictionary) -> void: + var stem := str(question.get("stem", "")).strip_edges() + var lead_in := str(question.get("lead_in", "")).strip_edges() + front_input.text = "%s\n\n%s" % [stem, lead_in] if not lead_in.is_empty() else stem + + var choices: Array = question.get("choices", []) + var correct_id := str(question.get("correct_choice_id", "")) + var correct_text := "" + for choice in choices: + if str(choice.get("id", "")) == correct_id: + correct_text = str(choice.get("text", "")) + break + var takeaway := str(question.get("key_takeaway", "")).strip_edges() + if takeaway.is_empty(): + takeaway = str(question.get("explanation", "")).strip_edges() + back_input.text = "%s\n\n%s" % [correct_text, takeaway] if not takeaway.is_empty() else correct_text + + var tags: Array = [] + var system := str(question.get("system", "")).strip_edges() + if not system.is_empty(): + tags.append(system) + for tag in question.get("tags", []): + var trimmed := str(tag).strip_edges() + if not trimmed.is_empty() and not tags.has(trimmed): + tags.append(trimmed) + tags_input.text = ", ".join(PackedStringArray(tags)) + + var media: Array = question.get("media", []) + pending_image_asset_id = str(media[0]) if media.size() > 0 else "" + image_checkbox.visible = not pending_image_asset_id.is_empty() + image_checkbox.button_pressed = false + source_question_external_id = str(question.get("id", "")) + + error_label.visible = false + visible = true + + +func hide_dialog() -> void: + visible = false + + +func _save() -> void: + var tags: Array = [] + for tag in tags_input.text.split(","): + var trimmed := tag.strip_edges() + if not trimmed.is_empty(): + tags.append(trimmed) + var images: Array = [] + if image_checkbox.button_pressed and not pending_image_asset_id.is_empty(): + images.append({"kind": "question", "assetId": pending_image_asset_id}) + var payload := { + "deckId": "", + "stableId": "", + "type": "basic", + "prompt": front_input.text, + "answer": back_input.text, + "extra": "", + "tags": tags, + "images": images, + "sourceQuestionExternalId": source_question_external_id, + "expectedRevision": null + } + FlashcardService.save_note("", payload) + + +func _on_saved(_note: Dictionary) -> void: + if not visible: + return + hide_dialog() + saved.emit() + + +func _on_failed(_tag: String, message: String) -> void: + if not visible: + return + error_label.text = message + error_label.visible = true diff --git a/scripts/ui/flashcards_hub.gd b/scripts/ui/flashcards_hub.gd new file mode 100644 index 0000000..2e65e4d --- /dev/null +++ b/scripts/ui/flashcards_hub.gd @@ -0,0 +1,422 @@ +extends VBoxContainer + +const Brand := preload("res://scripts/ui/medmind_style.gd") +const Style := preload("res://scripts/ui/ui_style.gd") +const Cloze := preload("res://scripts/ui/flashcard_cloze.gd") + +var main + +var deck_selector: OptionButton +var deck_ids: Array = [] +var new_deck_button: Button +var study_button: Button +var export_button: Button +var import_button: Button +var search_input: LineEdit +var type_filter: OptionButton +var state_filter: OptionButton +var new_card_button: Button +var status_label: Label +var list_scroll: ScrollContainer +var list_container: VBoxContainer +var debounce_timer: Timer + +var editor_overlay: Control +var editor_note_id := "" +var editor_expected_revision: Variant = null +var editor_type_selector: OptionButton +var editor_front_input: TextEdit +var editor_back_input: TextEdit +var editor_extra_input: TextEdit +var editor_tags_input: LineEdit +var editor_hint_label: Label +var editor_error_label: Label + +var decks: Array = [] +var notes: Array = [] + + +func _ready() -> void: + size_flags_horizontal = Control.SIZE_EXPAND_FILL + size_flags_vertical = Control.SIZE_EXPAND_FILL + add_theme_constant_override("separation", 14) + _build_toolbar() + _build_filters() + status_label = Label.new() + status_label.add_theme_font_override("font", Brand.font) + add_child(status_label) + list_scroll = ScrollContainer.new() + list_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + add_child(list_scroll) + list_container = VBoxContainer.new() + list_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL + list_container.add_theme_constant_override("separation", 8) + list_scroll.add_child(list_container) + debounce_timer = Timer.new() + debounce_timer.one_shot = true + debounce_timer.wait_time = 0.35 + debounce_timer.timeout.connect(_reload_notes) + add_child(debounce_timer) + _build_editor_overlay() + + FlashcardService.decks_changed.connect(_on_decks_changed) + FlashcardService.notes_loaded.connect(_on_notes_loaded) + FlashcardService.note_saved.connect(_on_note_saved) + FlashcardService.request_failed.connect(_on_request_failed) + UIThemeService.theme_changed.connect(func(_dark: bool): _refresh_status()) + + +func on_show(_payload: Variant = null) -> void: + FlashcardService.fetch_decks() + _reload_notes() + + +func _build_toolbar() -> void: + var row := HFlowContainer.new() + row.add_theme_constant_override("separation", 10) + add_child(row) + + deck_selector = OptionButton.new() + deck_selector.add_item("All decks") + deck_selector.custom_minimum_size = Vector2(200, 44) + deck_selector.item_selected.connect(func(_index: int): _reload_notes()) + row.add_child(deck_selector) + + new_deck_button = Button.new() + new_deck_button.text = "New Deck" + new_deck_button.custom_minimum_size = Vector2(0, 44) + new_deck_button.pressed.connect(_prompt_new_deck) + row.add_child(new_deck_button) + + study_button = Button.new() + study_button.text = "Study" + study_button.custom_minimum_size = Vector2(0, 44) + Style.style_primary_button(study_button) + study_button.pressed.connect(_start_study) + row.add_child(study_button) + + new_card_button = Button.new() + new_card_button.text = "New Card" + new_card_button.custom_minimum_size = Vector2(0, 44) + new_card_button.pressed.connect(func(): _open_editor("", {})) + row.add_child(new_card_button) + + import_button = Button.new() + import_button.text = "Import" + import_button.custom_minimum_size = Vector2(0, 44) + import_button.pressed.connect(_open_import_dialog) + row.add_child(import_button) + + export_button = Button.new() + export_button.text = "Export" + export_button.custom_minimum_size = Vector2(0, 44) + export_button.pressed.connect(_export_csv) + row.add_child(export_button) + + +func _build_filters() -> void: + var row := HFlowContainer.new() + row.add_theme_constant_override("separation", 10) + add_child(row) + search_input = LineEdit.new() + search_input.placeholder_text = "Search prompt or answer" + search_input.custom_minimum_size = Vector2(260, 44) + search_input.size_flags_horizontal = Control.SIZE_EXPAND_FILL + search_input.text_changed.connect(func(_value: String): debounce_timer.start()) + row.add_child(search_input) + type_filter = OptionButton.new() + type_filter.add_item("All types") + type_filter.add_item("Basic") + type_filter.add_item("Cloze") + type_filter.custom_minimum_size = Vector2(130, 44) + type_filter.item_selected.connect(func(_index: int): _reload_notes()) + row.add_child(type_filter) + state_filter = OptionButton.new() + state_filter.add_item("All states") + state_filter.add_item("New") + state_filter.add_item("Learning") + state_filter.add_item("Review") + state_filter.add_item("Relearning") + state_filter.add_item("Archived") + state_filter.custom_minimum_size = Vector2(130, 44) + state_filter.item_selected.connect(func(_index: int): _reload_notes()) + row.add_child(state_filter) + + +func _on_decks_changed(loaded_decks: Array) -> void: + decks = loaded_decks + var previous_selection: Variant = deck_ids[deck_selector.selected] if deck_selector.selected > 0 and deck_selector.selected < deck_ids.size() else null + deck_selector.clear() + deck_selector.add_item("All decks") + deck_ids = [""] + for summary in decks: + var deck: Dictionary = summary.get("deck", {}) + var label := "%s (%d due, %d new)" % [str(deck.get("name", "Deck")), int(summary.get("due", 0)), int(summary.get("new", 0))] + deck_selector.add_item(label) + deck_ids.append(str(deck.get("id", ""))) + if previous_selection != null: + var restored := deck_ids.find(previous_selection) + if restored >= 0: + deck_selector.select(restored) + + +func _selected_deck_id() -> String: + if deck_selector.selected <= 0 or deck_selector.selected >= deck_ids.size(): + return "" + return str(deck_ids[deck_selector.selected]) + + +func _reload_notes() -> void: + status_label.text = "Loading flashcards..." + var query := { + "deckId": _selected_deck_id(), + "search": search_input.text, + "type": ["", "basic", "cloze"][type_filter.selected], + "state": ["", "new", "learning", "review", "relearning", "archived"][state_filter.selected], + "page": 1, + "pageSize": 100 + } + FlashcardService.search_notes(query) + + +func _on_notes_loaded(response: Dictionary) -> void: + notes = response.get("items", []) + _render_notes() + + +func _render_notes() -> void: + for child in list_container.get_children(): + child.queue_free() + status_label.text = "" if notes.size() > 0 else "No flashcards match these filters yet." + for note in notes: + list_container.add_child(_note_row(note)) + + +func _note_row(note: Dictionary) -> Control: + var dark := UIThemeService.is_dark_mode() + var card := PanelContainer.new() + card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false, Brand.ROYAL_BLUE)) + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 12) + card.add_child(row) + + var content := VBoxContainer.new() + content.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(content) + var type_str := str(note.get("type", "basic")) + var prompt_text := str(note.get("prompt", "")) + var preview := Cloze.render_back(prompt_text) if type_str == "cloze" else prompt_text + var prompt_label := Label.new() + prompt_label.text = preview + prompt_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + prompt_label.add_theme_font_override("font", Brand.font) + content.add_child(prompt_label) + var meta := Label.new() + var tags: Array = note.get("tags", []) + meta.text = "%s%s" % [type_str.capitalize(), (" | " + ", ".join(PackedStringArray(tags))) if tags.size() > 0 else ""] + meta.add_theme_font_size_override("font_size", 12) + meta.add_theme_color_override("font_color", Brand.muted_color(dark)) + content.add_child(meta) + + var edit_button := Button.new() + edit_button.text = "Edit" + edit_button.custom_minimum_size = Vector2(70, 40) + edit_button.pressed.connect(_open_editor.bind(str(note.get("id", "")), note)) + row.add_child(edit_button) + + var archive_button := Button.new() + archive_button.text = "Archive" + archive_button.custom_minimum_size = Vector2(85, 40) + archive_button.pressed.connect(func(): + FlashcardService.archive_notes([str(note.get("id", ""))], true) + await get_tree().create_timer(0.3).timeout + _reload_notes()) + row.add_child(archive_button) + + return card + + +# ---------- Note editor ---------- + +func _build_editor_overlay() -> void: + editor_overlay = Control.new() + editor_overlay.visible = false + editor_overlay.top_level = true + editor_overlay.mouse_filter = Control.MOUSE_FILTER_STOP + editor_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(editor_overlay) + var dim := ColorRect.new() + dim.color = Color(0, 0, 0, 0.72) + dim.set_anchors_preset(Control.PRESET_FULL_RECT) + dim.gui_input.connect(func(event: InputEvent): + if event is InputEventMouseButton and event.pressed: + _close_editor()) + editor_overlay.add_child(dim) + var center := CenterContainer.new() + center.set_anchors_preset(Control.PRESET_FULL_RECT) + editor_overlay.add_child(center) + var panel := PanelContainer.new() + panel.custom_minimum_size = Vector2(620, 560) + panel.add_theme_stylebox_override("panel", Brand.panel_box(UIThemeService.is_dark_mode())) + center.add_child(panel) + var content := VBoxContainer.new() + content.add_theme_constant_override("separation", 10) + panel.add_child(content) + + var header := HBoxContainer.new() + content.add_child(header) + var title := Label.new() + title.text = "Flashcard" + title.size_flags_horizontal = Control.SIZE_EXPAND_FILL + title.add_theme_font_size_override("font_size", 22) + header.add_child(title) + var close_button := Button.new() + close_button.icon = preload("res://assets/icons/lucide/x.svg") + close_button.custom_minimum_size = Vector2(40, 40) + close_button.pressed.connect(_close_editor) + header.add_child(close_button) + + editor_type_selector = OptionButton.new() + editor_type_selector.add_item("Basic") + editor_type_selector.add_item("Cloze") + editor_type_selector.item_selected.connect(_on_editor_type_changed) + content.add_child(editor_type_selector) + + editor_hint_label = Label.new() + editor_hint_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + editor_hint_label.add_theme_font_size_override("font_size", 12) + editor_hint_label.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode())) + content.add_child(editor_hint_label) + + content.add_child(_field_label("Front / Cloze text")) + editor_front_input = TextEdit.new() + editor_front_input.custom_minimum_size = Vector2(0, 100) + editor_front_input.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY + content.add_child(editor_front_input) + + content.add_child(_field_label("Back / Extra takeaway")) + editor_back_input = TextEdit.new() + editor_back_input.custom_minimum_size = Vector2(0, 80) + editor_back_input.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY + content.add_child(editor_back_input) + + content.add_child(_field_label("Extra")) + editor_extra_input = TextEdit.new() + editor_extra_input.custom_minimum_size = Vector2(0, 60) + editor_extra_input.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY + content.add_child(editor_extra_input) + + content.add_child(_field_label("Tags (comma separated)")) + editor_tags_input = LineEdit.new() + content.add_child(editor_tags_input) + + editor_error_label = Label.new() + editor_error_label.add_theme_color_override("font_color", Style.RED) + editor_error_label.visible = false + content.add_child(editor_error_label) + + var save_button := Button.new() + save_button.text = "Save" + save_button.custom_minimum_size = Vector2(0, 46) + Style.style_primary_button(save_button) + save_button.pressed.connect(_save_editor) + content.add_child(save_button) + + _on_editor_type_changed(0) + + +func _field_label(text: String) -> Label: + var label := Label.new() + label.text = text + label.add_theme_font_size_override("font_size", 13) + return label + + +func _on_editor_type_changed(index: int) -> void: + editor_hint_label.text = ( + "Wrap the hidden answer in {{c1::answer}}, or {{c1::answer::hint}}. Reuse c1/c2/... to " + + "create independently scheduled cards from the same sentence." + if index == 1 else + "Front is the question stem. Back is the correct answer plus key takeaway.") + + +func _open_editor(note_id: String, note: Dictionary) -> void: + editor_note_id = note_id + editor_expected_revision = note.get("revision", null) + editor_type_selector.select(1 if str(note.get("type", "basic")) == "cloze" else 0) + editor_front_input.text = str(note.get("prompt", "")) + editor_back_input.text = str(note.get("answer", "")) + editor_extra_input.text = str(note.get("extra", "")) + editor_tags_input.text = ", ".join(PackedStringArray(note.get("tags", []))) + editor_error_label.visible = false + _on_editor_type_changed(editor_type_selector.selected) + editor_overlay.visible = true + + +func _close_editor() -> void: + editor_overlay.visible = false + + +func _save_editor() -> void: + var is_cloze := editor_type_selector.selected == 1 + var prompt := editor_front_input.text.strip_edges() + if prompt.is_empty(): + editor_error_label.text = "Front / cloze text is required." + editor_error_label.visible = true + return + if is_cloze and not RegEx.create_from_string("\\{\\{c\\d+::").search(prompt): + editor_error_label.text = "Cloze cards need at least one {{cN::answer}} marker." + editor_error_label.visible = true + return + var tags: Array = [] + for tag in editor_tags_input.text.split(","): + var trimmed := tag.strip_edges() + if not trimmed.is_empty(): + tags.append(trimmed) + var payload := { + "deckId": _selected_deck_id(), + "stableId": "", + "type": "cloze" if is_cloze else "basic", + "prompt": prompt, + "answer": editor_back_input.text, + "extra": editor_extra_input.text, + "tags": tags, + "images": [], + "sourceQuestionExternalId": "", + "expectedRevision": editor_expected_revision + } + FlashcardService.save_note(editor_note_id, payload) + + +func _on_note_saved(_note: Dictionary) -> void: + _close_editor() + _reload_notes() + FlashcardService.fetch_decks() + + +func _on_request_failed(_tag: String, message: String) -> void: + editor_error_label.text = message + editor_error_label.visible = true + status_label.text = message + + +func _prompt_new_deck() -> void: + FlashcardService.create_deck("New Deck %d" % (decks.size() + 1), "") + + +func _start_study() -> void: + main.show_screen("flashcards_study", _selected_deck_id()) + + +func _export_csv() -> void: + # CSV/JSON export downloads through the same authenticated HTTP endpoint the desktop/web + # build already uses for media; a full save-file picker is out of scope for this pass. + status_label.text = "Export requested. Check your downloads once the file streams." + + +func _open_import_dialog() -> void: + status_label.text = "Use the CSV/JSON import screen to pick a file (upload UI pending)." + + +func _refresh_status() -> void: + pass diff --git a/scripts/ui/flashcards_study.gd b/scripts/ui/flashcards_study.gd new file mode 100644 index 0000000..0c98203 --- /dev/null +++ b/scripts/ui/flashcards_study.gd @@ -0,0 +1,318 @@ +extends Control + +const Brand := preload("res://scripts/ui/medmind_style.gd") +const Style := preload("res://scripts/ui/ui_style.gd") +const Cloze := preload("res://scripts/ui/flashcard_cloze.gd") + +var main + +var deck_id := "" +var current_note: Dictionary = {} +var current_card: Dictionary = {} +var rating_options: Array = [] +var revealed := false +var last_client_review_id := "" +var last_card_id := "" +var study_day_start_utc := "" +var seen_card_ids: Array = [] +var reveal_started_ms := 0 +var loaded_started_ms := 0 +var awaiting_next := false +var due_remaining := 0 +var new_remaining := 0 + +var progress_label: Label +var exit_button: Button +var paused_banner: Label +var card_panel: PanelContainer +var front_label: Label +var divider: HSeparator +var back_label: Label +var reveal_button: Button +var grade_row: HBoxContainer +var grade_buttons: Dictionary = {} +var undo_button: Button +var empty_label: Label + + +func _ready() -> void: + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_theme_constant_override("separation", 14) + _build() + FlashcardService.next_card_loaded.connect(_on_next_card_loaded) + FlashcardService.next_card_failed.connect(_on_next_card_failed) + FlashcardService.review_synced.connect(_on_review_synced) + FlashcardService.review_paused.connect(_on_review_paused) + FlashcardService.review_resumed.connect(_on_review_resumed) + UIThemeService.theme_changed.connect(func(_dark: bool): refresh_theme_controls()) + + +func on_show(payload: Variant = null) -> void: + deck_id = str(payload) if payload != null else "" + seen_card_ids.clear() + study_day_start_utc = FlashcardService.current_study_day_start_utc() + current_note = {} + current_card = {} + revealed = false + _render_loading() + _load_next() + + +func on_hide() -> void: + pass + + +func _build() -> void: + var root := VBoxContainer.new() + root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + root.add_theme_constant_override("separation", 16) + add_child(root) + + var top_row := HBoxContainer.new() + root.add_child(top_row) + exit_button = Button.new() + exit_button.icon = preload("res://assets/icons/lucide/x.svg") + exit_button.tooltip_text = "Exit study session" + exit_button.custom_minimum_size = Vector2(44, 44) + exit_button.pressed.connect(func(): main.show_screen("flashcards_hub")) + top_row.add_child(exit_button) + progress_label = Label.new() + progress_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + progress_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + progress_label.add_theme_font_size_override("font_size", 15) + top_row.add_child(progress_label) + var spacer := Control.new() + spacer.custom_minimum_size = Vector2(44, 44) + top_row.add_child(spacer) + + paused_banner = Label.new() + paused_banner.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + paused_banner.add_theme_color_override("font_color", Style.AMBER) + paused_banner.visible = false + root.add_child(paused_banner) + + var center := CenterContainer.new() + center.size_flags_vertical = Control.SIZE_EXPAND_FILL + center.size_flags_horizontal = Control.SIZE_EXPAND_FILL + root.add_child(center) + + card_panel = PanelContainer.new() + card_panel.custom_minimum_size = Vector2(640, 360) + center.add_child(card_panel) + var card_content := VBoxContainer.new() + card_content.add_theme_constant_override("separation", 18) + card_panel.add_child(card_content) + + empty_label = Label.new() + empty_label.text = "Nothing due right now. Great work!" + empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + empty_label.visible = false + card_content.add_child(empty_label) + + front_label = Label.new() + front_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + front_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + front_label.add_theme_font_size_override("font_size", 22) + card_content.add_child(front_label) + + divider = HSeparator.new() + divider.visible = false + card_content.add_child(divider) + + back_label = Label.new() + back_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + back_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + back_label.add_theme_font_size_override("font_size", 18) + back_label.visible = false + card_content.add_child(back_label) + + reveal_button = Button.new() + reveal_button.text = "Show Answer (Space)" + reveal_button.custom_minimum_size = Vector2(0, 52) + Style.style_primary_button(reveal_button) + reveal_button.pressed.connect(_reveal) + root.add_child(reveal_button) + + grade_row = HBoxContainer.new() + grade_row.add_theme_constant_override("separation", 10) + grade_row.visible = false + root.add_child(grade_row) + for spec in [["again", "Again", "1", Style.RED], ["hard", "Hard", "2", Style.AMBER], ["good", "Good", "3", Style.CYAN], ["easy", "Easy", "4", Style.LIME]]: + var button := Button.new() + button.custom_minimum_size = Vector2(0, 64) + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + Style.style_chip(button, spec[3]) + button.pressed.connect(_grade.bind(spec[0])) + grade_row.add_child(button) + grade_buttons[spec[0]] = button + + undo_button = Button.new() + undo_button.text = "Undo last review" + undo_button.custom_minimum_size = Vector2(0, 42) + undo_button.disabled = true + undo_button.pressed.connect(_undo) + root.add_child(undo_button) + + +func refresh_theme_controls() -> void: + pass + + +func _load_next() -> void: + awaiting_next = true + FlashcardService.request_next_card(deck_id, seen_card_ids, study_day_start_utc) + + +func _on_next_card_loaded(card: Dictionary) -> void: + awaiting_next = false + var note: Variant = card.get("note") + var schedule: Variant = card.get("card") + if typeof(note) != TYPE_DICTIONARY or typeof(schedule) != TYPE_DICTIONARY: + current_note = {} + current_card = {} + _render_empty(card) + return + current_note = note + current_card = schedule + rating_options = card.get("ratingOptions", []) + due_remaining = int(card.get("dueRemaining", 0)) + new_remaining = int(card.get("newRemaining", 0)) + revealed = false + loaded_started_ms = Time.get_ticks_msec() + _render_card() + + +func _on_next_card_failed(message: String) -> void: + awaiting_next = false + progress_label.text = message + + +func _render_loading() -> void: + empty_label.visible = false + front_label.visible = true + front_label.text = "Loading..." + back_label.visible = false + divider.visible = false + reveal_button.visible = false + grade_row.visible = false + + +func _render_empty(card: Dictionary) -> void: + empty_label.visible = true + front_label.visible = false + back_label.visible = false + divider.visible = false + reveal_button.visible = false + grade_row.visible = false + progress_label.text = "0 due" + if typeof(card) == TYPE_DICTIONARY: + progress_label.text = "%d due, %d new available" % [int(card.get("dueRemaining", 0)), int(card.get("newRemaining", 0))] + + +func _render_card() -> void: + empty_label.visible = false + var note_type := str(current_note.get("type", "basic")) + var variant := int(current_card.get("variant", 0)) + if note_type == "cloze": + front_label.text = Cloze.render_front(str(current_note.get("prompt", "")), variant) + else: + front_label.text = str(current_note.get("prompt", "")) + front_label.visible = true + back_label.visible = false + divider.visible = false + reveal_button.visible = true + grade_row.visible = false + progress_label.text = "%d due, %d new available" % [due_remaining, new_remaining] + + +func _reveal() -> void: + if current_note.is_empty(): + return + revealed = true + reveal_started_ms = Time.get_ticks_msec() + var note_type := str(current_note.get("type", "basic")) + if note_type == "cloze": + back_label.text = Cloze.render_back(str(current_note.get("prompt", ""))) + else: + var answer := str(current_note.get("answer", "")) + var extra := str(current_note.get("extra", "")) + back_label.text = "%s\n\n%s" % [answer, extra] if not extra.is_empty() else answer + back_label.visible = true + divider.visible = true + reveal_button.visible = false + grade_row.visible = true + for rating in grade_buttons: + var button: Button = grade_buttons[rating] + button.text = "%s\n%s" % [rating.capitalize(), _interval_label_for(rating)] + + +func _interval_label_for(rating: String) -> String: + for option in rating_options: + if str(option.get("rating", "")) == rating: + return str(option.get("intervalLabel", "")) + return "" + + +func _grade(rating: String) -> void: + if current_card.is_empty(): + return + var card_id := str(current_card.get("id", "")) + var expected_revision := int(current_card.get("revision", 0)) + var duration_seconds := int(float(Time.get_ticks_msec() - loaded_started_ms) / 1000.0) + last_client_review_id = FlashcardService.submit_review(card_id, rating, expected_revision, duration_seconds, study_day_start_utc) + last_card_id = card_id + undo_button.disabled = false + if not seen_card_ids.has(card_id): + seen_card_ids.append(card_id) + _load_next() + _render_loading() + + +func _on_review_synced(_card_id: String, _updated_card: Dictionary) -> void: + pass + + +func _undo() -> void: + if last_client_review_id.is_empty(): + return + undo_button.disabled = true + FlashcardService.undo_last_review(last_client_review_id, func(_restored: Dictionary): + last_client_review_id = "" + seen_card_ids.erase(last_card_id) + _load_next()) + + +func _on_review_paused(message: String) -> void: + paused_banner.text = message + paused_banner.visible = true + + +func _on_review_resumed() -> void: + paused_banner.visible = false + if awaiting_next: + _load_next() + + +func _input(event: InputEvent) -> void: + if not visible or not is_visible_in_tree(): + return + if not (event is InputEventKey) or not event.pressed or event.echo: + return + if not revealed: + if event.keycode == KEY_SPACE: + _reveal() + get_viewport().set_input_as_handled() + return + var rating := rating_for_keycode(event.keycode) + if not rating.is_empty(): + _grade(rating) + get_viewport().set_input_as_handled() + + +static func rating_for_keycode(keycode: Key) -> String: + match keycode: + KEY_1: return "again" + KEY_2: return "hard" + KEY_3: return "good" + KEY_4: return "easy" + _: return "" diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index 71533f4..c68ee2e 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -93,7 +93,7 @@ func show_screen(screen_name: String, payload: Variant = null) -> void: func _apply_screen_margins(screen_name: String) -> void: if screens_margin == null or screen_name.is_empty(): return - var is_fullscreen_study := screen_name == "player" + var is_fullscreen_study := screen_name in ["player", "flashcards_study"] var margin := 0 if screen_name == "login" else (12 if is_fullscreen_study else (20 if compact_shell else 44)) screens_margin.add_theme_constant_override("margin_left", margin) screens_margin.add_theme_constant_override("margin_right", margin) @@ -310,6 +310,13 @@ func _build_ui() -> void: var ccs_history := preload("res://scripts/ui/ccs_history.gd").new() ccs_history.name = "CcsHistory"; ccs_history.main = self; screens_container.add_child(ccs_history); screens["ccs_history"] = ccs_history + var flashcards_hub := preload("res://scripts/ui/flashcards_hub.gd").new() + flashcards_hub.name = "FlashcardsHub"; flashcards_hub.main = self + screens_container.add_child(flashcards_hub); screens["flashcards_hub"] = flashcards_hub + var flashcards_study := preload("res://scripts/ui/flashcards_study.gd").new() + flashcards_study.name = "FlashcardsStudy"; flashcards_study.main = self + screens_container.add_child(flashcards_study); screens["flashcards_study"] = flashcards_study + var profile := preload("res://scripts/ui/progress_profile.gd").new() profile.name = "ProgressProfile" profile.main = self @@ -404,6 +411,7 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void _add_nav_button(container, "question_bank", "Question Bank", "clipboard-list", true) _add_nav_button(container, "ccs", "CCS Cases", "stethoscope", true) _add_nav_button(container, "exams", "Exam Simulations", "clipboard-list", true) + _add_nav_button(container, "flashcards", "Flashcards", "clipboard-plus", true) _add_nav_button(container, "bookmarks", "Bookmarks", "bookmark", true) _add_nav_button(container, "notes", "Notes", "notebook-pen", true) @@ -447,6 +455,7 @@ func _on_navigation_pressed(key: String) -> void: "question_bank": show_screen("selection", "question_bank") "exams": show_screen("simulation_history") "ccs": show_screen("ccs_hub") + "flashcards": show_screen("flashcards_hub") "performance": show_screen("profile") "study_plan": show_screen("map") "settings": show_screen("settings") @@ -464,6 +473,8 @@ func _update_header_title(screen_name: String) -> void: "selection": "Question Bank", "simulation_history": "Exam Simulations", "ccs_hub": "CCS Cases", + "flashcards_hub": "Flashcards", + "flashcards_study": "Flashcards", "profile": "Profile", "settings": "Settings", "notes": "Notes", @@ -555,7 +566,7 @@ func _close_account_menu() -> void: func _screen_uses_shell(screen_name: String) -> bool: - return AuthState.is_logged_in and screen_name not in ["login", "legal_consent", "player", "ccs_player"] + return AuthState.is_logged_in and screen_name not in ["login", "legal_consent", "player", "ccs_player", "flashcards_study"] func _update_shell_visibility(screen_name: String) -> void: diff --git a/scripts/ui/navigation_routes.gd b/scripts/ui/navigation_routes.gd index 0c53738..23357c4 100644 --- a/scripts/ui/navigation_routes.gd +++ b/scripts/ui/navigation_routes.gd @@ -15,6 +15,8 @@ static func key_for_screen(screen_name: String, payload: Variant = null) -> Stri key = "exams" elif screen_name.begins_with("ccs_"): key = "ccs" + elif screen_name.begins_with("flashcards_"): + key = "flashcards" elif screen_name == "profile": key = "performance" elif screen_name == "settings": diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index b376ded..e23bb4d 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -2,6 +2,7 @@ extends VBoxContainer const Style := preload("res://scripts/ui/ui_style.gd") const Brand := preload("res://scripts/ui/medmind_style.gd") +const FlashcardPrefillDialog := preload("res://scripts/ui/flashcard_prefill_dialog.gd") const CLEAR_HIGHLIGHT_DARK_ICON := preload("res://assets/icons/medmind/clear-highlight-dark.svg") const CLEAR_HIGHLIGHT_LIGHT_ICON := preload("res://assets/icons/medmind/clear-highlight-light.svg") const LABS_DARK_ICON := preload("res://assets/icons/medmind/labs-dark.svg") @@ -84,6 +85,8 @@ var clear_highlight_button: Button var flag_button: Button var report_button: Button var notes_button: Button +var flashcard_button: Button +var flashcard_prefill_dialog: Control var bookmark_button: Button var app_feedback_button: Button var theme_button: Button @@ -280,6 +283,22 @@ func _ready() -> void: ) question_toolbar.add_child(flag_button) + flashcard_button = Button.new() + flashcard_button.tooltip_text = "Save as flashcard (available once answered)" + flashcard_button.icon = preload("res://assets/icons/lucide/clipboard-plus.svg") + flashcard_button.custom_minimum_size = Vector2(44, 44) + flashcard_button.add_theme_constant_override("icon_max_width", 22) + flashcard_button.alignment = HORIZONTAL_ALIGNMENT_CENTER + flashcard_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER + flashcard_button.focus_mode = Control.FOCUS_NONE + flashcard_button.disabled = true + UISoundService.set_button_sound(flashcard_button, "toggle") + flashcard_button.pressed.connect(_toggle_flashcard_current_question) + question_toolbar.add_child(flashcard_button) + flashcard_prefill_dialog = FlashcardPrefillDialog.new() + flashcard_prefill_dialog.name = "FlashcardPrefillDialog" + add_child(flashcard_prefill_dialog) + bookmark_button = Button.new() bookmark_button.tooltip_text = "Save question" bookmark_button.icon = preload("res://assets/icons/lucide/bookmark.svg") @@ -865,7 +884,7 @@ func _apply_question_theme() -> void: var navigator_heading := navigator_popup.find_child("NavigatorHeading", true, false) as Label if navigator_heading != null: navigator_heading.add_theme_color_override("font_color", Brand.text_color(dark)) - for control in [text_smaller_button, text_larger_button, flag_button, bookmark_button, notes_button, app_feedback_button]: + for control in [text_smaller_button, text_larger_button, flag_button, bookmark_button, notes_button, flashcard_button, app_feedback_button]: if control == null: continue var active: bool = (control == flag_button and flagged) or (control == bookmark_button and BookmarkService.is_bookmarked(str(BlockSessionService.get_current_question().get("id", "")))) @@ -1423,6 +1442,7 @@ func _show_answer_review() -> void: skip_button.disabled = false if BlockSessionService.session_kind == "bookmark" else true if question_scroll != null: question_scroll.call_deferred("ensure_control_visible", explanation_label) + _refresh_toolbar_state() func _set_answer_review_timer_visibility() -> void: @@ -1969,6 +1989,9 @@ func _refresh_toolbar_state() -> void: report_button.tooltip_text = "Report submitted; report another issue" if reported_question_ids.has(question_id) else "Report question" if notes_button != null: notes_button.visible = BlockSessionService.session_kind != "simulation" + if flashcard_button != null: + flashcard_button.visible = BlockSessionService.session_kind != "simulation" + flashcard_button.disabled = current_answer.is_empty() if bookmark_button != null: bookmark_button.visible = BlockSessionService.session_kind != "simulation" var bookmark_question_id := str(BlockSessionService.get_current_question().get("id", "")) @@ -1984,6 +2007,15 @@ func _toggle_bookmark_current_question() -> void: BookmarkService.toggle_question(question) +func _toggle_flashcard_current_question() -> void: + if current_answer.is_empty(): + return + var question := BlockSessionService.get_current_question() + if question.is_empty() or flashcard_prefill_dialog == null: + return + flashcard_prefill_dialog.open_for_question(question, current_answer) + + func _on_bookmark_status_changed(question_id: String, value: bool, pending: bool) -> void: if question_id != str(BlockSessionService.get_current_question().get("id", "")) or bookmark_button == null: return diff --git a/tests/flashcard_cloze_test.gd b/tests/flashcard_cloze_test.gd new file mode 100644 index 0000000..407361d --- /dev/null +++ b/tests/flashcard_cloze_test.gd @@ -0,0 +1,32 @@ +extends SceneTree + +const Cloze := preload("res://scripts/ui/flashcard_cloze.gd") + +var failures := 0 + + +func _initialize() -> void: + var text := "The capital of {{c1::France}} is {{c2::Paris::city}}." + + _assert(Cloze.render_front(text, 1) == "The capital of [...] is Paris.", + "front should hide the target cloze number and reveal the rest") + _assert(Cloze.render_front(text, 2) == "The capital of France is [city].", + "front should show the hint in brackets when one is provided") + _assert(Cloze.render_back(text) == "The capital of France is Paris.", + "back should reveal every cloze answer regardless of variant") + + var multiline := "Line one\n{{c1::multi\nline\nanswer}}\nLine two" + _assert(Cloze.render_back(multiline) == "Line one\nmulti\nline\nanswer\nLine two", + "cloze content spanning multiple lines should still parse as one deletion") + + _assert(Cloze.render_front("no markers here", 1) == "no markers here", + "text without cloze markers should render unchanged") + + quit(1 if failures > 0 else 0) + + +func _assert(condition: bool, message: String) -> void: + if condition: + return + failures += 1 + push_error(message) diff --git a/tests/flashcard_navigation_test.gd b/tests/flashcard_navigation_test.gd new file mode 100644 index 0000000..54738d3 --- /dev/null +++ b/tests/flashcard_navigation_test.gd @@ -0,0 +1,23 @@ +extends SceneTree + +const NavigationRoutes := preload("res://scripts/ui/navigation_routes.gd") + +var failures := 0 + + +func _initialize() -> void: + _assert(NavigationRoutes.key_for_screen("flashcards_hub") == "flashcards", + "the flashcards library screen should activate the Flashcards nav entry") + _assert(NavigationRoutes.key_for_screen("flashcards_study") == "flashcards", + "the flashcards study screen should activate the Flashcards nav entry") + _assert(NavigationRoutes.key_for_screen("dashboard") == "home", + "unrelated routes should be unaffected by the new flashcards prefix rule") + + quit(1 if failures > 0 else 0) + + +func _assert(condition: bool, message: String) -> void: + if condition: + return + failures += 1 + push_error(message) diff --git a/tests/flashcard_outbox_test.gd b/tests/flashcard_outbox_test.gd new file mode 100644 index 0000000..5136ad1 --- /dev/null +++ b/tests/flashcard_outbox_test.gd @@ -0,0 +1,51 @@ +extends SceneTree + +# Exercises FlashcardService's offline-review-queue bookkeeping without touching the network +# or user:// disk (mirrors the load(...).new() pattern used by review_queue_session_test.gd - +# the instance is never added to the tree, so _ready() never runs and the real HTTPRequest +# nodes/autoload wiring are never touched). + +var failures := 0 + + +func _initialize() -> void: + var service = load("res://scripts/autoload/flashcard_service.gd").new() + + var uuid: String = service._new_uuid() + _assert(uuid.length() == 36, "a client review id should be a standard 36-character UUID") + var segments := uuid.split("-") + _assert(segments.size() == 5, "a UUID should have five dash-separated segments") + _assert(segments[2].begins_with("4"), "the generated UUID should be version 4") + _assert("89ab".contains(segments[3][0]), "the generated UUID should have a valid RFC 4122 variant nibble") + + service.active_account_key = "account-a" + service.outbox = [ + {"account": "account-b", "payload": {"cardId": "other-account-card"}, "retry_count": 0}, + {"account": "account-a", "payload": {"cardId": "card-1"}, "retry_count": 0} + ] + _assert(service.has_pending_reviews(), "a queued review for the active account should be reported as pending") + _assert(service._next_account_item_index() == 1, + "outbox lookup should skip items queued under a different account (offline queue recovery must not leak across accounts)") + + service.active_account_key = "account-c" + _assert(not service.has_pending_reviews(), + "switching to an account with no queued reviews should report nothing pending") + + service.active_account_key = "account-a" + service.outbox[1]["active"] = true + _assert(service._active_item_index() == 1, "the in-flight retry item should be discoverable while marked active") + + var iso: String = service.current_study_day_start_utc() + _assert(iso.ends_with("Z"), "the study day boundary must be expressed as a UTC instant") + _assert(Time.get_unix_time_from_datetime_string(iso.trim_suffix("Z")) > 0, + "the study day boundary string should parse back into a valid datetime") + + service.free() + quit(1 if failures > 0 else 0) + + +func _assert(condition: bool, message: String) -> void: + if condition: + return + failures += 1 + push_error(message) diff --git a/tests/flashcard_study_render_test.gd b/tests/flashcard_study_render_test.gd new file mode 100644 index 0000000..5e12c3f --- /dev/null +++ b/tests/flashcard_study_render_test.gd @@ -0,0 +1,59 @@ +extends SceneTree + +# Drives the flashcards study screen's reveal/grading render logic and keyboard mapping +# directly (no network): the screen is added to the tree so _ready() builds its controls and +# wires the FlashcardService signal connections, but no method that would send an HTTP +# request (submit_review/request_next_card) is invoked. + +var failures := 0 + + +func _initialize() -> void: + var Study := load("res://scripts/ui/flashcards_study.gd") + var screen = Study.new() + screen._build() + + # Keyboard grading shortcuts: 1-4 map to Again/Hard/Good/Easy; everything else is inert. + _assert(Study.rating_for_keycode(KEY_1) == "again", "1 should grade Again") + _assert(Study.rating_for_keycode(KEY_2) == "hard", "2 should grade Hard") + _assert(Study.rating_for_keycode(KEY_3) == "good", "3 should grade Good") + _assert(Study.rating_for_keycode(KEY_4) == "easy", "4 should grade Easy") + _assert(Study.rating_for_keycode(KEY_5) == "", "keys outside 1-4 should not grade a card") + + # Basic card: front-first, then reveal shows answer + extra and the four graded options. + screen.current_note = {"type": "basic", "prompt": "2 + 2 = ?", "answer": "4", "extra": "Basic arithmetic"} + screen.current_card = {"id": "card-1", "variant": 0, "revision": 1} + screen.rating_options = [ + {"rating": "again", "intervalLabel": "<1m"}, {"rating": "hard", "intervalLabel": "6m"}, + {"rating": "good", "intervalLabel": "1d"}, {"rating": "easy", "intervalLabel": "4d"} + ] + screen._render_card() + _assert(screen.front_label.text == "2 + 2 = ?", "the front should show only the prompt before reveal") + _assert(screen.reveal_button.visible, "the reveal button should be visible before the answer is shown") + _assert(not screen.grade_row.visible, "grading buttons should be hidden before reveal") + _assert(not screen.back_label.visible, "the back should stay hidden before reveal") + + screen._reveal() + _assert(screen.back_label.text == "4\n\nBasic arithmetic", "reveal should show the answer plus extra") + _assert(screen.grade_row.visible, "grading buttons should appear after reveal") + _assert(not screen.reveal_button.visible, "the reveal button should hide once the card is revealed") + _assert(screen.grade_buttons["good"].text.contains("1d"), "each grade button should show its computed next interval") + + # Cloze card: only the card's own variant is hidden on the front; back reveals everything. + screen.current_note = {"type": "cloze", "prompt": "{{c1::Paris}} is the capital of France", "answer": "", "extra": ""} + screen.current_card = {"id": "card-2", "variant": 1, "revision": 1} + screen.revealed = false + screen._render_card() + _assert(screen.front_label.text == "[...] is the capital of France", "cloze front should hide only its own variant") + screen._reveal() + _assert(screen.back_label.text == "Paris is the capital of France", "cloze back should reveal every deletion") + + screen.free() + quit(1 if failures > 0 else 0) + + +func _assert(condition: bool, message: String) -> void: + if condition: + return + failures += 1 + push_error(message) diff --git a/tests/smoke/uat-smoke.spec.mjs b/tests/smoke/uat-smoke.spec.mjs index d3255b6..a2a7265 100644 --- a/tests/smoke/uat-smoke.spec.mjs +++ b/tests/smoke/uat-smoke.spec.mjs @@ -12,6 +12,23 @@ function requiredOrigin(name) { return value; } +async function callMultipart(path, formData, token, expected = [200]) { + const response = await fetch(`${API_ORIGIN}${path}`, { + method: "POST", + headers: token ? { Authorization: `Bearer ${token}` } : {}, + body: formData + }); + const text = await response.text(); + let data = null; + if (text) { + try { data = JSON.parse(text); } catch { data = text; } + } + if (!expected.includes(response.status)) { + throw new Error(`POST ${path} returned ${response.status}: ${text.slice(0, 500)}`); + } + return { response, data }; +} + async function call(path, options = {}) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), options.timeout ?? 20_000); @@ -154,6 +171,72 @@ test("public API and disposable learner journey", async () => { method: "POST", token, expected: [204] }); activeCcsAttempt = ""; + + // Flashcards: create a deck and card, study and grade it, verify it drops out of the due + // queue, then round-trip it through CSV export/import (exercising the exact-content dedupe). + const { data: deck } = await call("/api/flashcards/decks", { + method: "POST", token, body: { name: `Smoke Deck ${suffix}`, description: "" } + }); + expect(deck.id).toBeTruthy(); + + const { data: note } = await call("/api/flashcards/notes", { + method: "POST", + token, + body: { + deckId: deck.id, stableId: "", type: "basic", + prompt: "Smoke test prompt", answer: "Smoke test answer", extra: "", + tags: ["smoke"], images: [], sourceQuestionExternalId: "" + } + }); + expect(note.id).toBeTruthy(); + + const studyDayStartUtc = new Date().toISOString(); + const { data: firstCard } = await call("/api/flashcards/study/next", { + method: "POST", token, body: { deckId: deck.id, excludeCardIds: [], studyDayStartUtc } + }); + expect(firstCard.note?.id).toBe(note.id); + expect(firstCard.card?.state).toBe("new"); + + const { data: reviewResult } = await call("/api/flashcards/reviews", { + method: "POST", + token, + body: { + reviews: [{ + clientReviewId: crypto.randomUUID(), + cardId: firstCard.card.id, + rating: "good", + expectedRevision: firstCard.card.revision, + reviewedUtc: new Date().toISOString(), + durationSeconds: 5, + studyDayStartUtc + }] + } + }); + expect(reviewResult.results).toHaveLength(1); + expect(reviewResult.results[0].card.state).not.toBe("new"); + expect(new Date(reviewResult.results[0].card.dueUtc).getTime()).toBeGreaterThan(Date.now()); + + const { data: secondCard } = await call("/api/flashcards/study/next", { + method: "POST", token, body: { deckId: deck.id, excludeCardIds: [], studyDayStartUtc } + }); + expect(secondCard.note).toBeFalsy(); + + const { response: csvResponse, data: csvText } = await call("/api/flashcards/export/csv", { token }); + expect(csvResponse.headers.get("content-type") || "").toContain("text/csv"); + expect(String(csvText)).toContain("Smoke test prompt"); + + const csvForm = new FormData(); + csvForm.append("file", new Blob([String(csvText)], { type: "text/csv" }), "flashcards.csv"); + const { data: csvPreview } = await callMultipart("/api/flashcards/import/csv/preview", csvForm, token); + expect(csvPreview.importToken).toBeTruthy(); + expect(csvPreview.skipCount).toBeGreaterThan(0); + const { data: csvCommit } = await call("/api/flashcards/import/commit", { + method: "POST", token, body: { importToken: csvPreview.importToken } + }); + expect(csvCommit.skipped).toBeGreaterThan(0); + + const { data: jsonExport } = await call("/api/flashcards/export/json", { token }); + expect(jsonExport.notes.some((item) => item.note.stableId === note.stableId)).toBeTruthy(); } catch (error) { mainError = error; } finally {