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:
@@ -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 MissedQuestionCount { get; set; }
|
||||||
public int BookmarkCount { get; set; }
|
public int BookmarkCount { get; set; }
|
||||||
public int NoteCount { 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<DashboardTrendPoint> PerformanceTrend { get; set; } = [];
|
||||||
public List<DashboardSystemPerformance> Systems { get; set; } = [];
|
public List<DashboardSystemPerformance> Systems { get; set; } = [];
|
||||||
public List<DashboardActivityDay> WeeklyActivity { get; set; } = [];
|
public List<DashboardActivityDay> WeeklyActivity { get; set; } = [];
|
||||||
|
|||||||
@@ -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; } = "";
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.HttpOverrides;
|
|||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Microsoft.OpenApi.Models;
|
using Microsoft.OpenApi.Models;
|
||||||
using QuestionsApi.Services;
|
using QuestionsApi.Services;
|
||||||
|
using QuestionsApi.Services.Flashcards;
|
||||||
using MongoDB.Bson;
|
using MongoDB.Bson;
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
@@ -43,6 +44,14 @@ builder.Services.AddSingleton<QuestionBookmarkStore>();
|
|||||||
builder.Services.AddSingleton<ReviewQueueStore>();
|
builder.Services.AddSingleton<ReviewQueueStore>();
|
||||||
builder.Services.AddSingleton<DashboardStore>();
|
builder.Services.AddSingleton<DashboardStore>();
|
||||||
builder.Services.AddSingleton<UserProfileStore>();
|
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<AuthUserStore>();
|
||||||
builder.Services.AddSingleton<TokenBlacklistStore>();
|
builder.Services.AddSingleton<TokenBlacklistStore>();
|
||||||
builder.Services.AddSingleton<JwtTokenService>();
|
builder.Services.AddSingleton<JwtTokenService>();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
<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="MailKit" Version="4.17.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using MongoDB.Driver;
|
using MongoDB.Driver;
|
||||||
using QuestionsApi.Models;
|
using QuestionsApi.Models;
|
||||||
|
using QuestionsApi.Services.Flashcards;
|
||||||
|
|
||||||
namespace QuestionsApi.Services;
|
namespace QuestionsApi.Services;
|
||||||
|
|
||||||
@@ -15,8 +16,15 @@ public sealed class DashboardStore
|
|||||||
private readonly IMongoCollection<UserNotepad> _notes;
|
private readonly IMongoCollection<UserNotepad> _notes;
|
||||||
private readonly QuestionStore _questions;
|
private readonly QuestionStore _questions;
|
||||||
private readonly ReviewQueueStore _reviewQueue;
|
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");
|
_attempts = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
|
||||||
_ratings = mongo.Database.GetCollection<UserRating>("UserRatings");
|
_ratings = mongo.Database.GetCollection<UserRating>("UserRatings");
|
||||||
@@ -28,6 +36,8 @@ public sealed class DashboardStore
|
|||||||
_notes = mongo.Database.GetCollection<UserNotepad>("UserNotepads");
|
_notes = mongo.Database.GetCollection<UserNotepad>("UserNotepads");
|
||||||
_questions = questions;
|
_questions = questions;
|
||||||
_reviewQueue = reviewQueue;
|
_reviewQueue = reviewQueue;
|
||||||
|
_flashcards = flashcards;
|
||||||
|
_flashcardSchedules = flashcardSchedules;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<DashboardResponse> GetAsync(string userId, DateTime nowUtc)
|
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 bookmarkCountTask = _bookmarks.CountDocumentsAsync(value => value.UserId == userId);
|
||||||
var notepadTask = _notes.Find(value => value.UserId == userId).FirstOrDefaultAsync();
|
var notepadTask = _notes.Find(value => value.UserId == userId).FirstOrDefaultAsync();
|
||||||
var reviewAttemptsTask = _reviewQueue.GetReviewsSinceAsync(userId, weekStart);
|
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,
|
await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask, completedCcsCountTask,
|
||||||
simulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask);
|
simulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask, flashcardCountsTask, flashcardReviewsTask);
|
||||||
|
|
||||||
var attempts = attemptsTask.Result;
|
var attempts = attemptsTask.Result;
|
||||||
var answeredAttempts = attempts.Where(value => !value.IsSkip).ToList();
|
var answeredAttempts = attempts.Where(value => !value.IsSkip).ToList();
|
||||||
@@ -71,7 +83,10 @@ public sealed class DashboardStore
|
|||||||
QuestionsThisWeek = answeredAttempts.Count(value => value.CreatedUtc >= weekStart),
|
QuestionsThisWeek = answeredAttempts.Count(value => value.CreatedUtc >= weekStart),
|
||||||
BookmarkCount = checked((int)bookmarkCountTask.Result),
|
BookmarkCount = checked((int)bookmarkCountTask.Result),
|
||||||
NoteCount = CountNotes(notepadTask.Result?.Content),
|
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 =>
|
response.PerformanceTrend = Enumerable.Range(0, 30).Select(offset =>
|
||||||
@@ -115,12 +130,14 @@ public sealed class DashboardStore
|
|||||||
+ dailyCcs.Sum(CcsDurationSeconds)
|
+ dailyCcs.Sum(CcsDurationSeconds)
|
||||||
+ dailySimulations.Sum(value => value.Responses.Sum(item => Math.Max(0, item.ElapsedSeconds)))
|
+ dailySimulations.Sum(value => value.Responses.Sum(item => Math.Max(0, item.ElapsedSeconds)))
|
||||||
+ reviewAttemptsTask.Result.Where(value => value.CreatedUtc.Date == date).Sum(value => value.DurationSeconds)
|
+ 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();
|
}).ToList();
|
||||||
response.StudyTimeSecondsThisWeek = response.WeeklyActivity.Sum(value => value.StudyTimeSeconds);
|
response.StudyTimeSecondsThisWeek = response.WeeklyActivity.Sum(value => value.StudyTimeSeconds);
|
||||||
var activeDates = attempts.Select(value => value.CreatedUtc.Date)
|
var activeDates = attempts.Select(value => value.CreatedUtc.Date)
|
||||||
.Concat(completedCcs.Select(value => value.CompletedUtc!.Value.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.CurrentStreak = CalculateCurrentStreak(activeDates, today);
|
||||||
response.RecentActivity = await BuildRecentActivityAsync(answeredAttempts, questionMap, completedCcs, simulations);
|
response.RecentActivity = await BuildRecentActivityAsync(answeredAttempts, questionMap, completedCcs, simulations);
|
||||||
return response;
|
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",
|
"ReviewQueueItems", "QuestionReviewAttempts", "ReviewQueueBackfillStates",
|
||||||
"CcsAttempts", "CcsUserRatings", "CcsRatedCaseResults", "ExamSimulations",
|
"CcsAttempts", "CcsUserRatings", "CcsRatedCaseResults", "ExamSimulations",
|
||||||
"Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles",
|
"Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles",
|
||||||
"ExternalIdentities", "ExternalAuthTransactions"
|
"ExternalIdentities", "ExternalAuthTransactions",
|
||||||
|
"FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs",
|
||||||
|
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews"
|
||||||
];
|
];
|
||||||
|
|
||||||
public UserProfileStore(MongoDatabaseProvider mongo)
|
public UserProfileStore(MongoDatabaseProvider mongo)
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Flashcards
|
||||||
|
|
||||||
|
A separate spaced-repetition system alongside the missed-question review queue. Users author
|
||||||
|
basic and cloze flashcards (or generate a basic card from a resolved question), organize them
|
||||||
|
in flat decks, and study them on an FSRS schedule.
|
||||||
|
|
||||||
|
## Scheduling
|
||||||
|
|
||||||
|
Reviews are scored by [FSRS.Core](https://www.nuget.org/packages/FSRS.Core) 1.0.7, wrapped
|
||||||
|
behind `IFlashcardScheduler` (`api/QuestionsApi/Services/Flashcards/FlashcardScheduler.cs`) so
|
||||||
|
the rest of the API never touches the library directly. Pinned, non-configurable defaults:
|
||||||
|
|
||||||
|
- Desired retention: 90%
|
||||||
|
- Maximum interval: 36,500 days
|
||||||
|
- Interval fuzzing: disabled, so a review's outcome is a deterministic function of stored state
|
||||||
|
(needed for idempotent replay from the offline outbox, and for golden-vector testing)
|
||||||
|
- Algorithm/parameter version is recorded per card (`FlashcardSchedule.AlgorithmVersion`) so a
|
||||||
|
future FSRS upgrade or personalized-parameter feature can be introduced without losing the
|
||||||
|
ability to tell which cards were scheduled under which version
|
||||||
|
|
||||||
|
There is no per-user parameter optimization yet; `FlashcardScheduleStore` stores complete
|
||||||
|
before/after snapshots on every `FlashcardReviewLog` entry specifically so that a future
|
||||||
|
optimizer can be trained from full history without a data migration.
|
||||||
|
|
||||||
|
`api/QuestionsApi.Tests/FlashcardSchedulerTests.cs` validates the wrapper against the official
|
||||||
|
FSRS formulas (initial stability/difficulty per rating, and the interval-equals-stability
|
||||||
|
identity that holds at exactly 90% desired retention) computed from the library's own shipped
|
||||||
|
default parameters, rather than against hand-copied constants - this stays correct even if
|
||||||
|
FSRS.Core's defaults are retrained upstream.
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
Six user-owned Mongo collections, all indexed on `(UserId, ...)`:
|
||||||
|
|
||||||
|
- `FlashcardDecks` - flat (no nesting); every user has exactly one `IsDefault` deck, created
|
||||||
|
lazily on first use.
|
||||||
|
- `FlashcardNotes` - authored content (`Prompt`/`Answer`/`Extra`/`Tags`/`Images`). Cloze notes
|
||||||
|
store their `{{cN::answer::hint}}` markup in `Prompt`.
|
||||||
|
- `FlashcardSchedules` - one row per generated card, keyed by `(NoteId, Variant)`. Basic notes
|
||||||
|
always have variant `0`; cloze notes get one row per distinct cloze number found in the
|
||||||
|
prompt. Kept in a separate collection from notes so editing a note's content never discards
|
||||||
|
its review history.
|
||||||
|
- `FlashcardReviewLogs` - immutable, keyed by the client-generated review UUID for idempotent
|
||||||
|
submission; carries full before/after schedule snapshots.
|
||||||
|
- `FlashcardMediaAssets` - user-uploaded images (PNG/JPEG/WebP, 5 MB/4-per-note limits).
|
||||||
|
- `FlashcardSettings` - one row per user (global new/review daily limits).
|
||||||
|
|
||||||
|
Attaching a question's own image to a flashcard does not copy bytes: `FlashcardImageReference`
|
||||||
|
has a `Kind` (`"question"` vs `"owned"`) so the client can resolve `"question"` references
|
||||||
|
against the existing `/api/questions/media/{id}` endpoint instead of duplicating the asset.
|
||||||
|
|
||||||
|
Sibling cloze cards are buried (`FlashcardSchedule.BuriedUntilUtc`) until the next local study
|
||||||
|
day whenever any one sibling is reviewed. Daily new/review limits reset at the same boundary.
|
||||||
|
That boundary is supplied by the client as `studyDayStartUtc` on study/review requests (the
|
||||||
|
local calendar day at session start, expressed as a UTC instant) rather than computed
|
||||||
|
server-side, per the spec's client-local-day requirement.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
All endpoints live under `/api/flashcards` (`FlashcardsController.cs`) and require `USER`/`SUPER`
|
||||||
|
auth. Deck/note mutations use the same optimistic-concurrency (`ExpectedRevision` + 409 Conflict)
|
||||||
|
pattern as the rest of the API. Review submission is idempotent on `ClientReviewId`; undo only
|
||||||
|
allows reverting the single most recent review on a card (it refuses if the card's revision has
|
||||||
|
moved since).
|
||||||
|
|
||||||
|
## Import/export
|
||||||
|
|
||||||
|
CSV is UTF-8, text-only, headers `stable_id,deck,type,prompt,answer,extra,tags,
|
||||||
|
source_question_external_id` (tags semicolon-separated); it omits images, schedule state, and
|
||||||
|
review history and reports those omissions via the `X-Flashcard-Export-Omissions` response
|
||||||
|
header. App JSON is full-fidelity (decks, settings, notes, schedules, review logs, base64
|
||||||
|
images) and merges on import by the note's stable UUID. Both formats go through a
|
||||||
|
create/update/skip/error preview (`FlashcardImportPreviews`, TTL-expired after an hour) before
|
||||||
|
`import/commit` applies it; commit never silently drops a row - everything is created, updated,
|
||||||
|
skipped as an exact-content duplicate, or reported as a row-level error. Limits: 25 MB and
|
||||||
|
10,000 notes per import.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
No new secrets. `FlashcardsController`/stores register the same way as the rest of the API
|
||||||
|
(`Program.cs`, `AddSingleton`). The Godot client resolves its API base URL from
|
||||||
|
`step3/flashcards_api_url` in `project.godot`, following the same same-origin-on-web resolution
|
||||||
|
as every other autoload service (`api_endpoint_config.gd`).
|
||||||
@@ -28,3 +28,7 @@ must initiate recovery with their exact username.
|
|||||||
Exports are versioned JSON files containing account metadata and user-owned study records.
|
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
|
Password hashes, refresh tokens, global question content, and moderation data owned by other
|
||||||
users are excluded.
|
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.
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ It checks:
|
|||||||
- registration, login, refresh, authenticated profile, dashboard, ratings,
|
- registration, login, refresh, authenticated profile, dashboard, ratings,
|
||||||
review queue, and randomized question retrieval;
|
review queue, and randomized question retrieval;
|
||||||
- random CCS attempt creation and abandonment;
|
- 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,
|
- public web/legal assets, completion of the Godot loader, and a visible,
|
||||||
non-uniform canvas without browser or critical asset errors.
|
non-uniform canvas without browser or critical asset errors.
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ NotesService="*res://scripts/autoload/notes_service.gd"
|
|||||||
BookmarkService="*res://scripts/autoload/bookmark_service.gd"
|
BookmarkService="*res://scripts/autoload/bookmark_service.gd"
|
||||||
ReviewQueueService="*res://scripts/autoload/review_queue_service.gd"
|
ReviewQueueService="*res://scripts/autoload/review_queue_service.gd"
|
||||||
DashboardService="*res://scripts/autoload/dashboard_service.gd"
|
DashboardService="*res://scripts/autoload/dashboard_service.gd"
|
||||||
|
FlashcardService="*res://scripts/autoload/flashcard_service.gd"
|
||||||
AccountProfileService="*res://scripts/autoload/account_profile_service.gd"
|
AccountProfileService="*res://scripts/autoload/account_profile_service.gd"
|
||||||
DonationService="*res://scripts/autoload/donation_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"
|
profile_api_url="https://us3q.ranaze.com/api/profile"
|
||||||
donations_api_url="https://us3q.ranaze.com/api/donations"
|
donations_api_url="https://us3q.ranaze.com/api/donations"
|
||||||
review_queue_api_url="https://us3q.ranaze.com/api/review-queue"
|
review_queue_api_url="https://us3q.ranaze.com/api/review-queue"
|
||||||
|
flashcards_api_url="https://us3q.ranaze.com/api/flashcards"
|
||||||
build_revision="development"
|
build_revision="development"
|
||||||
|
|||||||
@@ -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)]
|
||||||
@@ -244,6 +244,10 @@ func _render_review(data: Dictionary) -> void:
|
|||||||
missed_action.disabled = missed_count <= 0 or pending_review_launch
|
missed_action.disabled = missed_count <= 0 or pending_review_launch
|
||||||
missed_action.pressed.connect(_start_review_queue)
|
missed_action.pressed.connect(_start_review_queue)
|
||||||
review_list.add_child(missed_action)
|
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 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"))
|
review_list.add_child(_review_action("%d notes" % int(data.get("noteCount", 0)), "notebook-pen", Brand.AMBER, "notes"))
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -93,7 +93,7 @@ func show_screen(screen_name: String, payload: Variant = null) -> void:
|
|||||||
func _apply_screen_margins(screen_name: String) -> void:
|
func _apply_screen_margins(screen_name: String) -> void:
|
||||||
if screens_margin == null or screen_name.is_empty():
|
if screens_margin == null or screen_name.is_empty():
|
||||||
return
|
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))
|
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_left", margin)
|
||||||
screens_margin.add_theme_constant_override("margin_right", 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()
|
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
|
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()
|
var profile := preload("res://scripts/ui/progress_profile.gd").new()
|
||||||
profile.name = "ProgressProfile"
|
profile.name = "ProgressProfile"
|
||||||
profile.main = self
|
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, "question_bank", "Question Bank", "clipboard-list", true)
|
||||||
_add_nav_button(container, "ccs", "CCS Cases", "stethoscope", true)
|
_add_nav_button(container, "ccs", "CCS Cases", "stethoscope", true)
|
||||||
_add_nav_button(container, "exams", "Exam Simulations", "clipboard-list", 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, "bookmarks", "Bookmarks", "bookmark", true)
|
||||||
_add_nav_button(container, "notes", "Notes", "notebook-pen", 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")
|
"question_bank": show_screen("selection", "question_bank")
|
||||||
"exams": show_screen("simulation_history")
|
"exams": show_screen("simulation_history")
|
||||||
"ccs": show_screen("ccs_hub")
|
"ccs": show_screen("ccs_hub")
|
||||||
|
"flashcards": show_screen("flashcards_hub")
|
||||||
"performance": show_screen("profile")
|
"performance": show_screen("profile")
|
||||||
"study_plan": show_screen("map")
|
"study_plan": show_screen("map")
|
||||||
"settings": show_screen("settings")
|
"settings": show_screen("settings")
|
||||||
@@ -464,6 +473,8 @@ func _update_header_title(screen_name: String) -> void:
|
|||||||
"selection": "Question Bank",
|
"selection": "Question Bank",
|
||||||
"simulation_history": "Exam Simulations",
|
"simulation_history": "Exam Simulations",
|
||||||
"ccs_hub": "CCS Cases",
|
"ccs_hub": "CCS Cases",
|
||||||
|
"flashcards_hub": "Flashcards",
|
||||||
|
"flashcards_study": "Flashcards",
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
@@ -555,7 +566,7 @@ func _close_account_menu() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _screen_uses_shell(screen_name: String) -> bool:
|
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:
|
func _update_shell_visibility(screen_name: String) -> void:
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ static func key_for_screen(screen_name: String, payload: Variant = null) -> Stri
|
|||||||
key = "exams"
|
key = "exams"
|
||||||
elif screen_name.begins_with("ccs_"):
|
elif screen_name.begins_with("ccs_"):
|
||||||
key = "ccs"
|
key = "ccs"
|
||||||
|
elif screen_name.begins_with("flashcards_"):
|
||||||
|
key = "flashcards"
|
||||||
elif screen_name == "profile":
|
elif screen_name == "profile":
|
||||||
key = "performance"
|
key = "performance"
|
||||||
elif screen_name == "settings":
|
elif screen_name == "settings":
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ extends VBoxContainer
|
|||||||
|
|
||||||
const Style := preload("res://scripts/ui/ui_style.gd")
|
const Style := preload("res://scripts/ui/ui_style.gd")
|
||||||
const Brand := preload("res://scripts/ui/medmind_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_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 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")
|
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 flag_button: Button
|
||||||
var report_button: Button
|
var report_button: Button
|
||||||
var notes_button: Button
|
var notes_button: Button
|
||||||
|
var flashcard_button: Button
|
||||||
|
var flashcard_prefill_dialog: Control
|
||||||
var bookmark_button: Button
|
var bookmark_button: Button
|
||||||
var app_feedback_button: Button
|
var app_feedback_button: Button
|
||||||
var theme_button: Button
|
var theme_button: Button
|
||||||
@@ -280,6 +283,22 @@ func _ready() -> void:
|
|||||||
)
|
)
|
||||||
question_toolbar.add_child(flag_button)
|
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 = Button.new()
|
||||||
bookmark_button.tooltip_text = "Save question"
|
bookmark_button.tooltip_text = "Save question"
|
||||||
bookmark_button.icon = preload("res://assets/icons/lucide/bookmark.svg")
|
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
|
var navigator_heading := navigator_popup.find_child("NavigatorHeading", true, false) as Label
|
||||||
if navigator_heading != null:
|
if navigator_heading != null:
|
||||||
navigator_heading.add_theme_color_override("font_color", Brand.text_color(dark))
|
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:
|
if control == null:
|
||||||
continue
|
continue
|
||||||
var active: bool = (control == flag_button and flagged) or (control == bookmark_button and BookmarkService.is_bookmarked(str(BlockSessionService.get_current_question().get("id", ""))))
|
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
|
skip_button.disabled = false if BlockSessionService.session_kind == "bookmark" else true
|
||||||
if question_scroll != null:
|
if question_scroll != null:
|
||||||
question_scroll.call_deferred("ensure_control_visible", explanation_label)
|
question_scroll.call_deferred("ensure_control_visible", explanation_label)
|
||||||
|
_refresh_toolbar_state()
|
||||||
|
|
||||||
|
|
||||||
func _set_answer_review_timer_visibility() -> void:
|
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"
|
report_button.tooltip_text = "Report submitted; report another issue" if reported_question_ids.has(question_id) else "Report question"
|
||||||
if notes_button != null:
|
if notes_button != null:
|
||||||
notes_button.visible = BlockSessionService.session_kind != "simulation"
|
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:
|
if bookmark_button != null:
|
||||||
bookmark_button.visible = BlockSessionService.session_kind != "simulation"
|
bookmark_button.visible = BlockSessionService.session_kind != "simulation"
|
||||||
var bookmark_question_id := str(BlockSessionService.get_current_question().get("id", ""))
|
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)
|
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:
|
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:
|
if question_id != str(BlockSessionService.get_current_question().get("id", "")) or bookmark_button == null:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -12,6 +12,23 @@ function requiredOrigin(name) {
|
|||||||
return value;
|
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 = {}) {
|
async function call(path, options = {}) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), options.timeout ?? 20_000);
|
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]
|
method: "POST", token, expected: [204]
|
||||||
});
|
});
|
||||||
activeCcsAttempt = "";
|
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) {
|
} catch (error) {
|
||||||
mainError = error;
|
mainError = error;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user