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.
This commit is contained in:
hz
2026-07-21 15:08:52 -05:00
parent 29b89dc404
commit 48a8c8361d
41 changed files with 4458 additions and 8 deletions
@@ -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);
}
}
@@ -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]);
}
}
@@ -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<FlashcardJsonExport>(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);
}
}
@@ -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
};
}
@@ -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));
}
}
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> ArchiveNotes([FromBody] FlashcardBulkNoteIdsRequest request)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var results = new List<FlashcardNote>();
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<IActionResult> RestoreNotes([FromBody] FlashcardBulkNoteIdsRequest request)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var results = new List<FlashcardNote>();
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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> GetSettings()
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
return Ok(await settings.GetAsync(userId));
}
[HttpPut("settings")]
public async Task<IActionResult> 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<IActionResult> SubmitReviews([FromBody] FlashcardBatchReviewRequest request)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var results = new List<FlashcardReviewResponse>();
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<IActionResult> 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<IActionResult> 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<string> { "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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<string> CardIds { get; set; } = [];
public bool Suspended { get; set; } = true;
}
public sealed class FlashcardBuryRequest
{
public List<string> CardIds { get; set; } = [];
public DateTime UntilUtc { get; set; }
}
@@ -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<DashboardTrendPoint> PerformanceTrend { get; set; } = [];
public List<DashboardSystemPerformance> Systems { get; set; } = [];
public List<DashboardActivityDay> WeeklyActivity { get; set; } = [];
+377
View File
@@ -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<string> 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<string> Tags { get; set; } = [];
public List<FlashcardImageReference> 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<string> Tags { get; set; } = [];
public List<FlashcardImageReference> 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<FlashcardNote> 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<string> ExcludeCardIds { get; set; } = [];
public DateTime StudyDayStartUtc { get; set; } = DateTime.UtcNow.Date;
}
public sealed class FlashcardBulkNoteIdsRequest
{
public List<string> NoteIds { get; set; } = [];
}
public sealed class FlashcardMoveRequest
{
public List<string> NoteIds { get; set; } = [];
public string TargetDeckId { get; set; } = "";
}
public sealed class FlashcardBatchReviewRequest
{
public List<FlashcardReviewRequest> Reviews { get; set; } = [];
}
public sealed class FlashcardBatchReviewResponse
{
public List<FlashcardReviewResponse> 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<FlashcardRatingOption> 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<FlashcardRatingOption> 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<string> Tags { get; set; } = [];
public string SourceQuestionExternalId { get; set; } = "";
public List<FlashcardSchedule> Cards { get; set; } = [];
public List<FlashcardReviewLog> 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<FlashcardImportPendingRecord> 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<FlashcardImportPreviewRow> 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<FlashcardImportPreviewRow> 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<FlashcardDeck> Decks { get; set; } = [];
public List<FlashcardJsonExportNote> Notes { get; set; } = [];
}
public sealed class FlashcardJsonExportNote
{
public FlashcardNote Note { get; set; } = new();
public List<FlashcardSchedule> Cards { get; set; } = [];
public List<FlashcardReviewLog> ReviewLogs { get; set; } = [];
public List<FlashcardJsonExportImage> 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; } = "";
}
+9
View File
@@ -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<QuestionBookmarkStore>();
builder.Services.AddSingleton<ReviewQueueStore>();
builder.Services.AddSingleton<DashboardStore>();
builder.Services.AddSingleton<UserProfileStore>();
builder.Services.AddSingleton<IFlashcardScheduler, FsrsFlashcardScheduler>();
builder.Services.AddSingleton<FlashcardDeckStore>();
builder.Services.AddSingleton<FlashcardScheduleStore>();
builder.Services.AddSingleton<FlashcardNoteStore>();
builder.Services.AddSingleton<FlashcardSettingsStore>();
builder.Services.AddSingleton<FlashcardMediaStore>();
builder.Services.AddSingleton<FlashcardStudyService>();
builder.Services.AddSingleton<FlashcardImportExportService>();
builder.Services.AddSingleton<AuthUserStore>();
builder.Services.AddSingleton<TokenBlacklistStore>();
builder.Services.AddSingleton<JwtTokenService>();
+1
View File
@@ -8,6 +8,7 @@
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="FSRS.Core" Version="1.0.7" />
<PackageReference Include="MailKit" Version="4.17.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" />
+21 -4
View File
@@ -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<UserNotepad> _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<QuestionAttempt>("QuestionAttempts");
_ratings = mongo.Database.GetCollection<UserRating>("UserRatings");
@@ -28,6 +36,8 @@ public sealed class DashboardStore
_notes = mongo.Database.GetCollection<UserNotepad>("UserNotepads");
_questions = questions;
_reviewQueue = reviewQueue;
_flashcards = flashcards;
_flashcardSchedules = flashcardSchedules;
}
public async Task<DashboardResponse> 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;
@@ -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<ClozeDeletion> Parse(string text)
{
if (string.IsNullOrEmpty(text)) return [];
var results = new List<ClozeDeletion>();
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<int> 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();
});
}
@@ -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<string[]> ParseRows(string text)
{
var rows = new List<string[]>();
var field = new StringBuilder();
var row = new List<string>();
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<string> fields) => string.Join(",", fields.Select(Escape));
private static string Escape(string field) =>
field.IndexOfAny(['"', ',', '\n', '\r']) >= 0
? "\"" + field.Replace("\"", "\"\"") + "\""
: field;
}
@@ -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<FlashcardDeck> _decks;
public FlashcardDeckStore(MongoDatabaseProvider mongo)
{
_decks = mongo.Database.GetCollection<FlashcardDeck>("FlashcardDecks");
_decks.Indexes.CreateMany([
new CreateIndexModel<FlashcardDeck>(
Builders<FlashcardDeck>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.NormalizedName),
new CreateIndexOptions { Unique = true, Name = "flashcard_deck_unique_user_name" }),
new CreateIndexModel<FlashcardDeck>(
Builders<FlashcardDeck>.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<List<FlashcardDeck>> ListAsync(string userId) =>
_decks.Find(v => v.UserId == userId && v.ArchivedUtc == null).SortBy(v => v.Name).ToListAsync();
public async Task<FlashcardDeck?> GetAsync(string userId, string deckId) =>
await _decks.Find(v => v.UserId == userId && v.Id == deckId).FirstOrDefaultAsync();
public async Task<FlashcardDeck> 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<FlashcardDeckUpdateResult> 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<FlashcardDeck>.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<FlashcardDeck> { ReturnDocument = ReturnDocument.After });
return updated is not null
? new FlashcardDeckUpdateResult(updated, null)
: new FlashcardDeckUpdateResult(null, await GetAsync(userId, deckId));
}
public async Task<FlashcardDeck?> GetByNameAsync(string userId, string normalizedName) =>
await _decks.Find(v => v.UserId == userId && v.NormalizedName == normalizedName).FirstOrDefaultAsync();
public async Task<FlashcardDeckUpdateResult> 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<FlashcardDeck>.Update
.Set(v => v.ArchivedUtc, archived ? DateTime.UtcNow : null)
.Set(v => v.UpdatedUtc, DateTime.UtcNow)
.Inc(v => v.Revision, 1),
new FindOneAndUpdateOptions<FlashcardDeck> { ReturnDocument = ReturnDocument.After });
return updated is not null
? new FlashcardDeckUpdateResult(updated, null)
: new FlashcardDeckUpdateResult(null, deck);
}
}
@@ -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<FlashcardImportPreview> _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<FlashcardImportPreview>("FlashcardImportPreviews");
_previews.Indexes.CreateOne(new CreateIndexModel<FlashcardImportPreview>(
Builders<FlashcardImportPreview>.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<FlashcardExportOmission> 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<FlashcardExportOmission>
{
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<FlashcardJsonExport> 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<FlashcardReviewLog>();
foreach (var card in cards)
logs.AddRange(await _schedules.GetReviewLogsForCardAsync(userId, card.Id!));
var images = new List<FlashcardJsonExportImage>();
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<FlashcardImportPreviewResponse> 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<FlashcardImportPendingRecord>();
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<FlashcardImportPreviewResponse> PreviewJsonAsync(string userId, string jsonText)
{
FlashcardJsonExport parsed;
try
{
parsed = JsonSerializer.Deserialize<FlashcardJsonExport>(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<FlashcardImportPendingRecord>();
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<FlashcardImportCommitResponse> 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<string, string>(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<FlashcardImportPreviewResponse> SavePreviewAsync(
string userId, string format, List<FlashcardImportPendingRecord> 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<string> ResolveDeckIdAsync(string userId, string deckName, Dictionary<string, string> 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;
}
}
@@ -0,0 +1,41 @@
using MongoDB.Bson;
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services.Flashcards;
public sealed class FlashcardMediaStore
{
private readonly IMongoCollection<FlashcardMediaAsset> _assets;
public FlashcardMediaStore(MongoDatabaseProvider mongo)
{
_assets = mongo.Database.GetCollection<FlashcardMediaAsset>("FlashcardMediaAssets");
_assets.Indexes.CreateOne(new CreateIndexModel<FlashcardMediaAsset>(
Builders<FlashcardMediaAsset>.IndexKeys.Ascending(v => v.UserId),
new CreateIndexOptions { Name = "flashcard_media_user" }));
}
public async Task<FlashcardMediaAsset> 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<FlashcardMediaAsset?> 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<long> DeleteAsync(string userId, string id) =>
_assets.DeleteOneAsync(v => v.UserId == userId && v.Id == id).ContinueWith(t => t.Result.DeletedCount);
}
@@ -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<FlashcardNote> _notes;
private readonly FlashcardScheduleStore _schedules;
public FlashcardNoteStore(MongoDatabaseProvider mongo, FlashcardScheduleStore schedules)
{
_notes = mongo.Database.GetCollection<FlashcardNote>("FlashcardNotes");
_schedules = schedules;
_notes.Indexes.CreateMany([
new CreateIndexModel<FlashcardNote>(
Builders<FlashcardNote>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.StableId),
new CreateIndexOptions { Unique = true, Name = "flashcard_note_unique_user_stable" }),
new CreateIndexModel<FlashcardNote>(
Builders<FlashcardNote>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.DeckId),
new CreateIndexOptions { Name = "flashcard_note_user_deck" }),
new CreateIndexModel<FlashcardNote>(
Builders<FlashcardNote>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.SourceQuestionExternalId),
new CreateIndexOptions { Name = "flashcard_note_user_source_question" }),
new CreateIndexModel<FlashcardNote>(
Builders<FlashcardNote>.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<FlashcardNote?> GetAsync(string userId, string noteId) =>
await _notes.Find(v => v.UserId == userId && v.Id == noteId).FirstOrDefaultAsync();
public async Task<FlashcardNote?> GetByStableIdAsync(string userId, string stableId) =>
await _notes.Find(v => v.UserId == userId && v.StableId == stableId).FirstOrDefaultAsync();
public Task<List<FlashcardNote>> ListByDeckAsync(string userId, string deckId) =>
_notes.Find(v => v.UserId == userId && v.DeckId == deckId && v.ArchivedUtc == null).ToListAsync();
public Task<List<FlashcardNote>> ListAllActiveAsync(string userId) =>
_notes.Find(v => v.UserId == userId && v.ArchivedUtc == null).ToListAsync();
public async Task<(List<FlashcardNote> Items, int Total)> SearchAsync(string userId, FlashcardLibraryQuery query)
{
var filter = Builders<FlashcardNote>.Filter.Eq(v => v.UserId, userId);
if (query.State != FlashcardArchiveFilter.IncludeArchived)
filter &= Builders<FlashcardNote>.Filter.Eq(v => v.ArchivedUtc, null);
if (!string.IsNullOrWhiteSpace(query.DeckId))
filter &= Builders<FlashcardNote>.Filter.Eq(v => v.DeckId, query.DeckId);
if (!string.IsNullOrWhiteSpace(query.Type))
filter &= Builders<FlashcardNote>.Filter.Eq(v => v.Type, query.Type);
if (!string.IsNullOrWhiteSpace(query.Tag))
filter &= Builders<FlashcardNote>.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<FlashcardNote>.Filter.Or(
Builders<FlashcardNote>.Filter.Regex(v => v.Prompt, pattern),
Builders<FlashcardNote>.Filter.Regex(v => v.Answer, pattern),
Builders<FlashcardNote>.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<FlashcardNoteUpdateResult> 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<FlashcardNote>.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<FlashcardNote> { 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<FlashcardNoteUpdateResult> ArchiveAsync(string userId, string noteId, bool archived)
{
var updated = await _notes.FindOneAndUpdateAsync(
v => v.UserId == userId && v.Id == noteId,
Builders<FlashcardNote>.Update
.Set(v => v.ArchivedUtc, archived ? DateTime.UtcNow : null)
.Set(v => v.UpdatedUtc, DateTime.UtcNow)
.Inc(v => v.Revision, 1),
new FindOneAndUpdateOptions<FlashcardNote> { ReturnDocument = ReturnDocument.After });
return updated is not null
? new FlashcardNoteUpdateResult(updated, null)
: new FlashcardNoteUpdateResult(null, await GetAsync(userId, noteId));
}
public async Task<long> MoveAsync(string userId, IReadOnlyCollection<string> noteIds, string targetDeckId)
{
var result = await _notes.UpdateManyAsync(
v => v.UserId == userId && noteIds.Contains(v.Id!),
Builders<FlashcardNote>.Update.Set(v => v.DeckId, targetDeckId).Set(v => v.UpdatedUtc, DateTime.UtcNow).Inc(v => v.Revision, 1));
return result.ModifiedCount;
}
public Task<long> CountInDeckAsync(string userId, string deckId) =>
_notes.CountDocumentsAsync(v => v.UserId == userId && v.DeckId == deckId && v.ArchivedUtc == null);
private static List<string> NormalizeTags(List<string> 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";
}
@@ -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<FlashcardSchedule> _schedules;
private readonly IMongoCollection<FlashcardReviewLog> _reviewLogs;
private readonly IFlashcardScheduler _scheduler;
public FlashcardScheduleStore(MongoDatabaseProvider mongo, IFlashcardScheduler scheduler)
{
_schedules = mongo.Database.GetCollection<FlashcardSchedule>("FlashcardSchedules");
_reviewLogs = mongo.Database.GetCollection<FlashcardReviewLog>("FlashcardReviewLogs");
_scheduler = scheduler;
_schedules.Indexes.CreateMany([
new CreateIndexModel<FlashcardSchedule>(
Builders<FlashcardSchedule>.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<FlashcardSchedule>(
Builders<FlashcardSchedule>.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<FlashcardReviewLog>(
Builders<FlashcardReviewLog>.IndexKeys.Ascending(v => v.UserId).Descending(v => v.ReviewedUtc),
new CreateIndexOptions { Name = "flashcard_review_log_user_reviewed" }),
new CreateIndexModel<FlashcardReviewLog>(
Builders<FlashcardReviewLog>.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<FlashcardSchedule?> GetAsync(string userId, string cardId) =>
await _schedules.Find(v => v.UserId == userId && v.Id == cardId).FirstOrDefaultAsync();
public Task<List<FlashcardSchedule>> GetByNoteIdAsync(string userId, string noteId) =>
_schedules.Find(v => v.UserId == userId && v.NoteId == noteId).ToListAsync();
public async Task<List<FlashcardSchedule>> GetDueAsync(
string userId, IReadOnlyCollection<string> 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<List<FlashcardSchedule>> GetNewAsync(
string userId, IReadOnlyCollection<string> 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<long> CountDueAsync(string userId, IReadOnlyCollection<string> 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<long> CountNewAsync(string userId, IReadOnlyCollection<string> 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<long> CountReviewsSinceAsync(string userId, DateTime sinceUtc) =>
_reviewLogs.CountDocumentsAsync(v => v.UserId == userId && v.ReviewedUtc >= sinceUtc && v.UndoneUtc == null);
public async Task<int> 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<List<FlashcardReviewLog>> GetReviewsSinceAsync(string userId, DateTime sinceUtc) =>
_reviewLogs.Find(v => v.UserId == userId && v.ReviewedUtc >= sinceUtc).SortBy(v => v.ReviewedUtc).ToListAsync();
public Task<List<FlashcardReviewLog>> 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<FlashcardSchedule>.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<FlashcardRatingOption> PreviewRatingOptions(FlashcardSchedule schedule, DateTime nowUtc) =>
_scheduler.PreviewRatingOptions(schedule, nowUtc);
public async Task<FlashcardReviewOutcome> 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<FlashcardSchedule>.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<FlashcardSchedule> { 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<FlashcardSchedule>.Update.Set(v => v.BuriedUntilUtc, buriedUntil));
return FlashcardReviewOutcome.Success(updated);
}
public async Task<FlashcardReviewOutcome> 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<FlashcardSchedule>.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<FlashcardSchedule> { ReturnDocument = ReturnDocument.After });
if (restored is null) return FlashcardReviewOutcome.Conflict(await GetAsync(userId, log.CardId));
await _reviewLogs.UpdateOneAsync(
v => v.Id == reviewLogId,
Builders<FlashcardReviewLog>.Update.Set(v => v.UndoneUtc, DateTime.UtcNow));
return FlashcardReviewOutcome.Success(restored);
}
public async Task<long> SetSuspendedAsync(string userId, IReadOnlyCollection<string> cardIds, bool suspended)
{
if (cardIds.Count == 0) return 0;
var result = await _schedules.UpdateManyAsync(
v => v.UserId == userId && cardIds.Contains(v.Id!),
Builders<FlashcardSchedule>.Update.Set(v => v.IsSuspended, suspended).Inc(v => v.Revision, 1));
return result.ModifiedCount;
}
public async Task<long> BuryAsync(string userId, IReadOnlyCollection<string> cardIds, DateTime untilUtc)
{
if (cardIds.Count == 0) return 0;
var result = await _schedules.UpdateManyAsync(
v => v.UserId == userId && cardIds.Contains(v.Id!),
Builders<FlashcardSchedule>.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
};
}
@@ -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<FlashcardRatingOption> PreviewRatingOptions(FlashcardSchedule schedule, DateTime nowUtc);
}
// Wraps FSRS.Core 1.0.7 behind an internal interface, pinned to the repo's fixed defaults
// (90% desired retention, 36,500 day max interval, no per-user parameter optimization).
// Fuzzing is disabled so scheduling is deterministic: reproducible for tests/support, and
// so a review's outcome depends only on stored state, never on wall-clock randomness.
public sealed class FsrsFlashcardScheduler : IFlashcardScheduler
{
public const string AlgorithmVersion = "fsrs-core-1.0.7";
public const double DesiredRetention = 0.90;
public const int MaximumIntervalDays = 36_500;
private readonly Scheduler _scheduler = new(
desiredRetention: DesiredRetention,
maximumInterval: MaximumIntervalDays,
enableFuzzing: false);
public FlashcardScheduleResult Review(FlashcardSchedule schedule, string rating, DateTime reviewedUtc, int durationSeconds)
{
var card = ToCard(schedule);
var (updated, _) = _scheduler.ReviewCard(card, ParseRating(rating), reviewedUtc, Math.Max(0, durationSeconds) * 1000);
return FromCard(updated);
}
public List<FlashcardRatingOption> PreviewRatingOptions(FlashcardSchedule schedule, DateTime nowUtc)
{
var options = new List<FlashcardRatingOption>();
foreach (var rating in FlashcardRatings.All)
{
var card = ToCard(schedule);
var (updated, _) = _scheduler.ReviewCard(card, ParseRating(rating), nowUtc);
options.Add(new FlashcardRatingOption
{
Rating = rating,
DueUtc = updated.Due,
IntervalLabel = FormatInterval(updated.Due - nowUtc)
});
}
return options;
}
private static Card ToCard(FlashcardSchedule schedule) =>
schedule.LastReviewUtc is null
? new Card()
: new Card(
state: ParseState(schedule.State),
step: schedule.Step,
stability: schedule.Stability,
difficulty: schedule.Difficulty,
due: schedule.DueUtc,
lastReview: schedule.LastReviewUtc);
private static FlashcardScheduleResult FromCard(Card card) => new()
{
State = FormatState(card.State),
Step = card.Step,
Stability = card.Stability ?? 0,
Difficulty = card.Difficulty ?? 0,
DueUtc = card.Due,
LastReviewUtc = card.LastReview ?? DateTime.UtcNow
};
private static Rating ParseRating(string rating) => rating switch
{
FlashcardRatings.Again => Rating.Again,
FlashcardRatings.Hard => Rating.Hard,
FlashcardRatings.Good => Rating.Good,
FlashcardRatings.Easy => Rating.Easy,
_ => throw new ArgumentOutOfRangeException(nameof(rating), rating, "unknown flashcard rating")
};
private static State ParseState(string state) => state switch
{
FlashcardScheduleStates.Review => State.Review,
FlashcardScheduleStates.Relearning => State.Relearning,
_ => State.Learning
};
private static string FormatState(State state) => state switch
{
State.Review => FlashcardScheduleStates.Review,
State.Relearning => FlashcardScheduleStates.Relearning,
_ => FlashcardScheduleStates.Learning
};
public static string FormatInterval(TimeSpan span)
{
if (span.TotalMinutes < 1) return "<1m";
if (span.TotalHours < 1) return $"{(int)Math.Round(span.TotalMinutes)}m";
if (span.TotalDays < 1) return $"{(int)Math.Round(span.TotalHours)}h";
if (span.TotalDays < 30) return $"{(int)Math.Round(span.TotalDays)}d";
if (span.TotalDays < 365) return $"{Math.Round(span.TotalDays / 30, 1)}mo";
return $"{Math.Round(span.TotalDays / 365, 1)}y";
}
}
@@ -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);
}
@@ -0,0 +1,36 @@
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services.Flashcards;
public sealed class FlashcardSettingsStore
{
private readonly IMongoCollection<FlashcardSettings> _settings;
public FlashcardSettingsStore(MongoDatabaseProvider mongo)
{
_settings = mongo.Database.GetCollection<FlashcardSettings>("FlashcardSettings");
_settings.Indexes.CreateOne(new CreateIndexModel<FlashcardSettings>(
Builders<FlashcardSettings>.IndexKeys.Ascending(v => v.UserId),
new CreateIndexOptions { Unique = true, Name = "flashcard_settings_unique_user" }));
}
public async Task<FlashcardSettings> GetAsync(string userId) =>
await _settings.Find(v => v.UserId == userId).FirstOrDefaultAsync()
?? new FlashcardSettings { UserId = userId };
public async Task<FlashcardSettings> UpdateAsync(string userId, UpdateFlashcardSettingsRequest request)
{
var now = DateTime.UtcNow;
return await _settings.FindOneAndUpdateAsync(
v => v.UserId == userId,
Builders<FlashcardSettings>.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<FlashcardSettings> { IsUpsert = true, ReturnDocument = ReturnDocument.After });
}
}
@@ -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<FlashcardStudyCardResponse> 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<FlashcardDashboardCounts> 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<List<FlashcardDeckSummary>> GetDeckSummariesAsync(string userId, DateTime nowUtc, DateTime studyDayStart)
{
var deckList = await decks.ListAsync(userId);
var result = new List<FlashcardDeckSummary>();
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<List<string>> 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));
}
}
@@ -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)
+83
View File
@@ -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&nbsp;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&nbsp;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`).
+4
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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"
+375
View File
@@ -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)]
+4
View File
@@ -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"))
+41
View File
@@ -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
+178
View File
@@ -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
+422
View File
@@ -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
+318
View File
@@ -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 ""
+13 -2
View File
@@ -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:
+2
View File
@@ -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":
+33 -1
View File
@@ -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
+32
View File
@@ -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)
+23
View File
@@ -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)
+51
View File
@@ -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)
+59
View File
@@ -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)
+83
View File
@@ -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 {