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 BookmarkCount { get; set; }
|
||||
public int NoteCount { get; set; }
|
||||
public int FlashcardsDue { get; set; }
|
||||
public int FlashcardsNewAvailable { get; set; }
|
||||
public int FlashcardsReviewedThisWeek { get; set; }
|
||||
public List<DashboardTrendPoint> PerformanceTrend { get; set; } = [];
|
||||
public List<DashboardSystemPerformance> Systems { get; set; } = [];
|
||||
public List<DashboardActivityDay> WeeklyActivity { get; set; } = [];
|
||||
|
||||
@@ -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.OpenApi.Models;
|
||||
using QuestionsApi.Services;
|
||||
using QuestionsApi.Services.Flashcards;
|
||||
using MongoDB.Bson;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
@@ -43,6 +44,14 @@ builder.Services.AddSingleton<QuestionBookmarkStore>();
|
||||
builder.Services.AddSingleton<ReviewQueueStore>();
|
||||
builder.Services.AddSingleton<DashboardStore>();
|
||||
builder.Services.AddSingleton<UserProfileStore>();
|
||||
builder.Services.AddSingleton<IFlashcardScheduler, FsrsFlashcardScheduler>();
|
||||
builder.Services.AddSingleton<FlashcardDeckStore>();
|
||||
builder.Services.AddSingleton<FlashcardScheduleStore>();
|
||||
builder.Services.AddSingleton<FlashcardNoteStore>();
|
||||
builder.Services.AddSingleton<FlashcardSettingsStore>();
|
||||
builder.Services.AddSingleton<FlashcardMediaStore>();
|
||||
builder.Services.AddSingleton<FlashcardStudyService>();
|
||||
builder.Services.AddSingleton<FlashcardImportExportService>();
|
||||
builder.Services.AddSingleton<AuthUserStore>();
|
||||
builder.Services.AddSingleton<TokenBlacklistStore>();
|
||||
builder.Services.AddSingleton<JwtTokenService>();
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
<PackageReference Include="FSRS.Core" Version="1.0.7" />
|
||||
<PackageReference Include="MailKit" Version="4.17.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
using QuestionsApi.Services.Flashcards;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
@@ -15,8 +16,15 @@ public sealed class DashboardStore
|
||||
private readonly IMongoCollection<UserNotepad> _notes;
|
||||
private readonly QuestionStore _questions;
|
||||
private readonly ReviewQueueStore _reviewQueue;
|
||||
private readonly FlashcardStudyService _flashcards;
|
||||
private readonly FlashcardScheduleStore _flashcardSchedules;
|
||||
|
||||
public DashboardStore(MongoDatabaseProvider mongo, QuestionStore questions, ReviewQueueStore reviewQueue)
|
||||
public DashboardStore(
|
||||
MongoDatabaseProvider mongo,
|
||||
QuestionStore questions,
|
||||
ReviewQueueStore reviewQueue,
|
||||
FlashcardStudyService flashcards,
|
||||
FlashcardScheduleStore flashcardSchedules)
|
||||
{
|
||||
_attempts = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
|
||||
_ratings = mongo.Database.GetCollection<UserRating>("UserRatings");
|
||||
@@ -28,6 +36,8 @@ public sealed class DashboardStore
|
||||
_notes = mongo.Database.GetCollection<UserNotepad>("UserNotepads");
|
||||
_questions = questions;
|
||||
_reviewQueue = reviewQueue;
|
||||
_flashcards = flashcards;
|
||||
_flashcardSchedules = flashcardSchedules;
|
||||
}
|
||||
|
||||
public async Task<DashboardResponse> GetAsync(string userId, DateTime nowUtc)
|
||||
@@ -45,8 +55,10 @@ public sealed class DashboardStore
|
||||
var bookmarkCountTask = _bookmarks.CountDocumentsAsync(value => value.UserId == userId);
|
||||
var notepadTask = _notes.Find(value => value.UserId == userId).FirstOrDefaultAsync();
|
||||
var reviewAttemptsTask = _reviewQueue.GetReviewsSinceAsync(userId, weekStart);
|
||||
var flashcardCountsTask = _flashcards.GetDashboardCountsAsync(userId, nowUtc, weekStart);
|
||||
var flashcardReviewsTask = _flashcardSchedules.GetReviewsSinceAsync(userId, weekStart);
|
||||
await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask, completedCcsCountTask,
|
||||
simulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask);
|
||||
simulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask, flashcardCountsTask, flashcardReviewsTask);
|
||||
|
||||
var attempts = attemptsTask.Result;
|
||||
var answeredAttempts = attempts.Where(value => !value.IsSkip).ToList();
|
||||
@@ -71,7 +83,10 @@ public sealed class DashboardStore
|
||||
QuestionsThisWeek = answeredAttempts.Count(value => value.CreatedUtc >= weekStart),
|
||||
BookmarkCount = checked((int)bookmarkCountTask.Result),
|
||||
NoteCount = CountNotes(notepadTask.Result?.Content),
|
||||
MissedQuestionCount = checked((int)pendingReviewCount)
|
||||
MissedQuestionCount = checked((int)pendingReviewCount),
|
||||
FlashcardsDue = flashcardCountsTask.Result.Due,
|
||||
FlashcardsNewAvailable = flashcardCountsTask.Result.NewAvailable,
|
||||
FlashcardsReviewedThisWeek = flashcardCountsTask.Result.ReviewedThisWeek
|
||||
};
|
||||
|
||||
response.PerformanceTrend = Enumerable.Range(0, 30).Select(offset =>
|
||||
@@ -115,12 +130,14 @@ public sealed class DashboardStore
|
||||
+ dailyCcs.Sum(CcsDurationSeconds)
|
||||
+ dailySimulations.Sum(value => value.Responses.Sum(item => Math.Max(0, item.ElapsedSeconds)))
|
||||
+ reviewAttemptsTask.Result.Where(value => value.CreatedUtc.Date == date).Sum(value => value.DurationSeconds)
|
||||
+ flashcardReviewsTask.Result.Where(value => value.ReviewedUtc.Date == date).Sum(value => value.DurationSeconds)
|
||||
};
|
||||
}).ToList();
|
||||
response.StudyTimeSecondsThisWeek = response.WeeklyActivity.Sum(value => value.StudyTimeSeconds);
|
||||
var activeDates = attempts.Select(value => value.CreatedUtc.Date)
|
||||
.Concat(completedCcs.Select(value => value.CompletedUtc!.Value.Date))
|
||||
.Concat(simulations.Where(value => value.CompletedUtc.HasValue).Select(value => value.CompletedUtc!.Value.Date));
|
||||
.Concat(simulations.Where(value => value.CompletedUtc.HasValue).Select(value => value.CompletedUtc!.Value.Date))
|
||||
.Concat(flashcardReviewsTask.Result.Select(value => value.ReviewedUtc.Date));
|
||||
response.CurrentStreak = CalculateCurrentStreak(activeDates, today);
|
||||
response.RecentActivity = await BuildRecentActivityAsync(answeredAttempts, questionMap, completedCcs, simulations);
|
||||
return response;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace QuestionsApi.Services.Flashcards;
|
||||
|
||||
public sealed record ClozeDeletion(int Number, string Answer, string? Hint);
|
||||
|
||||
// {{c1::answer}} or {{c1::answer::hint}}. Numbers need not be contiguous or start at 1;
|
||||
// each distinct number becomes its own independently scheduled card (a "sibling").
|
||||
public static partial class FlashcardClozeParser
|
||||
{
|
||||
[GeneratedRegex(@"\{\{c(\d+)::((?:(?!\}\}).)*)\}\}", RegexOptions.Singleline)]
|
||||
private static partial Regex ClozeRegex();
|
||||
|
||||
public static List<ClozeDeletion> Parse(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return [];
|
||||
var results = new List<ClozeDeletion>();
|
||||
foreach (Match match in ClozeRegex().Matches(text))
|
||||
{
|
||||
if (!int.TryParse(match.Groups[1].Value, out var number) || number < 1)
|
||||
continue;
|
||||
var inner = match.Groups[2].Value;
|
||||
var parts = inner.Split("::", 2);
|
||||
var answer = parts[0].Trim();
|
||||
var hint = parts.Length > 1 ? parts[1].Trim() : null;
|
||||
if (answer.Length == 0) continue;
|
||||
results.Add(new ClozeDeletion(number, answer, string.IsNullOrEmpty(hint) ? null : hint));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
public static List<int> DistinctClozeNumbers(string text) =>
|
||||
Parse(text).Select(v => v.Number).Distinct().OrderBy(v => v).ToList();
|
||||
|
||||
public static bool HasValidCloze(string text) => DistinctClozeNumbers(text).Count > 0;
|
||||
|
||||
// Renders the front for a given cloze number: that number's answer is hidden (replaced
|
||||
// by its hint or "..."), all other cloze numbers are revealed as their answer text.
|
||||
public static string RenderFront(string text, int clozeNumber) =>
|
||||
ClozeRegex().Replace(text, match =>
|
||||
{
|
||||
if (!int.TryParse(match.Groups[1].Value, out var number)) return match.Value;
|
||||
var inner = match.Groups[2].Value;
|
||||
var parts = inner.Split("::", 2);
|
||||
if (number != clozeNumber) return parts[0].Trim();
|
||||
var hint = parts.Length > 1 ? parts[1].Trim() : "";
|
||||
return hint.Length > 0 ? $"[{hint}]" : "[...]";
|
||||
});
|
||||
|
||||
public static string RenderBack(string text, int clozeNumber) =>
|
||||
ClozeRegex().Replace(text, match =>
|
||||
{
|
||||
var inner = match.Groups[2].Value;
|
||||
var parts = inner.Split("::", 2);
|
||||
return parts[0].Trim();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Text;
|
||||
|
||||
namespace QuestionsApi.Services.Flashcards;
|
||||
|
||||
// Minimal RFC 4180 CSV reader/writer (quoted fields, embedded commas/newlines, "" escaping).
|
||||
// UTF-8, text-only, matches the flashcard CSV contract exactly.
|
||||
public static class FlashcardCsv
|
||||
{
|
||||
public static readonly string[] Headers =
|
||||
["stable_id", "deck", "type", "prompt", "answer", "extra", "tags", "source_question_external_id"];
|
||||
|
||||
public static List<string[]> ParseRows(string text)
|
||||
{
|
||||
var rows = new List<string[]>();
|
||||
var field = new StringBuilder();
|
||||
var row = new List<string>();
|
||||
var inQuotes = false;
|
||||
var i = 0;
|
||||
var n = text.Length;
|
||||
|
||||
while (i < n)
|
||||
{
|
||||
var c = text[i];
|
||||
if (inQuotes)
|
||||
{
|
||||
if (c == '"')
|
||||
{
|
||||
if (i + 1 < n && text[i + 1] == '"') { field.Append('"'); i += 2; continue; }
|
||||
inQuotes = false; i++; continue;
|
||||
}
|
||||
field.Append(c); i++; continue;
|
||||
}
|
||||
|
||||
switch (c)
|
||||
{
|
||||
case '"':
|
||||
inQuotes = true; i++; break;
|
||||
case ',':
|
||||
row.Add(field.ToString()); field.Clear(); i++; break;
|
||||
case '\r':
|
||||
i++; break;
|
||||
case '\n':
|
||||
row.Add(field.ToString()); field.Clear();
|
||||
rows.Add(row.ToArray()); row = [];
|
||||
i++; break;
|
||||
default:
|
||||
field.Append(c); i++; break;
|
||||
}
|
||||
}
|
||||
if (field.Length > 0 || row.Count > 0)
|
||||
{
|
||||
row.Add(field.ToString());
|
||||
rows.Add(row.ToArray());
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
public static string WriteRow(IEnumerable<string> fields) => string.Join(",", fields.Select(Escape));
|
||||
|
||||
private static string Escape(string field) =>
|
||||
field.IndexOfAny(['"', ',', '\n', '\r']) >= 0
|
||||
? "\"" + field.Replace("\"", "\"\"") + "\""
|
||||
: field;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services.Flashcards;
|
||||
|
||||
public sealed record FlashcardDeckUpdateResult(FlashcardDeck? Deck, FlashcardDeck? Current)
|
||||
{
|
||||
public bool Succeeded => Deck is not null;
|
||||
}
|
||||
|
||||
public sealed class FlashcardDeckStore
|
||||
{
|
||||
private readonly IMongoCollection<FlashcardDeck> _decks;
|
||||
|
||||
public FlashcardDeckStore(MongoDatabaseProvider mongo)
|
||||
{
|
||||
_decks = mongo.Database.GetCollection<FlashcardDeck>("FlashcardDecks");
|
||||
_decks.Indexes.CreateMany([
|
||||
new CreateIndexModel<FlashcardDeck>(
|
||||
Builders<FlashcardDeck>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.NormalizedName),
|
||||
new CreateIndexOptions { Unique = true, Name = "flashcard_deck_unique_user_name" }),
|
||||
new CreateIndexModel<FlashcardDeck>(
|
||||
Builders<FlashcardDeck>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.StableId),
|
||||
new CreateIndexOptions { Unique = true, Name = "flashcard_deck_unique_user_stable" })
|
||||
]);
|
||||
}
|
||||
|
||||
public static string Normalize(string name) => name.Trim().ToLowerInvariant();
|
||||
|
||||
public Task<List<FlashcardDeck>> ListAsync(string userId) =>
|
||||
_decks.Find(v => v.UserId == userId && v.ArchivedUtc == null).SortBy(v => v.Name).ToListAsync();
|
||||
|
||||
public async Task<FlashcardDeck?> GetAsync(string userId, string deckId) =>
|
||||
await _decks.Find(v => v.UserId == userId && v.Id == deckId).FirstOrDefaultAsync();
|
||||
|
||||
public async Task<FlashcardDeck> GetOrCreateDefaultDeckAsync(string userId)
|
||||
{
|
||||
var existing = await _decks.Find(v => v.UserId == userId && v.IsDefault).FirstOrDefaultAsync();
|
||||
if (existing is not null) return existing;
|
||||
|
||||
var deck = new FlashcardDeck
|
||||
{
|
||||
UserId = userId,
|
||||
Name = "Default",
|
||||
NormalizedName = Normalize("Default"),
|
||||
IsDefault = true
|
||||
};
|
||||
try
|
||||
{
|
||||
await _decks.InsertOneAsync(deck);
|
||||
return deck;
|
||||
}
|
||||
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return await _decks.Find(v => v.UserId == userId && v.IsDefault).FirstOrDefaultAsync()
|
||||
?? throw new InvalidOperationException("default deck race with no winner");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<FlashcardDeckUpdateResult> UpsertAsync(string userId, UpsertFlashcardDeckRequest request, string? deckId)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var normalized = Normalize(request.Name);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(deckId))
|
||||
{
|
||||
var created = new FlashcardDeck
|
||||
{
|
||||
UserId = userId,
|
||||
Name = request.Name.Trim(),
|
||||
NormalizedName = normalized,
|
||||
Description = request.Description.Trim(),
|
||||
NewCardsPerDay = request.NewCardsPerDay,
|
||||
ReviewsPerDay = request.ReviewsPerDay,
|
||||
DesiredRetention = request.DesiredRetention,
|
||||
CreatedUtc = now,
|
||||
UpdatedUtc = now
|
||||
};
|
||||
try
|
||||
{
|
||||
await _decks.InsertOneAsync(created);
|
||||
return new FlashcardDeckUpdateResult(created, null);
|
||||
}
|
||||
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return new FlashcardDeckUpdateResult(null, await GetByNameAsync(userId, normalized));
|
||||
}
|
||||
}
|
||||
|
||||
var expectedRevision = request.ExpectedRevision ?? 0;
|
||||
var updated = await _decks.FindOneAndUpdateAsync(
|
||||
v => v.UserId == userId && v.Id == deckId && v.Revision == expectedRevision,
|
||||
Builders<FlashcardDeck>.Update
|
||||
.Set(v => v.Name, request.Name.Trim())
|
||||
.Set(v => v.NormalizedName, normalized)
|
||||
.Set(v => v.Description, request.Description.Trim())
|
||||
.Set(v => v.NewCardsPerDay, request.NewCardsPerDay)
|
||||
.Set(v => v.ReviewsPerDay, request.ReviewsPerDay)
|
||||
.Set(v => v.DesiredRetention, request.DesiredRetention)
|
||||
.Set(v => v.UpdatedUtc, now)
|
||||
.Inc(v => v.Revision, 1),
|
||||
new FindOneAndUpdateOptions<FlashcardDeck> { ReturnDocument = ReturnDocument.After });
|
||||
|
||||
return updated is not null
|
||||
? new FlashcardDeckUpdateResult(updated, null)
|
||||
: new FlashcardDeckUpdateResult(null, await GetAsync(userId, deckId));
|
||||
}
|
||||
|
||||
public async Task<FlashcardDeck?> GetByNameAsync(string userId, string normalizedName) =>
|
||||
await _decks.Find(v => v.UserId == userId && v.NormalizedName == normalizedName).FirstOrDefaultAsync();
|
||||
|
||||
public async Task<FlashcardDeckUpdateResult> ArchiveAsync(string userId, string deckId, bool archived)
|
||||
{
|
||||
var deck = await GetAsync(userId, deckId);
|
||||
if (deck is not null && deck.IsDefault) return new FlashcardDeckUpdateResult(null, deck);
|
||||
|
||||
var updated = await _decks.FindOneAndUpdateAsync(
|
||||
v => v.UserId == userId && v.Id == deckId && !v.IsDefault,
|
||||
Builders<FlashcardDeck>.Update
|
||||
.Set(v => v.ArchivedUtc, archived ? DateTime.UtcNow : null)
|
||||
.Set(v => v.UpdatedUtc, DateTime.UtcNow)
|
||||
.Inc(v => v.Revision, 1),
|
||||
new FindOneAndUpdateOptions<FlashcardDeck> { ReturnDocument = ReturnDocument.After });
|
||||
return updated is not null
|
||||
? new FlashcardDeckUpdateResult(updated, null)
|
||||
: new FlashcardDeckUpdateResult(null, deck);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services.Flashcards;
|
||||
|
||||
public sealed class FlashcardImportExportService
|
||||
{
|
||||
private const int MaxNotesPerImport = 10_000;
|
||||
private const long MaxImportBytes = 25 * 1024 * 1024;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
private readonly FlashcardDeckStore _decks;
|
||||
private readonly FlashcardNoteStore _notes;
|
||||
private readonly FlashcardScheduleStore _schedules;
|
||||
private readonly FlashcardMediaStore _media;
|
||||
private readonly FlashcardSettingsStore _settings;
|
||||
private readonly IMongoCollection<FlashcardImportPreview> _previews;
|
||||
|
||||
public FlashcardImportExportService(
|
||||
MongoDatabaseProvider mongo,
|
||||
FlashcardDeckStore decks,
|
||||
FlashcardNoteStore notes,
|
||||
FlashcardScheduleStore schedules,
|
||||
FlashcardMediaStore media,
|
||||
FlashcardSettingsStore settings)
|
||||
{
|
||||
_decks = decks;
|
||||
_notes = notes;
|
||||
_schedules = schedules;
|
||||
_media = media;
|
||||
_settings = settings;
|
||||
_previews = mongo.Database.GetCollection<FlashcardImportPreview>("FlashcardImportPreviews");
|
||||
_previews.Indexes.CreateOne(new CreateIndexModel<FlashcardImportPreview>(
|
||||
Builders<FlashcardImportPreview>.IndexKeys.Ascending(v => v.ExpiresUtc),
|
||||
new CreateIndexOptions { ExpireAfter = TimeSpan.Zero, Name = "flashcard_import_preview_ttl" }));
|
||||
}
|
||||
|
||||
public static void ValidateSize(long byteLength)
|
||||
{
|
||||
if (byteLength > MaxImportBytes)
|
||||
throw new FlashcardValidationException("import file must be 25 MB or smaller");
|
||||
}
|
||||
|
||||
public async Task<(string Csv, List<FlashcardExportOmission> Omissions)> ExportCsvAsync(string userId)
|
||||
{
|
||||
var deckList = await _decks.ListAsync(userId);
|
||||
var deckNames = deckList.ToDictionary(v => v.Id!, v => v.Name, StringComparer.Ordinal);
|
||||
var allNotes = await _notes.ListAllActiveAsync(userId);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(FlashcardCsv.WriteRow(FlashcardCsv.Headers)).Append('\n');
|
||||
foreach (var note in allNotes.OrderBy(v => v.CreatedUtc))
|
||||
{
|
||||
var deckName = deckNames.GetValueOrDefault(note.DeckId, "Default");
|
||||
sb.Append(FlashcardCsv.WriteRow([
|
||||
note.StableId, deckName, note.Type, note.Prompt, note.Answer, note.Extra,
|
||||
string.Join(';', note.Tags), note.SourceQuestionExternalId
|
||||
])).Append('\n');
|
||||
}
|
||||
|
||||
var omissions = new List<FlashcardExportOmission>
|
||||
{
|
||||
new() { Kind = "schedule", Reason = "CSV export omits FSRS scheduling state" },
|
||||
new() { Kind = "history", Reason = "CSV export omits review history" }
|
||||
};
|
||||
if (allNotes.Any(v => v.Images.Count > 0))
|
||||
omissions.Insert(0, new FlashcardExportOmission { Kind = "images", Reason = "CSV export omits attached images" });
|
||||
return (sb.ToString(), omissions);
|
||||
}
|
||||
|
||||
public async Task<FlashcardJsonExport> ExportJsonAsync(string userId)
|
||||
{
|
||||
var export = new FlashcardJsonExport
|
||||
{
|
||||
Settings = await _settings.GetAsync(userId),
|
||||
Decks = await _decks.ListAsync(userId)
|
||||
};
|
||||
|
||||
foreach (var note in await _notes.ListAllActiveAsync(userId))
|
||||
{
|
||||
var cards = await _schedules.GetByNoteIdAsync(userId, note.Id!);
|
||||
var logs = new List<FlashcardReviewLog>();
|
||||
foreach (var card in cards)
|
||||
logs.AddRange(await _schedules.GetReviewLogsForCardAsync(userId, card.Id!));
|
||||
|
||||
var images = new List<FlashcardJsonExportImage>();
|
||||
foreach (var image in note.Images)
|
||||
{
|
||||
var asset = await _media.GetAsync(userId, image.AssetId);
|
||||
if (asset is null) continue;
|
||||
images.Add(new FlashcardJsonExportImage
|
||||
{
|
||||
AssetId = asset.Id!,
|
||||
FileName = asset.FileName,
|
||||
ContentType = asset.ContentType,
|
||||
Base64Bytes = Convert.ToBase64String(asset.Bytes)
|
||||
});
|
||||
}
|
||||
|
||||
export.Notes.Add(new FlashcardJsonExportNote { Note = note, Cards = cards, ReviewLogs = logs, Images = images });
|
||||
}
|
||||
return export;
|
||||
}
|
||||
|
||||
public async Task<FlashcardImportPreviewResponse> PreviewCsvAsync(string userId, string csvText)
|
||||
{
|
||||
var rows = FlashcardCsv.ParseRows(csvText);
|
||||
if (rows.Count == 0) throw new FlashcardValidationException("CSV file is empty");
|
||||
if (!rows[0].Select(v => v.Trim()).SequenceEqual(FlashcardCsv.Headers, StringComparer.OrdinalIgnoreCase))
|
||||
throw new FlashcardValidationException($"CSV header must be: {string.Join(",", FlashcardCsv.Headers)}");
|
||||
if (rows.Count - 1 > MaxNotesPerImport)
|
||||
throw new FlashcardValidationException($"import limited to {MaxNotesPerImport:N0} notes");
|
||||
|
||||
var existingNotes = await _notes.ListAllActiveAsync(userId);
|
||||
var byStableId = existingNotes.Where(v => v.StableId.Length > 0)
|
||||
.ToDictionary(v => v.StableId, StringComparer.Ordinal);
|
||||
var fingerprints = existingNotes.Select(v => v.ContentFingerprint).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var records = new List<FlashcardImportPendingRecord>();
|
||||
for (var r = 1; r < rows.Count; r++)
|
||||
{
|
||||
var cols = rows[r];
|
||||
var record = new FlashcardImportPendingRecord { RowNumber = r + 1 };
|
||||
if (cols.Length != FlashcardCsv.Headers.Length)
|
||||
{
|
||||
record.Action = FlashcardImportRowActions.Error;
|
||||
record.Error = $"expected {FlashcardCsv.Headers.Length} columns, found {cols.Length}";
|
||||
records.Add(record);
|
||||
continue;
|
||||
}
|
||||
|
||||
var stableId = cols[0].Trim();
|
||||
var deckName = cols[1].Trim();
|
||||
var type = cols[2].Trim().ToLowerInvariant();
|
||||
var prompt = cols[3];
|
||||
var answer = cols[4];
|
||||
var extra = cols[5];
|
||||
var tags = cols[6].Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||
var sourceQuestionId = cols[7].Trim();
|
||||
|
||||
record.StableId = stableId;
|
||||
record.DeckName = deckName.Length == 0 ? "Default" : deckName;
|
||||
record.Type = type;
|
||||
record.Prompt = prompt;
|
||||
record.Answer = answer;
|
||||
record.Extra = extra;
|
||||
record.Tags = tags;
|
||||
record.SourceQuestionExternalId = sourceQuestionId;
|
||||
|
||||
if (type != FlashcardTypes.Basic && type != FlashcardTypes.Cloze)
|
||||
{
|
||||
record.Action = FlashcardImportRowActions.Error;
|
||||
record.Error = "type must be 'basic' or 'cloze'";
|
||||
}
|
||||
else if (prompt.Trim().Length == 0)
|
||||
{
|
||||
record.Action = FlashcardImportRowActions.Error;
|
||||
record.Error = "prompt is required";
|
||||
}
|
||||
else if (type == FlashcardTypes.Cloze && !FlashcardClozeParser.HasValidCloze(prompt))
|
||||
{
|
||||
record.Action = FlashcardImportRowActions.Error;
|
||||
record.Error = "cloze prompt must contain at least one {{cN::answer}} marker";
|
||||
}
|
||||
else
|
||||
{
|
||||
var fingerprint = FlashcardNoteStore.Fingerprint(type, prompt, answer);
|
||||
if (stableId.Length > 0 && byStableId.TryGetValue(stableId, out var existing))
|
||||
{
|
||||
record.Action = existing.ContentFingerprint == fingerprint
|
||||
? FlashcardImportRowActions.Skip
|
||||
: FlashcardImportRowActions.Update;
|
||||
}
|
||||
else if (fingerprints.Contains(fingerprint))
|
||||
{
|
||||
record.Action = FlashcardImportRowActions.Skip;
|
||||
}
|
||||
else
|
||||
{
|
||||
record.Action = FlashcardImportRowActions.Create;
|
||||
if (stableId.Length == 0) record.StableId = Guid.NewGuid().ToString();
|
||||
}
|
||||
}
|
||||
records.Add(record);
|
||||
}
|
||||
|
||||
return await SavePreviewAsync(userId, FlashcardImportFormats.Csv, records);
|
||||
}
|
||||
|
||||
public async Task<FlashcardImportPreviewResponse> PreviewJsonAsync(string userId, string jsonText)
|
||||
{
|
||||
FlashcardJsonExport parsed;
|
||||
try
|
||||
{
|
||||
parsed = JsonSerializer.Deserialize<FlashcardJsonExport>(jsonText, JsonOptions)
|
||||
?? throw new FlashcardValidationException("empty JSON document");
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
throw new FlashcardValidationException($"invalid JSON: {exception.Message}");
|
||||
}
|
||||
|
||||
if (parsed.Notes.Count > MaxNotesPerImport)
|
||||
throw new FlashcardValidationException($"import limited to {MaxNotesPerImport:N0} notes");
|
||||
|
||||
var existingNotes = await _notes.ListAllActiveAsync(userId);
|
||||
var byStableId = existingNotes.Where(v => v.StableId.Length > 0)
|
||||
.ToDictionary(v => v.StableId, StringComparer.Ordinal);
|
||||
|
||||
var records = new List<FlashcardImportPendingRecord>();
|
||||
var rowNumber = 0;
|
||||
foreach (var item in parsed.Notes)
|
||||
{
|
||||
rowNumber++;
|
||||
var note = item.Note;
|
||||
var stableId = string.IsNullOrWhiteSpace(note.StableId) ? Guid.NewGuid().ToString() : note.StableId;
|
||||
var type = FlashcardTypes.All.Contains(note.Type) ? note.Type : FlashcardTypes.Basic;
|
||||
var record = new FlashcardImportPendingRecord
|
||||
{
|
||||
RowNumber = rowNumber,
|
||||
StableId = stableId,
|
||||
DeckName = parsed.Decks.FirstOrDefault(d => d.Id == note.DeckId)?.Name ?? "Default",
|
||||
Type = type,
|
||||
Prompt = note.Prompt,
|
||||
Answer = note.Answer,
|
||||
Extra = note.Extra,
|
||||
Tags = note.Tags,
|
||||
SourceQuestionExternalId = note.SourceQuestionExternalId,
|
||||
Cards = item.Cards,
|
||||
ReviewLogs = item.ReviewLogs
|
||||
};
|
||||
|
||||
record.Action = type == FlashcardTypes.Cloze && !FlashcardClozeParser.HasValidCloze(note.Prompt)
|
||||
? FlashcardImportRowActions.Error
|
||||
: byStableId.ContainsKey(stableId) ? FlashcardImportRowActions.Update : FlashcardImportRowActions.Create;
|
||||
if (record.Action == FlashcardImportRowActions.Error)
|
||||
record.Error = "cloze prompt must contain at least one {{cN::answer}} marker";
|
||||
|
||||
records.Add(record);
|
||||
}
|
||||
|
||||
return await SavePreviewAsync(userId, FlashcardImportFormats.Json, records);
|
||||
}
|
||||
|
||||
public async Task<FlashcardImportCommitResponse> CommitAsync(string userId, string importToken)
|
||||
{
|
||||
var preview = await _previews.Find(v => v.Id == importToken && v.UserId == userId).FirstOrDefaultAsync();
|
||||
if (preview is null) throw new FlashcardValidationException("import preview not found or expired; re-run preview");
|
||||
|
||||
var deckCache = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var response = new FlashcardImportCommitResponse();
|
||||
|
||||
foreach (var record in preview.Records)
|
||||
{
|
||||
if (record.Action == FlashcardImportRowActions.Error)
|
||||
{
|
||||
response.Errored++;
|
||||
response.Errors.Add(ToRow(record));
|
||||
continue;
|
||||
}
|
||||
if (record.Action == FlashcardImportRowActions.Skip)
|
||||
{
|
||||
response.Skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var deckId = await ResolveDeckIdAsync(userId, record.DeckName, deckCache);
|
||||
var existing = await _notes.GetByStableIdAsync(userId, record.StableId);
|
||||
var request = new UpsertFlashcardNoteRequest
|
||||
{
|
||||
DeckId = deckId,
|
||||
StableId = record.StableId,
|
||||
Type = record.Type,
|
||||
Prompt = record.Prompt,
|
||||
Answer = record.Answer,
|
||||
Extra = record.Extra,
|
||||
Tags = record.Tags,
|
||||
SourceQuestionExternalId = record.SourceQuestionExternalId,
|
||||
ExpectedRevision = existing?.Revision
|
||||
};
|
||||
|
||||
FlashcardNoteUpdateResult result;
|
||||
try
|
||||
{
|
||||
result = await _notes.UpsertAsync(userId, request, existing?.Id);
|
||||
}
|
||||
catch (FlashcardValidationException exception)
|
||||
{
|
||||
response.Errored++;
|
||||
response.Errors.Add(new FlashcardImportPreviewRow
|
||||
{
|
||||
RowNumber = record.RowNumber, Action = FlashcardImportRowActions.Error,
|
||||
Error = exception.Message, StableId = record.StableId
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
response.Errored++;
|
||||
response.Errors.Add(new FlashcardImportPreviewRow
|
||||
{
|
||||
RowNumber = record.RowNumber, Action = FlashcardImportRowActions.Error,
|
||||
Error = "note changed since preview; re-run import", StableId = record.StableId
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (record.Action == FlashcardImportRowActions.Create) response.Created++; else response.Updated++;
|
||||
|
||||
foreach (var card in record.Cards)
|
||||
await _schedules.ImportScheduleStateAsync(userId, result.Note!.Id!, card);
|
||||
foreach (var log in record.ReviewLogs)
|
||||
{
|
||||
var targetCard = (await _schedules.GetByNoteIdAsync(userId, result.Note!.Id!))
|
||||
.FirstOrDefault(v => v.Variant == record.Cards.FirstOrDefault(c => c.Id == log.CardId)?.Variant);
|
||||
if (targetCard is not null)
|
||||
await _schedules.ImportReviewLogAsync(userId, targetCard.Id!, log);
|
||||
}
|
||||
}
|
||||
|
||||
await _previews.DeleteOneAsync(v => v.Id == importToken);
|
||||
return response;
|
||||
}
|
||||
|
||||
private async Task<FlashcardImportPreviewResponse> SavePreviewAsync(
|
||||
string userId, string format, List<FlashcardImportPendingRecord> records)
|
||||
{
|
||||
var preview = new FlashcardImportPreview
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
UserId = userId,
|
||||
Format = format,
|
||||
Records = records
|
||||
};
|
||||
await _previews.InsertOneAsync(preview);
|
||||
|
||||
return new FlashcardImportPreviewResponse
|
||||
{
|
||||
ImportToken = preview.Id,
|
||||
CreateCount = records.Count(v => v.Action == FlashcardImportRowActions.Create),
|
||||
UpdateCount = records.Count(v => v.Action == FlashcardImportRowActions.Update),
|
||||
SkipCount = records.Count(v => v.Action == FlashcardImportRowActions.Skip),
|
||||
ErrorCount = records.Count(v => v.Action == FlashcardImportRowActions.Error),
|
||||
Rows = records.Select(ToRow).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static FlashcardImportPreviewRow ToRow(FlashcardImportPendingRecord record) => new()
|
||||
{
|
||||
RowNumber = record.RowNumber,
|
||||
Action = record.Action,
|
||||
Error = record.Error,
|
||||
StableId = record.StableId,
|
||||
Deck = record.DeckName,
|
||||
Type = record.Type,
|
||||
Prompt = Truncate(record.Prompt),
|
||||
Answer = Truncate(record.Answer)
|
||||
};
|
||||
|
||||
private static string Truncate(string value) => value.Length > 200 ? value[..200] + "…" : value;
|
||||
|
||||
private async Task<string> ResolveDeckIdAsync(string userId, string deckName, Dictionary<string, string> cache)
|
||||
{
|
||||
var key = deckName.Trim();
|
||||
if (key.Length == 0) key = "Default";
|
||||
if (cache.TryGetValue(key, out var cached)) return cached;
|
||||
|
||||
if (string.Equals(key, "Default", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var defaultDeck = await _decks.GetOrCreateDefaultDeckAsync(userId);
|
||||
cache[key] = defaultDeck.Id!;
|
||||
return defaultDeck.Id!;
|
||||
}
|
||||
|
||||
var normalized = FlashcardDeckStore.Normalize(key);
|
||||
var existing = await _decks.GetByNameAsync(userId, normalized);
|
||||
if (existing is not null)
|
||||
{
|
||||
cache[key] = existing.Id!;
|
||||
return existing.Id!;
|
||||
}
|
||||
|
||||
var created = await _decks.UpsertAsync(userId, new UpsertFlashcardDeckRequest { Name = key }, null);
|
||||
var deckId = created.Deck?.Id ?? (await _decks.GetByNameAsync(userId, normalized))?.Id
|
||||
?? throw new InvalidOperationException("deck resolution failed during import");
|
||||
cache[key] = deckId;
|
||||
return deckId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services.Flashcards;
|
||||
|
||||
public sealed class FlashcardMediaStore
|
||||
{
|
||||
private readonly IMongoCollection<FlashcardMediaAsset> _assets;
|
||||
|
||||
public FlashcardMediaStore(MongoDatabaseProvider mongo)
|
||||
{
|
||||
_assets = mongo.Database.GetCollection<FlashcardMediaAsset>("FlashcardMediaAssets");
|
||||
_assets.Indexes.CreateOne(new CreateIndexModel<FlashcardMediaAsset>(
|
||||
Builders<FlashcardMediaAsset>.IndexKeys.Ascending(v => v.UserId),
|
||||
new CreateIndexOptions { Name = "flashcard_media_user" }));
|
||||
}
|
||||
|
||||
public async Task<FlashcardMediaAsset> CreateAsync(string userId, string fileName, string contentType, byte[] bytes)
|
||||
{
|
||||
var asset = new FlashcardMediaAsset
|
||||
{
|
||||
UserId = userId,
|
||||
FileName = fileName,
|
||||
ContentType = contentType,
|
||||
Bytes = bytes,
|
||||
CreatedUtc = DateTime.UtcNow
|
||||
};
|
||||
await _assets.InsertOneAsync(asset);
|
||||
return asset;
|
||||
}
|
||||
|
||||
public async Task<FlashcardMediaAsset?> GetAsync(string userId, string id)
|
||||
{
|
||||
if (!ObjectId.TryParse(id, out _)) return null;
|
||||
return await _assets.Find(v => v.UserId == userId && v.Id == id).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public Task<long> DeleteAsync(string userId, string id) =>
|
||||
_assets.DeleteOneAsync(v => v.UserId == userId && v.Id == id).ContinueWith(t => t.Result.DeletedCount);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services.Flashcards;
|
||||
|
||||
public sealed record FlashcardNoteUpdateResult(FlashcardNote? Note, FlashcardNote? Current)
|
||||
{
|
||||
public bool Succeeded => Note is not null;
|
||||
}
|
||||
|
||||
public sealed class FlashcardNoteStore
|
||||
{
|
||||
private readonly IMongoCollection<FlashcardNote> _notes;
|
||||
private readonly FlashcardScheduleStore _schedules;
|
||||
|
||||
public FlashcardNoteStore(MongoDatabaseProvider mongo, FlashcardScheduleStore schedules)
|
||||
{
|
||||
_notes = mongo.Database.GetCollection<FlashcardNote>("FlashcardNotes");
|
||||
_schedules = schedules;
|
||||
_notes.Indexes.CreateMany([
|
||||
new CreateIndexModel<FlashcardNote>(
|
||||
Builders<FlashcardNote>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.StableId),
|
||||
new CreateIndexOptions { Unique = true, Name = "flashcard_note_unique_user_stable" }),
|
||||
new CreateIndexModel<FlashcardNote>(
|
||||
Builders<FlashcardNote>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.DeckId),
|
||||
new CreateIndexOptions { Name = "flashcard_note_user_deck" }),
|
||||
new CreateIndexModel<FlashcardNote>(
|
||||
Builders<FlashcardNote>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.SourceQuestionExternalId),
|
||||
new CreateIndexOptions { Name = "flashcard_note_user_source_question" }),
|
||||
new CreateIndexModel<FlashcardNote>(
|
||||
Builders<FlashcardNote>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.ContentFingerprint),
|
||||
new CreateIndexOptions { Name = "flashcard_note_user_fingerprint" })
|
||||
]);
|
||||
}
|
||||
|
||||
public static string Fingerprint(string type, string prompt, string answer) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{type}{prompt.Trim()}{answer.Trim()}")));
|
||||
|
||||
public async Task<FlashcardNote?> GetAsync(string userId, string noteId) =>
|
||||
await _notes.Find(v => v.UserId == userId && v.Id == noteId).FirstOrDefaultAsync();
|
||||
|
||||
public async Task<FlashcardNote?> GetByStableIdAsync(string userId, string stableId) =>
|
||||
await _notes.Find(v => v.UserId == userId && v.StableId == stableId).FirstOrDefaultAsync();
|
||||
|
||||
public Task<List<FlashcardNote>> ListByDeckAsync(string userId, string deckId) =>
|
||||
_notes.Find(v => v.UserId == userId && v.DeckId == deckId && v.ArchivedUtc == null).ToListAsync();
|
||||
|
||||
public Task<List<FlashcardNote>> ListAllActiveAsync(string userId) =>
|
||||
_notes.Find(v => v.UserId == userId && v.ArchivedUtc == null).ToListAsync();
|
||||
|
||||
public async Task<(List<FlashcardNote> Items, int Total)> SearchAsync(string userId, FlashcardLibraryQuery query)
|
||||
{
|
||||
var filter = Builders<FlashcardNote>.Filter.Eq(v => v.UserId, userId);
|
||||
if (query.State != FlashcardArchiveFilter.IncludeArchived)
|
||||
filter &= Builders<FlashcardNote>.Filter.Eq(v => v.ArchivedUtc, null);
|
||||
if (!string.IsNullOrWhiteSpace(query.DeckId))
|
||||
filter &= Builders<FlashcardNote>.Filter.Eq(v => v.DeckId, query.DeckId);
|
||||
if (!string.IsNullOrWhiteSpace(query.Type))
|
||||
filter &= Builders<FlashcardNote>.Filter.Eq(v => v.Type, query.Type);
|
||||
if (!string.IsNullOrWhiteSpace(query.Tag))
|
||||
filter &= Builders<FlashcardNote>.Filter.AnyEq(v => v.Tags, query.Tag);
|
||||
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||
{
|
||||
var pattern = new MongoDB.Bson.BsonRegularExpression(Regex.Escape(query.Search), "i");
|
||||
filter &= Builders<FlashcardNote>.Filter.Or(
|
||||
Builders<FlashcardNote>.Filter.Regex(v => v.Prompt, pattern),
|
||||
Builders<FlashcardNote>.Filter.Regex(v => v.Answer, pattern),
|
||||
Builders<FlashcardNote>.Filter.Regex(v => v.Extra, pattern));
|
||||
}
|
||||
|
||||
var total = checked((int)await _notes.CountDocumentsAsync(filter));
|
||||
var page = Math.Max(1, query.Page);
|
||||
var pageSize = Math.Clamp(query.PageSize, 1, 200);
|
||||
var items = await _notes.Find(filter)
|
||||
.SortByDescending(v => v.UpdatedUtc)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Limit(pageSize)
|
||||
.ToListAsync();
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
public async Task<FlashcardNoteUpdateResult> UpsertAsync(string userId, UpsertFlashcardNoteRequest request, string? noteId)
|
||||
{
|
||||
var type = FlashcardTypes.All.Contains(request.Type) ? request.Type : FlashcardTypes.Basic;
|
||||
if (type == FlashcardTypes.Cloze && !FlashcardClozeParser.HasValidCloze(request.Prompt))
|
||||
throw new FlashcardValidationException("cloze note must contain at least one {{cN::answer}} marker");
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var fingerprint = Fingerprint(type, request.Prompt, request.Answer);
|
||||
|
||||
FlashcardNote? saved;
|
||||
if (string.IsNullOrWhiteSpace(noteId))
|
||||
{
|
||||
var created = new FlashcardNote
|
||||
{
|
||||
UserId = userId,
|
||||
StableId = string.IsNullOrWhiteSpace(request.StableId) ? Guid.NewGuid().ToString() : request.StableId,
|
||||
DeckId = request.DeckId,
|
||||
Type = type,
|
||||
Prompt = request.Prompt,
|
||||
Answer = request.Answer,
|
||||
Extra = request.Extra,
|
||||
Tags = NormalizeTags(request.Tags),
|
||||
Images = request.Images,
|
||||
SourceQuestionExternalId = request.SourceQuestionExternalId,
|
||||
ContentFingerprint = fingerprint,
|
||||
CreatedUtc = now,
|
||||
UpdatedUtc = now
|
||||
};
|
||||
try
|
||||
{
|
||||
await _notes.InsertOneAsync(created);
|
||||
saved = created;
|
||||
}
|
||||
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return new FlashcardNoteUpdateResult(null, await GetByStableIdAsync(userId, created.StableId));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var expectedRevision = request.ExpectedRevision ?? 0;
|
||||
var updated = await _notes.FindOneAndUpdateAsync(
|
||||
v => v.UserId == userId && v.Id == noteId && v.Revision == expectedRevision,
|
||||
Builders<FlashcardNote>.Update
|
||||
.Set(v => v.DeckId, request.DeckId)
|
||||
.Set(v => v.Type, type)
|
||||
.Set(v => v.Prompt, request.Prompt)
|
||||
.Set(v => v.Answer, request.Answer)
|
||||
.Set(v => v.Extra, request.Extra)
|
||||
.Set(v => v.Tags, NormalizeTags(request.Tags))
|
||||
.Set(v => v.Images, request.Images)
|
||||
.Set(v => v.SourceQuestionExternalId, request.SourceQuestionExternalId)
|
||||
.Set(v => v.ContentFingerprint, fingerprint)
|
||||
.Set(v => v.UpdatedUtc, now)
|
||||
.Inc(v => v.Revision, 1),
|
||||
new FindOneAndUpdateOptions<FlashcardNote> { ReturnDocument = ReturnDocument.After });
|
||||
if (updated is null)
|
||||
return new FlashcardNoteUpdateResult(null, await GetAsync(userId, noteId));
|
||||
saved = updated;
|
||||
}
|
||||
|
||||
await _schedules.SyncVariantsAsync(saved);
|
||||
return new FlashcardNoteUpdateResult(saved, null);
|
||||
}
|
||||
|
||||
public async Task<FlashcardNoteUpdateResult> ArchiveAsync(string userId, string noteId, bool archived)
|
||||
{
|
||||
var updated = await _notes.FindOneAndUpdateAsync(
|
||||
v => v.UserId == userId && v.Id == noteId,
|
||||
Builders<FlashcardNote>.Update
|
||||
.Set(v => v.ArchivedUtc, archived ? DateTime.UtcNow : null)
|
||||
.Set(v => v.UpdatedUtc, DateTime.UtcNow)
|
||||
.Inc(v => v.Revision, 1),
|
||||
new FindOneAndUpdateOptions<FlashcardNote> { ReturnDocument = ReturnDocument.After });
|
||||
return updated is not null
|
||||
? new FlashcardNoteUpdateResult(updated, null)
|
||||
: new FlashcardNoteUpdateResult(null, await GetAsync(userId, noteId));
|
||||
}
|
||||
|
||||
public async Task<long> MoveAsync(string userId, IReadOnlyCollection<string> noteIds, string targetDeckId)
|
||||
{
|
||||
var result = await _notes.UpdateManyAsync(
|
||||
v => v.UserId == userId && noteIds.Contains(v.Id!),
|
||||
Builders<FlashcardNote>.Update.Set(v => v.DeckId, targetDeckId).Set(v => v.UpdatedUtc, DateTime.UtcNow).Inc(v => v.Revision, 1));
|
||||
return result.ModifiedCount;
|
||||
}
|
||||
|
||||
public Task<long> CountInDeckAsync(string userId, string deckId) =>
|
||||
_notes.CountDocumentsAsync(v => v.UserId == userId && v.DeckId == deckId && v.ArchivedUtc == null);
|
||||
|
||||
private static List<string> NormalizeTags(List<string> tags) => tags
|
||||
.Select(v => v.Trim())
|
||||
.Where(v => v.Length > 0)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public sealed class FlashcardValidationException(string message) : Exception(message);
|
||||
|
||||
public static class FlashcardArchiveFilter
|
||||
{
|
||||
public const string IncludeArchived = "archived";
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services.Flashcards;
|
||||
|
||||
public enum FlashcardReviewOutcomeKind { Success, Duplicate, Conflict, NotFound }
|
||||
|
||||
public sealed class FlashcardReviewOutcome
|
||||
{
|
||||
public FlashcardReviewOutcomeKind Kind { get; init; }
|
||||
public FlashcardSchedule? Schedule { get; init; }
|
||||
public static FlashcardReviewOutcome NotFound() => new() { Kind = FlashcardReviewOutcomeKind.NotFound };
|
||||
public static FlashcardReviewOutcome Conflict(FlashcardSchedule? current) => new() { Kind = FlashcardReviewOutcomeKind.Conflict, Schedule = current };
|
||||
public static FlashcardReviewOutcome Duplicate(FlashcardSchedule? current) => new() { Kind = FlashcardReviewOutcomeKind.Duplicate, Schedule = current };
|
||||
public static FlashcardReviewOutcome Success(FlashcardSchedule schedule) => new() { Kind = FlashcardReviewOutcomeKind.Success, Schedule = schedule };
|
||||
}
|
||||
|
||||
public sealed class FlashcardScheduleStore
|
||||
{
|
||||
private readonly IMongoCollection<FlashcardSchedule> _schedules;
|
||||
private readonly IMongoCollection<FlashcardReviewLog> _reviewLogs;
|
||||
private readonly IFlashcardScheduler _scheduler;
|
||||
|
||||
public FlashcardScheduleStore(MongoDatabaseProvider mongo, IFlashcardScheduler scheduler)
|
||||
{
|
||||
_schedules = mongo.Database.GetCollection<FlashcardSchedule>("FlashcardSchedules");
|
||||
_reviewLogs = mongo.Database.GetCollection<FlashcardReviewLog>("FlashcardReviewLogs");
|
||||
_scheduler = scheduler;
|
||||
|
||||
_schedules.Indexes.CreateMany([
|
||||
new CreateIndexModel<FlashcardSchedule>(
|
||||
Builders<FlashcardSchedule>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.NoteId).Ascending(v => v.Variant),
|
||||
new CreateIndexOptions { Unique = true, Name = "flashcard_schedule_unique_user_note_variant" }),
|
||||
new CreateIndexModel<FlashcardSchedule>(
|
||||
Builders<FlashcardSchedule>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.State).Ascending(v => v.DueUtc),
|
||||
new CreateIndexOptions { Name = "flashcard_schedule_user_state_due" })
|
||||
]);
|
||||
_reviewLogs.Indexes.CreateMany([
|
||||
new CreateIndexModel<FlashcardReviewLog>(
|
||||
Builders<FlashcardReviewLog>.IndexKeys.Ascending(v => v.UserId).Descending(v => v.ReviewedUtc),
|
||||
new CreateIndexOptions { Name = "flashcard_review_log_user_reviewed" }),
|
||||
new CreateIndexModel<FlashcardReviewLog>(
|
||||
Builders<FlashcardReviewLog>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.CardId),
|
||||
new CreateIndexOptions { Name = "flashcard_review_log_user_card" })
|
||||
]);
|
||||
}
|
||||
|
||||
// Basic notes always have exactly one card (variant 0). Cloze notes get one independently
|
||||
// scheduled card per distinct {{cN::...}} number found in the prompt. Existing variants are
|
||||
// never removed on re-sync (even if a cloze number disappears from edited text) so a note
|
||||
// edit never discards review history.
|
||||
public async Task SyncVariantsAsync(FlashcardNote note)
|
||||
{
|
||||
var desiredVariants = note.Type == FlashcardTypes.Cloze
|
||||
? FlashcardClozeParser.DistinctClozeNumbers(note.Prompt)
|
||||
: [0];
|
||||
if (desiredVariants.Count == 0) return;
|
||||
|
||||
var existing = await GetByNoteIdAsync(note.UserId, note.Id!);
|
||||
var existingVariants = existing.Select(v => v.Variant).ToHashSet();
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var variant in desiredVariants.Where(v => !existingVariants.Contains(v)))
|
||||
{
|
||||
var schedule = new FlashcardSchedule
|
||||
{
|
||||
UserId = note.UserId,
|
||||
NoteId = note.Id!,
|
||||
Variant = variant,
|
||||
State = FlashcardScheduleStates.New,
|
||||
DueUtc = now,
|
||||
CreatedUtc = now
|
||||
};
|
||||
try
|
||||
{
|
||||
await _schedules.InsertOneAsync(schedule);
|
||||
}
|
||||
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
// Concurrent save already created this variant.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<FlashcardSchedule?> GetAsync(string userId, string cardId) =>
|
||||
await _schedules.Find(v => v.UserId == userId && v.Id == cardId).FirstOrDefaultAsync();
|
||||
|
||||
public Task<List<FlashcardSchedule>> GetByNoteIdAsync(string userId, string noteId) =>
|
||||
_schedules.Find(v => v.UserId == userId && v.NoteId == noteId).ToListAsync();
|
||||
|
||||
public async Task<List<FlashcardSchedule>> GetDueAsync(
|
||||
string userId, IReadOnlyCollection<string> noteIds, DateTime nowUtc, DateTime studyDayStartUtc, int limit)
|
||||
{
|
||||
if (noteIds.Count == 0 || limit <= 0) return [];
|
||||
return await _schedules.Find(v => v.UserId == userId
|
||||
&& noteIds.Contains(v.NoteId)
|
||||
&& v.State != FlashcardScheduleStates.New
|
||||
&& !v.IsSuspended
|
||||
&& (v.BuriedUntilUtc == null || v.BuriedUntilUtc <= studyDayStartUtc)
|
||||
&& v.DueUtc <= nowUtc)
|
||||
.SortBy(v => v.DueUtc)
|
||||
.Limit(limit)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<FlashcardSchedule>> GetNewAsync(
|
||||
string userId, IReadOnlyCollection<string> noteIds, DateTime studyDayStartUtc, int limit)
|
||||
{
|
||||
if (noteIds.Count == 0 || limit <= 0) return [];
|
||||
return await _schedules.Find(v => v.UserId == userId
|
||||
&& noteIds.Contains(v.NoteId)
|
||||
&& v.State == FlashcardScheduleStates.New
|
||||
&& !v.IsSuspended
|
||||
&& (v.BuriedUntilUtc == null || v.BuriedUntilUtc <= studyDayStartUtc))
|
||||
.SortBy(v => v.CreatedUtc)
|
||||
.Limit(limit)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public Task<long> CountDueAsync(string userId, IReadOnlyCollection<string> noteIds, DateTime nowUtc, DateTime studyDayStartUtc) =>
|
||||
noteIds.Count == 0
|
||||
? Task.FromResult(0L)
|
||||
: _schedules.CountDocumentsAsync(v => v.UserId == userId
|
||||
&& noteIds.Contains(v.NoteId)
|
||||
&& v.State != FlashcardScheduleStates.New
|
||||
&& !v.IsSuspended
|
||||
&& (v.BuriedUntilUtc == null || v.BuriedUntilUtc <= studyDayStartUtc)
|
||||
&& v.DueUtc <= nowUtc);
|
||||
|
||||
public Task<long> CountNewAsync(string userId, IReadOnlyCollection<string> noteIds, DateTime studyDayStartUtc) =>
|
||||
noteIds.Count == 0
|
||||
? Task.FromResult(0L)
|
||||
: _schedules.CountDocumentsAsync(v => v.UserId == userId
|
||||
&& noteIds.Contains(v.NoteId)
|
||||
&& v.State == FlashcardScheduleStates.New
|
||||
&& !v.IsSuspended
|
||||
&& (v.BuriedUntilUtc == null || v.BuriedUntilUtc <= studyDayStartUtc));
|
||||
|
||||
public Task<long> CountReviewsSinceAsync(string userId, DateTime sinceUtc) =>
|
||||
_reviewLogs.CountDocumentsAsync(v => v.UserId == userId && v.ReviewedUtc >= sinceUtc && v.UndoneUtc == null);
|
||||
|
||||
public async Task<int> CountNewIntroducedSinceAsync(string userId, DateTime sinceUtc) =>
|
||||
checked((int)await _reviewLogs.CountDocumentsAsync(v => v.UserId == userId
|
||||
&& v.ReviewedUtc >= sinceUtc && v.UndoneUtc == null && v.Before.State == FlashcardScheduleStates.New));
|
||||
|
||||
public Task<List<FlashcardReviewLog>> GetReviewsSinceAsync(string userId, DateTime sinceUtc) =>
|
||||
_reviewLogs.Find(v => v.UserId == userId && v.ReviewedUtc >= sinceUtc).SortBy(v => v.ReviewedUtc).ToListAsync();
|
||||
|
||||
public Task<List<FlashcardReviewLog>> GetReviewLogsForCardAsync(string userId, string cardId) =>
|
||||
_reviewLogs.Find(v => v.UserId == userId && v.CardId == cardId).SortBy(v => v.ReviewedUtc).ToListAsync();
|
||||
|
||||
// Used only by JSON import: restores a previously-exported card's FSRS state verbatim
|
||||
// (not a normal review) so importing preserves history instead of re-deriving it.
|
||||
public Task ImportScheduleStateAsync(string userId, string noteId, FlashcardSchedule imported) =>
|
||||
_schedules.UpdateOneAsync(
|
||||
v => v.UserId == userId && v.NoteId == noteId && v.Variant == imported.Variant,
|
||||
Builders<FlashcardSchedule>.Update
|
||||
.Set(v => v.State, imported.State)
|
||||
.Set(v => v.Step, imported.Step)
|
||||
.Set(v => v.Stability, imported.Stability)
|
||||
.Set(v => v.Difficulty, imported.Difficulty)
|
||||
.Set(v => v.DueUtc, imported.DueUtc)
|
||||
.Set(v => v.LastReviewUtc, imported.LastReviewUtc)
|
||||
.Set(v => v.IsSuspended, imported.IsSuspended)
|
||||
.Set(v => v.BuriedUntilUtc, imported.BuriedUntilUtc)
|
||||
.Inc(v => v.Revision, 1));
|
||||
|
||||
public async Task ImportReviewLogAsync(string userId, string cardId, FlashcardReviewLog imported)
|
||||
{
|
||||
var log = new FlashcardReviewLog
|
||||
{
|
||||
Id = string.IsNullOrWhiteSpace(imported.Id) ? Guid.NewGuid().ToString() : imported.Id,
|
||||
UserId = userId,
|
||||
CardId = cardId,
|
||||
Rating = imported.Rating,
|
||||
ReviewedUtc = imported.ReviewedUtc,
|
||||
DurationSeconds = imported.DurationSeconds,
|
||||
Before = imported.Before,
|
||||
After = imported.After,
|
||||
CreatedUtc = imported.CreatedUtc,
|
||||
UndoneUtc = imported.UndoneUtc
|
||||
};
|
||||
try
|
||||
{
|
||||
await _reviewLogs.InsertOneAsync(log);
|
||||
}
|
||||
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
// Already imported (re-running the same import commit).
|
||||
}
|
||||
}
|
||||
|
||||
public List<FlashcardRatingOption> PreviewRatingOptions(FlashcardSchedule schedule, DateTime nowUtc) =>
|
||||
_scheduler.PreviewRatingOptions(schedule, nowUtc);
|
||||
|
||||
public async Task<FlashcardReviewOutcome> SubmitReviewAsync(string userId, FlashcardReviewRequest request)
|
||||
{
|
||||
var schedule = await GetAsync(userId, request.CardId);
|
||||
if (schedule is null) return FlashcardReviewOutcome.NotFound();
|
||||
|
||||
if (schedule.Revision != request.ExpectedRevision)
|
||||
{
|
||||
var priorLog = await _reviewLogs.Find(v => v.Id == request.ClientReviewId && v.UserId == userId).FirstOrDefaultAsync();
|
||||
return priorLog is not null && priorLog.CardId == request.CardId
|
||||
? FlashcardReviewOutcome.Duplicate(schedule)
|
||||
: FlashcardReviewOutcome.Conflict(schedule);
|
||||
}
|
||||
|
||||
var before = Snapshot(schedule);
|
||||
var result = _scheduler.Review(schedule, request.Rating, request.ReviewedUtc, request.DurationSeconds);
|
||||
|
||||
var updated = await _schedules.FindOneAndUpdateAsync(
|
||||
v => v.UserId == userId && v.Id == request.CardId && v.Revision == request.ExpectedRevision,
|
||||
Builders<FlashcardSchedule>.Update
|
||||
.Set(v => v.State, result.State)
|
||||
.Set(v => v.Step, result.Step)
|
||||
.Set(v => v.Stability, result.Stability)
|
||||
.Set(v => v.Difficulty, result.Difficulty)
|
||||
.Set(v => v.DueUtc, result.DueUtc)
|
||||
.Set(v => v.LastReviewUtc, result.LastReviewUtc)
|
||||
.Set(v => v.AlgorithmVersion, FsrsFlashcardScheduler.AlgorithmVersion)
|
||||
.Inc(v => v.Revision, 1),
|
||||
new FindOneAndUpdateOptions<FlashcardSchedule> { ReturnDocument = ReturnDocument.After });
|
||||
|
||||
if (updated is null)
|
||||
{
|
||||
var raceLog = await _reviewLogs.Find(v => v.Id == request.ClientReviewId && v.UserId == userId).FirstOrDefaultAsync();
|
||||
var current = await GetAsync(userId, request.CardId);
|
||||
return raceLog is not null && raceLog.CardId == request.CardId
|
||||
? FlashcardReviewOutcome.Duplicate(current)
|
||||
: FlashcardReviewOutcome.Conflict(current);
|
||||
}
|
||||
|
||||
var log = new FlashcardReviewLog
|
||||
{
|
||||
Id = request.ClientReviewId,
|
||||
UserId = userId,
|
||||
CardId = request.CardId,
|
||||
Rating = request.Rating,
|
||||
ReviewedUtc = request.ReviewedUtc,
|
||||
DurationSeconds = Math.Clamp(request.DurationSeconds, 0, 86_400),
|
||||
Before = before,
|
||||
After = Snapshot(updated),
|
||||
CreatedUtc = DateTime.UtcNow
|
||||
};
|
||||
try
|
||||
{
|
||||
await _reviewLogs.InsertOneAsync(log);
|
||||
}
|
||||
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
// A retried duplicate request already recorded this review; the schedule update
|
||||
// above only applies once because it matched the pre-review revision exactly.
|
||||
}
|
||||
|
||||
// Bury cloze siblings (basic notes have exactly one variant, so this is a no-op for them).
|
||||
var buriedUntil = FlashcardScheduling.NextStudyDayStart(request.StudyDayStartUtc);
|
||||
await _schedules.UpdateManyAsync(
|
||||
v => v.UserId == userId && v.NoteId == updated.NoteId && v.Id != updated.Id,
|
||||
Builders<FlashcardSchedule>.Update.Set(v => v.BuriedUntilUtc, buriedUntil));
|
||||
|
||||
return FlashcardReviewOutcome.Success(updated);
|
||||
}
|
||||
|
||||
public async Task<FlashcardReviewOutcome> UndoAsync(string userId, string reviewLogId)
|
||||
{
|
||||
var log = await _reviewLogs.Find(v => v.Id == reviewLogId && v.UserId == userId && v.UndoneUtc == null).FirstOrDefaultAsync();
|
||||
if (log is null) return FlashcardReviewOutcome.NotFound();
|
||||
|
||||
var current = await GetAsync(userId, log.CardId);
|
||||
if (current is null) return FlashcardReviewOutcome.NotFound();
|
||||
if (current.Revision != log.After.Revision)
|
||||
return FlashcardReviewOutcome.Conflict(current);
|
||||
|
||||
var restored = await _schedules.FindOneAndUpdateAsync(
|
||||
v => v.UserId == userId && v.Id == log.CardId && v.Revision == log.After.Revision,
|
||||
Builders<FlashcardSchedule>.Update
|
||||
.Set(v => v.State, log.Before.State)
|
||||
.Set(v => v.Step, log.Before.Step)
|
||||
.Set(v => v.Stability, log.Before.Stability)
|
||||
.Set(v => v.Difficulty, log.Before.Difficulty)
|
||||
.Set(v => v.DueUtc, log.Before.DueUtc)
|
||||
.Set(v => v.LastReviewUtc, log.Before.LastReviewUtc)
|
||||
.Inc(v => v.Revision, 1),
|
||||
new FindOneAndUpdateOptions<FlashcardSchedule> { ReturnDocument = ReturnDocument.After });
|
||||
|
||||
if (restored is null) return FlashcardReviewOutcome.Conflict(await GetAsync(userId, log.CardId));
|
||||
|
||||
await _reviewLogs.UpdateOneAsync(
|
||||
v => v.Id == reviewLogId,
|
||||
Builders<FlashcardReviewLog>.Update.Set(v => v.UndoneUtc, DateTime.UtcNow));
|
||||
return FlashcardReviewOutcome.Success(restored);
|
||||
}
|
||||
|
||||
public async Task<long> SetSuspendedAsync(string userId, IReadOnlyCollection<string> cardIds, bool suspended)
|
||||
{
|
||||
if (cardIds.Count == 0) return 0;
|
||||
var result = await _schedules.UpdateManyAsync(
|
||||
v => v.UserId == userId && cardIds.Contains(v.Id!),
|
||||
Builders<FlashcardSchedule>.Update.Set(v => v.IsSuspended, suspended).Inc(v => v.Revision, 1));
|
||||
return result.ModifiedCount;
|
||||
}
|
||||
|
||||
public async Task<long> BuryAsync(string userId, IReadOnlyCollection<string> cardIds, DateTime untilUtc)
|
||||
{
|
||||
if (cardIds.Count == 0) return 0;
|
||||
var result = await _schedules.UpdateManyAsync(
|
||||
v => v.UserId == userId && cardIds.Contains(v.Id!),
|
||||
Builders<FlashcardSchedule>.Update.Set(v => v.BuriedUntilUtc, untilUtc).Inc(v => v.Revision, 1));
|
||||
return result.ModifiedCount;
|
||||
}
|
||||
|
||||
private static FlashcardScheduleSnapshot Snapshot(FlashcardSchedule schedule) => new()
|
||||
{
|
||||
State = schedule.State,
|
||||
Step = schedule.Step,
|
||||
Stability = schedule.Stability,
|
||||
Difficulty = schedule.Difficulty,
|
||||
DueUtc = schedule.DueUtc,
|
||||
LastReviewUtc = schedule.LastReviewUtc,
|
||||
Revision = schedule.Revision
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using FSRS.Core.Enums;
|
||||
using FSRS.Core.Models;
|
||||
using FSRS.Core.Services;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services.Flashcards;
|
||||
|
||||
public sealed class FlashcardScheduleResult
|
||||
{
|
||||
public string State { get; init; } = FlashcardScheduleStates.Learning;
|
||||
public int? Step { get; init; }
|
||||
public double Stability { get; init; }
|
||||
public double Difficulty { get; init; }
|
||||
public DateTime DueUtc { get; init; }
|
||||
public DateTime LastReviewUtc { get; init; }
|
||||
}
|
||||
|
||||
public interface IFlashcardScheduler
|
||||
{
|
||||
FlashcardScheduleResult Review(FlashcardSchedule schedule, string rating, DateTime reviewedUtc, int durationSeconds);
|
||||
List<FlashcardRatingOption> PreviewRatingOptions(FlashcardSchedule schedule, DateTime nowUtc);
|
||||
}
|
||||
|
||||
// Wraps FSRS.Core 1.0.7 behind an internal interface, pinned to the repo's fixed defaults
|
||||
// (90% desired retention, 36,500 day max interval, no per-user parameter optimization).
|
||||
// Fuzzing is disabled so scheduling is deterministic: reproducible for tests/support, and
|
||||
// so a review's outcome depends only on stored state, never on wall-clock randomness.
|
||||
public sealed class FsrsFlashcardScheduler : IFlashcardScheduler
|
||||
{
|
||||
public const string AlgorithmVersion = "fsrs-core-1.0.7";
|
||||
public const double DesiredRetention = 0.90;
|
||||
public const int MaximumIntervalDays = 36_500;
|
||||
|
||||
private readonly Scheduler _scheduler = new(
|
||||
desiredRetention: DesiredRetention,
|
||||
maximumInterval: MaximumIntervalDays,
|
||||
enableFuzzing: false);
|
||||
|
||||
public FlashcardScheduleResult Review(FlashcardSchedule schedule, string rating, DateTime reviewedUtc, int durationSeconds)
|
||||
{
|
||||
var card = ToCard(schedule);
|
||||
var (updated, _) = _scheduler.ReviewCard(card, ParseRating(rating), reviewedUtc, Math.Max(0, durationSeconds) * 1000);
|
||||
return FromCard(updated);
|
||||
}
|
||||
|
||||
public List<FlashcardRatingOption> PreviewRatingOptions(FlashcardSchedule schedule, DateTime nowUtc)
|
||||
{
|
||||
var options = new List<FlashcardRatingOption>();
|
||||
foreach (var rating in FlashcardRatings.All)
|
||||
{
|
||||
var card = ToCard(schedule);
|
||||
var (updated, _) = _scheduler.ReviewCard(card, ParseRating(rating), nowUtc);
|
||||
options.Add(new FlashcardRatingOption
|
||||
{
|
||||
Rating = rating,
|
||||
DueUtc = updated.Due,
|
||||
IntervalLabel = FormatInterval(updated.Due - nowUtc)
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
private static Card ToCard(FlashcardSchedule schedule) =>
|
||||
schedule.LastReviewUtc is null
|
||||
? new Card()
|
||||
: new Card(
|
||||
state: ParseState(schedule.State),
|
||||
step: schedule.Step,
|
||||
stability: schedule.Stability,
|
||||
difficulty: schedule.Difficulty,
|
||||
due: schedule.DueUtc,
|
||||
lastReview: schedule.LastReviewUtc);
|
||||
|
||||
private static FlashcardScheduleResult FromCard(Card card) => new()
|
||||
{
|
||||
State = FormatState(card.State),
|
||||
Step = card.Step,
|
||||
Stability = card.Stability ?? 0,
|
||||
Difficulty = card.Difficulty ?? 0,
|
||||
DueUtc = card.Due,
|
||||
LastReviewUtc = card.LastReview ?? DateTime.UtcNow
|
||||
};
|
||||
|
||||
private static Rating ParseRating(string rating) => rating switch
|
||||
{
|
||||
FlashcardRatings.Again => Rating.Again,
|
||||
FlashcardRatings.Hard => Rating.Hard,
|
||||
FlashcardRatings.Good => Rating.Good,
|
||||
FlashcardRatings.Easy => Rating.Easy,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(rating), rating, "unknown flashcard rating")
|
||||
};
|
||||
|
||||
private static State ParseState(string state) => state switch
|
||||
{
|
||||
FlashcardScheduleStates.Review => State.Review,
|
||||
FlashcardScheduleStates.Relearning => State.Relearning,
|
||||
_ => State.Learning
|
||||
};
|
||||
|
||||
private static string FormatState(State state) => state switch
|
||||
{
|
||||
State.Review => FlashcardScheduleStates.Review,
|
||||
State.Relearning => FlashcardScheduleStates.Relearning,
|
||||
_ => FlashcardScheduleStates.Learning
|
||||
};
|
||||
|
||||
public static string FormatInterval(TimeSpan span)
|
||||
{
|
||||
if (span.TotalMinutes < 1) return "<1m";
|
||||
if (span.TotalHours < 1) return $"{(int)Math.Round(span.TotalMinutes)}m";
|
||||
if (span.TotalDays < 1) return $"{(int)Math.Round(span.TotalHours)}h";
|
||||
if (span.TotalDays < 30) return $"{(int)Math.Round(span.TotalDays)}d";
|
||||
if (span.TotalDays < 365) return $"{Math.Round(span.TotalDays / 30, 1)}mo";
|
||||
return $"{Math.Round(span.TotalDays / 365, 1)}y";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace QuestionsApi.Services.Flashcards;
|
||||
|
||||
// Small pure helpers pulled out of FlashcardStudyService/FlashcardScheduleStore so the
|
||||
// daily-limit and sibling-bury rules can be unit tested without a live Mongo instance.
|
||||
public static class FlashcardScheduling
|
||||
{
|
||||
// A card reviewed on a given local study day buries its siblings until the next local day.
|
||||
public static DateTime NextStudyDayStart(DateTime studyDayStartUtc) => studyDayStartUtc.Date.AddDays(1);
|
||||
|
||||
// Effective per-day budget: deck override wins over the global default, minus what's
|
||||
// already been consumed on the client's current local study day.
|
||||
public static int ResolveDailyBudget(int globalLimit, int? deckOverride, int consumedToday) =>
|
||||
Math.Max(0, (deckOverride ?? globalLimit) - consumedToday);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services.Flashcards;
|
||||
|
||||
public sealed class FlashcardSettingsStore
|
||||
{
|
||||
private readonly IMongoCollection<FlashcardSettings> _settings;
|
||||
|
||||
public FlashcardSettingsStore(MongoDatabaseProvider mongo)
|
||||
{
|
||||
_settings = mongo.Database.GetCollection<FlashcardSettings>("FlashcardSettings");
|
||||
_settings.Indexes.CreateOne(new CreateIndexModel<FlashcardSettings>(
|
||||
Builders<FlashcardSettings>.IndexKeys.Ascending(v => v.UserId),
|
||||
new CreateIndexOptions { Unique = true, Name = "flashcard_settings_unique_user" }));
|
||||
}
|
||||
|
||||
public async Task<FlashcardSettings> GetAsync(string userId) =>
|
||||
await _settings.Find(v => v.UserId == userId).FirstOrDefaultAsync()
|
||||
?? new FlashcardSettings { UserId = userId };
|
||||
|
||||
public async Task<FlashcardSettings> UpdateAsync(string userId, UpdateFlashcardSettingsRequest request)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
return await _settings.FindOneAndUpdateAsync(
|
||||
v => v.UserId == userId,
|
||||
Builders<FlashcardSettings>.Update
|
||||
.Set(v => v.NewCardsPerDay, Math.Clamp(request.NewCardsPerDay, 0, 2000))
|
||||
.Set(v => v.ReviewsPerDay, Math.Clamp(request.ReviewsPerDay, 0, 5000))
|
||||
.Set(v => v.DesiredRetention, FsrsFlashcardScheduler.DesiredRetention)
|
||||
.Set(v => v.UpdatedUtc, now)
|
||||
.Inc(v => v.Revision, 1)
|
||||
.SetOnInsert(v => v.UserId, userId),
|
||||
new FindOneAndUpdateOptions<FlashcardSettings> { IsUpsert = true, ReturnDocument = ReturnDocument.After });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services.Flashcards;
|
||||
|
||||
// Composes decks/notes/schedules/settings to answer "what's next" and daily-count questions.
|
||||
// Kept separate from the individual stores to avoid a circular dependency (notes need to
|
||||
// sync schedule variants; schedules must not need to know about notes/decks).
|
||||
public sealed class FlashcardStudyService(
|
||||
FlashcardDeckStore decks,
|
||||
FlashcardNoteStore notes,
|
||||
FlashcardScheduleStore schedules,
|
||||
FlashcardSettingsStore settingsStore)
|
||||
{
|
||||
public async Task<FlashcardStudyCardResponse> GetNextCardAsync(string userId, FlashcardStudyRequest request)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var studyDayStart = request.StudyDayStartUtc.Date;
|
||||
var noteIds = await ResolveNoteIdsAsync(userId, request.DeckId);
|
||||
var (newBudget, reviewBudget) = await ResolveBudgetsAsync(userId, request.DeckId, studyDayStart);
|
||||
var exclude = request.ExcludeCardIds.Where(v => !string.IsNullOrWhiteSpace(v)).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
// Overdue/due cards first (oldest due first), then new cards.
|
||||
FlashcardSchedule? card = null;
|
||||
if (reviewBudget > 0)
|
||||
{
|
||||
var due = await schedules.GetDueAsync(userId, noteIds, now, studyDayStart, reviewBudget + exclude.Count + 1);
|
||||
card = due.FirstOrDefault(v => !exclude.Contains(v.Id!));
|
||||
}
|
||||
if (card is null && newBudget > 0)
|
||||
{
|
||||
var fresh = await schedules.GetNewAsync(userId, noteIds, studyDayStart, newBudget + exclude.Count + 1);
|
||||
card = fresh.FirstOrDefault(v => !exclude.Contains(v.Id!));
|
||||
}
|
||||
|
||||
var dueRemaining = checked((int)await schedules.CountDueAsync(userId, noteIds, now, studyDayStart));
|
||||
var newRemaining = checked((int)await schedules.CountNewAsync(userId, noteIds, studyDayStart));
|
||||
var response = new FlashcardStudyCardResponse
|
||||
{
|
||||
DueRemaining = Math.Min(dueRemaining, reviewBudget),
|
||||
NewRemaining = Math.Min(newRemaining, newBudget)
|
||||
};
|
||||
if (card is null) return response;
|
||||
|
||||
var note = await notes.GetAsync(userId, card.NoteId);
|
||||
if (note is null) return response;
|
||||
|
||||
response.Note = note;
|
||||
response.Card = card;
|
||||
response.RatingOptions = schedules.PreviewRatingOptions(card, now);
|
||||
return response;
|
||||
}
|
||||
|
||||
public async Task<FlashcardDashboardCounts> GetDashboardCountsAsync(string userId, DateTime nowUtc, DateTime weekStartUtc)
|
||||
{
|
||||
var studyDayStart = nowUtc.Date;
|
||||
var noteIds = await ResolveNoteIdsAsync(userId, "");
|
||||
var (newBudget, _) = await ResolveBudgetsAsync(userId, "", studyDayStart);
|
||||
var due = checked((int)await schedules.CountDueAsync(userId, noteIds, nowUtc, studyDayStart));
|
||||
var newAvailable = Math.Min(checked((int)await schedules.CountNewAsync(userId, noteIds, studyDayStart)), newBudget);
|
||||
var reviewedThisWeek = checked((int)await schedules.CountReviewsSinceAsync(userId, weekStartUtc));
|
||||
return new FlashcardDashboardCounts { Due = due, NewAvailable = newAvailable, ReviewedThisWeek = reviewedThisWeek };
|
||||
}
|
||||
|
||||
public async Task<List<FlashcardDeckSummary>> GetDeckSummariesAsync(string userId, DateTime nowUtc, DateTime studyDayStart)
|
||||
{
|
||||
var deckList = await decks.ListAsync(userId);
|
||||
var result = new List<FlashcardDeckSummary>();
|
||||
foreach (var deck in deckList)
|
||||
{
|
||||
var deckNoteIds = (await notes.ListByDeckAsync(userId, deck.Id!)).Select(v => v.Id!).ToList();
|
||||
var due = checked((int)await schedules.CountDueAsync(userId, deckNoteIds, nowUtc, studyDayStart));
|
||||
var fresh = checked((int)await schedules.CountNewAsync(userId, deckNoteIds, studyDayStart));
|
||||
result.Add(new FlashcardDeckSummary
|
||||
{
|
||||
Deck = deck,
|
||||
Total = deckNoteIds.Count,
|
||||
Due = due,
|
||||
New = fresh
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<List<string>> ResolveNoteIdsAsync(string userId, string deckId)
|
||||
{
|
||||
var activeNotes = string.IsNullOrWhiteSpace(deckId)
|
||||
? await notes.ListAllActiveAsync(userId)
|
||||
: await notes.ListByDeckAsync(userId, deckId);
|
||||
return activeNotes.Select(v => v.Id!).ToList();
|
||||
}
|
||||
|
||||
private async Task<(int NewBudget, int ReviewBudget)> ResolveBudgetsAsync(string userId, string deckId, DateTime studyDayStart)
|
||||
{
|
||||
var settings = await settingsStore.GetAsync(userId);
|
||||
var newLimit = settings.NewCardsPerDay;
|
||||
var reviewLimit = settings.ReviewsPerDay;
|
||||
if (!string.IsNullOrWhiteSpace(deckId))
|
||||
{
|
||||
var deck = await decks.GetAsync(userId, deckId);
|
||||
if (deck is not null)
|
||||
{
|
||||
newLimit = deck.NewCardsPerDay ?? newLimit;
|
||||
reviewLimit = deck.ReviewsPerDay ?? reviewLimit;
|
||||
}
|
||||
}
|
||||
var reviewedToday = checked((int)await schedules.CountReviewsSinceAsync(userId, studyDayStart));
|
||||
var newIntroducedToday = await schedules.CountNewIntroducedSinceAsync(userId, studyDayStart);
|
||||
return (
|
||||
FlashcardScheduling.ResolveDailyBudget(newLimit, null, newIntroducedToday),
|
||||
FlashcardScheduling.ResolveDailyBudget(reviewLimit, null, reviewedToday));
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,9 @@ public sealed class UserProfileStore
|
||||
"ReviewQueueItems", "QuestionReviewAttempts", "ReviewQueueBackfillStates",
|
||||
"CcsAttempts", "CcsUserRatings", "CcsRatedCaseResults", "ExamSimulations",
|
||||
"Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles",
|
||||
"ExternalIdentities", "ExternalAuthTransactions"
|
||||
"ExternalIdentities", "ExternalAuthTransactions",
|
||||
"FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs",
|
||||
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews"
|
||||
];
|
||||
|
||||
public UserProfileStore(MongoDatabaseProvider mongo)
|
||||
|
||||
Reference in New Issue
Block a user