Files
step3/scripts/autoload/flashcard_service.gd
T
hz 09b670d5d4 Close the gap between the flashcards study screen and the mockup
Add real session-history navigation (Previous/Next/Shuffle), compact
side panels, and a segmented progress row to match the mockup layout.
Flashcards now opens straight into Study, with library management
moved into the Options menu ("Manage Flashcards") and Export hidden
from the UI (Import stays). Shuffle is backed by a real, due-before-new
randomized pick on the backend, not a decorative toggle.
2026-07-22 00:43:52 -05:00

538 lines
20 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)
signal export_ready(location: String)
signal import_preview_ready(preview: Dictionary)
signal import_committed(result: Dictionary)
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 export_request: HTTPRequest
var import_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
var export_busy := false
var export_pending: Dictionary = {}
var import_busy := 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)
export_request = _make_request(_on_export_completed)
import_request = _make_request(_on_import_preview_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, false)
# ---------- Next card / study session ----------
func request_next_card(deck_id: String, exclude_ids: Array, study_day_start_utc: String, shuffle: bool = false) -> 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,
"shuffle": shuffle
}
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")
# ---------- Export (browser download / desktop file save) ----------
func export_csv() -> void:
_download_export("csv", "text/csv", "flashcards.csv")
func export_json() -> void:
_download_export("json", "application/json", "flashcards.json")
func _download_export(format: String, mime: String, filename: String) -> void:
if not AuthState.can_send_authenticated_request():
request_failed.emit("export", "Login is required to export flashcards.")
return
if export_busy:
request_failed.emit("export", "An export is already in progress.")
return
export_busy = true
export_pending = {"mime": mime, "filename": filename}
var error := export_request.request("%s/export/%s" % [api_url, format], _headers(), HTTPClient.METHOD_GET)
if error != OK:
export_busy = false
export_pending = {}
request_failed.emit("export", "The export could not be started. Check your connection and try again.")
func _on_export_completed(result: int, response_code: int, _headers_value: PackedStringArray, body: PackedByteArray) -> void:
export_busy = false
var pending := export_pending
export_pending = {}
if response_code == 401:
AuthState.handle_unauthorized(AuthState.access_token)
return
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
request_failed.emit("export", "The export failed. Check your connection and try again.")
return
_save_export_file(body.get_string_from_utf8(), str(pending.get("filename", "flashcards.txt")), str(pending.get("mime", "text/plain")))
func _save_export_file(content: String, filename: String, mime: String) -> void:
if OS.has_feature("web"):
var encoded := Marshalls.raw_to_base64(content.to_utf8_buffer())
JavaScriptBridge.eval(
"(function(){const a=document.createElement('a');a.href='data:%s;base64,%s';a.download=%s;document.body.appendChild(a);a.click();a.remove();})();" % [mime, encoded, JSON.stringify(filename)],
true)
export_ready.emit(filename)
return
var directory := OS.get_system_dir(OS.SYSTEM_DIR_DOWNLOADS)
var path := directory.path_join(filename)
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
request_failed.emit("export", "The export could not be saved.")
return
file.store_string(content)
export_ready.emit(path)
# ---------- Import (file picker -> preview -> commit) ----------
func pick_import_file(format: String) -> void:
if not AuthState.can_send_authenticated_request():
request_failed.emit("import", "Login is required to import flashcards.")
return
if import_busy:
request_failed.emit("import", "An import is already in progress.")
return
var accept := ".csv" if format == "csv" else ".json"
if OS.has_feature("web"):
_pick_file_web(accept, format)
else:
_pick_file_native(accept, format)
func _pick_file_web(accept: String, format: String) -> void:
var callback := JavaScriptBridge.create_callback(_on_web_file_selected.bind(format))
JavaScriptBridge.eval(
("(function(cb){" +
"var input=document.createElement('input');" +
"input.type='file';input.accept=%s;input.style.display='none';" +
"document.body.appendChild(input);" +
"input.addEventListener('change',function(){" +
"var file=input.files&&input.files[0];" +
"document.body.removeChild(input);" +
"if(!file){cb('','');return;}" +
"var reader=new FileReader();" +
"reader.onload=function(){cb(reader.result,file.name);};" +
"reader.onerror=function(){cb('','');};" +
"reader.readAsText(file);" +
"});" +
"input.click();" +
"})(%s);") % [JSON.stringify(accept), callback],
true)
func _on_web_file_selected(text: String, filename: String, format: String) -> void:
if text.is_empty():
return
_upload_import_preview(text, filename, format)
func _pick_file_native(accept: String, format: String) -> void:
var dialog := FileDialog.new()
dialog.access = FileDialog.ACCESS_FILESYSTEM
dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
dialog.filters = PackedStringArray(["*%s ; %s files" % [accept, format.to_upper()]])
add_child(dialog)
dialog.file_selected.connect(func(path: String):
var text := FileAccess.get_file_as_string(path)
dialog.queue_free()
_upload_import_preview(text, path.get_file(), format))
dialog.canceled.connect(func(): dialog.queue_free())
dialog.popup_centered_ratio(0.6)
func _upload_import_preview(text: String, filename: String, format: String) -> void:
import_busy = true
var boundary := "----GodotFormBoundary%d" % Time.get_ticks_usec()
var content_type := "text/csv" if format == "csv" else "application/json"
var body := "--%s\r\nContent-Disposition: form-data; name=\"file\"; filename=\"%s\"\r\nContent-Type: %s\r\n\r\n%s\r\n--%s--\r\n" % [
boundary, filename, content_type, text, boundary]
var headers := PackedStringArray([
"Content-Type: multipart/form-data; boundary=%s" % boundary,
"Authorization: Bearer %s" % AuthState.access_token
])
var error := import_request.request(
"%s/import/%s/preview" % [api_url, format], headers, HTTPClient.METHOD_POST, body)
if error != OK:
import_busy = false
request_failed.emit("import", "The file could not be uploaded. Check your connection and try again.")
func _on_import_preview_completed(result: int, response_code: int, _headers_value: PackedStringArray, body: PackedByteArray) -> void:
import_busy = 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:
request_failed.emit("import", "The file could not be previewed. Check that it matches the expected format and try again.")
return
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if typeof(parsed) != TYPE_DICTIONARY:
request_failed.emit("import", "The import preview response was invalid.")
return
import_preview_ready.emit(parsed)
func commit_import(import_token: String) -> void:
_enqueue_json("POST", "/import/commit", JSON.stringify({"importToken": import_token}),
func(parsed): import_committed.emit(parsed if typeof(parsed) == TYPE_DICTIONARY else {}), "import_commit")
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)]