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
+59 -23
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
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,
+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]