extends Node const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") const DEFAULT_API_URL := "https://s3q.ranaze.com/api/notes" const REQUEST_TIMEOUT_SECONDS := 30.0 const AUTOSAVE_DELAY_SECONDS := 1.5 const RETRY_DELAY_SECONDS := 5.0 const MAX_CONTENT_LENGTH := 50000 signal content_changed(content: String) signal state_changed(state: String) signal conflict_detected(server_content: String) signal save_succeeded var api_url := DEFAULT_API_URL var content := "" var revision := 0 var updated_utc := "" var state := "idle" var dirty := false var conflict_server_content := "" var conflict_server_revision := 0 var conflict_active := false var request: HTTPRequest var autosave_timer: Timer var retry_timer: Timer var request_action := "" var request_access_token := "" var request_account_key := "" var sent_content := "" var active_account_key := "" var remote_loaded := false var auth_state: Node func _ready() -> void: auth_state = get_node("/root/AuthState") api_url = EndpointConfig.resolve( str(ProjectSettings.get_setting("step3/notes_api_url", DEFAULT_API_URL)), "/api/notes") request = HTTPRequest.new() request.timeout = REQUEST_TIMEOUT_SECONDS request.request_completed.connect(_on_request_completed) add_child(request) autosave_timer = Timer.new() autosave_timer.one_shot = true autosave_timer.wait_time = AUTOSAVE_DELAY_SECONDS autosave_timer.timeout.connect(_save_remote) add_child(autosave_timer) retry_timer = Timer.new() retry_timer.one_shot = true retry_timer.wait_time = RETRY_DELAY_SECONDS retry_timer.timeout.connect(_retry_sync) add_child(retry_timer) auth_state.auth_changed.connect(_on_auth_changed) auth_state.session_refreshed.connect(_on_session_refreshed) _on_auth_changed() func ensure_loaded() -> void: if not auth_state.is_logged_in: return var account_key := _account_key(auth_state.username) if account_key != active_account_key: _activate_account(account_key) if not remote_loaded and request_action.is_empty(): _load_remote() func set_content(value: String) -> void: if value.length() > MAX_CONTENT_LENGTH: value = value.left(MAX_CONTENT_LENGTH) if value == content: return content = value dirty = true _clear_conflict() _write_local_draft() _set_state("dirty") content_changed.emit(content) autosave_timer.start() func flush() -> void: if dirty and request_action.is_empty() and not conflict_active: autosave_timer.stop() _save_remote() func resolve_conflict_keep_draft() -> void: if not conflict_active: return revision = conflict_server_revision _clear_conflict() dirty = true _write_local_draft() _set_state("dirty") autosave_timer.start() func resolve_conflict_use_synced() -> void: if not conflict_active: return content = conflict_server_content revision = conflict_server_revision _clear_conflict() dirty = false _write_local_draft() _set_state("saved") content_changed.emit(content) func status_text() -> String: match state: "loading": return "Loading..." "saving": return "Saving..." "conflict": return "Changes found on another device" _: return "" func _on_auth_changed() -> void: if not auth_state.is_logged_in: _deactivate_account() return var account_key := _account_key(auth_state.username) if account_key != active_account_key: _activate_account(account_key) elif dirty and request_action.is_empty() and not conflict_active: retry_timer.start() func _on_session_refreshed() -> void: if dirty and request_action.is_empty() and not conflict_active: _save_remote() func _activate_account(account_key: String) -> void: _cancel_request() autosave_timer.stop() retry_timer.stop() active_account_key = account_key content = "" revision = 0 updated_utc = "" dirty = false remote_loaded = false _clear_conflict() _read_local_draft() content_changed.emit(content) _load_remote() func _deactivate_account() -> void: _cancel_request() if dirty: _write_local_draft() autosave_timer.stop() retry_timer.stop() active_account_key = "" content = "" revision = 0 updated_utc = "" dirty = false remote_loaded = false _clear_conflict() _set_state("idle") content_changed.emit(content) func _load_remote() -> void: if active_account_key.is_empty() or not auth_state.can_send_authenticated_request(): return _start_request("load", HTTPClient.METHOD_GET, "") if not dirty: _set_state("loading") func _save_remote() -> void: if not dirty or not request_action.is_empty() or conflict_active: return if not auth_state.can_send_authenticated_request(): _set_state("offline") return sent_content = content var payload := JSON.stringify({"content": sent_content, "expectedRevision": revision}) _start_request("save", HTTPClient.METHOD_PUT, payload) _set_state("saving") func _start_request(action: String, method: HTTPClient.Method, body: String) -> void: request_action = action request_access_token = auth_state.access_token request_account_key = active_account_key var headers := PackedStringArray([ "Content-Type: application/json", "Authorization: Bearer %s" % request_access_token ]) var error := request.request(api_url, headers, method, body) if error != OK: request_action = "" _set_state("offline") retry_timer.start() func _on_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: var completed_action := request_action var completed_account := request_account_key request_action = "" request_account_key = "" if completed_account != active_account_key or active_account_key.is_empty(): return if response_code == 401: auth_state.handle_unauthorized(request_access_token) if result != HTTPRequest.RESULT_SUCCESS or response_code == 0 or response_code >= 500: _set_state("offline") if dirty: retry_timer.start() return var parsed_variant: Variant = JSON.parse_string(body.get_string_from_utf8()) if typeof(parsed_variant) != TYPE_DICTIONARY: _set_state("offline") if dirty: retry_timer.start() return var parsed := parsed_variant as Dictionary if response_code == 409 and completed_action == "save": _set_conflict(parsed) return if response_code < 200 or response_code >= 300: _set_state("offline") if dirty: retry_timer.start() return remote_loaded = true if completed_action == "load": _apply_remote_load(parsed) elif completed_action == "save": _apply_remote_save(parsed) func _apply_remote_load(server: Dictionary) -> void: var server_content := str(server.get("content", "")) var server_revision := maxi(0, int(server.get("revision", 0))) updated_utc = str(server.get("updatedUtc", "")) if not dirty: content = server_content revision = server_revision _write_local_draft() _set_state("saved") content_changed.emit(content) return if content == server_content: revision = server_revision dirty = false _write_local_draft() _set_state("saved") return if revision == server_revision: _set_state("dirty") autosave_timer.start() return _set_conflict(server) func _apply_remote_save(server: Dictionary) -> void: revision = maxi(0, int(server.get("revision", revision + 1))) updated_utc = str(server.get("updatedUtc", "")) if content == sent_content: dirty = false _set_state("saved") save_succeeded.emit() else: dirty = true _set_state("dirty") autosave_timer.start() _write_local_draft() func _set_conflict(server: Dictionary) -> void: conflict_server_content = str(server.get("content", "")) conflict_server_revision = maxi(0, int(server.get("revision", 0))) conflict_active = true _set_state("conflict") conflict_detected.emit(conflict_server_content) func _retry_sync() -> void: if not auth_state.is_logged_in: return if not remote_loaded: _load_remote() elif dirty: _save_remote() func _set_state(value: String) -> void: if state == value: return state = value state_changed.emit(state) func _clear_conflict() -> void: conflict_server_content = "" conflict_server_revision = 0 conflict_active = false func _account_key(username: String) -> String: return username.strip_edges().to_lower().sha256_text() func _draft_path() -> String: return "user://notepad_%s.json" % active_account_key func _read_local_draft() -> void: if active_account_key.is_empty() or not FileAccess.file_exists(_draft_path()): return var file := FileAccess.open(_draft_path(), FileAccess.READ) if file == null: return var parsed_variant: Variant = JSON.parse_string(file.get_as_text()) if typeof(parsed_variant) != TYPE_DICTIONARY: return var parsed := parsed_variant as Dictionary content = str(parsed.get("content", "")).left(MAX_CONTENT_LENGTH) revision = maxi(0, int(parsed.get("revision", 0))) dirty = bool(parsed.get("dirty", false)) _set_state("dirty" if dirty else "saved") func _write_local_draft() -> void: if active_account_key.is_empty(): return var file := FileAccess.open(_draft_path(), FileAccess.WRITE) if file == null: return file.store_string(JSON.stringify({ "content": content, "revision": revision, "dirty": dirty, "updatedUtc": "%sZ" % Time.get_datetime_string_from_system(true) })) func _cancel_request() -> void: if request != null and request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: request.cancel_request() request_action = "" request_account_key = ""