2026-02-21 18:35:20 -06:00

681 lines
27 KiB
Python

import asyncio
import sys
import warnings
if sys.platform == "win32" and hasattr(asyncio, "WindowsSelectorEventLoopPolicy"):
# Avoid noisy Proactor transport resets on long-running Windows sessions.
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
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,
)
from journal.core.config import (
DATA_DIR,
VAULT_DIR,
SPEECH_RECOGNITION_ENGINE,
WHISPER_MODEL_SIZE,
)
from journal.core.parser import parse_journal_file
from journal.ai.analysis 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 rich_text_editor
from journal.ui.components.settings import settings_dialog
ui.dark_mode = 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.editor | None = None
analysis_box: ui.textarea | None = None
new_entry_box: ui.editor | 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()
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 list_journal_files():
# List files directly from the DATA_DIR (which is the decrypted workspace)
files = sorted(DATA_DIR.glob("*.md"))
print(f"list_journal_files found: {[f.name for f in files]}")
return [(f.name, str(f)) for f in 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:
entry = parse_journal_file(file_path)
return entry.raw_content
except Exception as e:
return f"Error loading file: {e}"
def save_existing_entry(file_path: str, content: str, password: str | None) -> str:
try:
# Overwriting is the correct behavior when editing a full, existing entry.
save_entry_content(content, file_path=Path(file_path), mode="Daily")
if password:
save_current_month_vault(password)
return f"Appended changes to {file_path}"
except Exception as e:
return f"Error saving file: {e}"
def analyze_entry(file_path: str) -> str:
try:
entry = parse_journal_file(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 = sorted(DATA_DIR.glob("*.md"))
if not journal_files:
return "No journal files found."
entries = [parse_journal_file(str(f)) for f in journal_files]
analysis = summarize_all_entries(entries)
return analysis
except Exception as e:
return f"Error analyzing all entries: {e}"
def update_sidebar():
global \
file_map, \
drawer, \
main_tab, \
edit_tab, \
entry_content_box, \
analysis_box, \
sidebar_content
print("update_sidebar called.")
if sidebar_content:
try:
sidebar_content.clear()
except RuntimeError as error:
if _is_deleted_client_error(error):
print("Skipping sidebar update because the client was deleted.")
return
raise
try:
with sidebar_content:
_ = ui.label("📅 Calendar").classes("text-md font-bold mb-4")
def on_date_select(date: str):
global selected_file_name
selected_file_name = f"{date}.md"
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(file_map[selected_file_name])
)
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
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(file_map[fname]))
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
raise
# --- 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").style(
"width: 100vw; height: calc(100vh - 64px); overflow: hidden; padding: 0; margin: 0;"
):
with ui.element("div").style(
"width: 100%; height: 100%; padding: 16px; box-sizing: border-box; display: flex; flex-direction: column;"
):
# Tabs
with ui.tabs().classes("w-full mb-4") 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"
):
# --- Edit Entry Tab ---
with ui.tab_panel(edit_tab).style(
"flex: 1; display: flex; flex-direction: column; padding: 0;"
):
entry_content_box = rich_text_editor().classes("w-full h-full")
# --- AI Analysis Tab ---
with ui.tab_panel(ai_tab).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")
.style("flex: 1; width: 100%; min-height: 0;")
)
with ui.row().classes("gap-4 mt-4").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 size=lg")
# --- AI Chat Tab ---
with ui.tab_panel(chat_tab).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")
.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).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-64")
.style("flex-shrink: 0;")
)
new_entry_box = rich_text_editor()
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()
):
new_entry_box.set_value(
today_file_path.read_text(encoding="utf-8")
)
ui.notify("Loaded today's existing entry.", type="info")
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").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 = cast(
str | None, getattr(app.storage.user, "vault_password", None)
)
if new_entry_value and new_entry_value.strip():
# For new entries, always append to today's file
msg = await run.io_bound(
save_entry, new_entry_value, mode_val, password
)
if not _is_client_active(client):
return
ui.notify(msg, type="positive")
if new_entry_box:
new_entry_box.set_value("")
update_sidebar() # Refresh the sidebar
else:
ui.notify("Entry content is empty", type="warning")
_ = ui.button("Create Template", on_click=fill_template).props(
"color=secondary size=lg"
)
_ = ui.button("Save Entry", on_click=save_new_wrapper).props(
"color=primary size=lg"
)
# --- Speech to Text Tab ---
with ui.tab_panel(speech_tab).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")
)
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 justify-between mt-4"):
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"):
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("w-64 bg-gray-900 text-white") as drawer_instance
):
drawer = drawer_instance
sidebar_content = ui.column().classes("p-4 gap-2 h-full")
# Sidebar content will be updated after vault load
with ui.header(elevated=True).classes(
"items-center justify-between px-4 bg-gray-900"
):
# Add a None check inside the lambda to satisfy the type checker
_ = ui.button(
icon="menu", on_click=lambda: drawer.toggle() if drawer else None
).props("flat color=white")
_ = ui.label("📓 Project Journal").classes("text-2xl font-bold")
_ = 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 h-full bg-gray-900 text-white"
) as content_container_instance:
content_container = content_container_instance
if not getattr(app.storage.user, "authenticated", False):
with ui.card().classes("absolute-center bg-gray-800 text-white"):
_ = 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:
# This branch will be executed if the page reloads and password is already set
# or if we navigate back to '/' after successful login.
journal_ui_layout()
update_sidebar() # Ensure sidebar is populated on reload
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:
# 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
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["vault_password"] = password
# Initialize user settings in session storage
app.storage.user["speech_engine"] = SPEECH_RECOGNITION_ENGINE
app.storage.user["whisper_model"] = WHISPER_MODEL_SIZE
ui.notify("Vault loaded successfully!", type="positive")
# Clear the password input area and render the main UI
if content_container is not None:
try:
content_container.clear()
with content_container:
journal_ui_layout()
except RuntimeError as error:
if _is_deleted_client_error(error):
print("Skipping layout update because the client was deleted.")
return
raise
update_sidebar() # Populate sidebar after vault is loaded
except ValueError as e:
if _is_client_active(client):
ui.notify(f"Error: {e}", type="negative")
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
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()