Add synced user notes

This commit is contained in:
hz
2026-07-18 22:22:29 -05:00
parent a232e18c46
commit d0bb271b14
22 changed files with 930 additions and 2 deletions
+353
View File
@@ -0,0 +1,353 @@
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 = ""
+1
View File
@@ -0,0 +1 @@
uid://b8juke0d675k5
+20 -1
View File
@@ -12,6 +12,7 @@ var sound_toggle: CheckButton
var theme_button: Button
var feedback_button: Button
var feedback_dialog: Control
var notes_dialog: Control
var session_expiry_dialog: Control
var theme_service
var current_screen_name := ""
@@ -283,9 +284,18 @@ func _build_ui() -> void:
screens_container.add_child(settings)
screens["settings"] = settings
var notes := preload("res://scripts/ui/notes_screen.gd").new()
notes.name = "NotesScreen"
notes.main = self
screens_container.add_child(notes)
screens["notes"] = notes
feedback_dialog = preload("res://scripts/ui/app_feedback_dialog.gd").new()
feedback_dialog.name = "AppFeedbackDialog"
add_child(feedback_dialog)
notes_dialog = preload("res://scripts/ui/notes_dialog.gd").new()
notes_dialog.name = "NotesDialog"
add_child(notes_dialog)
session_expiry_dialog = preload("res://scripts/ui/session_expiry_dialog.gd").new()
session_expiry_dialog.name = "SessionExpiryDialog"
session_expiry_dialog.logout_requested.connect(_logout)
@@ -348,7 +358,7 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void
_add_nav_button(container, "flashcards", "Flashcards", "copy", false)
_add_nav_button(container, "study_plan", "Study Plan", "calendar-days", true)
_add_nav_button(container, "bookmarks", "Bookmarks", "bookmark", false)
_add_nav_button(container, "notes", "Notes", "notebook-pen", false)
_add_nav_button(container, "notes", "Notes", "notebook-pen", true)
var spacer := Control.new()
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
@@ -391,6 +401,7 @@ func _on_navigation_pressed(key: String) -> void:
"performance": show_screen("profile")
"study_plan": show_screen("map")
"settings": show_screen("settings")
"notes": show_screen("notes")
"logout": _logout()
@@ -648,6 +659,14 @@ func open_feedback_dialog() -> void:
feedback_dialog.show_dialog(FeedbackService.build_context(current_screen_name, get_viewport_rect().size))
func open_notes_dialog() -> void:
if not AuthState.is_logged_in or notes_dialog == null:
return
if current_screen_name != "player" or BlockSessionService.session_kind == "simulation":
return
notes_dialog.show_dialog()
func _on_theme_changed(is_dark_mode: bool) -> void:
Style.refresh_tree(self, not is_dark_mode)
_refresh_theme_button()
+2
View File
@@ -17,4 +17,6 @@ static func key_for_screen(screen_name: String, payload: Variant = null) -> Stri
key = "performance"
elif screen_name == "settings":
key = "settings"
elif screen_name == "notes":
key = "notes"
return key
+108
View File
@@ -0,0 +1,108 @@
extends Control
const Brand := preload("res://scripts/ui/medmind_style.gd")
const NotesEditor := preload("res://scripts/ui/notes_editor.gd")
signal closed
var panel: PanelContainer
var notes_editor: VBoxContainer
var theme_service: Node
var block_session_service: Node
func _ready() -> void:
theme_service = get_node("/root/UIThemeService")
block_session_service = get_node("/root/BlockSessionService")
visible = false
top_level = true
mouse_filter = Control.MOUSE_FILTER_STOP
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
var dim := ColorRect.new()
dim.color = Color(0, 0, 0, 0.72)
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
dim.gui_input.connect(_on_dim_input)
add_child(dim)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(center)
panel = PanelContainer.new()
panel.custom_minimum_size = Vector2(760, 650)
center.add_child(panel)
var content := VBoxContainer.new()
content.add_theme_constant_override("separation", 12)
panel.add_child(content)
var header := HBoxContainer.new()
content.add_child(header)
var title := Label.new()
title.text = "Notes"
title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
title.add_theme_font_override("font", Brand.font)
title.add_theme_font_size_override("font_size", 26)
header.add_child(title)
var close_button := Button.new()
close_button.icon = preload("res://assets/icons/lucide/x.svg")
close_button.tooltip_text = "Close notes"
close_button.custom_minimum_size = Vector2(42, 42)
close_button.add_theme_constant_override("icon_max_width", 20)
close_button.pressed.connect(hide_dialog)
header.add_child(close_button)
notes_editor = NotesEditor.new()
content.add_child(notes_editor)
theme_service.theme_changed.connect(_on_theme_changed)
resized.connect(_fit_panel)
_apply_theme()
_fit_panel()
func show_dialog() -> void:
notes_editor.activate()
visible = true
move_to_front()
block_session_service.pause_current_question_timer()
call_deferred("_focus_editor")
func hide_dialog() -> void:
if not visible:
return
visible = false
get_node("/root/NotesService").flush()
block_session_service.resume_current_question_timer()
closed.emit()
func _focus_editor() -> void:
notes_editor.focus_editor()
func _input(event: InputEvent) -> void:
if visible and event.is_action_pressed("ui_cancel"):
hide_dialog()
get_viewport().set_input_as_handled()
func _on_dim_input(event: InputEvent) -> void:
var mouse_event := event as InputEventMouseButton
if mouse_event != null and mouse_event.pressed and mouse_event.button_index == MOUSE_BUTTON_LEFT:
hide_dialog()
get_viewport().set_input_as_handled()
func _on_theme_changed(_dark: bool) -> void:
_apply_theme()
func _apply_theme() -> void:
panel.add_theme_stylebox_override("panel", Brand.panel_box(theme_service.is_dark_mode()))
func _fit_panel() -> void:
if panel == null:
return
var available := get_viewport_rect().size - Vector2(32, 32)
var target_size := Vector2(
minf(760.0, maxf(300.0, available.x)),
minf(650.0, maxf(420.0, available.y)))
if panel.custom_minimum_size != target_size:
panel.custom_minimum_size = target_size
+1
View File
@@ -0,0 +1 @@
uid://c20n7rxtocujy
+142
View File
@@ -0,0 +1,142 @@
extends VBoxContainer
const Brand := preload("res://scripts/ui/medmind_style.gd")
var editor: TextEdit
var status_label: Label
var count_label: Label
var conflict_panel: PanelContainer
var applying_content := false
var notes_service: Node
var theme_service: Node
func _ready() -> void:
notes_service = get_node("/root/NotesService")
theme_service = get_node("/root/UIThemeService")
size_flags_horizontal = Control.SIZE_EXPAND_FILL
size_flags_vertical = Control.SIZE_EXPAND_FILL
add_theme_constant_override("separation", 10)
var metadata_row := HBoxContainer.new()
metadata_row.add_theme_constant_override("separation", 12)
add_child(metadata_row)
status_label = Label.new()
status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
Brand.apply_font(status_label)
metadata_row.add_child(status_label)
count_label = Label.new()
Brand.apply_font(count_label)
metadata_row.add_child(count_label)
editor = TextEdit.new()
editor.placeholder_text = "Write notes..."
editor.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY
editor.size_flags_horizontal = Control.SIZE_EXPAND_FILL
editor.size_flags_vertical = Control.SIZE_EXPAND_FILL
editor.custom_minimum_size = Vector2(0, 260)
editor.add_theme_font_override("font", Brand.font)
editor.add_theme_font_size_override("font_size", 17)
editor.text_changed.connect(_on_text_changed)
add_child(editor)
conflict_panel = PanelContainer.new()
conflict_panel.visible = false
add_child(conflict_panel)
var conflict_content := VBoxContainer.new()
conflict_content.add_theme_constant_override("separation", 8)
conflict_panel.add_child(conflict_content)
var conflict_message := Label.new()
conflict_message.text = "This note changed on another device. Choose which version to keep."
conflict_message.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Brand.apply_font(conflict_message)
conflict_content.add_child(conflict_message)
var conflict_actions := HBoxContainer.new()
conflict_actions.add_theme_constant_override("separation", 8)
conflict_content.add_child(conflict_actions)
var keep_draft := Button.new()
keep_draft.text = "Keep my draft"
keep_draft.pressed.connect(notes_service.resolve_conflict_keep_draft)
conflict_actions.add_child(keep_draft)
var use_synced := Button.new()
use_synced.text = "Use synced version"
use_synced.pressed.connect(notes_service.resolve_conflict_use_synced)
conflict_actions.add_child(use_synced)
notes_service.content_changed.connect(_on_service_content_changed)
notes_service.state_changed.connect(_on_service_state_changed)
theme_service.theme_changed.connect(_on_theme_changed)
_refresh_from_service()
_apply_theme()
func activate() -> void:
notes_service.ensure_loaded()
_refresh_from_service()
func focus_editor() -> void:
editor.grab_focus()
func _on_text_changed() -> void:
if applying_content:
return
if editor.text.length() > notes_service.MAX_CONTENT_LENGTH:
var caret_line := editor.get_caret_line()
var caret_column := editor.get_caret_column()
applying_content = true
editor.text = editor.text.left(NotesService.MAX_CONTENT_LENGTH)
editor.set_caret_line(mini(caret_line, editor.get_line_count() - 1))
editor.set_caret_column(caret_column)
applying_content = false
notes_service.set_content(editor.text)
_refresh_metadata()
func _on_service_content_changed(value: String) -> void:
if editor.text != value:
applying_content = true
editor.text = value
applying_content = false
_refresh_metadata()
func _on_service_state_changed(_new_state: String) -> void:
_refresh_metadata()
func _refresh_from_service() -> void:
_on_service_content_changed(notes_service.content)
func _refresh_metadata() -> void:
status_label.text = notes_service.status_text()
count_label.text = "%s / %s" % [
_format_count(editor.text.length()),
_format_count(notes_service.MAX_CONTENT_LENGTH)
]
conflict_panel.visible = notes_service.conflict_active
status_label.add_theme_color_override(
"font_color",
Brand.AMBER if notes_service.state in ["dirty", "offline", "conflict"] else Brand.muted_color(theme_service.is_dark_mode()))
func _format_count(value: int) -> String:
return "%d,%03d" % [int(floor(float(value) / 1000.0)), value % 1000] if value >= 1000 else str(value)
func _on_theme_changed(_dark: bool) -> void:
_apply_theme()
func _apply_theme() -> void:
var dark: bool = theme_service.is_dark_mode()
editor.add_theme_color_override("font_color", Brand.text_color(dark))
editor.add_theme_color_override("font_placeholder_color", Brand.muted_color(dark))
editor.add_theme_color_override("caret_color", Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE)
editor.add_theme_color_override("selection_color", Color(Brand.ROYAL_BLUE, 0.32))
editor.add_theme_stylebox_override("normal", Brand.field_box(dark))
editor.add_theme_stylebox_override("focus", Brand.field_box(dark, true))
conflict_panel.add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.AMBER))
_refresh_metadata()
+1
View File
@@ -0,0 +1 @@
uid://du8xhjfdkdakr
+34
View File
@@ -0,0 +1,34 @@
extends VBoxContainer
const Brand := preload("res://scripts/ui/medmind_style.gd")
const NotesEditor := preload("res://scripts/ui/notes_editor.gd")
var main
var notes_editor: VBoxContainer
func _ready() -> void:
size_flags_horizontal = Control.SIZE_EXPAND_FILL
size_flags_vertical = Control.SIZE_EXPAND_FILL
add_theme_constant_override("separation", 16)
var eyebrow := Label.new()
eyebrow.text = "STUDY TOOLS"
eyebrow.add_theme_font_override("font", Brand.font)
eyebrow.add_theme_font_size_override("font_size", 13)
eyebrow.add_theme_color_override("font_color", Brand.SKY_BLUE)
add_child(eyebrow)
var title := Label.new()
title.text = "Notes"
title.add_theme_font_override("font", Brand.font)
title.add_theme_font_size_override("font_size", 32)
add_child(title)
notes_editor = NotesEditor.new()
add_child(notes_editor)
func on_show(_payload: Variant = null) -> void:
notes_editor.activate()
func on_hide() -> void:
get_node("/root/NotesService").flush()
+1
View File
@@ -0,0 +1 @@
uid://e7os1ufiuo37
+16 -1
View File
@@ -83,6 +83,7 @@ var labs_button: Button
var clear_highlight_button: Button
var flag_button: Button
var report_button: Button
var notes_button: Button
var app_feedback_button: Button
var theme_button: Button
var question_toolbar: HFlowContainer
@@ -273,6 +274,18 @@ func _ready() -> void:
report_button.pressed.connect(_toggle_report_current_question)
question_toolbar.add_child(report_button)
notes_button = Button.new()
notes_button.tooltip_text = "Open notes"
notes_button.icon = preload("res://assets/icons/lucide/notebook-pen.svg")
notes_button.custom_minimum_size = Vector2(44, 44)
notes_button.add_theme_constant_override("icon_max_width", 22)
notes_button.alignment = HORIZONTAL_ALIGNMENT_CENTER
notes_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
notes_button.focus_mode = Control.FOCUS_NONE
UISoundService.set_button_sound(notes_button, "open")
notes_button.pressed.connect(func(): main.open_notes_dialog())
question_toolbar.add_child(notes_button)
app_feedback_button = Button.new()
app_feedback_button.text = "Feedback"
app_feedback_button.tooltip_text = "Send general feedback or report a bug"
@@ -827,7 +840,7 @@ func _apply_question_theme() -> void:
var navigator_heading := navigator_popup.find_child("NavigatorHeading", true, false) as Label
if navigator_heading != null:
navigator_heading.add_theme_color_override("font_color", Brand.text_color(dark))
for control in [text_smaller_button, text_larger_button, flag_button, app_feedback_button]:
for control in [text_smaller_button, text_larger_button, flag_button, notes_button, app_feedback_button]:
if control == null:
continue
var active: bool = control == flag_button and flagged
@@ -1864,6 +1877,8 @@ func _refresh_toolbar_state() -> void:
if report_button != null:
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
report_button.tooltip_text = "Report submitted; report another issue" if reported_question_ids.has(question_id) else "Report question"
if notes_button != null:
notes_button.visible = BlockSessionService.session_kind != "simulation"
_apply_question_theme()