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.
538 lines
20 KiB
GDScript
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)]
|