Wire flashcard CSV/JSON import/export and fix theme/responsive gaps

Import and export were stubbed with status-text placeholders; wire them
to the existing backend endpoints with a real file picker (web JS
bridge or native FileDialog) and browser/desktop download, plus an
import preview with create/update/skip/error counts before commit.

Also fix the library and study screens' theme-refresh handlers, which
were no-op stubs so cards never restyled on a theme toggle, and add
responsive layout handling for narrow viewports.
This commit is contained in:
hz
2026-07-21 16:41:26 -05:00
parent 48a8c8361d
commit 4dfbd811b9
5 changed files with 515 additions and 17 deletions
+161
View File
@@ -19,6 +19,9 @@ 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"
@@ -28,6 +31,8 @@ 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
@@ -36,6 +41,9 @@ 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:
@@ -45,6 +53,8 @@ func _ready() -> void:
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
@@ -271,6 +281,157 @@ func undo_last_review(review_log_id: String, on_done: Callable) -> void:
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()