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))