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,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";
|
||||
}
|
||||
Reference in New Issue
Block a user