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)]