import asyncio import sys import warnings import os import time if sys.platform == "win32": # Avoid noisy Proactor transport resets on long-running Windows sessions. # Python 3.14+ marks this policy deprecated, so probe/set under warning suppression. with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) selector_policy = getattr(asyncio, "WindowsSelectorEventLoopPolicy", None) if selector_policy is not None: asyncio.set_event_loop_policy(selector_policy()) from nicegui import ui, app, run from typing import cast from pathlib import Path from functools import partial from datetime import datetime # Add project root to sys.path to allow for absolute imports # This is now handled by the run_desktop.py and cli/main.py scripts, but good for linters. sys.path.append(str(Path(__file__).resolve().parent.parent.parent)) from journal.core.entry import ( create_daily_entry, create_deep_entry, create_fragment_entry, create_recovery_entry, ) from journal.core.storage import ( save_entry_content, load_all_vaults, rebuild_all_vaults, save_current_month_vault, initialize_vault, clear_data_directory, list_journal_files as list_journal_files_backend, load_entry_content, ) from journal.core.config import ( DATA_DIR, VAULT_DIR, BACKEND_MODE, CSHARP_SIDECAR_PATH, NLP_BACKEND, SPEECH_RECOGNITION_ENGINE, WHISPER_MODEL_SIZE, FASTER_WHISPER_DEVICE, FASTER_WHISPER_COMPUTE_TYPE, LLAMA_CPP_URL, LLAMA_CPP_MODEL, LLAMA_CPP_TIMEOUT, EMBEDDING_API_URL, EMBEDDING_MODEL_NAME, CLOUDAI_API_URL, CLOUDAI_API_KEY, CLOUDAI_CONNECT_TIMEOUT, CLOUDAI_TIMEOUT, MODEL_CONTEXT_TOKENS, CHUNK_TOKEN_BUDGET, ) from journal.core.models import JournalEntry from journal.ai.bridge import summarize_entry, summarize_all_entries from journal.ai.chat import get_cloud_ai_response from journal.ui.components.speech import speech_to_text from journal.ui.components.calendar import calendar_view from journal.ui.components.editor import markdown_editor from journal.ui.components.settings import settings_dialog ui.dark_mode = True _ = ui.add_head_html( """ """, shared=True, ) # Global variables selected_file_name: str = "" drawer: ui.left_drawer | None = None main_tab: ui.tabs | None = None edit_tab: ui.tab | None = None ai_tab: ui.tab | None = None new_tab: ui.tab | None = None entry_content_box: ui.textarea | None = None analysis_box: ui.textarea | None = None new_entry_box: ui.textarea | None = None file_map: dict[str, str] = {} vault_password: str | None = None # Global to store the password for the shutdown hook sidebar_content: ui.column | None = None content_container: ui.column | None = None # Must be created inside page/layout scope for NiceGUI. all_analysis_dialog: ui.dialog | None = None vault_load_lock = asyncio.Lock() _sidebar_retry_scheduled = False _USER_SETTINGS_DEFAULTS: dict[str, str] = { "backend_mode": BACKEND_MODE, "csharp_sidecar_path": CSHARP_SIDECAR_PATH, "nlp_backend": NLP_BACKEND, "speech_engine": SPEECH_RECOGNITION_ENGINE, "whisper_model": WHISPER_MODEL_SIZE, "faster_whisper_device": FASTER_WHISPER_DEVICE, "faster_whisper_compute_type": FASTER_WHISPER_COMPUTE_TYPE, "llama_cpp_url": LLAMA_CPP_URL, "llama_cpp_model": LLAMA_CPP_MODEL, "llama_cpp_timeout": str(LLAMA_CPP_TIMEOUT), "embedding_api_url": EMBEDDING_API_URL, "embedding_model_name": EMBEDDING_MODEL_NAME, "cloudai_api_url": CLOUDAI_API_URL, "cloudai_api_key": CLOUDAI_API_KEY, "cloudai_timeout": str(CLOUDAI_TIMEOUT), "cloudai_connect_timeout": str(CLOUDAI_CONNECT_TIMEOUT), "model_context_tokens": str(MODEL_CONTEXT_TOKENS), "chunk_token_budget": str(CHUNK_TOKEN_BUDGET), } _USER_SETTINGS_ENV_KEYS: dict[str, str] = { "backend_mode": "JOURNAL_BACKEND_MODE", "csharp_sidecar_path": "JOURNAL_CSHARP_SIDECAR_PATH", "nlp_backend": "JOURNAL_NLP_BACKEND", "speech_engine": "JOURNAL_SPEECH_ENGINE", "whisper_model": "JOURNAL_WHISPER_MODEL", "faster_whisper_device": "JOURNAL_FASTER_WHISPER_DEVICE", "faster_whisper_compute_type": "JOURNAL_FASTER_WHISPER_COMPUTE_TYPE", "llama_cpp_url": "JOURNAL_LLAMA_CPP_URL", "llama_cpp_model": "JOURNAL_LLAMA_CPP_MODEL", "llama_cpp_timeout": "JOURNAL_LLAMA_CPP_TIMEOUT", "embedding_api_url": "JOURNAL_EMBEDDING_API_URL", "embedding_model_name": "JOURNAL_EMBEDDING_MODEL_NAME", "cloudai_api_url": "JOURNAL_CLOUDAI_API_URL", "cloudai_api_key": "JOURNAL_CLOUDAI_API_KEY", "cloudai_timeout": "JOURNAL_CLOUDAI_TIMEOUT", "cloudai_connect_timeout": "JOURNAL_CLOUDAI_CONNECT_TIMEOUT", "model_context_tokens": "JOURNAL_MODEL_CONTEXT_TOKENS", "chunk_token_budget": "JOURNAL_CHUNK_TOKEN_BUDGET", } def _initialize_runtime_user_settings() -> None: for key, value in _USER_SETTINGS_DEFAULTS.items(): app.storage.user.setdefault(key, value) for key, env_key in _USER_SETTINGS_ENV_KEYS.items(): value = app.storage.user.get(key, _USER_SETTINGS_DEFAULTS.get(key, "")) os.environ[env_key] = str(value).strip() def _is_client_active(client: object | None) -> bool: return bool(client is not None and getattr(client, "has_socket_connection", False)) def _is_deleted_client_error(error: RuntimeError) -> bool: text = str(error).lower() return "client this element belongs to has been deleted" in text or "parent slot of the element has been deleted" in text def _render_authenticated_layout() -> bool: global content_container if content_container is None: print("Authenticated layout skipped: content container is not ready.") return False try: content_container.clear() with content_container: journal_ui_layout() if not update_sidebar(): _schedule_sidebar_retry("layout render") return True except RuntimeError as error: if _is_deleted_client_error(error): print("Skipping layout update because the client was deleted.") _schedule_sidebar_retry("deleted layout client") return False raise def _has_runtime_auth() -> bool: return cast(bool, app.storage.user.get("authenticated", False)) and bool(vault_password) def _schedule_sidebar_retry(reason: str, delay_seconds: float = 0.45) -> None: global _sidebar_retry_scheduled if _sidebar_retry_scheduled: return try: loop = asyncio.get_running_loop() except RuntimeError: return _sidebar_retry_scheduled = True async def _retry() -> None: global _sidebar_retry_scheduled try: await asyncio.sleep(delay_seconds) if not _has_runtime_auth(): return if update_sidebar(): return await asyncio.sleep(0.9) update_sidebar() finally: _sidebar_retry_scheduled = False print(f"Scheduling sidebar retry ({reason}).") loop.create_task(_retry()) def list_journal_files(): files = list_journal_files_backend() print(f"list_journal_files found: {[f[0] for f in files]}") return files def save_entry(content: str, mode: str, password: str | None) -> str: # Save to the DATA_DIR, then trigger a vault save save_entry_content(content, mode=mode) if password: save_current_month_vault(password) return "Entry saved." def load_entry(file_path: str) -> str: try: return load_entry_content(file_path) except Exception as e: return f"Error loading file: {e}" def save_existing_entry(file_path: str, content: str, password: str | None) -> str: try: # Existing-entry editing should persist exactly what the user sees in editor. save_entry_content(content, file_path=Path(file_path), mode="Overwrite") if password: save_current_month_vault(password) return f"Saved changes to {Path(file_path).name}" except Exception as e: return f"Error saving file: {e}" def analyze_entry(file_path: str) -> str: try: entry = JournalEntry(date=Path(file_path).stem, raw_content=load_entry_content(file_path)) analysis = summarize_entry(entry) return analysis except Exception as e: return f"Error analyzing entry: {e}" def analyze_all_entries() -> str: try: journal_files = list_journal_files() if not journal_files: return "No journal files found." entries = [ JournalEntry(date=Path(path).stem, raw_content=load_entry_content(path)) for _, path in journal_files ] analysis = summarize_all_entries(entries) return analysis except Exception as e: return f"Error analyzing all entries: {e}" def update_sidebar() -> bool: global \ file_map, \ drawer, \ main_tab, \ edit_tab, \ entry_content_box, \ analysis_box, \ sidebar_content print("update_sidebar called.") if not sidebar_content: print("Sidebar content is not ready yet; retry needed.") return False try: sidebar_content.clear() except RuntimeError as error: if _is_deleted_client_error(error): print("Skipping sidebar update because the client was deleted.") return False raise try: with sidebar_content: _ = ui.label("📅 Calendar").classes("text-md font-bold mb-4") def on_date_select(date: str | None): global selected_file_name if not date: return selected_file_name = f"{date}.md" if main_tab is not None and edit_tab is not None: main_tab.set_value(edit_tab) selected_path = file_map.get(selected_file_name) if selected_path is None: if entry_content_box is not None: entry_content_box.set_value("") if analysis_box is not None: analysis_box.set_value("") ui.notify( f"No entry exists for {date}. Use New Entry to create one.", type="info", ) if drawer: drawer.set_value(False) return if entry_content_box is not None: entry_content_box.set_value(load_entry(selected_path)) if analysis_box is not None: analysis_box.set_value("") if drawer: drawer.set_value(False) calendar_view(on_select=on_date_select) _ = ui.separator().classes("my-4") _ = ui.label("📁 Journal Files").classes("text-lg font-bold mb-4") files = list_journal_files() file_names = [f[0] for f in files] file_map = dict(files) print(f"file_map in update_sidebar: {file_map}") def on_file_select(fname: str): global selected_file_name selected_file_name = fname selected_path = file_map.get(fname) if selected_path is None: ui.notify("Selected file is no longer available.", type="warning") return if main_tab is not None and edit_tab is not None: main_tab.set_value(edit_tab) if entry_content_box is not None: entry_content_box.set_value(load_entry(selected_path)) if analysis_box is not None: analysis_box.set_value("") if drawer: drawer.set_value(False) for fname in file_names: _ = ( ui.button(fname, on_click=partial(on_file_select, fname)) .classes("w-full justify-start mb-1") .props("flat") ) _ = ui.separator().classes("my-4") def open_new_tab(): if main_tab is not None and new_tab is not None: main_tab.set_value(new_tab) if drawer: drawer.set_value(False) _ = ( ui.button("New Entry", on_click=open_new_tab) .props("color=primary") .classes("w-full mb-2") ) def open_all_analysis_dialog(): if all_analysis_dialog is not None: all_analysis_dialog.open() _ = ( ui.button("Analyze All Entries", on_click=open_all_analysis_dialog) .props("color=accent") .classes("w-full") ) except RuntimeError as error: if _is_deleted_client_error(error): print("Sidebar update aborted because client elements were deleted.") return False raise return True # --- Main UI Layout Function (Inner Content) --- def journal_ui_layout(): global \ main_tab, \ edit_tab, \ ai_tab, \ new_tab, \ entry_content_box, \ analysis_box, \ new_entry_box # --- Main Content - Full Screen --- with ui.element("div").classes("journal-root").style( "padding: 0; margin: 0;" ): with ui.element("div").style( "width: 100%; height: 100%; padding: clamp(10px, 2vw, 16px); box-sizing: border-box; display: flex; flex-direction: column; min-height: 0;" ): # Tabs with ui.tabs().props("outside-arrows mobile-arrows inline-label").classes("w-full mb-2") as main_tab_instance: global main_tab main_tab = main_tab_instance edit_tab = ui.tab("Edit Entry") ai_tab = ui.tab("AI Analysis") chat_tab = ui.tab("AI Chat") new_tab = ui.tab("New Entry") speech_tab = ui.tab("Speech to Text") # Tab Panels - Full Height with ui.tab_panels(main_tab, value=edit_tab).classes( "w-full flex-grow bg-gray-800 journal-tab-panels" ): # --- Edit Entry Tab --- with ui.tab_panel(edit_tab).style( "flex: 1; display: flex; flex-direction: column; padding: 0;" ): with ui.column().classes("journal-editor-wrapper w-full"): entry_content_box = markdown_editor().classes("w-full journal-editor") with ui.row().classes("gap-2 p-3 bg-gray-900 journal-action-row journal-edit-actions").style("flex-shrink: 0;"): async def save_selected_wrapper(): client = ui.context.client if not selected_file_name or selected_file_name not in file_map: ui.notify("No entry selected to save.", type="warning") return if entry_content_box is None: ui.notify("Editor is not ready.", type="warning") return content = cast(str, entry_content_box.value or "") password = vault_password if not content.strip(): ui.notify("Entry content is empty.", type="warning") return try: msg = await run.io_bound( save_existing_entry, file_map[selected_file_name], content, password, ) except Exception as error: if _is_client_active(client): ui.notify(f"Save failed: {error}", type="negative") return if not _is_client_active(client): return if msg.startswith("Error"): ui.notify(msg, type="negative") else: ui.notify(msg, type="positive") if not update_sidebar(): _schedule_sidebar_retry("existing entry save") async def reload_selected_wrapper(): client = ui.context.client if not selected_file_name or selected_file_name not in file_map: ui.notify("No entry selected to reload.", type="warning") return if entry_content_box is None: ui.notify("Editor is not ready.", type="warning") return try: content = await run.io_bound(load_entry, file_map[selected_file_name]) except Exception as error: if _is_client_active(client): ui.notify(f"Reload failed: {error}", type="negative") return if not _is_client_active(client): return entry_content_box.set_value(content) ui.notify("Entry reloaded from disk.", type="info") _ = ui.button("Save Changes", on_click=save_selected_wrapper).props( "color=primary" ) _ = ui.button("Reload From Disk", on_click=reload_selected_wrapper).props( "color=secondary flat" ) # --- AI Analysis Tab --- with ui.tab_panel(ai_tab).classes("journal-tab-panel").style( "flex: 1; display: flex; flex-direction: column; padding: 16px;" ): _ = ( ui.label("AI Analysis") .classes("text-xl font-bold mb-4") .style("flex-shrink: 0;") ) analysis_box = ( ui.textarea( label="Analysis Results", placeholder='Click "Analyze Entry" to get AI analysis of the selected journal entry...', ) .props("readonly outlined") .classes("journal-large-textarea") .style("flex: 1; width: 100%; min-height: 0;") ) with ui.row().classes("gap-4 mt-4 journal-action-row").style("flex-shrink: 0;"): async def analyze_selected_wrapper(): client = ui.context.client if ( selected_file_name and selected_file_name in file_map and analysis_box is not None ): ui.notify("Analyzing entry...", type="info") analysis = await run.io_bound( analyze_entry, file_map[selected_file_name] ) if not _is_client_active(client): return analysis_box.set_value(analysis) ui.notify("Analysis complete!", type="positive") else: ui.notify("No file selected", type="warning") _ = ui.button( "Analyze Entry", on_click=analyze_selected_wrapper ).props("color=primary").style("min-width: 10rem;") # --- AI Chat Tab --- with ui.tab_panel(chat_tab).classes("journal-tab-panel").style( "flex: 1; display: flex; flex-direction: column; padding: 16px;" ): _ = ( ui.label("AI Chat") .classes("text-xl font-bold mb-4") .style("flex-shrink: 0;") ) chat_output = ( ui.textarea( label="Chat History", placeholder="Chat with the AI...", ) .props("readonly outlined") .classes("journal-large-textarea") .style("flex: 1; width: 100%; min-height: 0;") ) chat_input = ui.input(placeholder="Enter your message").style( "width: 100%;" ) async def send_chat_message(): client = ui.context.client chat_value = cast(str | None, chat_input.value) if chat_value and chat_output: prompt = chat_value chat_input.value = "" response = await run.io_bound(get_cloud_ai_response, prompt) if not _is_client_active(client): return current_chat = str( cast(str | None, chat_output.value) or "" ) chat_output.value = ( current_chat + f"> {prompt}\n{response}\n" ) _ = chat_input.on("keydown.enter", send_chat_message) # --- New Entry Tab --- with ui.tab_panel(new_tab).classes("journal-tab-panel").style( "flex: 1; display: flex; flex-direction: column; padding: 16px;" ): _ = ( ui.label("Create New Entry") .classes("text-xl font-bold mb-4") .style("flex-shrink: 0;") ) mode = ( ui.select( ["Daily", "Deep Recovery", "Fragment", "Deep Entry"], label="Entry Type", value="Daily", ) .classes("mb-4 w-full max-w-sm") .style("flex-shrink: 0;") ) with ui.column().classes("w-full flex-grow min-h-0"): new_entry_box = markdown_editor().classes("w-full h-full") def update_new_entry_box_on_mode_change(): if new_entry_box is None: return # Can't do anything if the box doesn't exist yet mode_val = cast(str | None, mode.value) today_file_path = ( DATA_DIR / f"{datetime.now().strftime('%Y-%m-%d')}.md" ) if ( mode_val and mode_val in ["Daily", "Deep Recovery", "Deep Entry"] and today_file_path.exists() ): try: new_entry_box.set_value(load_entry_content(str(today_file_path))) ui.notify("Loaded today's existing entry.", type="info") except Exception as error: ui.notify(f"Error loading today's entry: {error}", type="negative") else: new_entry_box.set_value("") _ = mode.on( "update:model-value", update_new_entry_box_on_mode_change ) with ui.row().classes("gap-4 mt-4 journal-action-row").style("flex-shrink: 0;"): def fill_template(): mode_val = cast(str | None, mode.value) if new_entry_box is not None and mode_val is not None: template_func = { "Daily": create_daily_entry, "Deep Recovery": create_recovery_entry, "Fragment": create_fragment_entry, "Deep Entry": create_deep_entry, }[mode_val] new_entry_box.set_value(template_func()) ui.notify("Template loaded!", type="info") async def save_new_wrapper(): client = ui.context.client new_entry_value = cast( str | None, new_entry_box.value if new_entry_box else None, ) mode_val = cast(str, mode.value) password = vault_password if new_entry_value and new_entry_value.strip(): try: # For new entries, always append to today's file msg = await run.io_bound( save_entry, new_entry_value, mode_val, password ) except Exception as error: if _is_client_active(client): ui.notify(f"Save failed: {error}", type="negative") return if not _is_client_active(client): return ui.notify(msg, type="positive") if new_entry_box: new_entry_box.set_value("") if not update_sidebar(): _schedule_sidebar_retry("new entry save") else: ui.notify("Entry content is empty", type="warning") _ = ui.button("Create Template", on_click=fill_template).props( "color=secondary" ).style("min-width: 10rem;") _ = ui.button("Save Entry", on_click=save_new_wrapper).props( "color=primary" ).style("min-width: 10rem;") # --- Speech to Text Tab --- with ui.tab_panel(speech_tab).classes("journal-tab-panel").style( "flex: 1; display: flex; flex-direction: column; padding: 16px;" ): # Add a text area to display the live transcription speech_output_box = ( ui.textarea(label="Live Transcription") .props("outlined") .classes("w-full flex-grow journal-large-textarea") ) def on_speech_result(text: str): # Append new transcribed text to the speech output box current_text = cast(str, speech_output_box.value or "") speech_output_box.set_value(current_text + text) # The speech component itself, which now updates the local text area with ui.row().classes("w-full mt-4 journal-action-row"): speech_to_text(on_result=on_speech_result) def append_to_new_entry(): # Safely get and cast the value from the speech box to satisfy pyright. speech_text = cast(str, speech_output_box.value or "") if new_entry_box is not None and speech_text: # Append the transcribed text to the main new entry editor current_entry_text = cast( str, new_entry_box.value or "" ) new_entry_box.set_value( current_entry_text + speech_text ) # Clear the speech box and notify the user speech_output_box.set_value("") ui.notify( "Text appended to 'New Entry' tab.", type="positive", ) _ = ui.button( "Append to New Entry", on_click=append_to_new_entry ).props("color=primary") # --- Dialog for "Analyze All Entries" --- global all_analysis_dialog if all_analysis_dialog is None: all_analysis_dialog = ui.dialog() with all_analysis_dialog, ui.card().classes("w-full max-w-5xl max-h-[90vh]"): _ = ui.label("Comprehensive Journal Analysis").classes("text-xl font-bold mb-4") all_analysis_output = ( ui.textarea(label="Analysis Results") .props("readonly outlined") .classes("h-96 mb-4 w-full") ) with ui.row().classes("gap-4 journal-action-row"): async def do_analyze_all_wrapper(): client = ui.context.client ui.notify("Analyzing all entries...", type="info") result: str = await run.io_bound(analyze_all_entries) if not _is_client_active(client): return all_analysis_output.set_value(result) ui.notify("Analysis complete!", type="positive") _ = ui.button("Run Analysis", on_click=do_analyze_all_wrapper).props( "color=primary" ) _ = ui.button("Close", on_click=lambda: all_analysis_dialog.close() if all_analysis_dialog else None).props( "color=secondary" ) @ui.page("/") async def index_page(): global drawer, sidebar_content, content_container _ = ui.query("body").style("background-color: #111827") # Top-level layout elements (always present) with ( ui.left_drawer(value=False) .props("overlay") .classes("journal-drawer bg-gray-900 text-white") as drawer_instance ): drawer = drawer_instance sidebar_content = ui.column().classes("journal-sidebar p-3 gap-2 h-full") # Sidebar content will be updated after vault load with ui.header(elevated=True).classes( "items-center justify-between px-3 py-2 bg-gray-900" ): _ = ui.button( icon="menu", on_click=lambda: drawer.toggle() if drawer else None ).props("flat color=white") _ = ui.label("📓 Project Journal").classes("journal-header-title") _ = ui.space() # Instantiate and open the settings dialog settings = settings_dialog() _ = ui.button(icon="settings", on_click=settings.open).props("flat color=white") # Main content area (dynamically populated) with ui.column().classes( "w-full bg-gray-900 text-white journal-main-content" ) as content_container_instance: content_container = content_container_instance if vault_password is None and cast(bool, app.storage.user.get("authenticated", False)): # Stale browser/session flag from a previous process start; require password again. app.storage.user["authenticated"] = False app.storage.user.pop("vault_password", None) if not _has_runtime_auth(): with ui.card().classes("absolute-center bg-gray-800 text-white w-full max-w-md"): _ = ui.label("Welcome to Your Journal").classes("text-2xl font-bold") password_input = ( ui.input( "Enter Vault Password", password=True, placeholder="Type your password", ) .props("autofocus") .on("keydown.enter", lambda: password_button.run_method("click")) ) password_button = ui.button( "Load Vault", on_click=lambda: process_password( cast(str | None, password_input.value) ), ) else: _initialize_runtime_user_settings() if not _render_authenticated_layout(): _schedule_sidebar_retry("index page auth render") async def process_password(password: str | None): global vault_password, content_container client = ui.context.client if not password: if _is_client_active(client): ui.notify("Password cannot be empty.", type="negative") return if cast(bool, app.storage.user.get("auth_in_progress", False)): if _is_client_active(client): ui.notify("Vault load already in progress. Please wait...", type="warning") return if vault_load_lock.locked(): if _is_client_active(client): ui.notify("Another vault operation is already in progress. Please wait...", type="warning") return app.storage.user["auth_in_progress"] = True try: async with vault_load_lock: if not _is_client_active(client): return # Check if vault is empty to determine if we're setting a new password is_new_vault = not any(VAULT_DIR.iterdir()) if is_new_vault: await run.io_bound(initialize_vault, password) if _is_client_active(client): ui.notify("New vault initialized!", type="positive") vault_password = password # Load the newly initialized vault (which will be empty but sets up DATA_DIR) _ = await run.io_bound(load_all_vaults, password) else: # Attempt to load existing vaults load_success = await run.io_bound(load_all_vaults, password) if not load_success: if _is_client_active(client): ui.notify("Incorrect password.", type="negative") vault_password = None # Reset password if loading fails app.storage.user["authenticated"] = False app.storage.user.pop("vault_password", None) return # Store password globally for shutdown hook, and in session for this client if not _is_client_active(client): return vault_password = password app.storage.user["authenticated"] = True app.storage.user.pop("vault_password", None) _initialize_runtime_user_settings() if _is_client_active(client): ui.notify("Vault loaded successfully! Loading workspace...", type="positive") # Deterministically rebuild page state; avoids stale-container login hangs. await asyncio.sleep(0.05) if _is_client_active(client): target = f"/?auth={int(time.time() * 1000)}" ui.navigate.to(target) await asyncio.sleep(0.12) if _is_client_active(client): _ = ui.run_javascript( f"if (window.location.pathname + window.location.search !== '{target}') " + "{ window.location.replace('" + target + "'); }" ) except ValueError as e: if _is_client_active(client): ui.notify(f"Error: {e}", type="negative") vault_password = None app.storage.user["authenticated"] = False app.storage.user.pop("vault_password", None) except Exception as e: if _is_client_active(client): ui.notify(f"Error loading vault: {e}", type="negative") vault_password = None # Reset password if loading fails app.storage.user["authenticated"] = False app.storage.user.pop("vault_password", None) finally: try: app.storage.user["auth_in_progress"] = False except RuntimeError: # The page may be gone due to refresh/disconnect before callback completion. pass @app.get("/_health") def healthcheck() -> dict[str, str]: return {"status": "ok"} def _shutdown_handler(): """Handles saving and cleanup on application exit.""" print("NiceGUI shutdown hook triggered.") if vault_password: rebuild_all_vaults(vault_password) else: print("Vault password is None, skipping save.") clear_data_directory() def main(): ui.run( title="Project Journal", reload=False, host="0.0.0.0", port=8080, show=False, storage_secret="a_secret_key_for_session_storage", ) app.on_shutdown(_shutdown_handler) # pyright: ignore[reportUnknownMemberType] if __name__ in {"__main__", "__mp_main__"}: main()