Add system (curated) vs. user-uploaded flashcards

System decks/notes are owned by a reserved sentinel user id, reusing
all existing per-user store/index plumbing unchanged. Regular users
see them under a separate "Official Decks" section, can study them
with fully independent per-user FSRS progress (lazily materialized on
first visit), but can never edit or archive them - enforced both in
the client (no Edit/Archive controls) and server-side (ownership guard
on every mutating endpoint). Includes a small idempotent seed command
(`dotnet run -- seed-flashcards`) for testing, since there's no in-app
authoring UI yet.
This commit is contained in:
hz
2026-07-22 15:52:19 -05:00
parent 3a01e9156c
commit 7f76ca0fdc
13 changed files with 444 additions and 31 deletions
@@ -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);
}
}
@@ -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<FlashcardNote>();
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<FlashcardNote>();
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);
}
@@ -10,6 +10,14 @@ public static class FlashcardTypes
public static readonly HashSet<string> 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
+10
View File
@@ -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<ForwardedHeadersOptions>(options =>
@@ -33,6 +33,21 @@ public sealed class FlashcardDeckStore
public async Task<FlashcardDeck?> 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<List<FlashcardDeck>> ListVisibleAsync(string userId) =>
_decks.Find(v => (v.UserId == userId || v.UserId == FlashcardOwners.System) && v.ArchivedUtc == null)
.SortBy(v => v.Name).ToListAsync();
public async Task<FlashcardDeck?> 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<FlashcardDeck?> GetAnyAsync(string deckId) =>
await _decks.Find(v => v.Id == deckId).FirstOrDefaultAsync();
public async Task<FlashcardDeck> GetOrCreateDefaultDeckAsync(string userId)
{
var existing = await _decks.Find(v => v.UserId == userId && v.IsDefault).FirstOrDefaultAsync();
@@ -51,6 +51,58 @@ public sealed class FlashcardNoteStore
public Task<List<FlashcardNote>> 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<List<FlashcardNote>> ListByDeckVisibleAsync(string userId, string deckId) =>
_notes.Find(v => (v.UserId == userId || v.UserId == FlashcardOwners.System)
&& v.DeckId == deckId && v.ArchivedUtc == null).ToListAsync();
public Task<List<FlashcardNote>> ListAllVisibleActiveAsync(string userId) =>
_notes.Find(v => (v.UserId == userId || v.UserId == FlashcardOwners.System) && v.ArchivedUtc == null)
.ToListAsync();
public async Task<FlashcardNote?> 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<FlashcardNote?> GetAnyAsync(string noteId) =>
await _notes.Find(v => v.Id == noteId).FirstOrDefaultAsync();
public async Task<(List<FlashcardNote> Items, int Total)> SearchVisibleAsync(string userId, FlashcardLibraryQuery query)
{
var filter = Builders<FlashcardNote>.Filter.Or(
Builders<FlashcardNote>.Filter.Eq(v => v.UserId, userId),
Builders<FlashcardNote>.Filter.Eq(v => v.UserId, FlashcardOwners.System));
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<(List<FlashcardNote> Items, int Total)> SearchAsync(string userId, FlashcardLibraryQuery query)
{
var filter = Builders<FlashcardNote>.Filter.Eq(v => v.UserId, userId);
@@ -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;
}
@@ -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<FlashcardSchedule?> GetAsync(string userId, string cardId) =>
await _schedules.Find(v => v.UserId == userId && v.Id == cardId).FirstOrDefaultAsync();
@@ -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<List<FlashcardDeckSummary>> GetDeckSummariesAsync(string userId, DateTime nowUtc, DateTime studyDayStart)
{
var deckList = await decks.ListAsync(userId);
var deckList = await decks.ListVisibleAsync(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 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<List<string>> 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<FlashcardNote> 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;
@@ -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<int> 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<FlashcardDeck> 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);
}
}
+39 -3
View File
@@ -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
for summary in decks:
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:
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,6 +477,7 @@ func _note_row(note: Dictionary) -> Control:
actions.add_theme_constant_override("separation", 8)
row.add_child(actions)
if not is_system:
var edit_button := Button.new()
edit_button.text = "Edit"
edit_button.custom_minimum_size = Vector2(70, 40)
@@ -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,
+11
View File
@@ -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]
+107
View File
@@ -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)