Files
step3/scripts/autoload/flashcard_service.gd
T
hz 48f404518f Fix GDScript editor warnings (shadowed vars, unused param, integer division)
Renames locals/params that shadowed base-class properties (Node.name,
Control.size, BoxContainer.alignment), prefixes an intentionally-unused
parameter, and annotates a deliberate integer division so the console
stays clean during development.
2026-07-23 14:30:48 -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(deck_name: String, description: String) -> void:
_enqueue_json("POST", "/decks", JSON.stringify({"name": deck_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)]