Files
step3/scripts/autoload/bookmark_service.gd
T

494 lines
17 KiB
GDScript

extends Node
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
const DEFAULT_API_URL := "https://us3q.ranaze.com/api/bookmarks"
const REQUEST_TIMEOUT_SECONDS := 30.0
const RETRY_DELAY_SECONDS := 5.0
const QUEUE_PATH := "user://pending_question_bookmarks.json"
signal status_changed(question_id: String, is_bookmarked: bool, pending: bool)
signal list_changed
signal list_failed(message: String)
signal question_loaded(question: Dictionary)
signal question_failed(message: String)
var api_url := DEFAULT_API_URL
var items: Array = []
var next_cursor := ""
var has_more := false
var list_loading := false
var list_error := ""
var remote_list_loaded := false
var pending_list_reload := false
var pending_list_filters: Dictionary = {}
var active_filters := {"query": "", "system": "", "discipline": "", "sort": "newest"}
var available_systems: Array = []
var available_disciplines: Array = []
var practice_ids: Array = []
var practice_index := -1
var practice_waiting_for_page := false
var requested_question_id := ""
var bookmarked: Dictionary = {}
var pending_operations: Array = []
var active_account_key := ""
var active_operation_index := -1
var mutation_token := ""
var mutation_request: HTTPRequest
var list_request: HTTPRequest
var question_request: HTTPRequest
var retry_timer: Timer
var auth_state: Node
func _ready() -> void:
auth_state = get_node("/root/AuthState")
api_url = EndpointConfig.resolve(
str(ProjectSettings.get_setting("step3/bookmarks_api_url", DEFAULT_API_URL)),
"/api/bookmarks")
mutation_request = _create_request(_on_mutation_completed)
list_request = _create_request(_on_list_completed)
question_request = _create_request(_on_question_completed)
retry_timer = Timer.new()
retry_timer.one_shot = true
retry_timer.wait_time = RETRY_DELAY_SECONDS
retry_timer.timeout.connect(_process_queue)
add_child(retry_timer)
_load_queue()
auth_state.auth_changed.connect(_on_auth_changed)
auth_state.session_refreshed.connect(_process_queue)
_on_auth_changed()
func _create_request(callback: Callable) -> HTTPRequest:
var result := HTTPRequest.new()
result.timeout = REQUEST_TIMEOUT_SECONDS
result.request_completed.connect(callback)
add_child(result)
return result
func observe_question(question: Dictionary) -> void:
var question_id := str(question.get("id", ""))
if question_id == "":
return
if not bookmarked.has(question_id):
bookmarked[question_id] = bool(question.get("is_bookmarked", false))
_apply_pending_state(question_id)
func is_bookmarked(question_id: String) -> bool:
return bool(bookmarked.get(question_id, false))
func is_pending(question_id: String) -> bool:
for operation in pending_operations:
if str(operation.get("account", "")) == active_account_key and str(operation.get("question_id", "")) == question_id:
return true
return false
func toggle(question_id: String) -> void:
set_bookmarked(question_id, not is_bookmarked(question_id))
func toggle_question(question: Dictionary) -> void:
var question_id := str(question.get("id", ""))
set_bookmarked(question_id, not is_bookmarked(question_id), _summary_from_question(question))
func set_bookmarked(question_id: String, value: bool, summary: Dictionary = {}) -> void:
if active_account_key == "" or question_id == "":
return
bookmarked[question_id] = value
var replaced := false
for index in range(pending_operations.size()):
var operation: Dictionary = pending_operations[index]
if str(operation.get("account", "")) == active_account_key and str(operation.get("question_id", "")) == question_id:
if index == active_operation_index:
continue
operation["value"] = value
operation["retry_count"] = 0
if value and not summary.is_empty():
operation["summary"] = summary.duplicate(true)
pending_operations[index] = operation
replaced = true
break
if not replaced:
pending_operations.append({
"account": active_account_key,
"question_id": question_id,
"value": value,
"retry_count": 0,
"summary": summary.duplicate(true)
})
_save_queue()
status_changed.emit(question_id, value, true)
if not value:
_remove_list_item(question_id)
elif not summary.is_empty():
_upsert_local_item(summary)
_process_queue()
func load_bookmarks(reset: bool = true, filters: Dictionary = {}) -> void:
if list_loading:
if reset:
pending_list_reload = true
pending_list_filters = filters.duplicate(true)
return
if reset:
items.clear()
next_cursor = ""
has_more = false
remote_list_loaded = false
for key in active_filters:
active_filters[key] = str(filters.get(key, active_filters[key]))
_restore_pending_items()
elif not has_more:
return
if not auth_state.can_send_authenticated_request():
list_error = "Bookmarks are offline. Pending changes will sync when your connection returns."
list_failed.emit(list_error)
list_changed.emit()
return
list_loading = true
list_error = ""
var params: Array[String] = ["limit=20", "sort=%s" % str(active_filters.sort).uri_encode()]
for key in ["query", "system", "discipline"]:
var value := str(active_filters[key]).strip_edges()
if value != "":
params.append("%s=%s" % [key, value.uri_encode()])
if not reset and next_cursor != "":
params.append("cursor=%s" % next_cursor.uri_encode())
var error := list_request.request("%s?%s" % [api_url, "&".join(params)], _headers(), HTTPClient.METHOD_GET)
if error != OK:
list_loading = false
list_error = "Bookmarks are unavailable. Check your connection and retry."
list_failed.emit(list_error)
func load_question(question_id: String) -> void:
if question_id == "" or not auth_state.can_send_authenticated_request():
question_failed.emit("The bookmarked question could not be loaded.")
return
if question_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
return
requested_question_id = question_id
var url := "%s/%s/question" % [api_url, question_id.uri_encode()]
var error := question_request.request(url, _headers(), HTTPClient.METHOD_GET)
if error != OK:
question_failed.emit("The bookmarked question could not be loaded.")
func question_ids() -> Array:
return items.map(func(item: Dictionary): return str(item.get("question_id", "")))
func begin_practice(question_id: String) -> void:
practice_ids = question_ids()
practice_index = practice_ids.find(question_id)
if practice_index < 0:
practice_ids = [question_id]
practice_index = 0
load_question(question_id)
func has_next_practice() -> bool:
return practice_index + 1 < practice_ids.size() or has_more
func next_practice() -> void:
if practice_index + 1 < practice_ids.size():
practice_index += 1
load_question(str(practice_ids[practice_index]))
return
if has_more:
practice_waiting_for_page = true
load_bookmarks(false)
return
question_failed.emit("You have reached the end of this bookmark list.")
func _on_auth_changed() -> void:
if mutation_request != null and mutation_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
mutation_request.cancel_request()
active_operation_index = -1
if not auth_state.is_logged_in:
active_account_key = ""
bookmarked.clear()
items.clear()
next_cursor = ""
has_more = false
remote_list_loaded = false
return
active_account_key = auth_state.username.strip_edges().to_lower().sha256_text()
bookmarked.clear()
items.clear()
next_cursor = ""
has_more = false
remote_list_loaded = false
available_systems.clear()
available_disciplines.clear()
_apply_all_pending_states()
_restore_pending_items()
_process_queue()
func _process_queue() -> void:
if active_operation_index >= 0 or not auth_state.can_send_authenticated_request() or active_account_key == "":
return
for index in range(pending_operations.size()):
if str(pending_operations[index].get("account", "")) == active_account_key:
active_operation_index = index
break
if active_operation_index < 0:
return
var operation: Dictionary = pending_operations[active_operation_index]
var question_id := str(operation.get("question_id", ""))
var method := HTTPClient.METHOD_PUT if bool(operation.get("value", false)) else HTTPClient.METHOD_DELETE
mutation_token = auth_state.access_token
var error := mutation_request.request("%s/%s" % [api_url, question_id.uri_encode()], _headers(), method, "")
if error != OK:
active_operation_index = -1
retry_timer.start()
func _on_mutation_completed(result: int, response_code: int, _response_headers: PackedStringArray, _body: PackedByteArray) -> void:
if active_operation_index < 0 or active_operation_index >= pending_operations.size():
active_operation_index = -1
return
var operation: Dictionary = pending_operations[active_operation_index]
var question_id := str(operation.get("question_id", ""))
if response_code == 401:
auth_state.handle_unauthorized(mutation_token)
if result == HTTPRequest.RESULT_SUCCESS and response_code >= 200 and response_code < 300:
pending_operations.remove_at(active_operation_index)
_save_queue()
active_operation_index = -1
status_changed.emit(question_id, is_bookmarked(question_id), is_pending(question_id))
call_deferred("_process_queue")
return
if response_code == 404:
bookmarked[question_id] = false
pending_operations.remove_at(active_operation_index)
_save_queue()
active_operation_index = -1
_remove_list_item(question_id)
status_changed.emit(question_id, false, false)
call_deferred("_process_queue")
return
if response_code >= 400 and response_code < 500 and response_code not in [401, 408, 429]:
bookmarked[question_id] = not bool(operation.get("value", false))
pending_operations.remove_at(active_operation_index)
_save_queue()
active_operation_index = -1
status_changed.emit(question_id, is_bookmarked(question_id), is_pending(question_id))
call_deferred("_process_queue")
return
var retry_count := int(operation.get("retry_count", 0)) + 1
operation["retry_count"] = retry_count
pending_operations[active_operation_index] = operation
active_operation_index = -1
_save_queue()
retry_timer.start()
func _on_list_completed(result: int, response_code: int, _response_headers: PackedStringArray, body: PackedByteArray) -> void:
list_loading = false
if response_code == 401:
auth_state.handle_unauthorized(auth_state.access_token)
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
list_error = "Bookmarks are unavailable. Check your connection and retry."
list_failed.emit(list_error)
_run_pending_list_reload()
return
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if typeof(parsed) != TYPE_DICTIONARY:
list_error = "The bookmark service returned an invalid response."
list_failed.emit(list_error)
_run_pending_list_reload()
return
var page := parsed as Dictionary
remote_list_loaded = true
for raw_item in page.get("items", []):
if typeof(raw_item) != TYPE_DICTIONARY:
continue
var item := raw_item as Dictionary
var question_id := str(item.get("questionExternalId", ""))
if question_id == "":
continue
bookmarked[question_id] = true
_apply_pending_state(question_id)
if not bool(bookmarked.get(question_id, false)):
continue
items.append({
"question_id": question_id,
"stem": str(item.get("stem", "")),
"lead_in": str(item.get("leadIn", "")),
"system": str(item.get("system", "")),
"discipline": str(item.get("discipline", "")),
"difficulty": int(item.get("difficulty", 1)),
"has_media": bool(item.get("hasMedia", false)),
"tags": item.get("tags", []),
"created_utc": str(item.get("createdUtc", ""))
})
next_cursor = str(page.get("nextCursor", ""))
has_more = bool(page.get("hasMore", false))
available_systems = page.get("systems", []).duplicate()
available_disciplines = page.get("disciplines", []).duplicate()
practice_ids = question_ids()
_restore_pending_items()
list_changed.emit()
if practice_waiting_for_page:
practice_waiting_for_page = false
if practice_index + 1 < practice_ids.size():
practice_index += 1
load_question(str(practice_ids[practice_index]))
else:
question_failed.emit("You have reached the end of this bookmark list.")
_run_pending_list_reload()
func _run_pending_list_reload() -> void:
if not pending_list_reload:
return
var filters := pending_list_filters.duplicate(true)
pending_list_reload = false
pending_list_filters.clear()
call_deferred("load_bookmarks", true, filters)
func _on_question_completed(result: int, response_code: int, _response_headers: PackedStringArray, body: PackedByteArray) -> void:
var completed_question_id := requested_question_id
requested_question_id = ""
if response_code == 401:
auth_state.handle_unauthorized(auth_state.access_token)
if response_code == 404:
if completed_question_id != "":
bookmarked[completed_question_id] = false
_remove_list_item(completed_question_id)
status_changed.emit(completed_question_id, false, false)
question_failed.emit("This question is no longer available in your bookmarks.")
return
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
question_failed.emit("The bookmarked question could not be loaded.")
return
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if typeof(parsed) != TYPE_DICTIONARY:
question_failed.emit("The bookmark service returned an invalid question.")
return
var normalized := QuestionBankService.normalize_api_question(parsed)
if normalized.is_empty():
question_failed.emit("The bookmark service returned an invalid question.")
return
observe_question(normalized)
question_loaded.emit(normalized)
func _headers() -> PackedStringArray:
return PackedStringArray([
"Content-Type: application/json",
"Authorization: Bearer %s" % auth_state.access_token
])
func _apply_pending_state(question_id: String) -> void:
for operation in pending_operations:
if str(operation.get("account", "")) == active_account_key and str(operation.get("question_id", "")) == question_id:
bookmarked[question_id] = bool(operation.get("value", false))
func _apply_all_pending_states() -> void:
for operation in pending_operations:
if str(operation.get("account", "")) == active_account_key:
bookmarked[str(operation.get("question_id", ""))] = bool(operation.get("value", false))
func _remove_list_item(question_id: String) -> void:
var practice_position := practice_ids.find(question_id)
if practice_position >= 0:
practice_ids.remove_at(practice_position)
if practice_position <= practice_index:
practice_index -= 1
for index in range(items.size() - 1, -1, -1):
if str(items[index].get("question_id", "")) == question_id:
items.remove_at(index)
list_changed.emit()
func _summary_from_question(question: Dictionary) -> Dictionary:
return {
"question_id": str(question.get("id", "")),
"stem": str(question.get("stem", "")),
"lead_in": str(question.get("lead_in", "")),
"system": str(question.get("system", "")),
"discipline": str(question.get("discipline", "")),
"difficulty": int(question.get("difficulty", 1)),
"has_media": not question.get("media", []).is_empty(),
"tags": question.get("tags", []).duplicate(),
"created_utc": "%sZ" % Time.get_datetime_string_from_system(true)
}
func _restore_pending_items() -> void:
for operation in pending_operations:
if str(operation.get("account", "")) != active_account_key or not bool(operation.get("value", false)):
continue
var summary: Dictionary = operation.get("summary", {})
if not summary.is_empty():
_upsert_local_item(summary, false)
func _upsert_local_item(summary: Dictionary, emit_change: bool = true) -> void:
var question_id := str(summary.get("question_id", ""))
if question_id == "" or not _summary_matches_filters(summary):
return
for existing in items:
if str(existing.get("question_id", "")) == question_id:
return
items.append(summary.duplicate(true))
items.sort_custom(func(left: Dictionary, right: Dictionary):
var left_date := str(left.get("created_utc", ""))
var right_date := str(right.get("created_utc", ""))
return left_date < right_date if str(active_filters.sort) == "oldest" else left_date > right_date
)
practice_ids = question_ids()
if emit_change:
list_changed.emit()
func _summary_matches_filters(summary: Dictionary) -> bool:
var system := str(active_filters.system).strip_edges()
if system != "" and system != str(summary.get("system", "")):
return false
var discipline := str(active_filters.discipline).strip_edges()
if discipline != "" and discipline != str(summary.get("discipline", "")):
return false
var query := str(active_filters.query).strip_edges().to_lower()
if query == "":
return true
var searchable := "%s %s %s" % [
str(summary.get("stem", "")), str(summary.get("lead_in", "")),
" ".join(summary.get("tags", []))]
return searchable.to_lower().contains(query)
func _load_queue() -> void:
pending_operations = []
if not FileAccess.file_exists(QUEUE_PATH):
return
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(QUEUE_PATH))
if typeof(parsed) != TYPE_ARRAY:
return
for item in parsed:
if typeof(item) == TYPE_DICTIONARY and str(item.get("account", "")) != "" and str(item.get("question_id", "")) != "":
pending_operations.append(item)
func _save_queue() -> void:
var file := FileAccess.open(QUEUE_PATH, FileAccess.WRITE)
if file != null:
file.store_string(JSON.stringify(pending_operations))