494 lines
17 KiB
GDScript
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))
|