Files
step3/scripts/autoload/notes_service.gd
T
2026-07-18 22:22:29 -05:00

354 lines
9.2 KiB
GDScript

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)
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..."
"dirty": return "Unsaved changes"
"saving": return "Saving..."
"saved": return "Saved"
"offline": return "Saved locally - waiting for connection"
"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")
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 = ""