]*>", lowered)) >= 8
+
+
+def _strip_rich_html(content: str) -> str:
+ if not _looks_like_rich_html(content):
+ return content
+
+ text = content.replace("\r\n", "\n").replace("\r", "\n")
+ text = re.sub(r"(?is)<(script|style)\b[^>]*>.*?\1>", "", text)
+ text = re.sub(r"(?i)
", "\n", text)
+ text = re.sub(r"(?i)(p|div|h[1-6]|tr|table|ul|ol|blockquote)>", "\n", text)
+ text = re.sub(r"(?i)
]*>", "\n- ", text)
+ text = re.sub(r"(?i)", "\n", text)
+ text = re.sub(r"(?i)<(td|th)\b[^>]*>", " | ", text)
+ text = re.sub(r"(?i)(td|th)>", " ", text)
+ text = re.sub(r"(?i)
]*>", "\n---\n", text)
+ text = re.sub(r"(?is)<[^>]+>", "", text)
+ text = html.unescape(text)
+ text = text.replace("\u00a0", " ").replace("\u200b", "")
+ text = "\n".join(line.rstrip() for line in text.splitlines())
+ text = re.sub(r"[ \t]{2,}", " ", text)
+ text = re.sub(r"\n{3,}", "\n\n", text).strip()
+
+ if text:
+ return text
+ return content
+
+
# --- Monthly Vault Management ---
@@ -100,29 +155,89 @@ def get_today_filename() -> Path:
return DATA_DIR / f"{datetime.now().strftime('%Y-%m-%d')}.md"
+def list_journal_files() -> list[tuple[str, str]]:
+ """Lists decrypted markdown entries as (file_name, absolute_path)."""
+ if _using_csharp_hybrid():
+ results = call_sidecar_action(
+ "entries.list",
+ payload={"dataDirectory": str(DATA_DIR)},
+ )
+ if not isinstance(results, list):
+ return []
+
+ files: list[tuple[str, str]] = []
+ for item in results:
+ if not isinstance(item, dict):
+ continue
+ name = item.get("FileName") or item.get("fileName")
+ path = item.get("FilePath") or item.get("filePath")
+ if isinstance(name, str) and isinstance(path, str):
+ files.append((name, path))
+ return files
+
+ files = sorted(DATA_DIR.glob("*.md"))
+ return [(f.name, str(f)) for f in files]
+
+
+def load_entry_content(file_path: str | Path) -> str:
+ """Loads one journal entry and returns the raw markdown content."""
+ normalized_path = str(file_path)
+ if _using_csharp_hybrid():
+ data = call_sidecar_action(
+ "entries.load",
+ payload={"filePath": normalized_path},
+ )
+ if isinstance(data, str):
+ return _strip_rich_html(data)
+ if isinstance(data, dict):
+ raw = data.get("RawContent") or data.get("rawContent")
+ if isinstance(raw, str):
+ return _strip_rich_html(raw)
+ raise RuntimeError("Unexpected entries.load response shape from C# sidecar.")
+
+ entry = parse_journal_file(normalized_path)
+ return _strip_rich_html(entry.raw_content)
+
+
def save_entry_content(
content: str, file_path: Path | None = None, mode: str = "Daily"
):
+ sanitized_content = _strip_rich_html(content)
target_file = file_path or get_today_filename()
target_file.parent.mkdir(parents=True, exist_ok=True)
+ if _using_csharp_hybrid():
+ _ = call_sidecar_action(
+ "entries.save",
+ payload={
+ "content": sanitized_content,
+ "filePath": str(target_file),
+ "mode": mode,
+ },
+ )
+ return
+
+ if mode == "Overwrite":
+ _ = target_file.write_text(sanitized_content, encoding="utf-8")
+ return
+
if mode == "Fragment":
print(f"Appending fragment to {target_file.name}...")
with open(target_file, "a", encoding="utf-8") as f:
# Ensure there's a newline before the new content
- _ = f.write("\n\n" + content.strip())
+ _ = f.write("\n\n" + sanitized_content.strip())
return
# For Daily, Deep, etc., perform a merge
if target_file.exists():
print(f"Merging content into existing file: {target_file.name}")
existing_entry = parse_journal_file(str(target_file))
- new_entry_data = parse_journal_content(content, target_file.stem)
+ new_entry_data = parse_journal_content(sanitized_content, target_file.stem)
existing_entry.merge_with(new_entry_data)
final_content = existing_entry.to_markdown()
else:
print(f"Creating new entry: {target_file.name}")
- final_content = content
+ final_content = sanitized_content
_ = target_file.write_text(final_content, encoding="utf-8")
@@ -139,53 +254,79 @@ def load_all_vaults(password: str) -> bool:
with _vault_io_lock:
_month_fingerprint_cache.clear()
- # Clear DATA_DIR first
- _clear_data_dir_with_retries()
- DATA_DIR.mkdir(parents=True, exist_ok=True)
-
- if not VAULT_DIR.exists() or not any(VAULT_DIR.iterdir()):
- print("Vault directory is empty or does not exist. Assuming new vault.")
- return True # No vaults to load, so it's a success (new vault)
-
- decryption_successful = False
- for vault_file in VAULT_DIR.glob("*.vault"):
- if vault_file.name == "_init_vault.vault":
- print(f"Deleting old dummy vault file: {vault_file.name}")
- vault_file.unlink()
- continue
+ if _using_csharp_hybrid():
+ load_success = bool(
+ call_sidecar_action(
+ "vault.load_all",
+ payload={
+ "password": password,
+ "vaultDirectory": str(VAULT_DIR),
+ "dataDirectory": str(DATA_DIR),
+ },
+ )
+ )
+ if not load_success:
+ return False
try:
- with open(vault_file, "rb") as f_in:
- encrypted_data = f_in.read()
-
- decrypted_zip_content = decrypt_data(encrypted_data, password)
-
- # Write decrypted content to a temporary zip file
- temp_zip_path = VAULT_DIR / f"temp_{vault_file.name}.zip"
- with open(temp_zip_path, "wb") as f_out:
- _ = f_out.write(decrypted_zip_content)
-
- _extract_monthly_archive(temp_zip_path, DATA_DIR)
- temp_zip_path.unlink() # Clean up temp zip
- decryption_successful = True
- print(f"Successfully loaded {vault_file.name}")
- print(
- f"Contents of DATA_DIR after loading {vault_file.name}: {list(DATA_DIR.iterdir())}"
+ _ = call_sidecar_action(
+ "db.hydrate_workspace",
+ payload={
+ "password": password,
+ "dataDirectory": str(DATA_DIR),
+ },
)
- except InvalidTag:
- print(
- f"Warning: Could not decrypt '{vault_file.name}'. Invalid password for this file."
- )
- # Do not set decryption_successful to True if only some files fail
except Exception as e:
- print(f"Error loading vault '{vault_file.name}': {e}")
- # If any other error occurs, it's not necessarily a password issue
+ print(f"Fatal error during C# workspace hydration: {e}")
+ return False
+ return True
+ else:
+ # Clear DATA_DIR first
+ _clear_data_dir_with_retries()
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
- if not decryption_successful and any(VAULT_DIR.iterdir()):
- # If there are vault files, but none could be decrypted, password is wrong
- print("Error: No vault files could be decrypted with the provided password.")
- return False
+ if not VAULT_DIR.exists() or not any(VAULT_DIR.iterdir()):
+ print("Vault directory is empty or does not exist. Assuming new vault.")
+ return True # No vaults to load, so it's a success (new vault)
- # --- Database Hydration ---
+ decryption_successful = False
+ for vault_file in VAULT_DIR.glob("*.vault"):
+ if vault_file.name == "_init_vault.vault":
+ print(f"Deleting old dummy vault file: {vault_file.name}")
+ vault_file.unlink()
+ continue
+ try:
+ with open(vault_file, "rb") as f_in:
+ encrypted_data = f_in.read()
+
+ decrypted_zip_content = decrypt_data(encrypted_data, password)
+
+ # Write decrypted content to a temporary zip file
+ temp_zip_path = VAULT_DIR / f"temp_{vault_file.name}.zip"
+ with open(temp_zip_path, "wb") as f_out:
+ _ = f_out.write(decrypted_zip_content)
+
+ _extract_monthly_archive(temp_zip_path, DATA_DIR)
+ temp_zip_path.unlink() # Clean up temp zip
+ decryption_successful = True
+ print(f"Successfully loaded {vault_file.name}")
+ print(
+ f"Contents of DATA_DIR after loading {vault_file.name}: {list(DATA_DIR.iterdir())}"
+ )
+ except InvalidTag:
+ print(
+ f"Warning: Could not decrypt '{vault_file.name}'. Invalid password for this file."
+ )
+ # Do not set decryption_successful to True if only some files fail
+ except Exception as e:
+ print(f"Error loading vault '{vault_file.name}': {e}")
+ # If any other error occurs, it's not necessarily a password issue
+
+ if not decryption_successful and any(VAULT_DIR.iterdir()):
+ # If there are vault files, but none could be decrypted, password is wrong
+ print("Error: No vault files could be decrypted with the provided password.")
+ return False
+
+ # --- Database Hydration (Python mode only) ---
# After successfully decrypting files, hydrate the live, encrypted database.
conn = None
try:
@@ -216,6 +357,17 @@ def rebuild_all_vaults(password: str):
if not password:
raise ValueError("Password cannot be empty.")
+ if _using_csharp_hybrid():
+ _ = call_sidecar_action(
+ "vault.rebuild_all",
+ payload={
+ "password": password,
+ "vaultDirectory": str(VAULT_DIR),
+ "dataDirectory": str(DATA_DIR),
+ },
+ )
+ return
+
with _vault_io_lock:
# Group files by month
monthly_files: dict[str, list[Path]] = {}
@@ -248,6 +400,18 @@ def save_current_month_vault(password: str):
if not password:
raise ValueError("Password cannot be empty.")
+ if _using_csharp_hybrid():
+ _ = call_sidecar_action(
+ "vault.save_current_month",
+ payload={
+ "password": password,
+ "vaultDirectory": str(VAULT_DIR),
+ "dataDirectory": str(DATA_DIR),
+ "nowUtc": datetime.utcnow().isoformat() + "Z",
+ },
+ )
+ return
+
with _vault_io_lock:
# Determine current month
now = datetime.now()
@@ -279,6 +443,16 @@ def initialize_vault(password: str):
if not password:
raise ValueError("Password cannot be empty.")
+ if _using_csharp_hybrid():
+ _ = call_sidecar_action(
+ "vault.initialize",
+ payload={
+ "password": password,
+ "vaultDirectory": str(VAULT_DIR),
+ },
+ )
+ return
+
VAULT_DIR.mkdir(parents=True, exist_ok=True)
print("Vault directory ensured to exist.")
@@ -288,6 +462,14 @@ def clear_data_directory():
Clears the DATA_DIR. This should only be called on application shutdown.
"""
print("Clearing DATA_DIR...")
+ if _using_csharp_hybrid():
+ _ = call_sidecar_action(
+ "vault.clear_data_directory",
+ payload={"dataDirectory": str(DATA_DIR)},
+ )
+ print("DATA_DIR cleared.")
+ return
+
with _vault_io_lock:
# The encrypted database file lives in DATA_DIR, so this function
# will securely delete it along with all the decrypted .md files.
diff --git a/journal/run_desktop.py b/journal/run_desktop.py
index fb56575..f5968e5 100644
--- a/journal/run_desktop.py
+++ b/journal/run_desktop.py
@@ -25,13 +25,23 @@ _process_lock = threading.Lock()
_watchdog_stop = threading.Event()
_WATCHDOG_INTERVAL_SECONDS = 10.0
_WATCHDOG_MAX_HEALTH_FAILURES = 3
+_WATCHDOG_HEALTHCHECK_TIMEOUT_SECONDS = 2.5
_WATCHDOG_MIN_RESTART_INTERVAL_SECONDS = 5.0
_WATCHDOG_MAX_FAILED_RESTARTS = 5
+_WATCHDOG_MAX_CONSECUTIVE_CRASH_RESTARTS = 3
+_WATCHDOG_STARTUP_GRACE_SECONDS = 45.0
+_WATCHDOG_RESTART_GRACE_SECONDS = 30.0
_watchdog_failed_restarts = 0
_last_restart_monotonic = 0.0
+_watchdog_grace_until_monotonic = 0.0
SERVER_URL = "http://localhost:8080"
HEALTHCHECK_URL = f"{SERVER_URL}/_health"
VALID_SERVER_ACTIONS = {"restart", "shutdown"}
+DISABLE_WEBVIEW = os.getenv("JOURNAL_DISABLE_WEBVIEW", "").strip().lower() in {
+ "1",
+ "true",
+ "yes",
+}
def wait_for_server(url: str, timeout_seconds: float = 20.0) -> bool:
@@ -72,20 +82,45 @@ def _clear_server_action() -> None:
def get_python_executable() -> str:
"""
- Determines the correct Python executable path by searching the system's PATH.
+ Returns the current interpreter path for child server startup.
- This function uses `shutil.which('python')` to locate the `python` executable.
- When a virtual environment is active, its `bin` directory is at the front of the
- PATH, so this will correctly return the path to the venv's interpreter.
+ Prefer `sys.executable` so wrapper and child use the same runtime (e.g., 3.14t).
+ Fall back to PATH lookup only if `sys.executable` is missing/unusable.
Returns:
The absolute path to the Python executable.
"""
- python_executable = shutil.which('python')
+ if sys.executable:
+ return sys.executable
+
+ python_executable = shutil.which("python")
if python_executable:
return python_executable
- # Fallback to sys.executable if shutil.which fails for some reason.
- return sys.executable
+
+ raise RuntimeError("Could not determine Python executable path.")
+
+
+def can_use_webview() -> bool:
+ """
+ Returns True when pywebview backend is available on this host.
+ On Windows, pywebview requires pythonnet (`clr`) for WinForms backend.
+ """
+ if DISABLE_WEBVIEW:
+ print("Webview disabled via JOURNAL_DISABLE_WEBVIEW; using browser mode.")
+ return False
+
+ if webview is None:
+ return False
+
+ if sys.platform != "win32":
+ return True
+
+ try:
+ import clr # type: ignore # noqa: F401
+ return True
+ except Exception:
+ print("pywebview backend unavailable on Windows (pythonnet `clr` is missing).")
+ return False
def is_server_running():
@@ -178,7 +213,7 @@ def _stop_process(process: Optional[subprocess.Popen], timeout_seconds: float =
def _restart_nicegui(reason: str) -> None:
- global _watchdog_failed_restarts, _last_restart_monotonic
+ global _watchdog_failed_restarts, _last_restart_monotonic, _watchdog_grace_until_monotonic
if _watchdog_stop.is_set():
return
elapsed = time.monotonic() - _last_restart_monotonic
@@ -190,6 +225,9 @@ def _restart_nicegui(reason: str) -> None:
_safe_set_process(None)
_last_restart_monotonic = time.monotonic()
start_nicegui()
+ _watchdog_grace_until_monotonic = (
+ time.monotonic() + _WATCHDOG_RESTART_GRACE_SECONDS
+ )
if wait_for_server(HEALTHCHECK_URL, timeout_seconds=20.0):
_watchdog_failed_restarts = 0
print("Watchdog restart completed: server is healthy.")
@@ -205,7 +243,9 @@ def _restart_nicegui(reason: str) -> None:
def _watchdog_loop() -> None:
+ global _watchdog_grace_until_monotonic
consecutive_health_failures = 0
+ consecutive_crash_restarts = 0
while not _watchdog_stop.wait(_WATCHDOG_INTERVAL_SECONDS):
process = _safe_get_process()
if process is None:
@@ -221,13 +261,33 @@ def _watchdog_loop() -> None:
_watchdog_stop.set()
break
if action == "restart":
+ consecutive_crash_restarts = 0
_restart_nicegui("server restart requested from UI")
else:
+ if exit_code != 0:
+ consecutive_crash_restarts += 1
+ else:
+ consecutive_crash_restarts = 0
+ if consecutive_crash_restarts >= _WATCHDOG_MAX_CONSECUTIVE_CRASH_RESTARTS:
+ print(
+ "Server crashed repeatedly during startup. "
+ "Common cause: launching with free-threaded Python (python3.14t) while "
+ "binary wheels (orjson/pydantic_core) are installed for regular CPython. "
+ "Use python.exe for this project runtime."
+ )
+ _watchdog_stop.set()
+ break
_restart_nicegui(f"server process exited with code {exit_code}")
consecutive_health_failures = 0
continue
- is_healthy = wait_for_server(HEALTHCHECK_URL, timeout_seconds=1.0)
+ if time.monotonic() < _watchdog_grace_until_monotonic:
+ consecutive_health_failures = 0
+ continue
+
+ is_healthy = wait_for_server(
+ HEALTHCHECK_URL, timeout_seconds=_WATCHDOG_HEALTHCHECK_TIMEOUT_SECONDS
+ )
if is_healthy:
consecutive_health_failures = 0
continue
@@ -242,6 +302,7 @@ def _watchdog_loop() -> None:
def run():
+ global _watchdog_grace_until_monotonic
started_by_wrapper = False
watchdog_thread: Optional[threading.Thread] = None
_clear_server_action()
@@ -253,6 +314,9 @@ def run():
# Start NiceGUI server managed by this wrapper.
print("Starting NiceGUI server...")
start_nicegui()
+ _watchdog_grace_until_monotonic = (
+ time.monotonic() + _WATCHDOG_STARTUP_GRACE_SECONDS
+ )
started_by_wrapper = True
if started_by_wrapper:
@@ -264,7 +328,7 @@ def run():
try:
# Open desktop shell if available; otherwise use browser fallback.
- if webview is not None:
+ if can_use_webview():
try:
print("Opening webview window...")
_ = webview.create_window("Project Journal", SERVER_URL)
diff --git a/journal/ui/components/calendar.py b/journal/ui/components/calendar.py
index 5e46acd..669d05a 100644
--- a/journal/ui/components/calendar.py
+++ b/journal/ui/components/calendar.py
@@ -2,12 +2,12 @@ from nicegui import ui
from typing import Callable, cast
-def calendar_view(on_select: Callable[[str], None]) -> None:
- with ui.card().tight().classes("bg-gray-800 text-white"):
- with ui.row().classes("w-full items-center px-4"):
+def calendar_view(on_select: Callable[[str | None], None]) -> None:
+ with ui.card().classes("bg-gray-800 text-white w-full journal-calendar-card"):
+ with ui.row().classes("w-full items-center px-3"):
_ = ui.label("Journal Calendar").classes("text-lg font-bold")
- with ui.row().classes("w-full items-center px-4"):
+ with ui.row().classes("w-full items-center px-3"):
# Calendar view
_ = ui.date(
- on_change=lambda e: on_select(cast(str, e.value))
- ).classes("bg-gray-800 text-white")
+ on_change=lambda e: on_select(cast(str | None, e.value))
+ ).props("dark").classes("bg-gray-800 text-white journal-calendar")
diff --git a/journal/ui/components/editor.py b/journal/ui/components/editor.py
index e21e4fd..e703acf 100644
--- a/journal/ui/components/editor.py
+++ b/journal/ui/components/editor.py
@@ -136,3 +136,13 @@ def rich_text_editor() -> ui.editor:
_ = editor.on("vue-mounted", attach_paste_handler)
return editor
+
+
+def markdown_editor() -> ui.textarea:
+ """A markdown-native editor with stable scrolling and no HTML conversion."""
+ return (
+ ui.textarea(placeholder="Write markdown here...")
+ .props("outlined autogrow=false")
+ .classes("bg-gray-800 text-white journal-markdown-editor")
+ .style("flex: 1; width: 100%; min-height: 0;")
+ )
diff --git a/journal/ui/components/settings.py b/journal/ui/components/settings.py
index a0169b0..7f62d2c 100644
--- a/journal/ui/components/settings.py
+++ b/journal/ui/components/settings.py
@@ -1,16 +1,119 @@
import asyncio
+import os
+from typing import Any, cast
+
+from nicegui import app, ui
-from nicegui import ui, app
from journal.core.config import (
+ BACKEND_MODE,
+ CHUNK_TOKEN_BUDGET,
+ CLOUDAI_API_KEY,
+ CLOUDAI_API_URL,
+ CLOUDAI_TIMEOUT,
+ CSHARP_SIDECAR_PATH,
+ EMBEDDING_API_URL,
+ EMBEDDING_MODEL_NAME,
+ FASTER_WHISPER_COMPUTE_TYPE,
+ FASTER_WHISPER_DEVICE,
+ LLAMA_CPP_MODEL,
+ LLAMA_CPP_TIMEOUT,
+ LLAMA_CPP_URL,
+ MODEL_CONTEXT_TOKENS,
+ NLP_BACKEND,
+ SERVER_CONTROL_FILE,
SPEECH_RECOGNITION_ENGINE,
WHISPER_MODEL_SIZE,
- SERVER_CONTROL_FILE,
)
-from typing import cast
+
+_DEFAULT_SETTINGS: dict[str, Any] = {
+ "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": 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": CLOUDAI_TIMEOUT,
+ "model_context_tokens": MODEL_CONTEXT_TOKENS,
+ "chunk_token_budget": CHUNK_TOKEN_BUDGET,
+}
+
+_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",
+ "model_context_tokens": "JOURNAL_MODEL_CONTEXT_TOKENS",
+ "chunk_token_budget": "JOURNAL_CHUNK_TOKEN_BUDGET",
+}
+
+
+def _ensure_defaults() -> None:
+ for key, value in _DEFAULT_SETTINGS.items():
+ app.storage.user.setdefault(key, value)
+
+
+def _apply_env_from_storage() -> None:
+ for key, env_key in _ENV_KEYS.items():
+ value = app.storage.user.get(key, _DEFAULT_SETTINGS.get(key, ""))
+ os.environ[env_key] = str(value).strip()
+
+
+def _on_text_change(user_key: str, env_key: str, label: str, restart_required: bool = False):
+ def handler(e: Any) -> None:
+ value = str(e.value or "").strip()
+ app.storage.user[user_key] = value
+ os.environ[env_key] = value
+ suffix = " Restart server to apply." if restart_required else ""
+ ui.notify(f"{label} updated.{suffix}")
+
+ return handler
+
+
+def _on_int_change(user_key: str, env_key: str, label: str, restart_required: bool = False):
+ def handler(e: Any) -> None:
+ try:
+ value = int(float(cast(Any, e.value)))
+ except (TypeError, ValueError):
+ ui.notify(f"{label} must be a valid number.", type="negative")
+ return
+
+ if value <= 0:
+ ui.notify(f"{label} must be greater than 0.", type="negative")
+ return
+
+ app.storage.user[user_key] = value
+ os.environ[env_key] = str(value)
+ suffix = " Restart server to apply." if restart_required else ""
+ ui.notify(f"{label} updated.{suffix}")
+
+ return handler
def settings_dialog():
- """Creates a dialog for application settings."""
+ """Creates a dialog for runtime/application settings."""
+
+ _ensure_defaults()
+ _apply_env_from_storage()
+
async def request_server_action(action: str) -> None:
try:
SERVER_CONTROL_FILE.parent.mkdir(parents=True, exist_ok=True)
@@ -24,7 +127,6 @@ def settings_dialog():
else:
ui.notify("Server shutdown requested...", type="warning")
- # Give the notification a moment to flush before shutdown.
await asyncio.sleep(0.2)
app.shutdown()
@@ -34,56 +136,238 @@ def settings_dialog():
async def shutdown_server() -> None:
await request_server_action("shutdown")
- with ui.dialog() as dialog, ui.card().classes("w-80"):
+ with ui.dialog() as dialog, ui.card().classes("w-full max-w-[38rem] max-h-[85vh] overflow-y-auto"):
_ = ui.label("Settings").classes("text-xl font-bold")
+ _ = ui.markdown(
+ "Most values apply immediately. "
+ "Some runtime/module values are marked with restart hints."
+ ).classes("text-xs text-gray-500")
- _ = ui.label("Speech to Text").classes("text-lg font-medium mt-4")
-
- if "speech_engine" in app.storage.user:
- initial_engine_value = cast(str, app.storage.user["speech_engine"])
- else:
- initial_engine_value = SPEECH_RECOGNITION_ENGINE
-
+ _ = ui.separator().classes("my-2")
+ _ = ui.label("Backend").classes("text-lg font-medium")
_ = (
ui.select(
- ["whisper", "google", "sphinx"],
- label="Recognition Engine",
- value=initial_engine_value,
- on_change=lambda e: ui.notify(
- f"Engine set to {cast(str, e.value)}. Takes effect on next recording."
+ ["csharp-hybrid", "python"],
+ label="Backend Mode",
+ value=cast(str, app.storage.user["backend_mode"]),
+ on_change=_on_text_change(
+ "backend_mode",
+ "JOURNAL_BACKEND_MODE",
+ "Backend mode",
+ restart_required=True,
+ ),
+ )
+ .bind_value(app.storage.user, "backend_mode")
+ .classes("w-full")
+ )
+ _ = (
+ ui.input(
+ label="C# Sidecar Path (optional override)",
+ value=cast(str, app.storage.user["csharp_sidecar_path"]),
+ on_change=_on_text_change(
+ "csharp_sidecar_path",
+ "JOURNAL_CSHARP_SIDECAR_PATH",
+ "C# sidecar path",
+ restart_required=True,
+ ),
+ )
+ .bind_value(app.storage.user, "csharp_sidecar_path")
+ .classes("w-full")
+ )
+
+ _ = ui.separator().classes("my-2")
+ _ = ui.label("AI: Local Model Endpoints").classes("text-lg font-medium")
+ _ = (
+ ui.input(
+ label="Llama.cpp URL",
+ value=cast(str, app.storage.user["llama_cpp_url"]),
+ on_change=_on_text_change(
+ "llama_cpp_url", "JOURNAL_LLAMA_CPP_URL", "Llama.cpp URL"
+ ),
+ )
+ .bind_value(app.storage.user, "llama_cpp_url")
+ .classes("w-full")
+ )
+ _ = (
+ ui.input(
+ label="Llama.cpp Model",
+ value=cast(str, app.storage.user["llama_cpp_model"]),
+ on_change=_on_text_change(
+ "llama_cpp_model", "JOURNAL_LLAMA_CPP_MODEL", "Llama.cpp model"
+ ),
+ )
+ .bind_value(app.storage.user, "llama_cpp_model")
+ .classes("w-full")
+ )
+ _ = (
+ ui.number(
+ label="Llama.cpp Timeout (ms)",
+ value=float(cast(str, app.storage.user["llama_cpp_timeout"])),
+ on_change=_on_int_change(
+ "llama_cpp_timeout", "JOURNAL_LLAMA_CPP_TIMEOUT", "Llama timeout"
+ ),
+ )
+ .bind_value(app.storage.user, "llama_cpp_timeout")
+ .classes("w-full")
+ )
+ _ = (
+ ui.input(
+ label="Embedding API URL",
+ value=cast(str, app.storage.user["embedding_api_url"]),
+ on_change=_on_text_change(
+ "embedding_api_url", "JOURNAL_EMBEDDING_API_URL", "Embedding API URL"
+ ),
+ )
+ .bind_value(app.storage.user, "embedding_api_url")
+ .classes("w-full")
+ )
+ _ = (
+ ui.input(
+ label="Embedding Model Name",
+ value=cast(str, app.storage.user["embedding_model_name"]),
+ on_change=_on_text_change(
+ "embedding_model_name",
+ "JOURNAL_EMBEDDING_MODEL_NAME",
+ "Embedding model name",
+ ),
+ )
+ .bind_value(app.storage.user, "embedding_model_name")
+ .classes("w-full")
+ )
+ _ = (
+ ui.number(
+ label="Model Context Tokens",
+ value=float(cast(str, app.storage.user["model_context_tokens"])),
+ on_change=_on_int_change(
+ "model_context_tokens",
+ "JOURNAL_MODEL_CONTEXT_TOKENS",
+ "Model context tokens",
+ ),
+ )
+ .bind_value(app.storage.user, "model_context_tokens")
+ .classes("w-full")
+ )
+ _ = (
+ ui.number(
+ label="Chunk Token Budget",
+ value=float(cast(str, app.storage.user["chunk_token_budget"])),
+ on_change=_on_int_change(
+ "chunk_token_budget",
+ "JOURNAL_CHUNK_TOKEN_BUDGET",
+ "Chunk token budget",
+ ),
+ )
+ .bind_value(app.storage.user, "chunk_token_budget")
+ .classes("w-full")
+ )
+
+ _ = ui.separator().classes("my-2")
+ _ = ui.label("AI: Cloud Endpoint").classes("text-lg font-medium")
+ _ = (
+ ui.input(
+ label="Cloud AI URL",
+ value=cast(str, app.storage.user["cloudai_api_url"]),
+ on_change=_on_text_change(
+ "cloudai_api_url", "JOURNAL_CLOUDAI_API_URL", "Cloud AI URL"
+ ),
+ )
+ .bind_value(app.storage.user, "cloudai_api_url")
+ .classes("w-full")
+ )
+ _ = (
+ ui.input(
+ label="Cloud AI API Key",
+ password=True,
+ password_toggle_button=True,
+ value=cast(str, app.storage.user["cloudai_api_key"]),
+ on_change=_on_text_change(
+ "cloudai_api_key", "JOURNAL_CLOUDAI_API_KEY", "Cloud AI API key"
+ ),
+ )
+ .bind_value(app.storage.user, "cloudai_api_key")
+ .classes("w-full")
+ )
+ _ = (
+ ui.number(
+ label="Cloud AI Timeout (s)",
+ value=float(cast(str, app.storage.user["cloudai_timeout"])),
+ on_change=_on_int_change(
+ "cloudai_timeout", "JOURNAL_CLOUDAI_TIMEOUT", "Cloud AI timeout"
+ ),
+ )
+ .bind_value(app.storage.user, "cloudai_timeout")
+ .classes("w-full")
+ )
+
+ _ = ui.separator().classes("my-2")
+ _ = ui.label("NLP + Speech").classes("text-lg font-medium")
+ _ = (
+ ui.select(
+ ["auto", "spacy", "fallback"],
+ label="NLP Backend",
+ value=cast(str, app.storage.user["nlp_backend"]),
+ on_change=_on_text_change(
+ "nlp_backend",
+ "JOURNAL_NLP_BACKEND",
+ "NLP backend",
+ restart_required=True,
+ ),
+ )
+ .bind_value(app.storage.user, "nlp_backend")
+ .classes("w-full")
+ )
+ _ = (
+ ui.select(
+ ["faster-whisper", "whisper", "google", "sphinx"],
+ label="Speech Recognition Engine",
+ value=cast(str, app.storage.user["speech_engine"]),
+ on_change=_on_text_change(
+ "speech_engine", "JOURNAL_SPEECH_ENGINE", "Speech engine"
),
)
.bind_value(app.storage.user, "speech_engine")
.classes("w-full")
)
- _ = ui.markdown(
- "`whisper` is local & private (recommended).\n`google` is online.\n`sphinx` is offline & fast."
- ).classes("text-xs text-gray-500")
-
- # Add whisper model select
- _ = ui.separator().classes("my-2")
- _ = ui.label("Whisper Model Size").classes("font-medium")
-
- if "whisper_model" in app.storage.user:
- initial_whisper_model = cast(str, app.storage.user["whisper_model"])
- else:
- initial_whisper_model = WHISPER_MODEL_SIZE
-
_ = (
ui.select(
["tiny", "base", "small", "medium", "large"],
- label="Accuracy vs. Speed",
- value=initial_whisper_model,
- on_change=lambda e: ui.notify(
- f"Whisper model set to {cast(str, e.value)}. Takes effect on next recording."
+ label="Whisper Model Size",
+ value=cast(str, app.storage.user["whisper_model"]),
+ on_change=_on_text_change(
+ "whisper_model", "JOURNAL_WHISPER_MODEL", "Whisper model"
),
)
.bind_value(app.storage.user, "whisper_model")
.classes("w-full")
)
- _ = ui.markdown(
- "`base` is a good balance. Larger models are more accurate but slower."
- ).classes("text-xs text-gray-500")
+ _ = (
+ ui.select(
+ ["auto", "cpu", "cuda"],
+ label="faster-whisper Device",
+ value=cast(str, app.storage.user["faster_whisper_device"]),
+ on_change=_on_text_change(
+ "faster_whisper_device",
+ "JOURNAL_FASTER_WHISPER_DEVICE",
+ "faster-whisper device",
+ ),
+ )
+ .bind_value(app.storage.user, "faster_whisper_device")
+ .classes("w-full")
+ )
+ _ = (
+ ui.select(
+ ["float32", "int8", "default", "auto", "float16", "int8_float32", "int8_float16"],
+ label="faster-whisper Compute Type",
+ value=cast(str, app.storage.user["faster_whisper_compute_type"]),
+ on_change=_on_text_change(
+ "faster_whisper_compute_type",
+ "JOURNAL_FASTER_WHISPER_COMPUTE_TYPE",
+ "faster-whisper compute type",
+ ),
+ )
+ .bind_value(app.storage.user, "faster_whisper_compute_type")
+ .classes("w-full")
+ )
_ = ui.separator().classes("my-2")
_ = ui.label("Server Controls").classes("font-medium")
diff --git a/journal/ui/components/speech.py b/journal/ui/components/speech.py
index c57fd03..37c7814 100644
--- a/journal/ui/components/speech.py
+++ b/journal/ui/components/speech.py
@@ -6,7 +6,12 @@ import queue
# Add project root to sys.path to allow for absolute imports
sys.path.append(str(Path(__file__).resolve().parent.parent.parent))
-from journal.core.config import SPEECH_RECOGNITION_ENGINE, WHISPER_MODEL_SIZE
+from journal.core.config import (
+ SPEECH_RECOGNITION_ENGINE,
+ WHISPER_MODEL_SIZE,
+ FASTER_WHISPER_DEVICE,
+ FASTER_WHISPER_COMPUTE_TYPE,
+)
from journal.core.speech import start_background_listening, stop_background_listening
@@ -55,10 +60,27 @@ def speech_to_text(on_result: Callable[[str], None]) -> None:
else:
whisper_model = WHISPER_MODEL_SIZE
+ if "faster_whisper_device" in app.storage.user:
+ faster_whisper_device = cast(str, app.storage.user["faster_whisper_device"])
+ else:
+ faster_whisper_device = FASTER_WHISPER_DEVICE
+
+ if "faster_whisper_compute_type" in app.storage.user:
+ faster_whisper_compute_type = cast(
+ str, app.storage.user["faster_whisper_compute_type"]
+ )
+ else:
+ faster_whisper_compute_type = FASTER_WHISPER_COMPUTE_TYPE
+
if queue_timer is not None:
queue_timer.active = True
await run.io_bound(
- start_background_listening, message_queue, engine, whisper_model
+ start_background_listening,
+ message_queue,
+ engine,
+ whisper_model,
+ faster_whisper_device,
+ faster_whisper_compute_type,
)
async def stop_listening_task():
diff --git a/journal/ui/main.py b/journal/ui/main.py
index 90ddc6f..a8d4341 100644
--- a/journal/ui/main.py
+++ b/journal/ui/main.py
@@ -1,12 +1,17 @@
import asyncio
import sys
import warnings
+import os
+import time
-if sys.platform == "win32" and hasattr(asyncio, "WindowsSelectorEventLoopPolicy"):
+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)
- asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
+ 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
@@ -30,22 +35,153 @@ from journal.core.storage import (
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_TIMEOUT,
+ MODEL_CONTEXT_TOKENS,
+ CHUNK_TOKEN_BUDGET,
)
-from journal.core.parser import parse_journal_file
-from journal.ai.analysis import summarize_entry, summarize_all_entries
+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 rich_text_editor
+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
@@ -55,9 +191,9 @@ 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
+entry_content_box: ui.textarea | None = None
analysis_box: ui.textarea | None = None
-new_entry_box: ui.editor | 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
@@ -65,6 +201,56 @@ 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),
+ "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",
+ "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:
@@ -76,11 +262,63 @@ def _is_deleted_client_error(error: RuntimeError) -> bool:
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():
- # 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]
+ 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:
@@ -93,26 +331,25 @@ def save_entry(content: str, mode: str, password: str | None) -> str:
def load_entry(file_path: str) -> str:
try:
- entry = parse_journal_file(file_path)
- return entry.raw_content
+ 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:
- # Overwriting is the correct behavior when editing a full, existing entry.
- save_entry_content(content, file_path=Path(file_path), mode="Daily")
+ # 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"Appended changes to {file_path}"
+ 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 = parse_journal_file(file_path)
+ entry = JournalEntry(date=Path(file_path).stem, raw_content=load_entry_content(file_path))
analysis = summarize_entry(entry)
return analysis
except Exception as e:
@@ -121,17 +358,20 @@ def analyze_entry(file_path: str) -> str:
def analyze_all_entries() -> str:
try:
- journal_files = sorted(DATA_DIR.glob("*.md"))
+ journal_files = list_journal_files()
if not journal_files:
return "No journal files found."
- entries = [parse_journal_file(str(f)) for f in journal_files]
+ 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():
+def update_sidebar() -> bool:
global \
file_map, \
drawer, \
@@ -141,88 +381,111 @@ def update_sidebar():
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")
+ if not sidebar_content:
+ print("Sidebar content is not ready yet; retry needed.")
+ return False
- 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)
+ 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(
- load_entry(file_map[selected_file_name])
- )
+ entry_content_box.set_value("")
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.notify(
+ f"No entry exists for {date}. Use New Entry to create one.",
+ type="info",
)
-
- _ = 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)
+ 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("New Entry", on_click=open_new_tab)
- .props("color=primary")
- .classes("w-full mb-2")
+ ui.button(fname, on_click=partial(on_file_select, fname))
+ .classes("w-full justify-start mb-1")
+ .props("flat")
)
- def open_all_analysis_dialog():
- if all_analysis_dialog is not None:
- all_analysis_dialog.open()
+ _ = ui.separator().classes("my-4")
- _ = (
- 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
+ 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) ---
@@ -237,14 +500,14 @@ def journal_ui_layout():
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").classes("journal-root").style(
+ "padding: 0; margin: 0;"
):
with ui.element("div").style(
- "width: 100%; height: 100%; padding: 16px; box-sizing: border-box; display: flex; flex-direction: column;"
+ "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().classes("w-full mb-4") as main_tab_instance:
+ 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")
@@ -255,16 +518,81 @@ def journal_ui_layout():
# Tab Panels - Full Height
with ui.tab_panels(main_tab, value=edit_tab).classes(
- "w-full flex-grow bg-gray-800"
+ "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;"
):
- entry_content_box = rich_text_editor().classes("w-full h-full")
+ 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).style(
+ with ui.tab_panel(ai_tab).classes("journal-tab-panel").style(
"flex: 1; display: flex; flex-direction: column; padding: 16px;"
):
_ = (
@@ -279,10 +607,11 @@ def journal_ui_layout():
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").style("flex-shrink: 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
@@ -304,10 +633,10 @@ def journal_ui_layout():
_ = ui.button(
"Analyze Entry", on_click=analyze_selected_wrapper
- ).props("color=primary size=lg")
+ ).props("color=primary").style("min-width: 10rem;")
# --- AI Chat Tab ---
- with ui.tab_panel(chat_tab).style(
+ with ui.tab_panel(chat_tab).classes("journal-tab-panel").style(
"flex: 1; display: flex; flex-direction: column; padding: 16px;"
):
_ = (
@@ -322,6 +651,7 @@ def journal_ui_layout():
placeholder="Chat with the AI...",
)
.props("readonly outlined")
+ .classes("journal-large-textarea")
.style("flex: 1; width: 100%; min-height: 0;")
)
@@ -348,7 +678,7 @@ def journal_ui_layout():
_ = chat_input.on("keydown.enter", send_chat_message)
# --- New Entry Tab ---
- with ui.tab_panel(new_tab).style(
+ with ui.tab_panel(new_tab).classes("journal-tab-panel").style(
"flex: 1; display: flex; flex-direction: column; padding: 16px;"
):
_ = (
@@ -363,11 +693,12 @@ def journal_ui_layout():
label="Entry Type",
value="Daily",
)
- .classes("mb-4 w-64")
+ .classes("mb-4 w-full max-w-sm")
.style("flex-shrink: 0;")
)
- new_entry_box = rich_text_editor()
+ 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:
@@ -382,10 +713,11 @@ def journal_ui_layout():
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")
+ 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("")
@@ -393,7 +725,7 @@ def journal_ui_layout():
"update:model-value", update_new_entry_box_on_mode_change
)
- with ui.row().classes("gap-4 mt-4").style("flex-shrink: 0;"):
+ 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)
@@ -414,38 +746,42 @@ def journal_ui_layout():
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)
- )
+ password = vault_password
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
- )
+ 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("")
- update_sidebar() # Refresh the sidebar
+ 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 size=lg"
- )
+ "color=secondary"
+ ).style("min-width: 10rem;")
_ = ui.button("Save Entry", on_click=save_new_wrapper).props(
- "color=primary size=lg"
- )
+ "color=primary"
+ ).style("min-width: 10rem;")
# --- Speech to Text Tab ---
- with ui.tab_panel(speech_tab).style(
+ 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")
+ .classes("w-full flex-grow journal-large-textarea")
)
def on_speech_result(text: str):
@@ -454,7 +790,7 @@ def journal_ui_layout():
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"):
+ with ui.row().classes("w-full mt-4 journal-action-row"):
speech_to_text(on_result=on_speech_result)
def append_to_new_entry():
@@ -492,7 +828,7 @@ def journal_ui_layout():
.classes("h-96 mb-4 w-full")
)
- with ui.row().classes("gap-4"):
+ with ui.row().classes("gap-4 journal-action-row"):
async def do_analyze_all_wrapper():
client = ui.context.client
@@ -519,20 +855,19 @@ async def index_page():
with (
ui.left_drawer(value=False)
.props("overlay")
- .classes("w-64 bg-gray-900 text-white") as drawer_instance
+ .classes("journal-drawer bg-gray-900 text-white") as drawer_instance
):
drawer = drawer_instance
- sidebar_content = ui.column().classes("p-4 gap-2 h-full")
+ 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-4 bg-gray-900"
+ "items-center justify-between px-3 py-2 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.label("📓 Project Journal").classes("journal-header-title")
_ = ui.space()
# Instantiate and open the settings dialog
settings = settings_dialog()
@@ -540,11 +875,16 @@ async def index_page():
# Main content area (dynamically populated)
with ui.column().classes(
- "w-full h-full bg-gray-900 text-white"
+ "w-full bg-gray-900 text-white journal-main-content"
) 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"):
+ 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(
@@ -562,10 +902,9 @@ async def index_page():
),
)
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
+ _initialize_runtime_user_settings()
+ if not _render_authenticated_layout():
+ _schedule_sidebar_retry("index page auth render")
async def process_password(password: str | None):
@@ -588,6 +927,8 @@ async def process_password(password: str | None):
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())
@@ -605,6 +946,8 @@ async def process_password(password: str | None):
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
@@ -613,33 +956,34 @@ async def process_password(password: str | None):
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
+ 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
diff --git a/originalprojectplan.md b/originalprojectplan.md
deleted file mode 100644
index 493175f..0000000
--- a/originalprojectplan.md
+++ /dev/null
@@ -1,290 +0,0 @@
-# 🧠🗂️ JOURNAL SYSTEM OVERVIEW ("Mind Prosthetic Log")
-
-## 🛍️ PURPOSE
-
-To help externalize memory, track psychological patterns, log important events, and record internal states in a structured, searchable way—compensating for:
-
-- Rapid memory loss or forgetting what you were just thinking/saying
-
-- Difficulty organizing and verbalizing complex emotions
-
-- Inability to track patterns over time without external structure
-
-- Need for logs to aid therapy, legal documentation, co-parenting disputes
-
-- Cross-platform native app (Linux/Windows/macOS) with mobile access via Tailscale + NiceGUI
-
-- Designed for neurodivergent daily use with structured, low-friction interfaces
-
-- Allows fragment logging, full journal entry templates, tagging, and search
-
-- Uses Python where powerful NLP/AI tasks are needed
-
-- Expandable to Android (ideal) and iOS (via browser or shortcuts)
-
----
-
-## 🧱 COMPONENTS
-
-### 1. Templates & Daily Entries
-
-Multiple modular templates are available, including:
-
-- Full daily entry (see below)
-
-- Meltdown logs
-
-- Shutdown summaries
-
-- Therapy prep and recap
-
-- Legal event summaries
-
-#### Daily Entry Format Example:
-
-```markdown
-📅 Date: YYYY-MM-DD
-
-## 🧠 Cognitive State
-
-- [ ] Masking
-- [ ] Shutdown
-- [ ] Meltdown
-- [ ] Freeze
-- [ ] Flow state
-
-- Notes:
-
-## 🧠 Mental / Emotional Snapshot
-
-- Internal monologue or silence?
-- Thought loops or rumination?
-- Anxiety level: (0–10)
-- Depression level: (0–10)
-- Suicidal ideation: (Y/N, passive/active)
-- Emotional state(s): Angry / Tired / Hopeful / Numb / Triggered / Overwhelmed / Energized / etc.
-- Notes:
-
-## ⚡ Memory / Mind Failures
-
-- Forgot something mid-sentence?
-- Lost train of thought?
-- Couldn't speak thoughts?
-- Time blindness / lost hours?
-- Notes:
-
-## 📜 Events / Triggers
-
-- Interactions (e.g., with co-parent, child, officials)
-- Flashbacks / trauma triggers
-- Physical symptoms
-- Legal / medical events
-- Notes:
-
-## 💬 Communication / Expression Log
-
-- Messages I didn’t send
-- Things I forgot to say
-- Things I said that I didn’t mean
-- Verbal conflicts / miscommunication
-
-## 🧰 Coping / Tools Used
-
-- Breathing
-- Music
-- Walking
-- Writing
-- AI journaling
-- Hiding / Isolation
-- Notes:
-
-## 🧠 Reflection
-
-- What do I wish I’d done differently?
-- What patterns am I noticing?
-- Is this getting better or worse?
-- Notes:
-```
-
----
-
-### 2. Modular "Insert Blocks"
-
-Quick journal fragments for when you're too overwhelmed to fill out a full template.
-
-#### Example block types:
-
-- `!FLASHBACK:` description of what triggered it
-
-- `!FORGOT:` mid-thought freeze or sentence drop
-
-- `!QUOTE:` something I wish I'd said
-
-- `!TRIGGER:` encounter that caused a somatic or shutdown response
-
-- `!LOOP:` thought pattern or obsession
-
-- `!VIOLATION:` emotional harm from another person (e.g., co-parent)
-
-- `!SOMATIC:` physical response (shaking, tears, tight chest)
-
-These can be dropped into a daily log or used stand-alone.
-
----
-
-### 3. Indexing / Metadata System
-
-To make logs searchable:
-
-- **Tagging**: `#shutdown`, `#CPTSD`, `#co-parent`, `#legal`, etc.
-
-- **Timestamps**: `@HH:MM`
-
-- **Sources**: `> from text convo`, `> from therapy session`, `> from memory`, etc.
-
-- **Priority markers**:
-
- - `‼️` = urgent
-
- - `🔁` = recurring pattern
-
- - `🧩` = unexplained moment
-
----
-
-### 4. Use Cases
-
-This system supports:
-
-- **Therapy**: structure logs showing memory gaps, trauma patterns, breakdowns
-
-- **Legal**: document co-parenting issues and harmful behavior neutrally and time-stamped
-
-- **Internal Growth**: recognize cycles, triggers, and patterns
-
-- **Compensation**: catch memory failures before they damage communication or safety
-
----
-
-### 5. Capture Modes (Tools)
-
-**Currently Available:**
-
-- **Desktop UI** (NiceGUI)
-
-- **Mobile browser access** via Tailscale
-
-- **CLI tools**: `jfrag`, `vault`, `server`, `search`
-
-- **ChatGPT log syntax** (can copy-paste into assistant)
-
-- **Encrypted Vaults**: Journals saved as monthly `.vault` files
-
-- **Automatic data cleanup**: Decrypted data auto-cleared on shutdown
-
-
-- **Voice-to-text input**: (desktop + mobile)
-
-- **Calendar and rich Markdown**: preview in UI
-
-- **SQcypher backend**: Enrypted database backend.
-
----
-
-## 🛍️ Syntax Format
-
-```markdown
-!TYPE @time #tags
-Description of the event, thought, or experience.
-```
-
-### 📦 Example Fragments:
-
-```markdown
-!FLASHBACK @15:20 #CPTSD #shutdown
-Smelled her shampoo in the hallway and got hit with a memory of the hospital visit. Heart raced, froze completely.
-
-!FORGOT @16:45 #aphantasia #mindblank
-Mid-sentence memory drop while trying to explain Phaylynn’s school schedule. Just froze and couldn’t finish. Felt ashamed.
-
-!TRIGGER @email #co-parent #legal
-Kathryn’s message today saying “you never do anything for her” triggered a whole-body tension + tears. Completely false.
-
-!QUOTE @walk #unsaid
-What I *wanted* to say was: “You act like you want control more than peace.” Didn’t say it.
-
-!LOOP #rumination
-Keep repeating: “What if I’m the problem? What if it *is* all my fault?” over and over.
-
-!SOMATIC @22:05 #CPTSD
-Shaking in both arms, vision blurring, and that sharp ice-feeling in my chest. No obvious trigger identified yet.
-```
-
----
-
-## 🔢 Fragment Insert Interfaces
-
-### 📱 Mobile (Planned)
-
-- Shortcut or PWA access
-
-- Prompts: Type, Time, Tags, Description
-
-- Appends to `YYYY-MM-DD.md` securely via NiceGUI interface
-
-### 💻 CLI / Bash
-
-```bash
-jfrag "!TRIGGER" "Person texted me 'you don’t do anything for her'..." "#co-parent #shutdown"
-```
-
-Appends to journal or vault.
-
-### 🤖 AI Session Logging
-
-Say:
-
-```
-!QUOTE "I wish you would just work with me instead of against me."
-```
-
-Assistant logs it into today's entry using proper syntax.
-
----
-
-## ✅ Summary: Current & Future Tasks
-
-### 🔹 Completed
-
-- Vault encryption and cleanup
-
-- UI (NiceGUI), cross-platform and Tailscale-accessible
-
-- CLI tooling (vault, jfrag, search)
-
-- Metadata and tag system
-
-- AI summarization and pattern detection
-
-- Documentation and structured templates
-
-- Voice-to-text input (desktop & mobile)
-
-- Calendar view + richer Markdown preview in UI
-
-- Advanced NLP (sentiment, NER, topic modeling)
-
-- SQLcypher backend for fast structured search
-
-- Entry merging logic (into existing sections)
-
-
-### 🔹 In Progress / Planned
-
-- Export therapy-ready summaries
-
-- Weekly/monthly summary generator
-
-- AI tag suggestion
-
-- In-memory decrypted vault reading (no full file extraction)
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 52bd987..16411ec 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -14,6 +14,7 @@ dependencies = [
"uvicorn>=0.38,<1",
"pywebview>=6.1,<7; python_version < '3.14' or platform_system != 'Windows'",
"SpeechRecognition>=3.14,<4",
+ "pyaudiowpatch>=0.2.12.8,<0.3; platform_system == 'Windows'",
"pocketsphinx>=5,<6",
"soundfile>=0.13,<1",
"sounddevice>=0.5,<1",
@@ -29,11 +30,13 @@ nlp = [
cpu-ai = [
"torch>=2.9,<3",
"openai-whisper>=20250625",
+ "faster-whisper>=1.2,<2",
]
gpu-ai = [
"torch>=2.9,<3",
"triton>=3,<4; platform_system != 'Windows'",
"openai-whisper>=20250625",
+ "faster-whisper>=1.2,<2",
]
[project.urls]
diff --git a/requirements_base.txt b/requirements_base.txt
index 0643350..865d9e2 100644
--- a/requirements_base.txt
+++ b/requirements_base.txt
@@ -13,6 +13,7 @@ sqlcipher3-binary>=0.5,<1 ; platform_system != "Windows"
# Speech and audio
SpeechRecognition>=3.14,<4
+pyaudiowpatch>=0.2.12.8,<0.3 ; platform_system == "Windows"
pocketsphinx>=5,<6
soundfile>=0.13,<1
sounddevice>=0.5,<1
diff --git a/requirements_cpu_only.txt b/requirements_cpu_only.txt
index c74d023..01b5aa4 100644
--- a/requirements_cpu_only.txt
+++ b/requirements_cpu_only.txt
@@ -4,6 +4,7 @@
# AI (CPU)
torch>=2.9,<3
openai-whisper>=20250625
+faster-whisper>=1.2,<2
# Optional NLP backend:
# pip install -r requirements_nlp_optional.txt
diff --git a/requirements_gpu.txt b/requirements_gpu.txt
index 250c2a7..a343af1 100644
--- a/requirements_gpu.txt
+++ b/requirements_gpu.txt
@@ -5,6 +5,7 @@
torch>=2.9,<3
triton>=3,<4 ; platform_system != "Windows"
openai-whisper>=20250625
+faster-whisper>=1.2,<2
# Optional NLP backend:
# pip install -r requirements_nlp_optional.txt
diff --git a/scripts/dev-shell.ps1 b/scripts/dev-shell.ps1
new file mode 100644
index 0000000..999f3ce
--- /dev/null
+++ b/scripts/dev-shell.ps1
@@ -0,0 +1,42 @@
+# Run this in PowerShell before development commands:
+# . ./scripts/dev-shell.ps1
+
+$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
+
+# Clear dead proxy overrides and offline-only pip mode in current shell.
+Remove-Item Env:HTTP_PROXY -ErrorAction SilentlyContinue
+Remove-Item Env:HTTPS_PROXY -ErrorAction SilentlyContinue
+Remove-Item Env:ALL_PROXY -ErrorAction SilentlyContinue
+Remove-Item Env:http_proxy -ErrorAction SilentlyContinue
+Remove-Item Env:https_proxy -ErrorAction SilentlyContinue
+Remove-Item Env:all_proxy -ErrorAction SilentlyContinue
+Remove-Item Env:GIT_HTTP_PROXY -ErrorAction SilentlyContinue
+Remove-Item Env:GIT_HTTPS_PROXY -ErrorAction SilentlyContinue
+Remove-Item Env:PIP_NO_INDEX -ErrorAction SilentlyContinue
+
+# Keep .NET artifacts local to repo to avoid restricted user-profile paths.
+$env:DOTNET_CLI_HOME = Join-Path $repoRoot ".dotnet_home"
+$env:NUGET_PACKAGES = Join-Path $repoRoot ".nuget\packages"
+$env:NUGET_HTTP_CACHE_PATH = Join-Path $repoRoot ".nuget\http-cache"
+$env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = "1"
+$env:DOTNET_ADD_GLOBAL_TOOLS_TO_PATH = "0"
+$env:DOTNET_GENERATE_ASPNET_CERTIFICATE = "0"
+$env:DOTNET_CLI_TELEMETRY_OPTOUT = "1"
+$env:NUGET_CERT_REVOCATION_MODE = "offline"
+
+# Keep pip artifacts local to repo.
+$env:PIP_CACHE_DIR = Join-Path $repoRoot ".pip\cache"
+$env:TEMP = Join-Path $repoRoot ".tmp\pip-temp"
+$env:TMP = $env:TEMP
+$env:PIP_DISABLE_PIP_VERSION_CHECK = "1"
+$env:PIP_DEFAULT_TIMEOUT = "30"
+$env:PIP_RETRIES = "2"
+
+# Keep Hugging Face cache local and silence symlink-only warning on Windows.
+$env:HF_HOME = Join-Path $repoRoot ".cache\huggingface"
+$env:HUGGINGFACE_HUB_CACHE = Join-Path $env:HF_HOME "hub"
+$env:HF_HUB_DISABLE_SYMLINKS_WARNING = "1"
+
+New-Item -ItemType Directory -Force -Path $env:DOTNET_CLI_HOME,$env:NUGET_PACKAGES,$env:NUGET_HTTP_CACHE_PATH,$env:PIP_CACHE_DIR,$env:TEMP,$env:HUGGINGFACE_HUB_CACHE | Out-Null
+
+Write-Host "Development shell initialized for repo-local dotnet/pip paths."
diff --git a/scripts/dotnet-min.ps1 b/scripts/dotnet-min.ps1
new file mode 100644
index 0000000..a35844d
--- /dev/null
+++ b/scripts/dotnet-min.ps1
@@ -0,0 +1,16 @@
+param(
+ [Parameter(ValueFromRemainingArguments = $true)]
+ [string[]]$DotnetArgs
+)
+
+$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
+$innerScript = Join-Path $repoRoot "journal-master\journal\scripts\dotnet-min.ps1"
+
+if (-not (Test-Path $innerScript)) {
+ Write-Host "Missing script: $innerScript"
+ exit 2
+}
+
+& $innerScript @DotnetArgs
+exit $LASTEXITCODE
+
diff --git a/scripts/migration-gate.ps1 b/scripts/migration-gate.ps1
new file mode 100644
index 0000000..46781bc
--- /dev/null
+++ b/scripts/migration-gate.ps1
@@ -0,0 +1,46 @@
+param(
+ [switch]$SkipSmoke,
+ [switch]$SkipApi
+)
+
+$ErrorActionPreference = "Stop"
+$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
+$parityReport = Join-Path $repoRoot "logs\parity_harness_results.json"
+
+Write-Host "migration-gate: repo root = $repoRoot"
+
+Push-Location $repoRoot
+try {
+ Write-Host "migration-gate: building sidecar and api binaries..."
+ & "$repoRoot\scripts\dotnet-min.ps1" build journal-master/journal/Journal.Sidecar/Journal.Sidecar.csproj
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ & "$repoRoot\scripts\dotnet-min.ps1" build journal-master/journal/Journal.Api/Journal.Api.csproj
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+
+ if (-not $SkipSmoke) {
+ Write-Host "migration-gate: running csharp smoke tests..."
+ & "$repoRoot\scripts\dotnet-min.ps1" run --project journal-master/journal/Journal.SmokeTests/Journal.SmokeTests.csproj
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ } else {
+ Write-Host "migration-gate: skipping smoke tests (--SkipSmoke)."
+ }
+
+ Write-Host "migration-gate: running parity harness + fixture matrix..."
+ $env:PARITY_HARNESS_REPORT = $parityReport
+ & python -m unittest discover -s tests -p "test_parity_harness.py" -v
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+
+ if (-not $SkipApi) {
+ Write-Host "migration-gate: running API contract tests..."
+ & python -m unittest discover -s tests -p "test_api_contract.py" -v
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ } else {
+ Write-Host "migration-gate: skipping API contract tests (--SkipApi)."
+ }
+
+ Write-Host "migration-gate: PASS"
+ Write-Host "migration-gate: parity report => $parityReport"
+}
+finally {
+ Pop-Location
+}
diff --git a/scripts/pip-min.ps1 b/scripts/pip-min.ps1
new file mode 100644
index 0000000..8ee36fb
--- /dev/null
+++ b/scripts/pip-min.ps1
@@ -0,0 +1,70 @@
+param(
+ [Parameter(ValueFromRemainingArguments = $true)]
+ [string[]]$PipArgs
+)
+
+$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
+
+# Keep pip artifacts local for easy cleanup on minimal/portable machines.
+$env:PIP_CACHE_DIR = Join-Path $repoRoot ".pip\cache"
+$env:TEMP = Join-Path $repoRoot ".tmp\pip-temp"
+$env:TMP = $env:TEMP
+
+New-Item -ItemType Directory -Force -Path $env:PIP_CACHE_DIR | Out-Null
+New-Item -ItemType Directory -Force -Path $env:TEMP | Out-Null
+
+# Clear proxy/no-index env vars that commonly break package fetch.
+Remove-Item Env:HTTP_PROXY -ErrorAction SilentlyContinue
+Remove-Item Env:HTTPS_PROXY -ErrorAction SilentlyContinue
+Remove-Item Env:ALL_PROXY -ErrorAction SilentlyContinue
+Remove-Item Env:http_proxy -ErrorAction SilentlyContinue
+Remove-Item Env:https_proxy -ErrorAction SilentlyContinue
+Remove-Item Env:all_proxy -ErrorAction SilentlyContinue
+Remove-Item Env:GIT_HTTP_PROXY -ErrorAction SilentlyContinue
+Remove-Item Env:GIT_HTTPS_PROXY -ErrorAction SilentlyContinue
+Remove-Item Env:PIP_NO_INDEX -ErrorAction SilentlyContinue
+
+# Keep behavior deterministic and avoid interactive/network hangs.
+$env:PIP_DISABLE_PIP_VERSION_CHECK = "1"
+$env:PIP_DEFAULT_TIMEOUT = "30"
+$env:PIP_RETRIES = "2"
+
+if (-not $PipArgs -or $PipArgs.Count -eq 0) {
+ Write-Host "Usage: ./scripts/pip-min.ps1
"
+ Write-Host "Example: ./scripts/pip-min.ps1 install --index-url https://pypi.org/simple faster-whisper"
+ exit 2
+}
+
+# Default install target to a repo-local directory so installs do not require
+# user/site-packages write access on constrained hosts.
+$effectiveArgs = @($PipArgs)
+$firstArg = $effectiveArgs[0].ToLowerInvariant()
+if ($firstArg -eq "install") {
+ # On Windows, map PyAudio to pyaudiowpatch (wheel available for newer CPython),
+ # avoiding source builds that require PortAudio headers/toolchain wiring.
+ for ($i = 0; $i -lt $effectiveArgs.Count; $i++) {
+ $arg = $effectiveArgs[$i]
+ if ($arg -match '^(?i)pyaudio($|[<>=!~].*)') {
+ $suffix = $arg.Substring(7)
+ $effectiveArgs[$i] = "pyaudiowpatch$suffix"
+ Write-Host "pip-min: mapped '$arg' -> '$($effectiveArgs[$i])' on Windows."
+ }
+ }
+
+ $hasTarget = $effectiveArgs -contains "--target" -or $effectiveArgs -contains "-t" -or $effectiveArgs -contains "--prefix"
+ if (-not $hasTarget) {
+ $effectiveArgs = $effectiveArgs | Where-Object { $_ -ne "--user" }
+ $localTarget = Join-Path $repoRoot ".pydeps\py314"
+ New-Item -ItemType Directory -Force -Path $localTarget | Out-Null
+ $effectiveArgs += @("--target", $localTarget)
+ Write-Host "pip-min: using local target $localTarget"
+ }
+}
+
+$pipWrapper = Join-Path $PSScriptRoot "pip_safe.py"
+if (Test-Path $pipWrapper) {
+ & python $pipWrapper @effectiveArgs
+} else {
+ & python -m pip @effectiveArgs
+}
+exit $LASTEXITCODE
diff --git a/scripts/pip_safe.py b/scripts/pip_safe.py
new file mode 100644
index 0000000..a02a307
--- /dev/null
+++ b/scripts/pip_safe.py
@@ -0,0 +1,46 @@
+from __future__ import annotations
+
+import os
+import tempfile
+from typing import Callable
+
+
+def _mkdtemp_compat(
+ suffix: str | None = None,
+ prefix: str | None = None,
+ dir: str | None = None,
+) -> str:
+ # Python 3.14 on some Windows hosts creates mkdtemp dirs that are
+ # immediately non-writable by the same process when mode=0o700 is used.
+ # pip relies heavily on tempfile; force 0o777 for compatibility.
+ if dir is None:
+ dir = tempfile.gettempdir()
+ if prefix is None:
+ prefix = tempfile.template
+ if suffix is None:
+ suffix = ""
+
+ names = tempfile._get_candidate_names()
+ for _ in range(tempfile.TMP_MAX):
+ name = next(names)
+ path = os.path.join(dir, f"{prefix}{name}{suffix}")
+ try:
+ os.mkdir(path, 0o777)
+ return path
+ except FileExistsError:
+ continue
+
+ raise FileExistsError("No usable temporary directory name found.")
+
+
+def main(argv: list[str]) -> int:
+ tempfile.mkdtemp = _mkdtemp_compat # type: ignore[assignment]
+
+ from pip._internal.cli.main import main as pip_main
+
+ return int(pip_main(argv))
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(__import__("sys").argv[1:]))
+
diff --git a/tests/test_ai_hybrid_bridge.py b/tests/test_ai_hybrid_bridge.py
new file mode 100644
index 0000000..d80cebd
--- /dev/null
+++ b/tests/test_ai_hybrid_bridge.py
@@ -0,0 +1,51 @@
+import sys
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+if str(PROJECT_ROOT) not in sys.path:
+ sys.path.insert(0, str(PROJECT_ROOT))
+
+from journal.core.models import JournalEntry
+from journal.ai import bridge
+
+
+class AiHybridBridgeTests(unittest.TestCase):
+ def test_summarize_entry_uses_local_analysis_in_hybrid_mode(self):
+ entry = JournalEntry(date="2026-02-22", raw_content="sample content")
+ with (
+ patch("journal.ai.bridge.analysis.summarize_entry", return_value="local result") as mock_local,
+ ):
+ result = bridge.summarize_entry(entry)
+
+ self.assertEqual(result, "local result")
+ mock_local.assert_called_once_with(entry)
+
+ def test_summarize_all_uses_local_analysis_in_hybrid_mode(self):
+ entries = [
+ JournalEntry(date="2026-02-21", raw_content="entry one"),
+ JournalEntry(date="2026-02-22", raw_content="entry two"),
+ ]
+ with (
+ patch("journal.ai.bridge.analysis.summarize_all_entries", return_value="all local result") as mock_local,
+ ):
+ result = bridge.summarize_all_entries(entries)
+
+ self.assertEqual(result, "all local result")
+ mock_local.assert_called_once()
+
+ def test_summarize_entry_uses_local_analysis_in_python_mode(self):
+ entry = JournalEntry(date="2026-02-22", raw_content="sample content")
+ with (
+ patch("journal.ai.bridge.analysis.summarize_entry", return_value="local result") as mock_local,
+ ):
+ result = bridge.summarize_entry(entry)
+
+ self.assertEqual(result, "local result")
+ mock_local.assert_called_once_with(entry)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py
new file mode 100644
index 0000000..db27880
--- /dev/null
+++ b/tests/test_api_contract.py
@@ -0,0 +1,154 @@
+from __future__ import annotations
+
+import json
+import os
+import socket
+import subprocess
+import time
+import unittest
+from pathlib import Path
+from urllib.error import HTTPError, URLError
+from urllib.request import Request, urlopen
+
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+API_WORKDIR = PROJECT_ROOT / "journal-master" / "journal"
+API_PROJECT = API_WORKDIR / "Journal.Api" / "Journal.Api.csproj"
+API_DLL = API_WORKDIR / "Journal.Api" / "bin" / "Debug" / "net10.0" / "Journal.Api.dll"
+
+
+def _pick_free_port() -> int:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+ sock.bind(("127.0.0.1", 0))
+ return int(sock.getsockname()[1])
+
+
+def _http_json(method: str, url: str, body: str | None = None) -> tuple[int, dict]:
+ headers = {"Accept": "application/json"}
+ data = None
+ if body is not None:
+ headers["Content-Type"] = "application/json"
+ data = body.encode("utf-8")
+
+ request = Request(url=url, method=method, data=data, headers=headers)
+ try:
+ with urlopen(request, timeout=5.0) as response:
+ payload = response.read().decode("utf-8")
+ return int(response.status), json.loads(payload)
+ except HTTPError as ex:
+ payload = ex.read().decode("utf-8")
+ try:
+ parsed = json.loads(payload)
+ except json.JSONDecodeError:
+ parsed = {"raw": payload}
+ return int(ex.code), parsed
+
+
+class ApiContractTests(unittest.TestCase):
+ process: subprocess.Popen[str] | None = None
+ base_url: str = ""
+
+ @classmethod
+ def setUpClass(cls) -> None:
+ if not API_DLL.exists():
+ raise FileNotFoundError(
+ f"Missing API binary: {API_DLL}. Build Journal.Api first."
+ )
+
+ port = _pick_free_port()
+ cls.base_url = f"http://127.0.0.1:{port}"
+ env = os.environ.copy()
+ env["DOTNET_CLI_HOME"] = str(PROJECT_ROOT / ".dotnet_home")
+ env["NUGET_PACKAGES"] = str(PROJECT_ROOT / ".nuget" / "packages")
+ env["NUGET_HTTP_CACHE_PATH"] = str(PROJECT_ROOT / ".nuget" / "http-cache")
+ env["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1"
+ env["DOTNET_GENERATE_ASPNET_CERTIFICATE"] = "0"
+ env["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1"
+ env["ASPNETCORE_URLS"] = cls.base_url
+
+ cls.process = subprocess.Popen(
+ [
+ "dotnet",
+ str(API_DLL),
+ ],
+ cwd=str(API_WORKDIR),
+ env=env,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ text=True,
+ )
+
+ deadline = time.time() + 60
+ while time.time() < deadline:
+ if cls.process.poll() is not None:
+ raise RuntimeError("Journal.Api exited during startup.")
+ try:
+ status, payload = _http_json("GET", f"{cls.base_url}/health")
+ if status == 200 and payload.get("ok") is True:
+ return
+ except (URLError, TimeoutError):
+ pass
+ time.sleep(0.5)
+
+ raise TimeoutError("Timed out waiting for Journal.Api /health endpoint.")
+
+ @classmethod
+ def tearDownClass(cls) -> None:
+ if cls.process is None:
+ return
+ if cls.process.poll() is None:
+ cls.process.terminate()
+ try:
+ cls.process.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ cls.process.kill()
+ cls.process = None
+
+ def test_health_endpoint_envelope(self):
+ status, payload = _http_json("GET", f"{self.base_url}/health")
+ self.assertEqual(status, 200)
+ self.assertEqual(payload, {"ok": True, "data": "healthy"})
+
+ def test_command_success_path(self):
+ status, payload = _http_json(
+ "POST",
+ f"{self.base_url}/api/command",
+ body=json.dumps({"action": "config.get", "payload": {}}),
+ )
+ self.assertEqual(status, 200)
+ self.assertTrue(payload.get("ok"))
+ self.assertIsInstance(payload.get("data"), dict)
+
+ def test_command_unknown_action_error_envelope(self):
+ status, payload = _http_json(
+ "POST",
+ f"{self.base_url}/api/command",
+ body=json.dumps({"action": "unknown.action", "payload": {}}),
+ )
+ self.assertEqual(status, 200)
+ self.assertFalse(payload.get("ok"))
+ self.assertIn("unknown action", str(payload.get("error", "")).lower())
+
+ def test_command_missing_payload_error_envelope(self):
+ status, payload = _http_json(
+ "POST",
+ f"{self.base_url}/api/command",
+ body=json.dumps({"action": "entries.save"}),
+ )
+ self.assertEqual(status, 200)
+ self.assertFalse(payload.get("ok"))
+ self.assertIn("payload", str(payload.get("error", "")).lower())
+
+ def test_command_malformed_json_error_envelope(self):
+ status, payload = _http_json(
+ "POST",
+ f"{self.base_url}/api/command",
+ body='{"action":',
+ )
+ self.assertEqual(status, 200)
+ self.assertFalse(payload.get("ok"))
+ self.assertIn("invalid command json", str(payload.get("error", "")).lower())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_cli_fragments_hybrid.py b/tests/test_cli_fragments_hybrid.py
new file mode 100644
index 0000000..cf3b46b
--- /dev/null
+++ b/tests/test_cli_fragments_hybrid.py
@@ -0,0 +1,94 @@
+import io
+import sys
+import unittest
+from contextlib import redirect_stdout
+from pathlib import Path
+from unittest.mock import patch
+
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+if str(PROJECT_ROOT) not in sys.path:
+ sys.path.insert(0, str(PROJECT_ROOT))
+
+from journal.cli import main as cli_main
+
+
+class CliFragmentsHybridTests(unittest.TestCase):
+ def test_fragments_list_calls_sidecar(self):
+ with (
+ patch.object(sys, "argv", ["journal", "fragments", "list"]),
+ patch("journal.cli.main.BACKEND_MODE", "csharp-hybrid"),
+ patch(
+ "journal.cli.main.call_sidecar_action",
+ return_value=[{"Id": "1", "Type": "!NOTE", "Description": "desc", "Tags": []}],
+ ) as mock_call,
+ redirect_stdout(io.StringIO()) as stdout,
+ ):
+ cli_main.main()
+
+ mock_call.assert_called_once_with("fragments.list")
+ self.assertIn("!NOTE", stdout.getvalue())
+
+ def test_fragments_create_calls_sidecar(self):
+ with (
+ patch.object(
+ sys,
+ "argv",
+ [
+ "journal",
+ "fragments",
+ "create",
+ "--type",
+ "!TRIGGER",
+ "--description",
+ "flashback",
+ "--tag",
+ "stress",
+ ],
+ ),
+ patch("journal.cli.main.BACKEND_MODE", "csharp-hybrid"),
+ patch("journal.cli.main.call_sidecar_action", return_value={"Id": "1"}) as mock_call,
+ redirect_stdout(io.StringIO()) as stdout,
+ ):
+ cli_main.main()
+
+ mock_call.assert_called_once_with(
+ "fragments.create",
+ payload={"type": "!TRIGGER", "description": "flashback", "tags": ["stress"]},
+ )
+ self.assertIn("Fragment created.", stdout.getvalue())
+
+ def test_fragments_search_calls_sidecar_with_command_fields(self):
+ with (
+ patch.object(
+ sys,
+ "argv",
+ ["journal", "fragments", "search", "--type", "!NOTE", "--tag", "daily"],
+ ),
+ patch("journal.cli.main.BACKEND_MODE", "csharp-hybrid"),
+ patch("journal.cli.main.call_sidecar_action", return_value=[]) as mock_call,
+ redirect_stdout(io.StringIO()) as stdout,
+ ):
+ cli_main.main()
+
+ mock_call.assert_called_once_with(
+ "fragments.search",
+ command_fields={"type": "!NOTE", "tag": "daily"},
+ )
+ self.assertIn("No fragments found.", stdout.getvalue())
+
+ def test_fragments_command_requires_hybrid(self):
+ with (
+ patch.object(sys, "argv", ["journal", "fragments", "list"]),
+ patch("journal.cli.main.BACKEND_MODE", "python"),
+ patch("journal.cli.main.call_sidecar_action") as mock_call,
+ redirect_stdout(io.StringIO()) as stdout,
+ ):
+ cli_main.main()
+
+ mock_call.assert_not_called()
+ self.assertIn("requires JOURNAL_BACKEND_MODE=csharp-hybrid", stdout.getvalue())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_parity_harness.py b/tests/test_parity_harness.py
new file mode 100644
index 0000000..2dd47b7
--- /dev/null
+++ b/tests/test_parity_harness.py
@@ -0,0 +1,401 @@
+import difflib
+import hashlib
+import json
+import os
+import shutil
+import unittest
+from contextlib import contextmanager
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+from uuid import uuid4
+
+from journal.core import storage
+from journal.core.csharp_sidecar import call_sidecar_action
+from journal.core.parser import parse_journal_content, parse_journal_file
+
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+FIXTURES_ROOT = PROJECT_ROOT / "fixtures"
+ENTRY_FIXTURES = FIXTURES_ROOT / "entries"
+SEARCH_FIXTURES = FIXTURES_ROOT / "search" / "queries.json"
+VAULT_MANIFEST = FIXTURES_ROOT / "vaults" / "manifest.json"
+PARITY_REPORT: list[dict[str, Any]] = []
+
+
+def _load_queries() -> list[dict[str, Any]]:
+ return json.loads(SEARCH_FIXTURES.read_text(encoding="utf-8"))
+
+
+def _load_vault_manifest() -> dict[str, Any]:
+ return json.loads(VAULT_MANIFEST.read_text(encoding="utf-8"))
+
+
+def _copy_entry_fixtures(target_dir: Path) -> None:
+ target_dir.mkdir(parents=True, exist_ok=True)
+ for source in sorted(ENTRY_FIXTURES.glob("*.md")):
+ shutil.copy2(source, target_dir / source.name)
+
+
+def _copy_vault_fixtures(manifest: dict[str, Any], target_dir: Path) -> None:
+ target_dir.mkdir(parents=True, exist_ok=True)
+ for vault_row in manifest.get("vaults", []):
+ if not isinstance(vault_row, dict):
+ continue
+ name = vault_row.get("vault_file")
+ if not isinstance(name, str):
+ continue
+ source = FIXTURES_ROOT / "vaults" / name
+ shutil.copy2(source, target_dir / name)
+
+
+def _sha256_file(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as handle:
+ while True:
+ chunk = handle.read(1024 * 1024)
+ if not chunk:
+ break
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+@contextmanager
+def _workspace():
+ root = PROJECT_ROOT / ".tmp" / "parity-tests" / uuid4().hex
+ root.mkdir(parents=True, exist_ok=True)
+ try:
+ yield root
+ finally:
+ shutil.rmtree(root, ignore_errors=True)
+
+
+def _normalize_for_json(value: Any) -> Any:
+ if isinstance(value, dict):
+ return {str(k): _normalize_for_json(v) for k, v in sorted(value.items(), key=lambda item: str(item[0]))}
+ if isinstance(value, list):
+ return [_normalize_for_json(item) for item in value]
+ if isinstance(value, tuple):
+ return [_normalize_for_json(item) for item in value]
+ return value
+
+
+def _record_parity(name: str, python_result: Any, csharp_result: Any) -> dict[str, Any]:
+ normalized_python = _normalize_for_json(python_result)
+ normalized_csharp = _normalize_for_json(csharp_result)
+ python_json = json.dumps(normalized_python, indent=2, ensure_ascii=True, sort_keys=True)
+ csharp_json = json.dumps(normalized_csharp, indent=2, ensure_ascii=True, sort_keys=True)
+ match = python_json == csharp_json
+ diff = ""
+ if not match:
+ diff = "\n".join(
+ difflib.unified_diff(
+ python_json.splitlines(),
+ csharp_json.splitlines(),
+ fromfile="python_result",
+ tofile="csharp_result",
+ lineterm="",
+ )
+ )
+ row = {
+ "name": name,
+ "python_result": normalized_python,
+ "csharp_result": normalized_csharp,
+ "match": match,
+ "diff": diff,
+ }
+ PARITY_REPORT.append(row)
+ return row
+
+
+def _normalize_search_results(results: list[dict[str, Any]]) -> list[tuple[str, str]]:
+ normalized: list[tuple[str, str]] = []
+ for item in results:
+ date_value = item.get("Date") or item.get("date")
+ file_name = item.get("FileName") or item.get("fileName")
+ if isinstance(date_value, str) and isinstance(file_name, str):
+ normalized.append((date_value, file_name))
+ return sorted(normalized, key=lambda row: row[1])
+
+
+def _python_search(data_dir: Path, payload: dict[str, Any]) -> list[tuple[str, str]]:
+ query = (payload.get("query") or "").strip()
+ section = (payload.get("section") or "").strip()
+ tags = {v.strip() for v in payload.get("tags", []) if isinstance(v, str) and v.strip()}
+ types = {v.strip() for v in payload.get("types", []) if isinstance(v, str) and v.strip()}
+ checked = {v.strip() for v in payload.get("checked", []) if isinstance(v, str) and v.strip()}
+ unchecked = {v.strip() for v in payload.get("unchecked", []) if isinstance(v, str) and v.strip()}
+
+ start_date = _parse_optional_date(payload.get("startDate"))
+ end_date = _parse_optional_date(payload.get("endDate"))
+ if start_date and end_date and start_date > end_date:
+ raise ValueError("startDate cannot be after endDate.")
+
+ results: list[tuple[str, str]] = []
+ for file_path in sorted(data_dir.glob("*.md"), key=lambda p: p.name):
+ entry = parse_journal_file(str(file_path))
+ entry_date = _parse_optional_date(entry.date)
+
+ if (start_date or end_date) and entry_date is None:
+ continue
+ if start_date and entry_date and entry_date < start_date:
+ continue
+ if end_date and entry_date and entry_date > end_date:
+ continue
+
+ if query:
+ haystack = entry.get_section(section) if section else entry.raw_content
+ if query.lower() not in haystack.lower():
+ continue
+
+ if tags or types:
+ matched_fragment = False
+ for fragment in entry.fragments:
+ type_ok = not types or fragment.type in types
+ tag_ok = not tags or any(tag in tags for tag in fragment.tags)
+ if type_ok and tag_ok:
+ matched_fragment = True
+ break
+ if not matched_fragment:
+ continue
+
+ if checked or unchecked:
+ matched_checkbox = False
+ for parsed_section in entry.sections.values():
+ for checkbox_text, is_checked in parsed_section.checkboxes.items():
+ if checked and is_checked and checkbox_text in checked:
+ matched_checkbox = True
+ break
+ if unchecked and (not is_checked) and checkbox_text in unchecked:
+ matched_checkbox = True
+ break
+ if matched_checkbox:
+ break
+ if not matched_checkbox:
+ continue
+
+ results.append((entry.date, file_path.name))
+
+ return sorted(results, key=lambda row: row[1])
+
+
+def _parse_optional_date(value: str | None):
+ if not value or not isinstance(value, str):
+ return None
+ try:
+ return datetime.strptime(value.strip(), "%Y-%m-%d").date()
+ except ValueError:
+ return None
+
+
+class ParityHarnessTests(unittest.TestCase):
+ @classmethod
+ def tearDownClass(cls) -> None:
+ report_path = Path(
+ os.environ.get(
+ "PARITY_HARNESS_REPORT",
+ str(PROJECT_ROOT / "logs" / "parity_harness_results.json"),
+ )
+ )
+ report_path.parent.mkdir(parents=True, exist_ok=True)
+ payload = {
+ "generated_at_utc": datetime.now(timezone.utc).isoformat(),
+ "total_cases": len(PARITY_REPORT),
+ "passed_cases": sum(1 for case in PARITY_REPORT if case["match"]),
+ "failed_cases": sum(1 for case in PARITY_REPORT if not case["match"]),
+ "cases": PARITY_REPORT,
+ }
+ report_path.write_text(json.dumps(payload, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
+
+ def test_entries_list_load_parity(self):
+ with _workspace() as root:
+ data_dir = root / "data"
+ _copy_entry_fixtures(data_dir)
+
+ csharp_list = call_sidecar_action(
+ "entries.list",
+ payload={"dataDirectory": str(data_dir)},
+ )
+ self.assertIsInstance(csharp_list, list)
+
+ csharp_names = sorted(
+ [
+ item.get("FileName") or item.get("fileName")
+ for item in csharp_list
+ if isinstance(item, dict)
+ ]
+ )
+ python_names = sorted([path.name for path in data_dir.glob("*.md")])
+ row = _record_parity("entries.list", python_names, csharp_names)
+ self.assertTrue(row["match"], row["diff"])
+
+ for name in python_names:
+ file_path = data_dir / name
+ csharp_loaded = call_sidecar_action(
+ "entries.load",
+ payload={"filePath": str(file_path)},
+ )
+ self.assertIsInstance(csharp_loaded, dict)
+ csharp_raw = csharp_loaded.get("RawContent") or csharp_loaded.get("rawContent")
+ python_raw = storage._strip_rich_html(file_path.read_text(encoding="utf-8")) # pylint: disable=protected-access
+ row = _record_parity(f"entries.load::{name}", python_raw, csharp_raw)
+ self.assertTrue(row["match"], row["diff"])
+
+ def test_entries_save_merge_parity(self):
+ with _workspace() as root:
+ data_dir = root / "data"
+ _copy_entry_fixtures(data_dir)
+ target = data_dir / "2026-01-05.md"
+ original = target.read_text(encoding="utf-8")
+
+ new_content = (
+ "**Date:** 2026-01-05\n\n"
+ "## Triggers\n"
+ "Crowded grocery store caused severe panic.\n\n"
+ "## Reflections\n"
+ "Added one new thought after grounding.\n"
+ )
+
+ python_existing = parse_journal_content(original, target.stem)
+ python_incoming = parse_journal_content(new_content, target.stem)
+ python_existing.merge_with(python_incoming)
+ python_markdown = python_existing.to_markdown()
+
+ _ = call_sidecar_action(
+ "entries.save",
+ payload={
+ "content": new_content,
+ "filePath": str(target),
+ "mode": "Daily",
+ },
+ )
+
+ csharp_markdown = target.read_text(encoding="utf-8")
+ python_entry = parse_journal_content(python_markdown, target.stem)
+ csharp_entry = parse_journal_content(csharp_markdown, target.stem)
+ row = _record_parity(
+ "entries.save::merge",
+ {
+ "date": python_entry.date,
+ "triggers": python_entry.get_section("Triggers").strip(),
+ "reflections": python_entry.get_section("Reflections").strip(),
+ },
+ {
+ "date": csharp_entry.date,
+ "triggers": csharp_entry.get_section("Triggers").strip(),
+ "reflections": csharp_entry.get_section("Reflections").strip(),
+ },
+ )
+ self.assertTrue(row["match"], row["diff"])
+
+ def test_search_parity_against_python_and_expected_ids(self):
+ with _workspace() as root:
+ data_dir = root / "data"
+ _copy_entry_fixtures(data_dir)
+ queries = _load_queries()
+
+ for case in queries:
+ case_name = str(case.get("name", "unnamed"))
+ payload = dict(case.get("payload", {}))
+ payload["dataDirectory"] = str(data_dir)
+
+ python_result = _python_search(data_dir, payload)
+ csharp_result = call_sidecar_action("search.entries", payload=payload)
+ self.assertIsInstance(csharp_result, list)
+ csharp_normalized = _normalize_search_results(csharp_result)
+
+ parity_row = _record_parity(f"search.entries::{case_name}", python_result, csharp_normalized)
+ self.assertTrue(parity_row["match"], parity_row["diff"])
+
+ expected_file_names = sorted(case.get("expected_file_names", []))
+ expected_row = _record_parity(
+ f"search.expected::{case_name}",
+ expected_file_names,
+ [item[1] for item in csharp_normalized],
+ )
+ self.assertTrue(expected_row["match"], expected_row["diff"])
+
+ def test_sanitizer_parity_for_html_heavy_input(self):
+ with _workspace() as root:
+ data_dir = root / "data"
+ data_dir.mkdir(parents=True, exist_ok=True)
+ target = data_dir / "2026-02-26.md"
+ html_input = (
+ 'Hello World
'
+ ""
+ )
+ python_sanitized = storage._strip_rich_html(html_input) # pylint: disable=protected-access
+ _ = call_sidecar_action(
+ "entries.save",
+ payload={
+ "content": html_input,
+ "filePath": str(target),
+ "mode": "Overwrite",
+ },
+ )
+ csharp_saved = target.read_text(encoding="utf-8")
+ row = _record_parity("sanitizer.rich_html", python_sanitized, csharp_saved)
+ self.assertTrue(row["match"], row["diff"])
+
+ def test_vault_manifest_load_and_hash_integrity(self):
+ manifest = _load_vault_manifest()
+ fixture_password = manifest.get("password")
+ self.assertIsInstance(fixture_password, str)
+ self.assertTrue(fixture_password)
+
+ with _workspace() as root:
+ vault_dir = root / "vault"
+ data_dir = root / "data"
+ _copy_vault_fixtures(manifest, vault_dir)
+
+ expected_hashes: dict[str, str] = {}
+ for vault_row in manifest.get("vaults", []):
+ for entry_row in vault_row.get("expected_entries", []):
+ expected_hashes[str(entry_row["file_name"])] = str(entry_row["sha256"])
+
+ loaded = call_sidecar_action(
+ "vault.load_all",
+ payload={
+ "password": fixture_password,
+ "vaultDirectory": str(vault_dir),
+ "dataDirectory": str(data_dir),
+ },
+ )
+ self.assertTrue(bool(loaded), "Expected fixture vaults to load with manifest password.")
+
+ actual_hashes: dict[str, str] = {}
+ for file_path in sorted(data_dir.glob("*.md"), key=lambda p: p.name):
+ actual_hashes[file_path.name] = _sha256_file(file_path)
+
+ row = _record_parity("vault.load_all::hashes", expected_hashes, actual_hashes)
+ self.assertTrue(row["match"], row["diff"])
+
+ def test_vault_wrong_password_preserves_bytes(self):
+ manifest = _load_vault_manifest()
+ wrong_password = manifest.get("wrong_password")
+ self.assertIsInstance(wrong_password, str)
+ self.assertTrue(wrong_password)
+
+ with _workspace() as root:
+ vault_dir = root / "vault"
+ data_dir = root / "data"
+ _copy_vault_fixtures(manifest, vault_dir)
+
+ before_hashes = {path.name: _sha256_file(path) for path in sorted(vault_dir.glob("*.vault"), key=lambda p: p.name)}
+ loaded = call_sidecar_action(
+ "vault.load_all",
+ payload={
+ "password": wrong_password,
+ "vaultDirectory": str(vault_dir),
+ "dataDirectory": str(data_dir),
+ },
+ )
+ self.assertFalse(bool(loaded), "Wrong password should fail vault.load_all.")
+ after_hashes = {path.name: _sha256_file(path) for path in sorted(vault_dir.glob("*.vault"), key=lambda p: p.name)}
+
+ row = _record_parity("vault.load_all::wrong_password_invariant", before_hashes, after_hashes)
+ self.assertTrue(row["match"], row["diff"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_storage_hybrid_bridge.py b/tests/test_storage_hybrid_bridge.py
new file mode 100644
index 0000000..631ff43
--- /dev/null
+++ b/tests/test_storage_hybrid_bridge.py
@@ -0,0 +1,132 @@
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+if str(PROJECT_ROOT) not in sys.path:
+ sys.path.insert(0, str(PROJECT_ROOT))
+
+from journal.core import storage
+
+
+class StorageHybridBridgeTests(unittest.TestCase):
+ def test_save_entry_content_uses_entries_save_in_hybrid_mode(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ target = Path(tmp) / "2026-02-22.md"
+ with (
+ patch("journal.core.storage.BACKEND_MODE", "csharp-hybrid"),
+ patch("journal.core.storage.call_sidecar_action", return_value={"FilePath": str(target)}) as mock_call,
+ ):
+ storage.save_entry_content("hello world", file_path=target, mode="Daily")
+
+ mock_call.assert_called_once_with(
+ "entries.save",
+ payload={
+ "content": "hello world",
+ "filePath": str(target),
+ "mode": "Daily",
+ },
+ )
+
+ def test_load_entry_content_uses_entries_load_in_hybrid_mode(self):
+ fake_path = "E:/tmp/2026-02-22.md"
+ with (
+ patch("journal.core.storage.BACKEND_MODE", "csharp-hybrid"),
+ patch(
+ "journal.core.storage.call_sidecar_action",
+ return_value={"RawContent": "entry content"},
+ ) as mock_call,
+ ):
+ result = storage.load_entry_content(fake_path)
+
+ self.assertEqual(result, "entry content")
+ mock_call.assert_called_once_with(
+ "entries.load",
+ payload={"filePath": fake_path},
+ )
+
+ def test_save_entry_content_strips_rich_html_before_hybrid_save(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ target = Path(tmp) / "2026-02-22.md"
+ nasty_html = (
+ 'Hello World
'
+ ""
+ )
+ with (
+ patch("journal.core.storage.BACKEND_MODE", "csharp-hybrid"),
+ patch("journal.core.storage.call_sidecar_action", return_value={"FilePath": str(target)}) as mock_call,
+ ):
+ storage.save_entry_content(nasty_html, file_path=target, mode="Overwrite")
+
+ sent_payload = mock_call.call_args.kwargs["payload"]
+ sent_content = sent_payload["content"]
+ self.assertNotIn("Top
'
+ 'Body
'
+ )
+ with (
+ patch("journal.core.storage.BACKEND_MODE", "csharp-hybrid"),
+ patch(
+ "journal.core.storage.call_sidecar_action",
+ return_value={"RawContent": nasty_html},
+ ),
+ ):
+ result = storage.load_entry_content(fake_path)
+
+ self.assertEqual(result, "Top\nBody")
+
+ def test_list_journal_files_uses_entries_list_in_hybrid_mode(self):
+ with (
+ patch("journal.core.storage.BACKEND_MODE", "csharp-hybrid"),
+ patch(
+ "journal.core.storage.call_sidecar_action",
+ return_value=[
+ {"FileName": "2026-02-01.md", "FilePath": "E:/tmp/2026-02-01.md"},
+ {"FileName": "2026-02-02.md", "FilePath": "E:/tmp/2026-02-02.md"},
+ ],
+ ) as mock_call,
+ ):
+ result = storage.list_journal_files()
+
+ self.assertEqual(
+ result,
+ [
+ ("2026-02-01.md", "E:/tmp/2026-02-01.md"),
+ ("2026-02-02.md", "E:/tmp/2026-02-02.md"),
+ ],
+ )
+ mock_call.assert_called_once()
+
+ def test_load_all_vaults_uses_csharp_workspace_hydration_in_hybrid_mode(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ with (
+ patch("journal.core.storage.BACKEND_MODE", "csharp-hybrid"),
+ patch("journal.core.storage.VAULT_DIR", Path(tmp) / "vault"),
+ patch("journal.core.storage.DATA_DIR", Path(tmp) / "data"),
+ patch(
+ "journal.core.storage.call_sidecar_action",
+ side_effect=[True, {"EntryFilesProcessed": 2}],
+ ) as mock_call,
+ ):
+ result = storage.load_all_vaults("vault-pass-123")
+
+ self.assertTrue(result)
+ self.assertEqual(mock_call.call_count, 2)
+ first_call = mock_call.call_args_list[0]
+ second_call = mock_call.call_args_list[1]
+ self.assertEqual(first_call.args[0], "vault.load_all")
+ self.assertEqual(second_call.args[0], "db.hydrate_workspace")
+
+
+if __name__ == "__main__":
+ unittest.main()