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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user