354 lines
9.2 KiB
GDScript
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 = ""
|