diff --git a/api/QuestionsApi.Tests/FlashcardOwnershipTests.cs b/api/QuestionsApi.Tests/FlashcardOwnershipTests.cs new file mode 100644 index 0000000..0d67cac --- /dev/null +++ b/api/QuestionsApi.Tests/FlashcardOwnershipTests.cs @@ -0,0 +1,32 @@ +using QuestionsApi.Models; +using QuestionsApi.Services.Flashcards; +using Xunit; + +namespace QuestionsApi.Tests; + +public sealed class FlashcardOwnershipTests +{ + [Theory] + [InlineData(false, false, true)] + [InlineData(false, true, false)] + [InlineData(true, false, true)] + [InlineData(true, true, true)] + public void CanMutate_MatchesTruthTable(bool isSuper, bool targetIsSystem, bool expected) + { + Assert.Equal(expected, FlashcardOwnershipGuard.CanMutate(isSuper, targetIsSystem)); + } + + [Fact] + public void FlashcardDeck_IsSystem_TrueOnlyForTheReservedOwner() + { + Assert.True(new FlashcardDeck { UserId = FlashcardOwners.System }.IsSystem); + Assert.False(new FlashcardDeck { UserId = "a-real-user-id" }.IsSystem); + } + + [Fact] + public void FlashcardNote_IsSystem_TrueOnlyForTheReservedOwner() + { + Assert.True(new FlashcardNote { UserId = FlashcardOwners.System }.IsSystem); + Assert.False(new FlashcardNote { UserId = "a-real-user-id" }.IsSystem); + } +} diff --git a/api/QuestionsApi/Controllers/FlashcardsController.cs b/api/QuestionsApi/Controllers/FlashcardsController.cs index c944f5d..4670864 100644 --- a/api/QuestionsApi/Controllers/FlashcardsController.cs +++ b/api/QuestionsApi/Controllers/FlashcardsController.cs @@ -47,6 +47,8 @@ public sealed class FlashcardsController( var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest("name required"); + if (!FlashcardOwnershipGuard.CanMutate(User.IsInRole("SUPER"), (await decks.GetAnyAsync(deckId))?.IsSystem == true)) + return NotFound(); var result = await decks.UpsertAsync(userId, request, deckId); if (!result.Succeeded) return Conflict(result.Current); return Ok(result.Deck); @@ -57,6 +59,8 @@ public sealed class FlashcardsController( { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + if (!FlashcardOwnershipGuard.CanMutate(User.IsInRole("SUPER"), (await decks.GetAnyAsync(deckId))?.IsSystem == true)) + return NotFound(); 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); @@ -67,6 +71,8 @@ public sealed class FlashcardsController( { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + if (!FlashcardOwnershipGuard.CanMutate(User.IsInRole("SUPER"), (await decks.GetAnyAsync(deckId))?.IsSystem == true)) + return NotFound(); var result = await decks.ArchiveAsync(userId, deckId, false); if (!result.Succeeded) return NotFound(); return Ok(result.Deck); @@ -77,7 +83,7 @@ public sealed class FlashcardsController( { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); - var (items, total) = await notes.SearchAsync(userId, query); + var (items, total) = await notes.SearchVisibleAsync(userId, query); return Ok(new FlashcardLibraryResponse { Items = items, Total = total }); } @@ -86,7 +92,7 @@ public sealed class FlashcardsController( { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); - var note = await notes.GetAsync(userId, noteId); + var note = await notes.GetVisibleAsync(userId, noteId); if (note is null) return NotFound(); return Ok(note); } @@ -99,6 +105,8 @@ public sealed class FlashcardsController( if (string.IsNullOrWhiteSpace(request.Prompt)) return BadRequest("prompt required"); if (string.IsNullOrWhiteSpace(request.DeckId)) request.DeckId = (await decks.GetOrCreateDefaultDeckAsync(userId)).Id!; + else if (!FlashcardOwnershipGuard.CanMutate(User.IsInRole("SUPER"), (await decks.GetAnyAsync(request.DeckId))?.IsSystem == true)) + return BadRequest("cannot add cards to a system deck"); try { @@ -118,6 +126,12 @@ public sealed class FlashcardsController( var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); if (string.IsNullOrWhiteSpace(request.Prompt)) return BadRequest("prompt required"); + var isSuper = User.IsInRole("SUPER"); + if (!FlashcardOwnershipGuard.CanMutate(isSuper, (await notes.GetAnyAsync(noteId))?.IsSystem == true)) + return NotFound(); + if (!string.IsNullOrWhiteSpace(request.DeckId) + && !FlashcardOwnershipGuard.CanMutate(isSuper, (await decks.GetAnyAsync(request.DeckId))?.IsSystem == true)) + return BadRequest("cannot move cards into a system deck"); try { @@ -137,6 +151,8 @@ public sealed class FlashcardsController( var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); if (string.IsNullOrWhiteSpace(request.TargetDeckId)) return BadRequest("targetDeckId required"); + if (!FlashcardOwnershipGuard.CanMutate(User.IsInRole("SUPER"), (await decks.GetAnyAsync(request.TargetDeckId))?.IsSystem == true)) + return BadRequest("cannot move cards into a system deck"); var moved = await notes.MoveAsync(userId, request.NoteIds, request.TargetDeckId); return Ok(new { moved }); } @@ -146,9 +162,11 @@ public sealed class FlashcardsController( { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + var isSuper = User.IsInRole("SUPER"); var results = new List(); foreach (var noteId in request.NoteIds) { + if (!FlashcardOwnershipGuard.CanMutate(isSuper, (await notes.GetAnyAsync(noteId))?.IsSystem == true)) continue; var result = await notes.ArchiveAsync(userId, noteId, true); if (result.Note is not null) results.Add(result.Note); } @@ -160,9 +178,11 @@ public sealed class FlashcardsController( { var userId = UserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + var isSuper = User.IsInRole("SUPER"); var results = new List(); foreach (var noteId in request.NoteIds) { + if (!FlashcardOwnershipGuard.CanMutate(isSuper, (await notes.GetAnyAsync(noteId))?.IsSystem == true)) continue; var result = await notes.ArchiveAsync(userId, noteId, false); if (result.Note is not null) results.Add(result.Note); } diff --git a/api/QuestionsApi/Models/FlashcardModels.cs b/api/QuestionsApi/Models/FlashcardModels.cs index 6531d97..17ad08f 100644 --- a/api/QuestionsApi/Models/FlashcardModels.cs +++ b/api/QuestionsApi/Models/FlashcardModels.cs @@ -10,6 +10,14 @@ public static class FlashcardTypes public static readonly HashSet All = [Basic, Cloze]; } +// A reserved, never-authenticatable owner id. Decks/notes owned by this id are shared, +// admin-curated content visible to every user (studied via their own independent schedule +// rows) rather than a real user's private content. +public static class FlashcardOwners +{ + public const string System = "system"; +} + public static class FlashcardRatings { public const string Again = "again"; @@ -43,6 +51,7 @@ public sealed class FlashcardDeck public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; public DateTime? ArchivedUtc { get; set; } + [BsonIgnore] public bool IsSystem => UserId == FlashcardOwners.System; } public sealed class FlashcardImageReference @@ -69,6 +78,7 @@ public sealed class FlashcardNote public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; public DateTime? ArchivedUtc { get; set; } + [BsonIgnore] public bool IsSystem => UserId == FlashcardOwners.System; } public sealed class FlashcardSchedule diff --git a/api/QuestionsApi/Program.cs b/api/QuestionsApi/Program.cs index c5b71e0..b62f74f 100644 --- a/api/QuestionsApi/Program.cs +++ b/api/QuestionsApi/Program.cs @@ -17,6 +17,16 @@ if (args.FirstOrDefault() == "promote-content") return; } +if (args.FirstOrDefault() == "seed-flashcards") +{ + var seedConfiguration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json", optional: true) + .AddEnvironmentVariables() + .Build(); + Environment.ExitCode = await SeedFlashcardsCommand.RunAsync(seedConfiguration, Console.Out); + return; +} + var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.Configure(options => diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardDeckStore.cs b/api/QuestionsApi/Services/Flashcards/FlashcardDeckStore.cs index 4141c92..0dfe78a 100644 --- a/api/QuestionsApi/Services/Flashcards/FlashcardDeckStore.cs +++ b/api/QuestionsApi/Services/Flashcards/FlashcardDeckStore.cs @@ -33,6 +33,21 @@ public sealed class FlashcardDeckStore public async Task GetAsync(string userId, string deckId) => await _decks.Find(v => v.UserId == userId && v.Id == deckId).FirstOrDefaultAsync(); + // Read-only: the caller's own decks plus every system (shared, admin-curated) deck. Never + // used to back a write path - mutations must stay strictly own-user via ListAsync/GetAsync. + public Task> ListVisibleAsync(string userId) => + _decks.Find(v => (v.UserId == userId || v.UserId == FlashcardOwners.System) && v.ArchivedUtc == null) + .SortBy(v => v.Name).ToListAsync(); + + public async Task GetVisibleAsync(string userId, string deckId) => + await _decks.Find(v => (v.UserId == userId || v.UserId == FlashcardOwners.System) && v.Id == deckId) + .FirstOrDefaultAsync(); + + // No owner filter at all - used only by the controller's mutation guard to check IsSystem + // regardless of who's asking. + public async Task GetAnyAsync(string deckId) => + await _decks.Find(v => v.Id == deckId).FirstOrDefaultAsync(); + public async Task GetOrCreateDefaultDeckAsync(string userId) { var existing = await _decks.Find(v => v.UserId == userId && v.IsDefault).FirstOrDefaultAsync(); diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardNoteStore.cs b/api/QuestionsApi/Services/Flashcards/FlashcardNoteStore.cs index 2a15dc3..5c02d07 100644 --- a/api/QuestionsApi/Services/Flashcards/FlashcardNoteStore.cs +++ b/api/QuestionsApi/Services/Flashcards/FlashcardNoteStore.cs @@ -51,6 +51,58 @@ public sealed class FlashcardNoteStore public Task> ListAllActiveAsync(string userId) => _notes.Find(v => v.UserId == userId && v.ArchivedUtc == null).ToListAsync(); + // Read-only: the caller's own notes plus every system (shared, admin-curated) note. Never + // used to back a write path - mutations must stay strictly own-user via the methods above. + public Task> ListByDeckVisibleAsync(string userId, string deckId) => + _notes.Find(v => (v.UserId == userId || v.UserId == FlashcardOwners.System) + && v.DeckId == deckId && v.ArchivedUtc == null).ToListAsync(); + + public Task> ListAllVisibleActiveAsync(string userId) => + _notes.Find(v => (v.UserId == userId || v.UserId == FlashcardOwners.System) && v.ArchivedUtc == null) + .ToListAsync(); + + public async Task GetVisibleAsync(string userId, string noteId) => + await _notes.Find(v => (v.UserId == userId || v.UserId == FlashcardOwners.System) && v.Id == noteId) + .FirstOrDefaultAsync(); + + // No owner filter at all - used only by the controller's mutation guard to check IsSystem + // regardless of who's asking. + public async Task GetAnyAsync(string noteId) => + await _notes.Find(v => v.Id == noteId).FirstOrDefaultAsync(); + + public async Task<(List Items, int Total)> SearchVisibleAsync(string userId, FlashcardLibraryQuery query) + { + var filter = Builders.Filter.Or( + Builders.Filter.Eq(v => v.UserId, userId), + Builders.Filter.Eq(v => v.UserId, FlashcardOwners.System)); + if (query.State != FlashcardArchiveFilter.IncludeArchived) + filter &= Builders.Filter.Eq(v => v.ArchivedUtc, null); + if (!string.IsNullOrWhiteSpace(query.DeckId)) + filter &= Builders.Filter.Eq(v => v.DeckId, query.DeckId); + if (!string.IsNullOrWhiteSpace(query.Type)) + filter &= Builders.Filter.Eq(v => v.Type, query.Type); + if (!string.IsNullOrWhiteSpace(query.Tag)) + filter &= Builders.Filter.AnyEq(v => v.Tags, query.Tag); + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var pattern = new MongoDB.Bson.BsonRegularExpression(Regex.Escape(query.Search), "i"); + filter &= Builders.Filter.Or( + Builders.Filter.Regex(v => v.Prompt, pattern), + Builders.Filter.Regex(v => v.Answer, pattern), + Builders.Filter.Regex(v => v.Extra, pattern)); + } + + var total = checked((int)await _notes.CountDocumentsAsync(filter)); + var page = Math.Max(1, query.Page); + var pageSize = Math.Clamp(query.PageSize, 1, 200); + var items = await _notes.Find(filter) + .SortByDescending(v => v.UpdatedUtc) + .Skip((page - 1) * pageSize) + .Limit(pageSize) + .ToListAsync(); + return (items, total); + } + public async Task<(List Items, int Total)> SearchAsync(string userId, FlashcardLibraryQuery query) { var filter = Builders.Filter.Eq(v => v.UserId, userId); diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardOwnershipGuard.cs b/api/QuestionsApi/Services/Flashcards/FlashcardOwnershipGuard.cs new file mode 100644 index 0000000..4ecafcd --- /dev/null +++ b/api/QuestionsApi/Services/Flashcards/FlashcardOwnershipGuard.cs @@ -0,0 +1,8 @@ +namespace QuestionsApi.Services.Flashcards; + +// Pure authorization decision for mutating a flashcard deck/note, kept dependency-free (like +// FlashcardScheduling) so it's unit testable without a controller or Mongo. +public static class FlashcardOwnershipGuard +{ + public static bool CanMutate(bool isSuper, bool targetIsSystem) => isSuper || !targetIsSystem; +} diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardScheduleStore.cs b/api/QuestionsApi/Services/Flashcards/FlashcardScheduleStore.cs index 038e881..32e7e8c 100644 --- a/api/QuestionsApi/Services/Flashcards/FlashcardScheduleStore.cs +++ b/api/QuestionsApi/Services/Flashcards/FlashcardScheduleStore.cs @@ -81,6 +81,44 @@ public sealed class FlashcardScheduleStore } } + // Sibling of SyncVariantsAsync: materializes this note's variant schedule rows under a + // DIFFERENT user's ownership (the studying user, not the note's own owner). Used only for + // system notes, so a regular user studying shared content gets independent, per-user FSRS + // state. Deliberately duplicated rather than sharing a parameterized private helper with + // SyncVariantsAsync, which is called eagerly on every regular note save and must stay + // untouched/low-risk. + public async Task SyncVariantsForStudyingUserAsync(string studyingUserId, FlashcardNote note) + { + var desiredVariants = note.Type == FlashcardTypes.Cloze + ? FlashcardClozeParser.DistinctClozeNumbers(note.Prompt) + : [0]; + if (desiredVariants.Count == 0) return; + + var existing = await GetByNoteIdAsync(studyingUserId, 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 = studyingUserId, + NoteId = note.Id!, + Variant = variant, + State = FlashcardScheduleStates.New, + DueUtc = now, + CreatedUtc = now + }; + try + { + await _schedules.InsertOneAsync(schedule); + } + catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + // Concurrent save already created this variant. + } + } + } + public async Task GetAsync(string userId, string cardId) => await _schedules.Find(v => v.UserId == userId && v.Id == cardId).FirstOrDefaultAsync(); diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardStudyService.cs b/api/QuestionsApi/Services/Flashcards/FlashcardStudyService.cs index dfd4fbb..4ccdffe 100644 --- a/api/QuestionsApi/Services/Flashcards/FlashcardStudyService.cs +++ b/api/QuestionsApi/Services/Flashcards/FlashcardStudyService.cs @@ -51,7 +51,7 @@ public sealed class FlashcardStudyService( }; if (card is null) return response; - var note = await notes.GetAsync(userId, card.NoteId); + var note = await notes.GetVisibleAsync(userId, card.NoteId); if (note is null) return response; response.Note = note; @@ -89,11 +89,13 @@ public sealed class FlashcardStudyService( public async Task> GetDeckSummariesAsync(string userId, DateTime nowUtc, DateTime studyDayStart) { - var deckList = await decks.ListAsync(userId); + var deckList = await decks.ListVisibleAsync(userId); var result = new List(); foreach (var deck in deckList) { - var deckNoteIds = (await notes.ListByDeckAsync(userId, deck.Id!)).Select(v => v.Id!).ToList(); + var deckNotes = await notes.ListByDeckVisibleAsync(userId, deck.Id!); + await EnsureSystemScheduleRowsAsync(userId, deckNotes); + var deckNoteIds = deckNotes.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)); var learning = checked((int)await schedules.CountByStateAsync(userId, deckNoteIds, FlashcardScheduleStates.Learning)) @@ -118,11 +120,21 @@ public sealed class FlashcardStudyService( private async Task> ResolveNoteIdsAsync(string userId, string deckId) { var activeNotes = string.IsNullOrWhiteSpace(deckId) - ? await notes.ListAllActiveAsync(userId) - : await notes.ListByDeckAsync(userId, deckId); + ? await notes.ListAllVisibleActiveAsync(userId) + : await notes.ListByDeckVisibleAsync(userId, deckId); + await EnsureSystemScheduleRowsAsync(userId, activeNotes); return activeNotes.Select(v => v.Id!).ToList(); } + // Lazily materializes this user's own FlashcardSchedule rows for any note they don't own + // (i.e. system notes) before it's counted/served. A no-op once a user has touched a given + // system note (SyncVariantsForStudyingUserAsync is duplicate-key-tolerant and additive-only). + private async Task EnsureSystemScheduleRowsAsync(string userId, List activeNotes) + { + foreach (var note in activeNotes.Where(v => v.IsSystem)) + await schedules.SyncVariantsForStudyingUserAsync(userId, note); + } + private async Task<(int NewBudget, int ReviewBudget)> ResolveBudgetsAsync(string userId, string deckId, DateTime studyDayStart) { var settings = await settingsStore.GetAsync(userId); @@ -130,7 +142,7 @@ public sealed class FlashcardStudyService( var reviewLimit = settings.ReviewsPerDay; if (!string.IsNullOrWhiteSpace(deckId)) { - var deck = await decks.GetAsync(userId, deckId); + var deck = await decks.GetVisibleAsync(userId, deckId); if (deck is not null) { newLimit = deck.NewCardsPerDay ?? newLimit; diff --git a/api/QuestionsApi/Services/SeedFlashcardsCommand.cs b/api/QuestionsApi/Services/SeedFlashcardsCommand.cs new file mode 100644 index 0000000..89123ba --- /dev/null +++ b/api/QuestionsApi/Services/SeedFlashcardsCommand.cs @@ -0,0 +1,62 @@ +using QuestionsApi.Models; +using QuestionsApi.Services.Flashcards; + +namespace QuestionsApi.Services; + +// Idempotent, single-target seed for testing "system" (shared, admin-curated) flashcards. +// Unlike ContentPromotionCommand (cross-environment copy, source+target connections), this only +// ever writes to the one database the app is already configured against, using the exact same +// store methods a real user's create/edit path uses - just under the reserved FlashcardOwners.System +// identity - so seeded content is created/updated through the same validation and revision +// handling as any other deck/note. +public static class SeedFlashcardsCommand +{ + public static async Task RunAsync(IConfiguration configuration, TextWriter output) + { + var mongo = new MongoDatabaseProvider(configuration); + var scheduleStore = new FlashcardScheduleStore(mongo, new FsrsFlashcardScheduler()); + var deckStore = new FlashcardDeckStore(mongo); + var noteStore = new FlashcardNoteStore(mongo, scheduleStore); + + var deck = await UpsertSystemDeckAsync(deckStore, "Step 3 Essentials", "Official curated review deck"); + await UpsertSystemNoteAsync(noteStore, deck.Id!, FlashcardTypes.Basic, + "A 62-year-old woman presents with sudden, painless, monocular vision loss. What is the diagnosis?", + "Central retinal artery occlusion", "\"Cherry-red spot\" on fundoscopy is the classic finding."); + await UpsertSystemNoteAsync(noteStore, deck.Id!, FlashcardTypes.Basic, + "Thyroid storm: correct order of pharmacologic treatment?", + "Beta-blocker (propranolol) first -> thionamide (PTU/methimazole) -> iodine solution (started >=1hr after thionamide) -> glucocorticoids", + "Giving iodine before the thionamide can fuel new hormone synthesis (Wolff-Chaikoff escape risk)."); + await UpsertSystemNoteAsync(noteStore, deck.Id!, FlashcardTypes.Cloze, + "First-line treatment for uncomplicated hypertension in a Black patient is {{c1::a thiazide diuretic or calcium channel blocker}}.", + "", ""); + + await output.WriteLineAsync($"Seeded system flashcards into deck '{deck.Name}' ({deck.Id})."); + return 0; + } + + private static async Task UpsertSystemDeckAsync(FlashcardDeckStore deckStore, string name, string description) + { + var existing = await deckStore.GetByNameAsync(FlashcardOwners.System, FlashcardDeckStore.Normalize(name)); + var request = new UpsertFlashcardDeckRequest { Name = name, Description = description }; + var result = await deckStore.UpsertAsync(FlashcardOwners.System, request, existing?.Id); + return result.Deck ?? existing ?? throw new InvalidOperationException($"failed to seed deck '{name}'"); + } + + private static async Task UpsertSystemNoteAsync( + FlashcardNoteStore noteStore, string deckId, string type, string prompt, string answer, string extra) + { + var fingerprint = FlashcardNoteStore.Fingerprint(type, prompt, answer); + var existingNotes = await noteStore.ListByDeckAsync(FlashcardOwners.System, deckId); + var existing = existingNotes.FirstOrDefault(v => v.ContentFingerprint == fingerprint); + var request = new UpsertFlashcardNoteRequest + { + DeckId = deckId, + Type = type, + Prompt = prompt, + Answer = answer, + Extra = extra, + ExpectedRevision = existing?.Revision + }; + await noteStore.UpsertAsync(FlashcardOwners.System, request, existing?.Id); + } +} diff --git a/scripts/ui/flashcards_hub.gd b/scripts/ui/flashcards_hub.gd index 3144102..b018da9 100644 --- a/scripts/ui/flashcards_hub.gd +++ b/scripts/ui/flashcards_hub.gd @@ -31,6 +31,7 @@ var editor_overlay: Control var editor_panel: PanelContainer var editor_deck_label: Label var editor_note_id := "" +var editor_target_deck_id := "" var editor_expected_revision: Variant = null var editor_type_selector: OptionButton var editor_front_input: TextEdit @@ -333,10 +334,23 @@ func _on_decks_changed(loaded_decks: Array) -> void: deck_ids = [""] var total_due := 0 var total_new := 0 + var my_decks := decks.filter(func(s): return not bool(s.get("deck", {}).get("isSystem", false))) + var official_decks := decks.filter(func(s): return bool(s.get("deck", {}).get("isSystem", false))) + if my_decks.size() > 0: + deck_selector.add_separator("My Decks") + deck_ids.append("") + for summary in my_decks: + var deck: Dictionary = summary.get("deck", {}) + deck_selector.add_item(str(deck.get("name", "Deck"))) + deck_ids.append(str(deck.get("id", ""))) + if official_decks.size() > 0: + deck_selector.add_separator("Official Decks") + deck_ids.append("") + for summary in official_decks: + var deck: Dictionary = summary.get("deck", {}) + deck_selector.add_item(str(deck.get("name", "Deck"))) + deck_ids.append(str(deck.get("id", ""))) for summary in decks: - var deck: Dictionary = summary.get("deck", {}) - deck_selector.add_item(str(deck.get("name", "Deck"))) - deck_ids.append(str(deck.get("id", ""))) total_due += int(summary.get("due", 0)) total_new += int(summary.get("new", 0)) if previous_selection != null: @@ -355,6 +369,16 @@ func _selected_deck_id() -> String: return str(deck_ids[deck_selector.selected]) +func _is_system_deck(id: String) -> bool: + if id.is_empty(): + return false + for summary in decks: + var deck: Dictionary = summary.get("deck", {}) + if str(deck.get("id", "")) == id: + return bool(deck.get("isSystem", false)) + return false + + func _reload_notes() -> void: status_label.text = "Loading flashcards..." var query := { @@ -426,6 +450,13 @@ func _note_row(note: Dictionary) -> Control: type_label.add_theme_font_size_override("font_size", 12) type_label.add_theme_color_override("font_color", accent) header_row.add_child(type_label) + var is_system := bool(note.get("isSystem", false)) + if is_system: + var official_label := Label.new() + official_label.text = "Official" + official_label.add_theme_font_size_override("font_size", 12) + official_label.add_theme_color_override("font_color", Brand.PURPLE) + header_row.add_child(official_label) var tags: Array = note.get("tags", []) if tags.size() > 0: var tags_label := Label.new() @@ -446,25 +477,26 @@ func _note_row(note: Dictionary) -> Control: actions.add_theme_constant_override("separation", 8) row.add_child(actions) - var edit_button := Button.new() - edit_button.text = "Edit" - edit_button.custom_minimum_size = Vector2(70, 40) - edit_button.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark)) - edit_button.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true)) - edit_button.pressed.connect(_open_editor.bind(str(note.get("id", "")), note)) - actions.add_child(edit_button) + if not is_system: + var edit_button := Button.new() + edit_button.text = "Edit" + edit_button.custom_minimum_size = Vector2(70, 40) + edit_button.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark)) + edit_button.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true)) + edit_button.pressed.connect(_open_editor.bind(str(note.get("id", "")), note)) + actions.add_child(edit_button) - var archive_button := Button.new() - archive_button.text = "Archive" - archive_button.custom_minimum_size = Vector2(85, 40) - archive_button.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark)) - archive_button.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true)) - archive_button.add_theme_color_override("font_color", Color("ff5f76") if dark else Color("be123c")) - archive_button.pressed.connect(func(): - FlashcardService.archive_notes([str(note.get("id", ""))], true) - await get_tree().create_timer(0.3).timeout - _reload_notes()) - actions.add_child(archive_button) + var archive_button := Button.new() + archive_button.text = "Archive" + archive_button.custom_minimum_size = Vector2(85, 40) + archive_button.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark)) + archive_button.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true)) + archive_button.add_theme_color_override("font_color", Color("ff5f76") if dark else Color("be123c")) + archive_button.pressed.connect(func(): + FlashcardService.archive_notes([str(note.get("id", ""))], true) + await get_tree().create_timer(0.3).timeout + _reload_notes()) + actions.add_child(archive_button) return row @@ -594,7 +626,11 @@ func _open_editor(note_id: String, note: Dictionary) -> void: editor_extra_input.text = str(note.get("extra", "")) editor_tags_input.text = ", ".join(PackedStringArray(note.get("tags", []))) editor_error_label.visible = false - editor_deck_label.text = "Saving to: %s" % _deck_name_for_id(str(note.get("deckId", "")) if not note_id.is_empty() else _selected_deck_id()) + if note_id.is_empty(): + editor_target_deck_id = "" if _is_system_deck(_selected_deck_id()) else _selected_deck_id() + else: + editor_target_deck_id = str(note.get("deckId", "")) + editor_deck_label.text = "Saving to: %s" % _deck_name_for_id(editor_target_deck_id) _on_editor_type_changed(editor_type_selector.selected) editor_overlay.visible = true @@ -629,7 +665,7 @@ func _save_editor() -> void: if not trimmed.is_empty(): tags.append(trimmed) var payload := { - "deckId": _selected_deck_id(), + "deckId": editor_target_deck_id, "stableId": "", "type": "cloze" if is_cloze else "basic", "prompt": prompt, diff --git a/scripts/ui/flashcards_study.gd b/scripts/ui/flashcards_study.gd index b2aa911..74601ef 100644 --- a/scripts/ui/flashcards_study.gd +++ b/scripts/ui/flashcards_study.gd @@ -72,6 +72,7 @@ var compact_layout := false var deck_panel: PanelContainer var deck_name_label: Label +var deck_system_badge: Label var deck_ring: Control var deck_mastered_label: Label var change_deck_button: Button @@ -371,6 +372,13 @@ func _build_deck_panel() -> void: deck_name_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART content.add_child(deck_name_label) + deck_system_badge = Label.new() + deck_system_badge.text = "Official Deck" + deck_system_badge.add_theme_font_size_override("font_size", 12) + deck_system_badge.add_theme_color_override("font_color", Brand.PURPLE) + deck_system_badge.visible = false + content.add_child(deck_system_badge) + var ring_center := CenterContainer.new() content.add_child(ring_center) deck_ring = CircularProgress.new() @@ -597,6 +605,7 @@ func _refresh_deck_panel() -> void: var name := "All decks" var total := 0 var mastered := 0 + var is_system := false if deck_id.is_empty(): for summary in decks: total += int(summary.get("total", 0)) @@ -608,8 +617,10 @@ func _refresh_deck_panel() -> void: name = str(deck.get("name", "Deck")) total = int(summary.get("total", 0)) mastered = int(summary.get("mastered", 0)) + is_system = bool(deck.get("isSystem", false)) break deck_name_label.text = name + deck_system_badge.visible = is_system var fraction := float(mastered) / float(total) if total > 0 else 0.0 deck_ring.configure(fraction, "%d%%" % roundi(fraction * 100.0), "Mastery", Brand.ROYAL_BLUE, dark) deck_mastered_label.text = "%d / %d cards mastered" % [mastered, total] diff --git a/tests/flashcard_ownership_ui_test.gd b/tests/flashcard_ownership_ui_test.gd new file mode 100644 index 0000000..54fb50c --- /dev/null +++ b/tests/flashcard_ownership_ui_test.gd @@ -0,0 +1,107 @@ +extends SceneTree + +# Exercises the client-side "system vs. user" flashcard split added to the hub screen: deck +# summaries splitting into My/Official groups in the deck selector, Edit/Archive controls being +# absent on system-owned note rows, and "New Card" refusing to target a system deck. Mirrors +# flashcard_theme_responsive_test.gd's instantiation pattern (added to the tree so _ready() runs). + +var failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var host := Control.new() + host.size = Vector2(1280, 800) + root.add_child(host) + + var Hub := load("res://scripts/ui/flashcards_hub.gd") + var hub = Hub.new() + hub.size = host.size + host.add_child(hub) + await process_frame + + # ---------- Deck selector splits into "My Decks" / "Official Decks" ---------- + var summaries := [ + {"deck": {"id": "deck-mine", "name": "My Cardio Deck", "isSystem": false}, "total": 5, "mastered": 0, "due": 1, "new": 2}, + {"deck": {"id": "deck-system", "name": "Step 3 Essentials", "isSystem": true}, "total": 10, "mastered": 3, "due": 0, "new": 4}, + ] + hub._on_decks_changed(summaries) + _assert(hub.deck_ids.size() == hub.deck_selector.item_count, + "deck_ids should stay index-aligned with the OptionButton's items (including separators)") + + var my_header_index := -1 + var official_header_index := -1 + var mine_item_index := -1 + var system_item_index := -1 + for i in hub.deck_selector.item_count: + if hub.deck_selector.is_item_separator(i): + if hub.deck_selector.get_item_text(i) == "My Decks": + my_header_index = i + elif hub.deck_selector.get_item_text(i) == "Official Decks": + official_header_index = i + elif hub.deck_selector.get_item_text(i) == "My Cardio Deck": + mine_item_index = i + elif hub.deck_selector.get_item_text(i) == "Step 3 Essentials": + system_item_index = i + + _assert(my_header_index >= 0, "a 'My Decks' separator header should be present") + _assert(official_header_index >= 0, "an 'Official Decks' separator header should be present") + _assert(mine_item_index > my_header_index, "the user's own deck should be listed under the 'My Decks' header") + _assert(system_item_index > official_header_index, "the system deck should be listed under the 'Official Decks' header") + _assert(str(hub.deck_ids[mine_item_index]) == "deck-mine", "deck_ids should still resolve the correct id for the user's own deck") + _assert(str(hub.deck_ids[system_item_index]) == "deck-system", "deck_ids should still resolve the correct id for the system deck") + _assert(hub._is_system_deck("deck-system"), "_is_system_deck should report true for the seeded system deck") + _assert(not hub._is_system_deck("deck-mine"), "_is_system_deck should report false for the user's own deck") + + # ---------- System note rows have no Edit/Archive controls, plus an "Official" badge ---------- + hub.notes = [ + {"id": "note-mine", "type": "basic", "prompt": "Mine", "answer": "A", "tags": [], "isSystem": false}, + {"id": "note-system", "type": "basic", "prompt": "System", "answer": "A", "tags": [], "isSystem": true}, + ] + hub._render_notes() + await process_frame + + var mine_row := hub.list_container.get_child(0) as BoxContainer + var mine_actions := mine_row.get_child(2) as HBoxContainer + _assert(mine_actions.get_child_count() == 2, "the user's own note row should show both Edit and Archive") + + var system_row := hub.list_container.get_child(2) as BoxContainer + var system_actions := system_row.get_child(2) as HBoxContainer + _assert(system_actions.get_child_count() == 0, "a system note row should show no Edit/Archive controls at all") + + var system_header_row := (system_row.get_child(1) as VBoxContainer).get_child(0) as HBoxContainer + var found_official_badge := false + for child in system_header_row.get_children(): + if child is Label and (child as Label).text == "Official": + found_official_badge = true + _assert(found_official_badge, "a system note row should show an 'Official' badge") + + # ---------- "New Card" against a system-selected deck falls back to the default deck ---------- + hub.decks = summaries + var system_index: int = hub.deck_ids.find("deck-system") + hub.deck_selector.select(system_index) + hub._open_editor("", {}) + _assert(hub.editor_target_deck_id.is_empty(), + "opening the editor for a brand-new card while a system deck is selected should fall back to the default deck instead") + hub._close_editor() + + var mine_index: int = hub.deck_ids.find("deck-mine") + hub.deck_selector.select(mine_index) + hub._open_editor("", {}) + _assert(hub.editor_target_deck_id == "deck-mine", + "opening the editor for a brand-new card while the user's own deck is selected should target that deck") + + hub.free() + host.queue_free() + await process_frame + quit(1 if failures > 0 else 0) + + +func _assert(condition: bool, message: String) -> void: + if condition: + return + failures += 1 + push_error(message)