Files
step3/scripts/autoload/flashcard_service.gd
T
hz 48a8c8361d 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.
2026-07-21 15:08:52 -05:00

376 lines
13 KiB
GDScript

extends Node
# Flashcard autoload: study-session networking (next card, review submission) plus best-effort
# CRUD for decks/notes/settings used by the library screens.
#
# Review submission is the one path that must survive a dropped connection mid-session: graded
# reviews are queued to a durable per-account outbox (mirrors ReviewQueueService) and retried
# until the server confirms them. Everything else (deck/note CRUD, counts) is a plain
# best-effort request - if it fails, the caller shows an error and the user retries manually.
signal decks_changed(decks: Array)
signal settings_changed(settings: Dictionary)
signal counts_changed(counts: Dictionary)
signal next_card_loaded(card: Dictionary)
signal next_card_failed(message: String)
signal review_synced(card_id: String, updated_card: Dictionary)
signal review_paused(message: String)
signal review_resumed
signal notes_loaded(response: Dictionary)
signal note_saved(note: Dictionary)
signal request_failed(tag: String, message: String)
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
const DEFAULT_API_URL := "https://us3q.ranaze.com/api/flashcards"
const OUTBOX_PATH := "user://pending_flashcard_reviews.json"
var api_url := DEFAULT_API_URL
var next_request: HTTPRequest
var review_request: HTTPRequest
var misc_request: HTTPRequest
var retry_timer: Timer
var loading_next := false
var submitting_review := false
var misc_busy := false
var misc_queue: Array = []
var outbox: Array = []
var active_account_key := ""
var is_paused := false
func _ready() -> void:
api_url = EndpointConfig.resolve(
str(ProjectSettings.get_setting("step3/flashcards_api_url", DEFAULT_API_URL)),
"/api/flashcards")
next_request = _make_request(_on_next_completed)
review_request = _make_request(_on_review_completed)
misc_request = _make_request(_on_misc_completed)
retry_timer = Timer.new()
retry_timer.one_shot = true
retry_timer.wait_time = 5.0
retry_timer.timeout.connect(_process_outbox)
add_child(retry_timer)
_load_outbox()
AuthState.auth_changed.connect(_on_auth_changed)
_on_auth_changed()
func _make_request(callback: Callable) -> HTTPRequest:
var request := HTTPRequest.new()
request.timeout = 25.0
request.request_completed.connect(callback)
add_child(request)
return request
func _headers() -> PackedStringArray:
return PackedStringArray([
"Content-Type: application/json",
"Authorization: Bearer %s" % AuthState.access_token
])
func current_study_day_start_utc() -> String:
# The local calendar day at session start, expressed as a UTC instant, per the spec's
# "daily limits are based on the client's local study day recorded at session start."
var local := Time.get_datetime_dict_from_system(false)
var utc_offset_minutes: int = Time.get_time_zone_from_system().get("bias", 0)
var local_midnight_unix: int = Time.get_unix_time_from_datetime_dict({
"year": local.year, "month": local.month, "day": local.day,
"hour": 0, "minute": 0, "second": 0
})
var utc_unix: int = local_midnight_unix + utc_offset_minutes * 60
return "%sZ" % Time.get_datetime_string_from_unix_time(utc_unix, true)
# ---------- Next card / study session ----------
func request_next_card(deck_id: String, exclude_ids: Array, study_day_start_utc: String) -> void:
if loading_next or not AuthState.can_send_authenticated_request():
next_card_failed.emit("Login is required to study flashcards.")
return
loading_next = true
var payload := {
"deckId": deck_id,
"excludeCardIds": exclude_ids,
"studyDayStartUtc": study_day_start_utc
}
var error := next_request.request(
"%s/study/next" % api_url, _headers(), HTTPClient.METHOD_POST, JSON.stringify(payload))
if error != OK:
loading_next = false
next_card_failed.emit("The next flashcard could not be loaded. Check your connection and try again.")
func _on_next_completed(result: int, response_code: int, _headers_value: PackedStringArray, body: PackedByteArray) -> void:
loading_next = false
if response_code == 401:
AuthState.handle_unauthorized(AuthState.access_token)
return
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
next_card_failed.emit("The next flashcard could not be loaded. Check your connection and try again.")
return
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if typeof(parsed) != TYPE_DICTIONARY:
next_card_failed.emit("The flashcard service returned an invalid response.")
return
next_card_loaded.emit(parsed)
# ---------- Review submission (offline-safe outbox) ----------
func submit_review(card_id: String, rating: String, expected_revision: int, duration_seconds: int, study_day_start_utc: String) -> String:
var client_review_id := _new_uuid()
var payload := {
"clientReviewId": client_review_id,
"cardId": card_id,
"rating": rating,
"expectedRevision": expected_revision,
"reviewedUtc": "%sZ" % Time.get_datetime_string_from_system(true),
"durationSeconds": clampi(duration_seconds, 0, 86400),
"studyDayStartUtc": study_day_start_utc
}
outbox.append({"account": _account_key(), "payload": payload, "retry_count": 0})
_save_outbox()
_process_outbox()
return client_review_id
func has_pending_reviews() -> bool:
return _next_account_item_index() >= 0
func _process_outbox() -> void:
if submitting_review or not AuthState.can_send_authenticated_request():
return
var index := _next_account_item_index()
if index < 0:
if is_paused:
is_paused = false
review_resumed.emit()
return
var item: Dictionary = outbox[index]
item["active"] = true
outbox[index] = item
submitting_review = true
var error := review_request.request(
"%s/reviews" % api_url, _headers(), HTTPClient.METHOD_POST,
JSON.stringify({"reviews": [item.get("payload", {})]}))
if error != OK:
submitting_review = false
item.erase("active")
item["retry_count"] = int(item.get("retry_count", 0)) + 1
outbox[index] = item
_save_outbox()
_mark_paused()
retry_timer.start()
func _on_review_completed(result: int, response_code: int, _headers_value: PackedStringArray, body: PackedByteArray) -> void:
submitting_review = false
var index := _active_item_index()
if index < 0:
return
var item: Dictionary = outbox[index]
item.erase("active")
var card_id := str(item.get("payload", {}).get("cardId", ""))
if response_code == 401:
outbox[index] = item
_save_outbox()
AuthState.handle_unauthorized(AuthState.access_token)
return
if result == HTTPRequest.RESULT_SUCCESS and response_code >= 200 and response_code < 300:
outbox.remove_at(index)
_save_outbox()
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if typeof(parsed) == TYPE_DICTIONARY:
var results: Array = parsed.get("results", [])
if results.size() > 0:
review_synced.emit(card_id, (results[0] as Dictionary).get("card", {}))
_process_outbox()
return
if response_code == 409 or (response_code >= 400 and response_code < 500 and response_code not in [408, 429]):
# Stale revision or otherwise rejected: don't retry forever with bad state.
outbox.remove_at(index)
_save_outbox()
_process_outbox()
return
item["retry_count"] = int(item.get("retry_count", 0)) + 1
outbox[index] = item
_save_outbox()
_mark_paused()
retry_timer.start()
func _mark_paused() -> void:
if not is_paused:
is_paused = true
review_paused.emit("Reconnecting to sync your reviews. Your progress is saved.")
# ---------- Best-effort JSON CRUD (decks, notes, settings, counts, undo, cards) ----------
func fetch_decks() -> void:
_enqueue_json("GET", "/decks", "", func(parsed): decks_changed.emit(parsed if typeof(parsed) == TYPE_ARRAY else []), "decks")
func fetch_settings() -> void:
_enqueue_json("GET", "/settings", "", func(parsed): settings_changed.emit(parsed if typeof(parsed) == TYPE_DICTIONARY else {}), "settings")
func update_settings(payload: Dictionary) -> void:
_enqueue_json("PUT", "/settings", JSON.stringify(payload), func(parsed): settings_changed.emit(parsed if typeof(parsed) == TYPE_DICTIONARY else {}), "settings")
func fetch_counts() -> void:
_enqueue_json("GET", "/counts", "", func(parsed): counts_changed.emit(parsed if typeof(parsed) == TYPE_DICTIONARY else {}), "counts")
func create_deck(name: String, description: String) -> void:
_enqueue_json("POST", "/decks", JSON.stringify({"name": name, "description": description}),
func(_parsed): fetch_decks(), "decks")
func archive_deck(deck_id: String, archived: bool) -> void:
var action := "archive" if archived else "restore"
_enqueue_json("POST", "/decks/%s/%s" % [deck_id, action], "", func(_parsed): fetch_decks(), "decks")
func search_notes(query: Dictionary) -> void:
_enqueue_json("POST", "/notes/search", JSON.stringify(query),
func(parsed): notes_loaded.emit(parsed if typeof(parsed) == TYPE_DICTIONARY else {"items": [], "total": 0}), "notes")
func save_note(note_id: String, payload: Dictionary) -> void:
var path := "/notes/%s" % note_id if not note_id.is_empty() else "/notes"
var method := "PUT" if not note_id.is_empty() else "POST"
_enqueue_json(method, path, JSON.stringify(payload),
func(parsed): note_saved.emit(parsed if typeof(parsed) == TYPE_DICTIONARY else {}), "note_save")
func move_notes(note_ids: Array, target_deck_id: String) -> void:
_enqueue_json("POST", "/notes/move", JSON.stringify({"noteIds": note_ids, "targetDeckId": target_deck_id}),
func(_parsed): pass, "notes")
func archive_notes(note_ids: Array, archived: bool) -> void:
var action := "archive" if archived else "restore"
_enqueue_json("POST", "/notes/%s" % action, JSON.stringify({"noteIds": note_ids}), func(_parsed): pass, "notes")
func suspend_cards(card_ids: Array, suspended: bool) -> void:
_enqueue_json("POST", "/cards/suspend", JSON.stringify({"cardIds": card_ids, "suspended": suspended}), func(_parsed): pass, "cards")
func bury_cards(card_ids: Array, until_utc: String) -> void:
_enqueue_json("POST", "/cards/bury", JSON.stringify({"cardIds": card_ids, "untilUtc": until_utc}), func(_parsed): pass, "cards")
func undo_last_review(review_log_id: String, on_done: Callable) -> void:
_enqueue_json("POST", "/reviews/undo", JSON.stringify({"reviewLogId": review_log_id}),
func(parsed): on_done.call(parsed if typeof(parsed) == TYPE_DICTIONARY else {}), "undo")
func _enqueue_json(method: String, path: String, body: String, on_success: Callable, tag: String) -> void:
misc_queue.append({"method": method, "path": path, "body": body, "on_success": on_success, "tag": tag})
_pump_misc_queue()
func _pump_misc_queue() -> void:
if misc_busy or misc_queue.is_empty() or not AuthState.can_send_authenticated_request():
return
var job: Dictionary = misc_queue.pop_front()
misc_busy = true
var method_map := {
"GET": HTTPClient.METHOD_GET, "POST": HTTPClient.METHOD_POST,
"PUT": HTTPClient.METHOD_PUT, "DELETE": HTTPClient.METHOD_DELETE
}
var error := misc_request.request(
"%s%s" % [api_url, str(job.get("path", ""))],
_headers(),
method_map.get(job.get("method", "GET"), HTTPClient.METHOD_GET),
str(job.get("body", "")))
if error != OK:
misc_busy = false
request_failed.emit(str(job.get("tag", "")), "Request could not be sent. Check your connection.")
_pump_misc_queue()
return
misc_request.set_meta("current_job", job)
func _on_misc_completed(result: int, response_code: int, _headers_value: PackedStringArray, body: PackedByteArray) -> void:
misc_busy = false
var job: Dictionary = misc_request.get_meta("current_job", {})
var tag := str(job.get("tag", ""))
if response_code == 401:
AuthState.handle_unauthorized(AuthState.access_token)
_pump_misc_queue()
return
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
request_failed.emit(tag, "That request failed. Check your connection and try again.")
_pump_misc_queue()
return
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
var on_success: Callable = job.get("on_success", Callable())
if on_success.is_valid():
on_success.call(parsed)
_pump_misc_queue()
# ---------- Account lifecycle / outbox persistence ----------
func _on_auth_changed() -> void:
active_account_key = _account_key()
if AuthState.is_logged_in:
_process_outbox()
elif retry_timer != null:
retry_timer.stop()
func _account_key() -> String:
return AuthState.username.strip_edges().to_lower().sha256_text() if not AuthState.username.strip_edges().is_empty() else ""
func _next_account_item_index() -> int:
for index in range(outbox.size()):
if str(outbox[index].get("account", "")) == active_account_key:
return index
return -1
func _active_item_index() -> int:
for index in range(outbox.size()):
if bool(outbox[index].get("active", false)):
return index
return -1
func _load_outbox() -> void:
outbox = []
if not FileAccess.file_exists(OUTBOX_PATH):
return
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(OUTBOX_PATH))
if typeof(parsed) != TYPE_ARRAY:
return
for item in parsed:
if typeof(item) == TYPE_DICTIONARY and typeof(item.get("payload", {})) == TYPE_DICTIONARY:
item.erase("active")
outbox.append(item)
func _save_outbox() -> void:
var stored := outbox.duplicate(true)
for item in stored:
item.erase("active")
var file := FileAccess.open(OUTBOX_PATH, FileAccess.WRITE)
if file != null:
file.store_string(JSON.stringify(stored))
func _new_uuid() -> String:
var bytes := Crypto.new().generate_random_bytes(16)
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
var value := bytes.hex_encode()
return "%s-%s-%s-%s-%s" % [value.substr(0, 8), value.substr(8, 4), value.substr(12, 4), value.substr(16, 4), value.substr(20, 12)]