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