From 7ba9c8c952769bd4ebdd2a7e1838c3949d20148d Mon Sep 17 00:00:00 2001 From: stan44 Date: Sat, 21 Feb 2026 18:35:20 -0600 Subject: [PATCH] main commit --- .gitignore | 29 + installreqs.sh | 73 +++ journal/__init__.py | 0 journal/ai/__init__.py | 0 journal/ai/analysis.py | 373 +++++++++++++ journal/ai/chat.py | 19 + journal/cli/__init__.py | 0 journal/cli/main.py | 299 +++++++++++ journal/core/__init__.py | 0 journal/core/config.py | 62 +++ journal/core/database.py | 114 ++++ journal/core/encryption.py | 61 +++ journal/core/entry.py | 368 +++++++++++++ journal/core/fragments.py | 10 + journal/core/models.py | 100 ++++ journal/core/parser.py | 96 ++++ journal/core/speech.py | 82 +++ journal/core/storage.py | 310 +++++++++++ journal/run_desktop.py | 308 +++++++++++ journal/ui/__init__.py | 0 journal/ui/components/__init__.py | 0 journal/ui/components/calendar.py | 13 + journal/ui/components/editor.py | 138 +++++ journal/ui/components/settings.py | 123 +++++ journal/ui/components/speech.py | 82 +++ journal/ui/main.py | 680 ++++++++++++++++++++++++ originalprojectplan.md | 290 ++++++++++ pyproject.toml | 52 ++ pyrightconfig.json | 3 + requirements_base.txt | 18 + requirements_cpu_only.txt | 9 + requirements_gpu.txt | 10 + requirements_nlp_optional.txt | 4 + tests/test_ai_backend.py | 47 ++ typings/speech_recognition/__init__.pyi | 55 ++ typings/sqlcipher3/dbapi2.pyi | 24 + 36 files changed, 3852 insertions(+) create mode 100644 .gitignore create mode 100644 installreqs.sh create mode 100644 journal/__init__.py create mode 100644 journal/ai/__init__.py create mode 100644 journal/ai/analysis.py create mode 100644 journal/ai/chat.py create mode 100644 journal/cli/__init__.py create mode 100644 journal/cli/main.py create mode 100644 journal/core/__init__.py create mode 100644 journal/core/config.py create mode 100644 journal/core/database.py create mode 100644 journal/core/encryption.py create mode 100644 journal/core/entry.py create mode 100644 journal/core/fragments.py create mode 100644 journal/core/models.py create mode 100644 journal/core/parser.py create mode 100644 journal/core/speech.py create mode 100644 journal/core/storage.py create mode 100644 journal/run_desktop.py create mode 100644 journal/ui/__init__.py create mode 100644 journal/ui/components/__init__.py create mode 100644 journal/ui/components/calendar.py create mode 100644 journal/ui/components/editor.py create mode 100644 journal/ui/components/settings.py create mode 100644 journal/ui/components/speech.py create mode 100644 journal/ui/main.py create mode 100644 originalprojectplan.md create mode 100644 pyproject.toml create mode 100644 pyrightconfig.json create mode 100644 requirements_base.txt create mode 100644 requirements_cpu_only.txt create mode 100644 requirements_gpu.txt create mode 100644 requirements_nlp_optional.txt create mode 100644 tests/test_ai_backend.py create mode 100644 typings/speech_recognition/__init__.pyi create mode 100644 typings/sqlcipher3/dbapi2.pyi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff2a231 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Environments +.venv/ +.nicegui +.obsidian/ +/lyricflow/ +/journal-master/ +/srczip/ + +# Cache +__pycache__/ +data/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +*.pyc + +# Ignore the encrypted vault and directorys +journal/vault/ +journal/data/ +journal_app.egg-info/ +output/ +logs/ + +# Other Data +/emptytrash.sh +/Journaling_TODO.md +/setup_swap.sh +/system_tune.sh + diff --git a/installreqs.sh b/installreqs.sh new file mode 100644 index 0000000..16e266c --- /dev/null +++ b/installreqs.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash + +# Deterministic installer for Project_Journal (Python 3.14) +# Defaults: CPU profile, no optional NLP model. +# +# Usage: +# ./installreqs.sh +# ./installreqs.sh --gpu +# ./installreqs.sh --with-nlp +# ./installreqs.sh --gpu --with-nlp + +set -euo pipefail + +REQ_FILE="requirements_cpu_only.txt" +WITH_NLP=0 +VENV_DIR=".venv" + +while [[ $# -gt 0 ]]; do + case "$1" in + --gpu) + REQ_FILE="requirements_gpu.txt" + shift + ;; + --with-nlp) + WITH_NLP=1 + shift + ;; + --venv) + VENV_DIR="$2" + shift 2 + ;; + -h|--help) + echo "Usage: $0 [--gpu] [--with-nlp] [--venv PATH]" + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + echo "Usage: $0 [--gpu] [--with-nlp] [--venv PATH]" + exit 1 + ;; + esac +done + +if [[ ! -f "$REQ_FILE" ]]; then + echo "Error: $REQ_FILE not found." >&2 + exit 1 +fi + +echo "Creating/updating virtual environment at: $VENV_DIR" +python3.14 -m venv "$VENV_DIR" + +# shellcheck disable=SC1090 +source "$VENV_DIR/bin/activate" + +python -m pip install --upgrade pip setuptools wheel + +if [[ "$REQ_FILE" == "requirements_cpu_only.txt" ]]; then + echo "Installing CPU dependency profile..." + python -m pip install --extra-index-url https://download.pytorch.org/whl/cpu -r "$REQ_FILE" +else + echo "Installing GPU dependency profile..." + python -m pip install -r "$REQ_FILE" +fi + +if [[ $WITH_NLP -eq 1 ]]; then + echo "Installing optional NLP dependencies..." + python -m pip install -r requirements_nlp_optional.txt + echo "Attempting spaCy model install (ignored if unsupported on this Python)..." + python -m spacy download en_core_web_sm || true +fi + +echo "Done." +echo "Activate with: source $VENV_DIR/bin/activate" diff --git a/journal/__init__.py b/journal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/journal/ai/__init__.py b/journal/ai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/journal/ai/analysis.py b/journal/ai/analysis.py new file mode 100644 index 0000000..7839b29 --- /dev/null +++ b/journal/ai/analysis.py @@ -0,0 +1,373 @@ +from collections import Counter +import re +from typing import Any + +import requests + +import sys +from pathlib import Path + +sys.path.append(str(Path(__file__).resolve().parent.parent.parent)) +from journal.core.models import JournalEntry, Fragment +from journal.core.config import ( + NLP_BACKEND, + LLAMA_CPP_URL, + LLAMA_CPP_MODEL, + LLAMA_CPP_TIMEOUT, + EMBEDDING_API_URL, + EMBEDDING_MODEL_NAME, + MODEL_CONTEXT_TOKENS, + CHUNK_TOKEN_BUDGET, +) + +_BACKEND_AUTO = "auto" +_BACKEND_SPACY = "spacy" +_BACKEND_FALLBACK = "fallback" +_VALID_BACKENDS = {_BACKEND_AUTO, _BACKEND_SPACY, _BACKEND_FALLBACK} +_backend_name: str | None = None +_spacy_nlp: Any | None = None +_fallback_warning_printed = False + +_STOP_WORDS = { + "about", + "after", + "again", + "against", + "also", + "and", + "because", + "before", + "being", + "between", + "both", + "could", + "during", + "from", + "have", + "into", + "just", + "like", + "more", + "most", + "over", + "same", + "some", + "such", + "than", + "that", + "their", + "them", + "then", + "there", + "these", + "they", + "this", + "those", + "through", + "under", + "until", + "very", + "what", + "when", + "where", + "which", + "while", + "with", + "would", + "your", +} + + +def _resolve_backend() -> str: + global _backend_name, _spacy_nlp, _fallback_warning_printed + + if _backend_name is not None: + return _backend_name + + requested = NLP_BACKEND if NLP_BACKEND in _VALID_BACKENDS else _BACKEND_AUTO + if requested == _BACKEND_FALLBACK: + _backend_name = _BACKEND_FALLBACK + return _backend_name + + try: + import spacy + + _spacy_nlp = spacy.load("en_core_web_sm") + _backend_name = _BACKEND_SPACY + return _backend_name + except Exception as exc: + if requested == _BACKEND_SPACY: + raise RuntimeError( + "JOURNAL_NLP_BACKEND=spacy but spaCy backend initialization failed. " + "Install optional NLP deps/model or set JOURNAL_NLP_BACKEND=auto|fallback." + ) from exc + + _backend_name = _BACKEND_FALLBACK + if not _fallback_warning_printed: + print( + "WARNING: spaCy backend unavailable; using fallback NLP heuristics. " + "Set JOURNAL_NLP_BACKEND=fallback to silence this warning." + ) + _fallback_warning_printed = True + return _backend_name + + +def get_nlp_backend() -> str: + """Returns the active NLP backend: 'spacy' or 'fallback'.""" + return _resolve_backend() + + +def count_tokens(text: str) -> int: + # Simple token estimator: 1 token ≈ 1-4 char (very rough) + + return max(1, len(text) // 4) + + +def llama_cpp_generate( + prompt: str, + model: str = LLAMA_CPP_MODEL, + temperature: float = 0.7, + max_tokens: int = 2048, +) -> str: + payload = { + "model": model, + "prompt": prompt, + "max_tokens": max_tokens, + "temperature": temperature, + "stop": [], + "stream": False, + } + try: + response = requests.post(LLAMA_CPP_URL, json=payload, timeout=LLAMA_CPP_TIMEOUT) + response.raise_for_status() + data = response.json() + # llama.cpp returns choices array with text field + if "choices" in data and len(data["choices"]) > 0: + result = data["choices"][0]["text"].strip() + print(f"DEBUG: Generated {len(result)} characters") # Debug output + if len(result) < 10: # If very short response + print(f"DEBUG: Short response: '{result}'") + return result + else: + print("DEBUG: No choices in response") + return "No response generated." + except Exception as e: + print(f"DEBUG: Exception occurred: {e}") + return f"Error communicating with llama.cpp server: {e}" + + +def generate_embedding(text: str) -> list[float]: + """ + Generates an embedding for the given text using the configured embedding model. + """ + payload = { + "model": EMBEDDING_MODEL_NAME, + "input": text, + } + try: + response = requests.post( + EMBEDDING_API_URL, json=payload, timeout=LLAMA_CPP_TIMEOUT + ) # Reusing LLAMA_CPP_TIMEOUT for now + response.raise_for_status() + data = response.json() + if "data" in data and len(data["data"]) > 0 and "embedding" in data["data"][0]: + return data["data"][0]["embedding"] + else: + print("DEBUG: No embedding data in response") + return [] + except Exception as e: + print(f"DEBUG: Exception occurred during embedding generation: {e}") + return [] + + +def synthesize_summaries(chunk_summaries: list[str]) -> str: + combined = "\n\n---\n\n".join(chunk_summaries) + print( + f"DEBUG: Synthesizing {len(chunk_summaries)} summaries, total chars: {len(combined)}" + ) + + # Try a much simpler prompt first + prompt = ( + "Please analyze and summarize the following Journals as a professional Psychologist:\n\n" + f"{combined}\n\n" + "Summary:" + ) + print(f"DEBUG: Synthesis prompt length: {len(prompt)} characters") + result = llama_cpp_generate(prompt, max_tokens=2048) + print(f"DEBUG: Final synthesis result: '{result[:100]}...'") # Show first 100 chars + return result + + +def summarize_chunk(entries: list[JournalEntry]) -> str: + combined_text = """ + +--- + +""".join([entry.raw_content for entry in entries]) + prompt = ( + "You are a psychological analysis agent. Given the following journal entries, " + "analyze and report on:\n" + "- Recurring psychological themes\n" + "- Behavioral patterns\n" + "- Emotional trends\n" + "- Coping mechanisms\n" + "- Notable changes over time\n\n" + "Journal entries:\n" + f"{combined_text}\n\n" + "Respond with a concise, insightful analysis for this batch." + ) + return llama_cpp_generate(prompt, max_tokens=2048) + + +def extract_themes(text: str) -> list[str]: + backend = _resolve_backend() + if backend == _BACKEND_SPACY and _spacy_nlp is not None: + try: + doc = _spacy_nlp(text) + themes = [] + for ent in doc.ents: + if ent.label_ in [ + "PERSON", + "ORG", + "EVENT", + "WORK_OF_ART", + "LAW", + "LANGUAGE", + ]: + themes.append(ent.text.lower()) + for chunk in doc.noun_chunks: + if 2 <= len(chunk.text.split()) <= 4: + themes.append(chunk.text.lower()) + theme_counts = Counter(themes) + return [ + theme for theme, count in theme_counts.most_common(10) if count > 1 + ] + except Exception: + # Fall through to non-spaCy extraction when model parsing fails at runtime. + pass + + return _extract_themes_fallback(text) + + +def _extract_themes_fallback(text: str) -> list[str]: + words = re.findall(r"[A-Za-z][A-Za-z'-]{2,}", text.lower()) + filtered_words = [w for w in words if w not in _STOP_WORDS] + if not filtered_words: + return [] + + single_counts = Counter(filtered_words) + phrase_counts = Counter() + for first, second in zip(filtered_words, filtered_words[1:]): + if first == second: + continue + phrase_counts[f"{first} {second}"] += 1 + + themes: list[str] = [] + for phrase, count in phrase_counts.most_common(20): + if count > 1: + themes.append(phrase) + if len(themes) >= 10: + return themes + + for word, count in single_counts.most_common(30): + if count > 1 and word not in themes: + themes.append(word) + if len(themes) >= 10: + break + + return themes + + +def analyze_fragments(fragments: list[Fragment]) -> str: + if not fragments: + return "No fragments recorded." + fragment_types = Counter([frag.type for frag in fragments]) + all_tags = [] + for frag in fragments: + all_tags.extend(frag.tags) + tag_counts = Counter(all_tags) + analysis = f"{len(fragments)} discrete events recorded. " + if fragment_types: + top_type = fragment_types.most_common(1)[0] + analysis += f"Most frequent: {top_type[0]} ({top_type[1]} times). " + if tag_counts: + top_tags = [tag for tag, _ in tag_counts.most_common(3)] + analysis += f"Key themes: {', '.join(top_tags)}." + return analysis + + +def summarize_all_entries(entries: list[JournalEntry]) -> str: + _ = _resolve_backend() + if not entries: + return "No entries found to analyze." + + # Chunk entries to fit model context + chunks = chunk_journal_entries(entries) + chunk_summaries = [] + for i, chunk in enumerate(chunks): + print(f"Analyzing chunk {i + 1}/{len(chunks)} ({len(chunk)} entries)...") + summary = summarize_chunk(chunk) + chunk_summaries.append(summary) + + print("Synthesizing final report...") + final_report = synthesize_summaries(chunk_summaries) + return final_report + + +def identify_patterns(entries: list[JournalEntry]) -> list[str]: + _ = _resolve_backend() + if not entries: + return ["No entries to analyze."] + all_content = [entry.raw_content for entry in entries] + dates = [entry.date for entry in entries] + combined_text = " ".join(all_content) + prompt = ( + f"You are a psychological pattern analysis agent. " + f"Given the following journal entries, identify:\n" + f"- Recurring psychological themes\n" + f"- Behavioral patterns\n" + f"- Emotional trends\n" + f"- Coping mechanisms\n" + f"- Notable changes over time\n\n" + f"Journal entries span from {dates[0]} to {dates[-1]}.\n" + f"Entries:\n{combined_text}\n\n" + f"Respond with a concise, insightful pattern analysis." + ) + return [llama_cpp_generate(prompt)] + + +def chunk_journal_entries( + entries: list[JournalEntry], token_budget: int = CHUNK_TOKEN_BUDGET +) -> list[list[JournalEntry]]: + chunks = [] + current_chunk = [] + current_tokens = 0 + + for entry in entries: + entry_tokens = count_tokens(entry.raw_content) + + if current_tokens + entry_tokens > token_budget and current_chunk: + chunks.append(current_chunk) + current_chunk = [] + current_tokens = 0 + + current_chunk.append(entry) + current_tokens += entry_tokens + + if current_chunk: + chunks.append(current_chunk) + return chunks + + +def summarize_entry(entry: JournalEntry) -> str: + _ = _resolve_backend() + prompt = ( + "You are a psychological analysis agent. Given the following journal entry, " + "analyze and report on:\n" + "- Recurring psychological themes\n" + "- Behavioral patterns\n\n" + "Journal entry:\n" + f"{entry.raw_content}\n\n" + "Respond with a concise, insightful analysis." + ) + return llama_cpp_generate(prompt, max_tokens=2048) diff --git a/journal/ai/chat.py b/journal/ai/chat.py new file mode 100644 index 0000000..faaa22a --- /dev/null +++ b/journal/ai/chat.py @@ -0,0 +1,19 @@ + +import requests +from journal.core.config import CLOUDAI_API_KEY, CLOUDAI_API_URL + +def get_cloud_ai_response(prompt: str) -> str: + """ + Gets a response from the cloud AI service. + """ + headers = { + "Authorization": f"Bearer {CLOUDAI_API_KEY}", + "Content-Type": "application/json", + } + payload = {"prompt": prompt} + try: + response = requests.post(CLOUDAI_API_URL, headers=headers, json=payload) + response.raise_for_status() + return response.json().get("response", "No response from AI.") + except requests.exceptions.RequestException as e: + return f"Error communicating with Cloud AI: {e}" diff --git a/journal/cli/__init__.py b/journal/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/journal/cli/main.py b/journal/cli/main.py new file mode 100644 index 0000000..6421aef --- /dev/null +++ b/journal/cli/main.py @@ -0,0 +1,299 @@ +import argparse +from argparse import Namespace +import getpass +import subprocess +import sys +import os +import signal +import time +from pathlib import Path +from datetime import datetime, date + +sys.path.append(str(Path(__file__).resolve().parent.parent.parent)) + +from journal.core.storage import rebuild_all_vaults, load_all_vaults +from journal.core.parser import parse_journal_file +from journal.core.models import JournalEntry +from journal.core.config import DATA_DIR, PID_FILE, PROJECT_ROOT + + +class Args(Namespace): + """Typed namespace for command-line arguments.""" + + command: str = "" + + # Vault and Server + action: str | None = None + + # Search + query: str | None = None + tag: list[str] | None = None + type: list[str] | None = None + start_date: date | None = None + end_date: date | None = None + section: str | None = None + checked: list[str] | None = None + unchecked: list[str] | None = None + + # Chat + prompt: str | None = None + + +def main(): + parser = argparse.ArgumentParser( + description="A command-line interface for your journal." + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + # Vault commands + vault_parser = subparsers.add_parser("vault", help="Manage the encrypted vault.") + _ = vault_parser.add_argument( + "action", choices=["save", "load"], help="The action to perform on the vault." + ) + + # Search command + search_parser = subparsers.add_parser("search", help="Search your journal entries.") + _ = search_parser.add_argument( + "query", nargs="?", default=None, help="The text to search for." + ) + _ = search_parser.add_argument( + "--tag", + "-t", + action="append", + help="Filter by tag (can be used multiple times).", + ) + _ = search_parser.add_argument( + "--type", + "-y", + action="append", + help="Filter by fragment type (e.g., !FLASHBACK).", + ) + _ = search_parser.add_argument( + "--start-date", + "-s", + type=lambda s: datetime.strptime(s, "%Y-%m-%d").date(), + help="Filter by start date (YYYY-MM-DD).", + ) + _ = search_parser.add_argument( + "--end-date", + "-e", + type=lambda s: datetime.strptime(s, "%Y-%m-%d").date(), + help="Filter by end date (YYYY-MM-DD).", + ) + _ = search_parser.add_argument( + "--section", "-sec", help="Search for query within a specific section title." + ) + _ = search_parser.add_argument( + "--checked", + "-chk", + action="append", + help="Filter by checked checkbox text (can be used multiple times).", + ) + _ = search_parser.add_argument( + "--unchecked", + "-uchk", + action="append", + help="Filter by unchecked checkbox text (can be used multiple times).", + ) + + # Server commands + server_parser = subparsers.add_parser("server", help="Manage the NiceGUI server.") + _ = server_parser.add_argument( + "action", choices=["start", "stop"], help="Start or stop the server." + ) + + # Chat command + chat_parser = subparsers.add_parser("chat", help="Chat with the AI.") + _ = chat_parser.add_argument("prompt", help="The prompt to send to the AI.") + + # Devices command + devices_parser = subparsers.add_parser("devices", help="Manage hardware devices.") + _ = devices_parser.add_argument( + "action", choices=["list"], help="List available devices (e.g., microphones)." + ) + + args = parser.parse_args(namespace=Args()) + + if args.command == "vault": + password = getpass.getpass("Vault password: ") + if args.action == "save": + rebuild_all_vaults(password) + elif args.action == "load": + _ = load_all_vaults(password) + print(f"Vault loaded. Decrypted files are in {DATA_DIR}") + + elif args.command == "search": + if not any(DATA_DIR.iterdir()): + print( + "No decrypted journal entries found. Please load the vault first: journal vault load" + ) + return + + found_entries: list[JournalEntry] = [] + for filepath in DATA_DIR.glob("*.md"): + entry = parse_journal_file(str(filepath)) + + # Date filtering + entry_date = datetime.strptime(entry.date, "%Y-%m-%d").date() + if args.start_date and entry_date < args.start_date: + continue + if args.end_date and entry_date > args.end_date: + continue + + # Content filtering + content_match = False + if args.query is not None: + if args.section is not None: + section_content = entry.get_section(args.section) + if args.query.lower() in section_content.lower(): + content_match = True + elif args.query.lower() in entry.raw_content.lower(): + content_match = True + else: + content_match = True # If no query, content always matches + + # Tag and Type filtering (for fragments) + fragment_match = False + if args.tag is not None or args.type is not None: + for fragment in entry.fragments: + if args.type is not None and fragment.type not in args.type: + continue + if args.tag is not None and not any( + tag in args.tag for tag in fragment.tags + ): + continue + fragment_match = True + break + else: + fragment_match = True # If no tag/type filter, fragments always match + + # Checkbox filtering + checkbox_match = True + if args.checked is not None or args.unchecked is not None: + checkbox_match = False # Assume no match until proven otherwise + for _, parsed_section in entry.sections.items(): + for checkbox_text, is_checked in parsed_section.checkboxes.items(): + if ( + args.checked is not None + and checkbox_text in args.checked + and is_checked + ): + checkbox_match = True + break + if ( + args.unchecked is not None + and checkbox_text in args.unchecked + and not is_checked + ): + checkbox_match = True + break + if ( + checkbox_match + ): # Found a match in this section, no need to check other sections + break + if ( + args.checked is not None or args.unchecked is not None + ) and not checkbox_match: # If filters were applied but no match found + continue # Skip this entry + + # Combine filters + if content_match and fragment_match and checkbox_match: + found_entries.append(entry) + + if found_entries: + for entry in found_entries: + print(f"--- {entry.date} ---") + print(entry.raw_content) + print("\n") + else: + print("No entries found matching the criteria.") + + elif args.command == "chat": + from journal.ai.chat import get_cloud_ai_response + + if args.prompt: + response = get_cloud_ai_response(args.prompt) + print(response) + + elif args.command == "devices": + if args.action == "list": + try: + import speech_recognition as sr + + print("Available Microphones:") + for index, name in enumerate(sr.Microphone.list_microphone_names()): + print(f' Index {index}: "{name}"') + except Exception as e: + print(f"Could not list microphones. Error: {e}") + + elif args.command == "server": + if args.action == "start": + if PID_FILE.exists(): + print("Server already running (PID file exists).") + sys.exit(1) + + # Use the same Python interpreter that is running this script. + # This is more robust and portable than a hardcoded path. + venv_python = sys.executable + + # Correct path to the UI application + ui_app_path = PROJECT_ROOT / "journal" / "ui" / "main.py" + command = [str(venv_python), str(ui_app_path)] + + print(f"Starting NiceGUI server in background: {' '.join(command)}") + process = subprocess.Popen( + command, + cwd=PROJECT_ROOT, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + _ = PID_FILE.write_text(str(process.pid)) + print(f"Server started with PID {process.pid}") + + elif args.action == "stop": + if not PID_FILE.exists(): + print("Server not running (PID file not found).") + sys.exit(1) + + try: + pid = int(PID_FILE.read_text()) + if sys.platform == "win32": + _ = subprocess.run( + ["taskkill", "/F", "/PID", str(pid)], + check=True, + capture_output=True, + ) + print(f"Terminated server process with PID {pid}.") + else: + # On Unix-like systems, try graceful shutdown first + print(f"Sending SIGTERM to server with PID {pid}...") + os.kill(pid, signal.SIGTERM) + time.sleep(3) # Give it a moment to shut down + try: + # Check if it's still alive, then kill it forcefully + os.kill(pid, 0) + print( + f"Server with PID {pid} did not terminate gracefully. Sending SIGKILL." + ) + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + # This is the expected outcome if SIGTERM worked + print("Server terminated gracefully.") + except (ValueError, FileNotFoundError): + print("PID file is invalid or missing.") + except ProcessLookupError as e: + print(f"Process not found: {e}") + except subprocess.CalledProcessError: + print( + "Failed to terminate process with taskkill (it may already be gone)." + ) + except PermissionError as e: + print(f"Permission denied to terminate process: {e}") + finally: + if PID_FILE.exists(): + PID_FILE.unlink() + print("Server stop command finished.") + + +if __name__ == "__main__": + main() diff --git a/journal/core/__init__.py b/journal/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/journal/core/config.py b/journal/core/config.py new file mode 100644 index 0000000..ef0a154 --- /dev/null +++ b/journal/core/config.py @@ -0,0 +1,62 @@ +import sys +import os +from pathlib import Path + +sys.path.append(str(Path(__file__).resolve().parent.parent)) +# --- Directories --- +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +DATA_DIR = ( + PROJECT_ROOT / "journal" / "data" +) # This will become the temporary decrypted data directory +VAULT_DIR = ( + PROJECT_ROOT / "journal" / "vault" +) # This will store the encrypted monthly vault files +LOG_DIR = PROJECT_ROOT / "logs" +PID_FILE = LOG_DIR / "nicegui_server.pid" +SERVER_CONTROL_FILE = LOG_DIR / "server_control.action" +DATABASE_FILENAME = "journal_cache.db" + +# --- Vault & Encryption --- +SALT_SIZE = 16 +KEY_SIZE = 32 # AES-256 +AES_NONCE_SIZE = 12 +AES_TAG_SIZE = 16 +ITERATIONS = 600_000 +MONTHLY_VAULT_FORMAT = "%Y-%m.vault" # e.g., 2025-07.vault + +# --- AI Configuration --- +CLOUDAI_API_KEY = "" +CLOUDAI_API_URL = "" +LLAMA_CPP_URL = "http://127.0.0.1:8085/v1/completions" +LLAMA_CPP_MODEL = "qwen/qwen3-4b" +LLAMA_CPP_TIMEOUT = 6000 + +EMBEDDING_API_URL = "http://127.0.0.1:8086/v1/embeddings" +EMBEDDING_MODEL_NAME = "text-embedding-nomic-embed-text-v2-moe" +MODEL_CONTEXT_TOKENS = 131072 +CHUNK_TOKEN_BUDGET = 120000 + +# --- Hardware Configuration --- +# Set this to a specific index from `journal devices list` to force a microphone. +# Setting this to `None` is recommended as it will use the system's default device. +MICROPHONE_DEVICE_INDEX: int | None = None + +# --- Speech Recognition --- +# "whisper" is local, private, and highly accurate (recommended). Downloads a model on first use. +# "google" is online, accurate, but sends data to Google. +# "sphinx" is offline, fast, but much less accurate. +SPEECH_RECOGNITION_ENGINE: str = "whisper" +WHISPER_MODEL_SIZE: str = "base" # Options: "tiny", "base", "small", "medium", "large" + +# NLP backend selection: +# - auto: use spaCy when available, otherwise fallback heuristics. +# - spacy: require spaCy backend and fail clearly if unavailable. +# - fallback: always use lightweight fallback heuristics. +NLP_BACKEND: str = os.getenv("JOURNAL_NLP_BACKEND", "auto").strip().lower() or "auto" +if NLP_BACKEND not in {"auto", "spacy", "fallback"}: + NLP_BACKEND = "auto" + +# --- Ensure directories exist --- +DATA_DIR.mkdir(exist_ok=True) +VAULT_DIR.mkdir(exist_ok=True) +LOG_DIR.mkdir(exist_ok=True) diff --git a/journal/core/database.py b/journal/core/database.py new file mode 100644 index 0000000..bf9fe0f --- /dev/null +++ b/journal/core/database.py @@ -0,0 +1,114 @@ +import sys +from pathlib import Path +try: + from sqlcipher3 import dbapi2 as sqlite + + SQLCIPHER_AVAILABLE = True +except ImportError: + import sqlite3 as sqlite + + SQLCIPHER_AVAILABLE = False + +sys.path.append(str(Path(__file__).resolve().parent.parent.parent)) +from .config import DATA_DIR, DATABASE_FILENAME +from .models import JournalEntry +from .encryption import derive_key + + +def get_db_connection(password: str) -> sqlite.Connection: + """ + Creates and returns a connection to the encrypted SQLite database. + The database key is derived from the user's main vault password. + """ + db_path = DATA_DIR / DATABASE_FILENAME + # Use a fixed salt for the DB key so it's the same for the session. + # This is secure because the salt is only used with the user's high-entropy password. + db_salt = b"a_fixed_salt_for_the_db_key_deriv" + db_key = derive_key(password, db_salt) + + conn = sqlite.connect(str(db_path)) + if SQLCIPHER_AVAILABLE: + # The key must be provided as a hex string. + _ = conn.execute(f"PRAGMA key = \"x'{db_key.hex()}'\"") + # Test the connection to ensure the key is correct. + _ = conn.execute("SELECT count(*) FROM sqlite_master;") + else: + print( + "WARNING: sqlcipher3 is unavailable; using sqlite3 fallback without DB encryption." + ) + return conn + + +def create_schema(conn: sqlite.Connection): + """Creates the database schema if it doesn't already exist.""" + cursor = conn.cursor() + # Entries Table + _ = cursor.execute( + """ + CREATE TABLE IF NOT EXISTS entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL UNIQUE + ) + """ + ) + # Sections Table + _ = cursor.execute( + """ + CREATE TABLE IF NOT EXISTS sections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entry_id INTEGER NOT NULL, + title TEXT NOT NULL, + content TEXT, + FOREIGN KEY (entry_id) REFERENCES entries (id) + ) + """ + ) + # Fragments Table + _ = cursor.execute( + """ + CREATE TABLE IF NOT EXISTS fragments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entry_id INTEGER NOT NULL, + type TEXT NOT NULL, + description TEXT, + time TEXT, + FOREIGN KEY (entry_id) REFERENCES entries (id) + ) + """ + ) + # Tags Table + _ = cursor.execute( + """ + CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE + ) + """ + ) + # Fragment-Tags Join Table + _ = cursor.execute( + """ + CREATE TABLE IF NOT EXISTS fragment_tags ( + fragment_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + PRIMARY KEY (fragment_id, tag_id), + FOREIGN KEY (fragment_id) REFERENCES fragments (id), + FOREIGN KEY (tag_id) REFERENCES tags (id) + ) + """ + ) + conn.commit() + + +def hydrate_database(conn: sqlite.Connection, entries: list[JournalEntry]): + """ + Populates the database with a list of JournalEntry objects. + This function is designed to be idempotent but is typically run on a clean DB. + """ + # This is a placeholder for the full hydration logic. + # A complete implementation would iterate through entries, sections, and fragments, + # inserting them into their respective tables. + print(f"Hydrating database with {len(entries)} entries...") + # For now, we just ensure the schema is created. + create_schema(conn) + print("Database hydration complete.") diff --git a/journal/core/encryption.py b/journal/core/encryption.py new file mode 100644 index 0000000..2e0f547 --- /dev/null +++ b/journal/core/encryption.py @@ -0,0 +1,61 @@ +import os +import sys +from pathlib import Path +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from cryptography.hazmat.backends import default_backend + +sys.path.append(str(Path(__file__).resolve().parent.parent.parent)) +from .config import ( + SALT_SIZE, + KEY_SIZE, + AES_NONCE_SIZE, + AES_TAG_SIZE, + ITERATIONS, +) + + +def derive_key(password: str, salt: bytes) -> bytes: + """Derives a key from a password and salt using PBKDF2-HMAC-SHA256.""" + if not password: + raise ValueError("Password cannot be empty.") + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=KEY_SIZE, + salt=salt, + iterations=ITERATIONS, + backend=default_backend(), + ) + return kdf.derive(password.encode("utf-8")) + + +def encrypt_data(data: bytes, password: str) -> bytes: + """Encrypts data using AES-256 GCM.""" + salt = os.urandom(SALT_SIZE) + nonce = os.urandom(AES_NONCE_SIZE) + key = derive_key(password, salt) + + cipher = Cipher(algorithms.AES(key), modes.GCM(nonce), backend=default_backend()) + encryptor = cipher.encryptor() + ciphertext = encryptor.update(data) + encryptor.finalize() + + return salt + nonce + encryptor.tag + ciphertext + + +def decrypt_data(encrypted_data: bytes, password: str) -> bytes: + """Decrypts data using AES-256 GCM.""" + salt = encrypted_data[:SALT_SIZE] + nonce = encrypted_data[SALT_SIZE : SALT_SIZE + AES_NONCE_SIZE] + tag = encrypted_data[ + SALT_SIZE + AES_NONCE_SIZE : SALT_SIZE + AES_NONCE_SIZE + AES_TAG_SIZE + ] + ciphertext = encrypted_data[SALT_SIZE + AES_NONCE_SIZE + AES_TAG_SIZE :] + + key = derive_key(password, salt) + cipher = Cipher( + algorithms.AES(key), modes.GCM(nonce, tag), backend=default_backend() + ) + decryptor = cipher.decryptor() + + return decryptor.update(ciphertext) + decryptor.finalize() diff --git a/journal/core/entry.py b/journal/core/entry.py new file mode 100644 index 0000000..223a4ba --- /dev/null +++ b/journal/core/entry.py @@ -0,0 +1,368 @@ +from datetime import datetime + + +# region Daily Entry +def create_daily_entry() -> str: + """ + Generates the full markdown for a new daily journal entry. + """ + today = datetime.now().strftime("%Y-%m-%d") + return f"""--- +type: journal +mode: daily +title: "Daily Mind & Mood Log" +--- +**Date:** {today} + +## 🧠 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 +- Notes: + +## 🧰 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: +""" + + +# endregion + + +# region Fragment Entry +def create_fragment_entry() -> str: + """ + Generates the full markdown for a new fragment journal entry. + """ + today = datetime.now().strftime("%Y-%m-%d") + return f"""--- +type: journal +mode: fragment +title: "Fragment Insert Format" +--- +**Date:** {today} +# 🧠 Fragment Logging Mode + +Use this mode for logging quick thoughts, triggers, shutdowns, or notable moments when filling out a full entry isn't possible. + +## Syntax Format: + +```markdown +!TYPE @time #tags +Description of the event, thought, or experience. +``` + +## Example: + +```markdown +!TRIGGER @16:45 #co-parent #shutdown +She texted "you don't care about her" instant stomach drop and fogged thinking. + +!FORGOT @17:00 #memory #mindblank +Lost my train of thought mid-sentence with Person. Blank mind, embarrassed. +``` +""" + + +# endregion + + +# region Recovery Entry +def create_recovery_entry() -> str: + """ + Generates the full markdown for a new trauma recovery journal entry. + """ + today = datetime.now().strftime("%Y-%m-%d") + return f"""--- +type: journal +mode: deep_recovery +title: "Trauma Recovery Log" +--- + +# 📓 Trauma Recovery Log — Entry #[#] + +**Date:** {today} +**Title (Optional):** [Short phrase that helps you remember the theme] + +## 🔍 Summary + +> *(Briefly describe what happened or how you're feeling—1 to 3 paragraphs.)* + +## 🧠 Cognitive State + +- [ ] Masking + +- [ ] Shutdown + +- [ ] Meltdown + +- [ ] Freeze + +- [ ] Flow state + +- Notes: + +## 🧠 Core Events or Memories + +- Significant event or memory (e.g., “Major change at work/school”) +- Notable pattern or experience (e.g., “Felt dismissed when expressing needs”) +- Impact (e.g., “Difficulty connecting with others”) + +## 🧠 Mental / Emotional Snapshot + +- Internal monologue or silence? +- Thought loops or rumination? +- Anxiety level: (0–10) +- Depression level: (0–10) +- Emotional state(s): (e.g., Angry / Tired / Hopeful / Numb / Triggered / Overwhelmed / Energized) +- Notes: + +## 🧬 Autism/ADHD-Related Elements + +- Masking to fit in or avoid negative attention +- Self-teaching or adapting due to lack of support +- Shutdowns or meltdowns misunderstood by others +- Sensory issues or communication differences +- Notes: + +## 🧯 Emotional & Bodily Reactions (Then/Now) + +| Then | Now | +| ------------------------ | -------------------------------- | +| [e.g., Fear, confusion] | [e.g., Anger, fatigue] | +| [e.g., Shame for crying] | [e.g., Still struggle to grieve] | +| [e.g., Helplessness] | [e.g., Determined, but tired] | + +## ⚡ Memory / Mind Failures + +- Forgetting details or losing train of thought? +- Difficulty expressing thoughts? +- Time blindness or lost hours? +- Notes: + +## 📜 Events / Triggers + +- Notes: + +## 💬 Communication / Expression Log + +- Notes: + +## 🧰 Coping / Tools Used + +- Notes: + +## 🧠 Reflection & Therapy Prep + +### What I want to bring up in session: + +- [e.g., “Why do I struggle to trust others?”] +- [e.g., “How can I process past experiences safely?”] +- [e.g., “What helps me feel grounded?”] + +### What helps me cope: + +- [e.g., Talking to a supportive person or AI] +- [e.g., Writing or creative expression] +- [e.g., Learning new skills or information] + +## 🧭 Truth to Anchor Myself To + +> _“I am not broken—I am healing and growing.”_ +""" + + +# endregion + + +# region Deep Entry +def create_deep_entry() -> str: + today = datetime.now().strftime("%Y-%m-%d") + return f"""--- +type: journal +mode: deep_recovery +title: "Trauma Recovery Log" +--- + +# 📓 Deep Log — Entry #[#] + +**Date:** {today} +**Title (Optional):** [Short phrase that helps you remember the theme] + +--- + +## 🔍 Summary + +> *(Briefly describe what happened or how you're feeling—1 to 3 paragraphs.)* + +--- + +## 🧠 Cognitive State + +- [ ] Masking + +- [ ] Shutdown + +- [ ] Meltdown + +- [ ] Freeze + +- [ ] Flow state + +- Notes: + +--- + +## 🧠 Core Events or Memories + +- Significant event or memory (e.g., “Change in daily routine”) +- Notable pattern or experience (e.g., “Felt dismissed when expressing needs”) +- Impact (e.g., “Difficulty connecting with others”) + +--- + +## 🧠 Mental / Emotional Snapshot + +- Internal monologue or silence? +- Thought loops or rumination? +- Anxiety level: (0–10) +- Depression level: (0–10) +- Emotional state(s): (e.g., Angry / Tired / Hopeful / Numb / Triggered / Overwhelmed / Energized) +- Notes: + +--- + +## 🧬 Autism/ADHD-Related Elements + +- Masking to fit in or avoid negative attention +- Self-teaching or adapting due to lack of support +- Shutdowns or meltdowns misunderstood by others +- Sensory issues or communication differences +- Notes: + +--- + +## 🧯 Emotional & Bodily Reactions (Then/Now) + +| Then | Now | +| ------------------------ | -------------------------------- | +| [e.g., Fear, confusion] | [e.g., Anger, fatigue] | +| [e.g., Shame for crying] | [e.g., Still struggle to grieve] | +| [e.g., Helplessness] | [e.g., Determined, but tired] | + +--- + +## ⚡ Memory / Mind Failures + +- Forgetting details or losing train of thought? +- Difficulty expressing thoughts? +- Time blindness or lost hours? +- Notes: + +## 📜 Events / Triggers + +- Interactions (e.g., with others, officials, etc.) +- Flashbacks or triggers +- Physical symptoms +- Notable events +- Notes: + +## 💬 Communication / Expression Log + +- Messages not sent +- Things forgotten to say +- Things said that weren't meant +- Verbal conflicts or miscommunication + +## 🧰 Coping / Tools Used + +- Breathing +- Music +- Walking +- Writing +- 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: + +--- + +## 🧠 Reflection & Therapy Prep + +### What I want to bring up in session: + +- [e.g., “Why do I struggle to trust others?”] +- [e.g., “How can I process past experiences safely?”] +- [e.g., “What helps me feel grounded?”] + +### What helps me cope: + +- [e.g., Talking to a supportive person or AI] +- [e.g., Writing or creative expression] +- [e.g., Learning new skills or information] + +--- + +## 🧭 Truth to Anchor Myself To + +> _“I am not broken—I am healing and growing.”_ +""" + + +# endregion diff --git a/journal/core/fragments.py b/journal/core/fragments.py new file mode 100644 index 0000000..27f8470 --- /dev/null +++ b/journal/core/fragments.py @@ -0,0 +1,10 @@ +from .models import Fragment +from datetime import datetime + + +def create_fragment( + type_: str, description: str, tags: list[str], time: str | None = None +): + if not time: + time = datetime.now().strftime("%H:%M") + return Fragment(type=type_, time=time, tags=tags, description=description) diff --git a/journal/core/models.py b/journal/core/models.py new file mode 100644 index 0000000..7c30cb7 --- /dev/null +++ b/journal/core/models.py @@ -0,0 +1,100 @@ +from dataclasses import dataclass, field + +# Define canonical section titles for parsing and reconstruction +SECTION_TITLES = [ + "Summary", + "Cognitive State", + "Mental / Emotional Snapshot", + "Memory / Mind Failures", + "Events / Triggers", + "Communication / Expression Log", + "Coping / Tools Used", + "Reflection", + "Core Events or Memories", + "Autism/ADHD-Related Elements", + "Emotional & Bodily Reactions", + "Truth to Anchor Myself To", +] + + +@dataclass +class Fragment: + type: str + description: str + time: str | None = None + tags: list[str] = field(default_factory=list) + + +@dataclass +class ParsedSection: + title: str + content: list[str] = field(default_factory=list) # Each item is a line or a block + checkboxes: dict[str, bool] = field( + default_factory=dict + ) # { "checkbox_text": is_checked } + + +@dataclass +class JournalEntry: + date: str + fragments: list[Fragment] = field(default_factory=list) + raw_content: str = "" + # New: Structured representation of sections + sections: dict[str, ParsedSection] = field(default_factory=dict) + + def merge_with(self, other_entry: "JournalEntry"): + """Merges another entry's data into this one.""" + # Merge sections: Overwrite if the new section has meaningful content + for title, new_section in other_entry.sections.items(): + # Heuristic: content is meaningful if it's not empty or just whitespace + if any(line.strip() for line in new_section.content): + self.sections[title] = new_section + + # Merge fragments: Add new fragments, avoid duplicates by description + existing_fragment_descs = {f.description for f in self.fragments} + for new_fragment in other_entry.fragments: + if new_fragment.description not in existing_fragment_descs: + self.fragments.append(new_fragment) + + def to_markdown(self) -> str: + """Reconstructs the journal entry as a markdown string.""" + lines: list[str] = [] + # Frontmatter (simplified for now) + lines.append("---") + lines.append("type: journal") + lines.append("---") + lines.append(f"**Date:** {self.date}\n") + + # Write sections in canonical order + for title in SECTION_TITLES: + if title in self.sections: + section = self.sections[title] + lines.append(f"## {section.title}\n") + # The content list no longer contains the header + lines.extend(section.content) + lines.append("") # newline after section + + # Append all fragments at the end + if self.fragments: + lines.append("# Fragments\n") + for frag in self.fragments: + time_str = f"@{frag.time}" if frag.time else "" + tags_str = " ".join([f"#{tag}" for tag in frag.tags]) + header = f"{frag.type} {time_str} {tags_str}".strip() + lines.append(f"{header}\n{frag.description}\n") + + return "\n".join(lines) + + def get_section(self, section_title: str) -> str: + # This method will now retrieve content from the parsed sections + if section_title in self.sections: + return "\n".join(self.sections[section_title].content) + return "" + + def get_checkbox_state(self, section_title: str, checkbox_text: str) -> bool | None: + if ( + section_title in self.sections + and checkbox_text in self.sections[section_title].checkboxes + ): + return self.sections[section_title].checkboxes[checkbox_text] + return None diff --git a/journal/core/parser.py b/journal/core/parser.py new file mode 100644 index 0000000..b743209 --- /dev/null +++ b/journal/core/parser.py @@ -0,0 +1,96 @@ +import sys +import re +from pathlib import Path + +sys.path.append(str(Path(__file__).resolve().parent.parent.parent)) +from .models import JournalEntry, Fragment, ParsedSection, SECTION_TITLES + +CHECKBOX_PATTERN = re.compile(r"^\s*[-*]\s*\[([xX ])\]\s*(.*)$") + + +def parse_journal_file(file_path: str) -> JournalEntry: + content = Path(file_path).read_text(encoding="utf-8") + return parse_journal_content(content, Path(file_path).stem) + + +def parse_journal_content(content: str, file_stem: str) -> JournalEntry: + """Parses the raw text content of a journal entry.""" + date_match = re.search(r"(?:\*\*Date:|Date:)\s*(.+)", content) + date = date_match.group(1).strip() if date_match else file_stem + + parsed_sections: dict[str, ParsedSection] = {} + current_section_title: str | None = None + current_section_content: list[str] = [] + current_section_checkboxes: dict[str, bool] = {} + + # Iterate through blocks to find sections + # We need to re-parse the content to correctly associate lines with sections + lines = content.splitlines() + for line in lines: + section_header_match = re.match(r"^\#\#+\s*(.*)$", line.strip()) + if section_header_match: + # Save previous section if exists + if current_section_title: + parsed_sections[current_section_title] = ParsedSection( + title=current_section_title, + content=current_section_content, + checkboxes=current_section_checkboxes, + ) + + # Start new section + header_text = section_header_match.group(1).strip() + found_title = None + for title_key in SECTION_TITLES: + if title_key.lower() in header_text.lower(): + found_title = title_key + break + + if found_title: + current_section_title = found_title + current_section_content = [] + current_section_checkboxes = {} + else: + current_section_title = None # Not a recognized section + current_section_content = [] + current_section_checkboxes = {} + continue # Don't add the header itself to the content + + if current_section_title: + checkbox_match = CHECKBOX_PATTERN.match(line) + if checkbox_match: + is_checked = checkbox_match.group(1).strip().lower() == "x" + checkbox_text = checkbox_match.group(2).strip() + current_section_checkboxes[checkbox_text] = is_checked + current_section_content.append(line) + + # Save the last section + if current_section_title: + parsed_sections[current_section_title] = ParsedSection( + title=current_section_title, + content=current_section_content, + checkboxes=current_section_checkboxes, + ) + + fragments: list[Fragment] = [] + # Regex for !TYPE @time #tag1 #tag2 description (can be multi-line) + # This pattern is more robust for fragments that might span multiple lines + fragment_pattern = re.compile( + r"^(!\w+)\s*((?:@\S+\s*)?)(?:\s*((?:#\S+\s*)*))?\s*\n" # Type, optional time, optional tags, newline + + r"((?:(?!^!\w+\s*).*\n)*)", # Content lines (non-fragment start) until next fragment or end + re.MULTILINE, + ) + + for match in fragment_pattern.finditer(content): + frag_type = match.group(1) + time_str = match.group(2).strip().lstrip("@") if match.group(2) else None + tag_str = match.group(3).strip() if match.group(3) else "" + description = match.group(4).strip() + + tags = [t.strip().lstrip("#") for t in tag_str.split()] if tag_str else [] + fragments.append( + Fragment(type=frag_type, description=description, time=time_str, tags=tags) + ) + + return JournalEntry( + date=date, raw_content=content, fragments=fragments, sections=parsed_sections + ) diff --git a/journal/core/speech.py b/journal/core/speech.py new file mode 100644 index 0000000..8c38a6e --- /dev/null +++ b/journal/core/speech.py @@ -0,0 +1,82 @@ +import speech_recognition as sr +from typing import Protocol +import queue +from .config import MICROPHONE_DEVICE_INDEX + + +class Stoppable(Protocol): + """Protocol for a callable that can be stopped, like the background listener.""" + + def __call__(self, wait_for_stop: bool = True) -> None: ... + + +# This global variable will hold the background listening process handle +background_listener_stop: Stoppable | None = None + + +def start_background_listening( + message_queue: queue.Queue[tuple[str, str]], engine: str, whisper_model: str +): + """ + Starts listening to the microphone in a background thread. + + Puts status and result messages into the provided queue. + """ + global background_listener_stop + if background_listener_stop: + message_queue.put(("status", "Already listening.")) + return + + try: + recognizer = sr.Recognizer() + microphone = sr.Microphone(device_index=MICROPHONE_DEVICE_INDEX) + except (OSError, AttributeError) as e: + message_queue.put(("status", f"Error: No microphone found. ({e})")) + return + + with microphone as source: + message_queue.put(("status", "Adjusting for ambient noise...")) + recognizer.adjust_for_ambient_noise(source) + message_queue.put(("status", "Listening...")) + + def recognition_callback(recognizer: sr.Recognizer, audio: sr.AudioData) -> None: + message_queue.put(("status", "Processing...")) + try: + if engine == "google": + text = recognizer.recognize_google(audio) + elif engine == "whisper": + # Use local Whisper for high accuracy and privacy. + # The model will be downloaded automatically on first use. + text = recognizer.recognize_whisper(audio, model=whisper_model) + else: # Default to the fast, offline, but less accurate sphinx + text = recognizer.recognize_sphinx(audio) + + message_queue.put( + ("result", f"{text} ") + ) # Add space for continuous dictation + message_queue.put(("status", "Listening...")) # Ready for the next phrase + except sr.UnknownValueError: + message_queue.put( + ("status", "Could not understand audio. Still listening...") + ) + except sr.RequestError as e: + error_msg = f"API error: {e}" + if engine == "google": + error_msg += ". Check internet connection." + message_queue.put(("status", error_msg)) + except Exception as e: + message_queue.put( + ("status", f"An unexpected error occurred in speech recognition: {e}") + ) + + # `listen_in_background` returns a function to stop the background listener + stop_listening = recognizer.listen_in_background(microphone, recognition_callback) + background_listener_stop = stop_listening + + +def stop_background_listening(): + """Stops the background listener if it is running.""" + global background_listener_stop + if background_listener_stop: + background_listener_stop(wait_for_stop=False) + background_listener_stop = None diff --git a/journal/core/storage.py b/journal/core/storage.py new file mode 100644 index 0000000..eb84867 --- /dev/null +++ b/journal/core/storage.py @@ -0,0 +1,310 @@ +import sys +import hashlib +import threading +import time +from cryptography.exceptions import InvalidTag +import shutil +import zipfile +from datetime import datetime +from pathlib import Path + +sys.path.append(str(Path(__file__).resolve().parent.parent.parent)) +from .parser import parse_journal_content, parse_journal_file +from .database import get_db_connection, hydrate_database +from .encryption import encrypt_data, decrypt_data + +from .config import ( + DATA_DIR, + VAULT_DIR, + MONTHLY_VAULT_FORMAT, +) + +_month_fingerprint_cache: dict[str, str] = {} +_vault_io_lock = threading.RLock() + + +# --- Monthly Vault Management --- + + +def _get_monthly_vault_path(date: datetime) -> Path: + """Returns the path for the monthly vault file.""" + return VAULT_DIR / date.strftime(MONTHLY_VAULT_FORMAT) + + +def _create_monthly_archive(month_path: Path, archive_path: Path): + """Creates a zip archive of a temporary monthly directory.""" + with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as zipf: + for file_path in month_path.iterdir(): + zipf.write(file_path, arcname=file_path.name) # Store only filename in zip + + +def _extract_monthly_archive(archive_path: Path, extract_to_path: Path): + """Extracts a zip archive to a specified directory.""" + with zipfile.ZipFile(archive_path, "r") as zipf: + zipf.extractall(extract_to_path) + + +# --- Public API for Journal Storage --- + + +def _save_month(password: str, month_key: str, files_in_month: list[Path]): + """Helper function to save a single month's vault.""" + # We need a datetime object to generate the vault path, strptime is perfect. + month_as_date = datetime.strptime(month_key, "%Y-%m") + monthly_vault_path = _get_monthly_vault_path(month_as_date) + + # Create a temporary directory to stage files for zipping + temp_month_dir = VAULT_DIR / f"temp_{month_key}" + temp_month_dir.mkdir(exist_ok=True) + temp_zip_path: Path | None = None + + try: + for file_path in files_in_month: + _ = shutil.copy(file_path, temp_month_dir) + + # Create a temporary zip archive + temp_zip_path = VAULT_DIR / f"temp_{month_key}.zip" + _create_monthly_archive(temp_month_dir, temp_zip_path) + + with open(temp_zip_path, "rb") as f_in: + zip_content = f_in.read() + + encrypted_vault_content = encrypt_data(zip_content, password) + with open(monthly_vault_path, "wb") as f_out: + _ = f_out.write(encrypted_vault_content) + _month_fingerprint_cache[month_key] = _compute_month_fingerprint(files_in_month) + print(f"Successfully saved {monthly_vault_path.name}") + except Exception as e: + print(f"Error saving month {month_key}: {e}") + finally: + shutil.rmtree(temp_month_dir, ignore_errors=True) + if temp_zip_path and temp_zip_path.exists(): + temp_zip_path.unlink() + + +def _compute_month_fingerprint(files: list[Path]) -> str: + fingerprint = hashlib.sha256() + for file_path in sorted(files, key=lambda p: p.name): + try: + stat = file_path.stat() + except OSError: + continue + fingerprint.update(file_path.name.encode("utf-8")) + fingerprint.update(str(stat.st_mtime_ns).encode("ascii")) + fingerprint.update(str(stat.st_size).encode("ascii")) + return fingerprint.hexdigest() + + +def get_today_filename() -> Path: + """Returns the path for today's journal entry in the active DATA_DIR.""" + return DATA_DIR / f"{datetime.now().strftime('%Y-%m-%d')}.md" + + +def save_entry_content( + content: str, file_path: Path | None = None, mode: str = "Daily" +): + target_file = file_path or get_today_filename() + target_file.parent.mkdir(parents=True, exist_ok=True) + + 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()) + 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) + 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 + + _ = target_file.write_text(final_content, encoding="utf-8") + + +def load_all_vaults(password: str) -> bool: + """ + Decrypts and extracts all monthly vaults into the DATA_DIR. + Cleans DATA_DIR before extraction. + Returns True on success, False if password is incorrect for existing vaults. + """ + if not password: + raise ValueError("Password cannot be empty.") + + 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 + 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 --- + # After successfully decrypting files, hydrate the live, encrypted database. + conn = None + try: + all_entries = [parse_journal_file(str(f)) for f in DATA_DIR.glob("*.md")] + if all_entries: + conn = get_db_connection(password) + hydrate_database(conn, all_entries) + except Exception as e: + print(f"Fatal error during database hydration: {e}") + return False # Treat DB hydration failure as a critical error + finally: + if conn is not None: + conn.close() + + return True + + +def rebuild_all_vaults(password: str): + """ + Rebuilds all monthly vaults from the files in the DATA_DIR. + + This is a comprehensive but slower operation, intended for use on shutdown + or via the CLI to ensure all changes, including to older entries, are + persisted. It iterates through all decrypted files and saves them to their + respective monthly vaults. + """ + print("rebuild_all_vaults called.") + if not password: + raise ValueError("Password cannot be empty.") + + with _vault_io_lock: + # Group files by month + monthly_files: dict[str, list[Path]] = {} + for file_path in DATA_DIR.glob("*.md"): + try: + file_date = datetime.strptime(file_path.stem, "%Y-%m-%d") + month_key = file_date.strftime("%Y-%m") + if month_key not in monthly_files: + monthly_files[month_key] = [] + monthly_files[month_key].append(file_path) + except ValueError: # Skip files that don't match YYYY-MM-DD format + print(f"Skipping non-journal file in DATA_DIR: {file_path.name}") + continue + + # Ensure VAULT_DIR exists + VAULT_DIR.mkdir(parents=True, exist_ok=True) + + for month_key, files_in_month in monthly_files.items(): + _save_month(password, month_key, files_in_month) + + +def save_current_month_vault(password: str): + """ + Optimized save function that only rebuilds the current month's vault. + + This is used for frequent, in-session saves from the UI to provide better + performance, as it only operates on the files for the current month. + """ + print("save_current_month_vault called.") + if not password: + raise ValueError("Password cannot be empty.") + + with _vault_io_lock: + # Determine current month + now = datetime.now() + month_key = now.strftime("%Y-%m") + + # Collect files for the current month + files_in_month: list[Path] = [] + for file_path in DATA_DIR.glob("*.md"): + if file_path.stem.startswith(month_key): + files_in_month.append(file_path) + + if not files_in_month: + print(f"No files found for the current month ({month_key}) to save.") + return + + current_fingerprint = _compute_month_fingerprint(files_in_month) + cached_fingerprint = _month_fingerprint_cache.get(month_key) + if cached_fingerprint == current_fingerprint: + print(f"Skipping vault save for {month_key}; no file changes detected.") + return + + _save_month(password, month_key, files_in_month) + + +def initialize_vault(password: str): + """ + Ensures the VAULT_DIR exists. The first save operation will create the initial vault files. + """ + if not password: + raise ValueError("Password cannot be empty.") + + VAULT_DIR.mkdir(parents=True, exist_ok=True) + print("Vault directory ensured to exist.") + + +def clear_data_directory(): + """ + Clears the DATA_DIR. This should only be called on application shutdown. + """ + print("Clearing DATA_DIR...") + 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. + _clear_data_dir_with_retries() + DATA_DIR.mkdir(parents=True, exist_ok=True) + _month_fingerprint_cache.clear() + print("DATA_DIR cleared.") + + +def _clear_data_dir_with_retries(retries: int = 5, delay_seconds: float = 0.2) -> None: + if not DATA_DIR.exists(): + return + for attempt in range(retries): + try: + shutil.rmtree(DATA_DIR) + return + except PermissionError: + if attempt == retries - 1: + raise + time.sleep(delay_seconds) diff --git a/journal/run_desktop.py b/journal/run_desktop.py new file mode 100644 index 0000000..fb56575 --- /dev/null +++ b/journal/run_desktop.py @@ -0,0 +1,308 @@ +import subprocess +import threading +import time +import sys +import os +import shutil +import webbrowser +from pathlib import Path +from urllib.request import urlopen +from urllib.error import URLError +from typing import Optional + +try: + import webview +except Exception: + webview = None + +# Add project root to sys.path to allow for absolute imports +sys.path.append(str(Path(__file__).resolve().parent.parent)) +from journal.core.config import PID_FILE, SERVER_CONTROL_FILE + +# Global variable to store the NiceGUI server process +nicegui_process = None +_process_lock = threading.Lock() +_watchdog_stop = threading.Event() +_WATCHDOG_INTERVAL_SECONDS = 10.0 +_WATCHDOG_MAX_HEALTH_FAILURES = 3 +_WATCHDOG_MIN_RESTART_INTERVAL_SECONDS = 5.0 +_WATCHDOG_MAX_FAILED_RESTARTS = 5 +_watchdog_failed_restarts = 0 +_last_restart_monotonic = 0.0 +SERVER_URL = "http://localhost:8080" +HEALTHCHECK_URL = f"{SERVER_URL}/_health" +VALID_SERVER_ACTIONS = {"restart", "shutdown"} + + +def wait_for_server(url: str, timeout_seconds: float = 20.0) -> bool: + """Polls the local server until it responds or timeout is hit.""" + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + try: + with urlopen(url, timeout=1.0) as response: + if 200 <= response.status < 300: + return True + except URLError: + pass + except Exception: + pass + time.sleep(0.2) + return False + + +def _read_server_action(clear: bool = True) -> str | None: + if not SERVER_CONTROL_FILE.exists(): + return None + action = None + try: + action = SERVER_CONTROL_FILE.read_text(encoding="utf-8").strip().lower() + except OSError: + action = None + finally: + if clear: + SERVER_CONTROL_FILE.unlink(missing_ok=True) + if action in VALID_SERVER_ACTIONS: + return action + return None + + +def _clear_server_action() -> None: + SERVER_CONTROL_FILE.unlink(missing_ok=True) + + +def get_python_executable() -> str: + """ + Determines the correct Python executable path by searching the system's PATH. + + 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. + + Returns: + The absolute path to the Python 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 + + +def is_server_running(): + """Checks if a server process is running based on the PID file.""" + if not PID_FILE.exists(): + return False + + try: + pid = int(PID_FILE.read_text()) + if sys.platform == "win32": + # On Windows, check if the process is in the task list + result = subprocess.run( + ["tasklist", "/FI", f"PID eq {pid}"], + capture_output=True, + text=True, + ) + return str(pid) in result.stdout + else: + # On Unix-like systems, os.kill(pid, 0) checks for process existence + os.kill(pid, 0) + return True # Process exists + except (ValueError, ProcessLookupError, subprocess.CalledProcessError): + print("Stale PID file found. Removing it.") + PID_FILE.unlink() + return False + except PermissionError: + # We don't have permission to signal the process, but it exists. + return True + + +def start_nicegui(): + global nicegui_process + project_root = Path(__file__).resolve().parent.parent + # The UI app is now at journal/ui/main.py + ui_app_path = project_root / "journal" / "ui" / "main.py" + + # Use the same Python interpreter that is running this script. + # This is more robust and portable than a hardcoded path. + venv_python = get_python_executable() + command = [str(venv_python), str(ui_app_path)] + print(f"Starting NiceGUI server with command: {' '.join(command)}") + # Use Popen to run in the background and store the process object. + process = subprocess.Popen(command, cwd=project_root) + with _process_lock: + nicegui_process = process + # Create the PID file to signal that the server is running + PID_FILE.parent.mkdir(exist_ok=True) + _ = PID_FILE.write_text(str(process.pid)) + + +def _safe_get_process() -> Optional[subprocess.Popen]: + with _process_lock: + return nicegui_process + + +def _safe_set_process(process: Optional[subprocess.Popen]) -> None: + global nicegui_process + with _process_lock: + nicegui_process = process + + +def _cleanup_pid_if_matches(process: Optional[subprocess.Popen]) -> None: + if process is None or not PID_FILE.exists(): + return + try: + pid_in_file = int(PID_FILE.read_text()) + except (ValueError, OSError): + pid_in_file = None + if pid_in_file == process.pid: + PID_FILE.unlink(missing_ok=True) + + +def _stop_process(process: Optional[subprocess.Popen], timeout_seconds: float = 5.0) -> None: + if process is None: + return + if process.poll() is not None: + _cleanup_pid_if_matches(process) + return + + print(f"Stopping NiceGUI server process {process.pid}...") + process.terminate() + try: + process.wait(timeout=timeout_seconds) + except subprocess.TimeoutExpired: + print("NiceGUI server did not terminate gracefully, killing...") + process.kill() + process.wait(timeout=timeout_seconds) + finally: + _cleanup_pid_if_matches(process) + + +def _restart_nicegui(reason: str) -> None: + global _watchdog_failed_restarts, _last_restart_monotonic + if _watchdog_stop.is_set(): + return + elapsed = time.monotonic() - _last_restart_monotonic + if elapsed < _WATCHDOG_MIN_RESTART_INTERVAL_SECONDS: + time.sleep(_WATCHDOG_MIN_RESTART_INTERVAL_SECONDS - elapsed) + print(f"Watchdog restart triggered: {reason}") + current = _safe_get_process() + _stop_process(current) + _safe_set_process(None) + _last_restart_monotonic = time.monotonic() + start_nicegui() + if wait_for_server(HEALTHCHECK_URL, timeout_seconds=20.0): + _watchdog_failed_restarts = 0 + print("Watchdog restart completed: server is healthy.") + else: + _watchdog_failed_restarts += 1 + print("Watchdog restart warning: server did not become healthy in time.") + if _watchdog_failed_restarts >= _WATCHDOG_MAX_FAILED_RESTARTS: + print( + "Watchdog giving up after repeated failed restarts. " + "Stopping wrapper so it does not run forever without a healthy server." + ) + _watchdog_stop.set() + + +def _watchdog_loop() -> None: + consecutive_health_failures = 0 + while not _watchdog_stop.wait(_WATCHDOG_INTERVAL_SECONDS): + process = _safe_get_process() + if process is None: + continue + + exit_code = process.poll() + if exit_code is not None: + _cleanup_pid_if_matches(process) + _safe_set_process(None) + action = _read_server_action(clear=True) + if action == "shutdown": + print("Server shutdown requested from UI. Stopping wrapper.") + _watchdog_stop.set() + break + if action == "restart": + _restart_nicegui("server restart requested from UI") + else: + _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 is_healthy: + consecutive_health_failures = 0 + continue + + consecutive_health_failures += 1 + print( + f"Watchdog health check failed ({consecutive_health_failures}/{_WATCHDOG_MAX_HEALTH_FAILURES})." + ) + if consecutive_health_failures >= _WATCHDOG_MAX_HEALTH_FAILURES: + _restart_nicegui("health endpoint was unresponsive repeatedly") + consecutive_health_failures = 0 + + +def run(): + started_by_wrapper = False + watchdog_thread: Optional[threading.Thread] = None + _clear_server_action() + + # Check if the server is already running from another process (e.g., CLI) + if is_server_running(): + print("NiceGUI server is already running. Connecting to it.") + else: + # Start NiceGUI server managed by this wrapper. + print("Starting NiceGUI server...") + start_nicegui() + started_by_wrapper = True + + if started_by_wrapper: + _watchdog_stop.clear() + watchdog_thread = threading.Thread(target=_watchdog_loop, daemon=True) + watchdog_thread.start() + if not wait_for_server(HEALTHCHECK_URL): + print("Warning: server readiness check timed out; opening webview anyway.") + + try: + # Open desktop shell if available; otherwise use browser fallback. + if webview is not None: + try: + print("Opening webview window...") + _ = webview.create_window("Project Journal", SERVER_URL) + webview.start() # Blocks until the window is closed + except Exception as exc: + print(f"Webview failed ({exc}). Falling back to system browser...") + _ = webbrowser.open(SERVER_URL) + print(f"Browser opened at {SERVER_URL}. Press Ctrl+C to stop.") + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + pass + else: + print("pywebview not available; opening system browser instead.") + _ = webbrowser.open(SERVER_URL) + print(f"Browser opened at {SERVER_URL}. Press Ctrl+C to stop.") + try: + while True: + if started_by_wrapper and _watchdog_stop.is_set(): + print( + "Server watchdog stopped (repeated recovery failures). " + "Exiting wrapper loop." + ) + break + time.sleep(1) + except KeyboardInterrupt: + pass + finally: + _watchdog_stop.set() + if watchdog_thread is not None: + watchdog_thread.join(timeout=2.0) + + # Stop child server only if this wrapper started/managed it. + if started_by_wrapper: + _stop_process(_safe_get_process()) + _safe_set_process(None) + + +if __name__ == "__main__": + run() diff --git a/journal/ui/__init__.py b/journal/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/journal/ui/components/__init__.py b/journal/ui/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/journal/ui/components/calendar.py b/journal/ui/components/calendar.py new file mode 100644 index 0000000..5e46acd --- /dev/null +++ b/journal/ui/components/calendar.py @@ -0,0 +1,13 @@ +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"): + _ = ui.label("Journal Calendar").classes("text-lg font-bold") + with ui.row().classes("w-full items-center px-4"): + # Calendar view + _ = ui.date( + on_change=lambda e: on_select(cast(str, e.value)) + ).classes("bg-gray-800 text-white") diff --git a/journal/ui/components/editor.py b/journal/ui/components/editor.py new file mode 100644 index 0000000..e21e4fd --- /dev/null +++ b/journal/ui/components/editor.py @@ -0,0 +1,138 @@ +from nicegui import ui + + +def rich_text_editor() -> ui.editor: + """A rich text editor component with a customizable toolbar and styling.""" + toolbar = ( + "[" + + "['bold', 'italic', 'strike', 'underline']," + + "['quote', 'unordered', 'ordered', 'code']," + + "['link', 'remove-formatting']," + + "['print', 'fullscreen']," + + "['viewsource']" + + "]" + ) + + props_string = ( + f"toolbar={toolbar} " + + "content-style='font-size: 16px; line-height: 1.6; color: white;' " + + "toolbar-bg='gray-800' " + + "toolbar-text-color='white' " + + "toolbar-toggle-color='yellow-8'" + ) + editor = ( + ui.editor(placeholder="Start writing or use Create Template...") + .classes("flex-grow bg-gray-800 text-white") + .props(props_string) + ) + + def attach_paste_handler(): + # This script runs after the editor's Vue component is mounted, + # ensuring the element is available in the DOM. + # It uses a raw f-string (rf"...") to handle backslashes in the regex + # and doubled curly braces ({{...}}) for JavaScript code blocks. + _ = ui.run_javascript(rf""" + const editorElement = document.getElementById('{editor.id}'); + if (editorElement) {{ + const contentArea = editorElement.querySelector('.q-editor__content'); + if (contentArea) {{ + // Function to parse a CSS color string into an [r, g, b] array. + const parseColor = (colorStr) => {{ + const d = document.createElement("div"); + d.style.color = colorStr; + document.body.appendChild(d); + const computedColor = window.getComputedStyle(d).color; + document.body.removeChild(d); + const parts = computedColor.match(/rgba?\((\d+), (\d+), (\d+)\)/); + if (parts) {{ + return [parseInt(parts[1]), parseInt(parts[2]), parseInt(parts[3])]; + }} + return null; + }}; + + // Function to calculate the perceived luminance of a color. + const getLuminance = (r, g, b) => {{ + return 0.299 * r + 0.587 * g + 0.114 * b; + }}; + + const paste_handler = (evt) => {{ + evt.preventDefault(); + const clipboardData = evt.clipboardData; + if (!clipboardData) {{ return; }} + + let pastedHtml = clipboardData.getData('text/html'); + const selection = window.getSelection(); + if (!selection || !selection.rangeCount) {{ return; }} + selection.deleteFromDocument(); + const range = selection.getRangeAt(0); + + if (pastedHtml) {{ + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = pastedHtml; + + tempDiv.querySelectorAll('*').forEach(el => {{ + el.style.backgroundColor = ''; + el.style.background = ''; + + let colorToCheck = el.style.color; + if (el.tagName === 'FONT' && el.getAttribute('color')) {{ + colorToCheck = el.getAttribute('color'); + }} + + if (colorToCheck) {{ + const rgb = parseColor(colorToCheck); + if (rgb) {{ + const luminance = getLuminance(rgb[0], rgb[1], rgb[2]); + // A luminance threshold of 100 is a good starting point + // for identifying dark colors on a dark background. + if (luminance < 100) {{ + el.style.color = ''; // Reset to inherit default color + if (el.tagName === 'FONT') {{ + el.removeAttribute('color'); + }} + }} + }} + }} + + if (el.hasAttribute('style') && !el.getAttribute('style').trim()) {{ + el.removeAttribute('style'); + }} + }}); + + const sanitizedHtml = tempDiv.innerHTML; + const fragment = range.createContextualFragment(sanitizedHtml); + const lastNode = fragment.lastChild; + range.insertNode(fragment); + + if (lastNode) {{ + const newRange = document.createRange(); + newRange.setStartAfter(lastNode); + newRange.collapse(true); + selection.removeAllRanges(); + selection.addRange(newRange); + }} + }} else {{ + const pastedText = clipboardData.getData('text/plain'); + if (pastedText) {{ + const textNode = document.createTextNode(pastedText); + range.insertNode(textNode); + const newRange = document.createRange(); + newRange.setStartAfter(textNode); + newRange.collapse(true); + selection.removeAllRanges(); + selection.addRange(newRange); + }} + }} + }}; + contentArea.addEventListener('paste', paste_handler); + }} else {{ + console.error('Error: Could not find the .q-editor__content element.'); + }} + }} else {{ + console.error(`Error: Could not find editor element with ID: {editor.id}`); + }} + """) + + _ = editor.on("vue-mounted", attach_paste_handler) + + return editor diff --git a/journal/ui/components/settings.py b/journal/ui/components/settings.py new file mode 100644 index 0000000..a0169b0 --- /dev/null +++ b/journal/ui/components/settings.py @@ -0,0 +1,123 @@ +import asyncio + +from nicegui import ui, app +from journal.core.config import ( + SPEECH_RECOGNITION_ENGINE, + WHISPER_MODEL_SIZE, + SERVER_CONTROL_FILE, +) +from typing import cast + + +def settings_dialog(): + """Creates a dialog for application settings.""" + async def request_server_action(action: str) -> None: + try: + SERVER_CONTROL_FILE.parent.mkdir(parents=True, exist_ok=True) + SERVER_CONTROL_FILE.write_text(action, encoding="utf-8") + except Exception as error: + ui.notify(f"Failed to queue server action: {error}", type="negative") + return + + if action == "restart": + ui.notify("Server restart requested...", type="warning") + else: + ui.notify("Server shutdown requested...", type="warning") + + # Give the notification a moment to flush before shutdown. + await asyncio.sleep(0.2) + app.shutdown() + + async def restart_server() -> None: + await request_server_action("restart") + + async def shutdown_server() -> None: + await request_server_action("shutdown") + + with ui.dialog() as dialog, ui.card().classes("w-80"): + _ = ui.label("Settings").classes("text-xl font-bold") + + _ = 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.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." + ), + ) + .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." + ), + ) + .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.separator().classes("my-2") + _ = ui.label("Server Controls").classes("font-medium") + if cast(bool, app.storage.user.get("authenticated", False)): + _ = ui.markdown( + "Use these when running via `run_desktop.py`.\n" + "`Restart` reconnects quickly, `Shutdown` stops the server wrapper." + ).classes("text-xs text-gray-500") + + with ui.row().classes("w-full gap-2 mt-1"): + _ = ( + ui.button( + "Restart Server", + on_click=restart_server, + ) + .props("color=warning") + .classes("flex-1") + ) + _ = ( + ui.button( + "Shutdown Server", + on_click=shutdown_server, + ) + .props("color=negative") + .classes("flex-1") + ) + else: + _ = ui.label("Log in to enable restart/shutdown controls.").classes( + "text-xs text-gray-500" + ) + + _ = ( + ui.button("Close", on_click=dialog.close) + .props("flat") + .classes("mt-4 self-end") + ) + return dialog diff --git a/journal/ui/components/speech.py b/journal/ui/components/speech.py new file mode 100644 index 0000000..c57fd03 --- /dev/null +++ b/journal/ui/components/speech.py @@ -0,0 +1,82 @@ +from nicegui import ui, run, app +from typing import Callable, cast +import sys +from pathlib import Path +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.speech import start_background_listening, stop_background_listening + + +def speech_to_text(on_result: Callable[[str], None]) -> None: + """ + A speech-to-text component that uses the server's microphone for transcription. + This approach is more reliable for desktop webview apps than browser-based APIs. + """ + + # This label provides feedback to the user about the state of the listener. + status_label = ui.label("Ready").classes("text-sm text-gray-500 my-auto") + # Create a queue for thread-safe communication from the background thread + message_queue: queue.Queue[tuple[str, str]] = queue.Queue() + + queue_timer = None + + def process_queue(): + """Process messages from the background thread to update the UI.""" + try: + message_type, data = message_queue.get_nowait() + if message_type == "status": + status_label.set_text(data) + ui.notify(data) + elif message_type == "result": + on_result(data) + except queue.Empty: + pass # No messages to process + except RuntimeError: + # Parent UI container may be gone (page switch/refresh); stop polling. + if queue_timer is not None: + queue_timer.active = False + + # A timer polls the queue every 100ms to update the UI from the main thread + queue_timer = ui.timer(0.1, process_queue, active=False) + + async def start_listening_task(): + """Runs the blocking speech recognition function in an executor thread.""" + # Using an if/else is more explicit for the type checker than .get(). + if "speech_engine" in app.storage.user: + engine = cast(str, app.storage.user["speech_engine"]) + else: + engine = SPEECH_RECOGNITION_ENGINE + + if "whisper_model" in app.storage.user: + whisper_model = cast(str, app.storage.user["whisper_model"]) + else: + whisper_model = WHISPER_MODEL_SIZE + + if queue_timer is not None: + queue_timer.active = True + await run.io_bound( + start_background_listening, message_queue, engine, whisper_model + ) + + async def stop_listening_task(): + """Runs the blocking stop function in an executor thread.""" + await run.io_bound(stop_background_listening) + if queue_timer is not None: + queue_timer.active = False + status_label.set_text("Stopped listening.") + ui.notify("Stopped listening.") + + with ui.card().tight(): + with ui.row().classes("w-full items-center justify-between px-4"): + _ = ui.label("Speech to Text").classes("text-lg font-bold") + _ = status_label + with ui.row().classes("w-full items-center px-4"): + _ = ui.button(icon="mic", on_click=start_listening_task).props( + "flat round dense" + ) + _ = ui.button(icon="stop", on_click=stop_listening_task).props( + "flat round dense" + ) diff --git a/journal/ui/main.py b/journal/ui/main.py new file mode 100644 index 0000000..90ddc6f --- /dev/null +++ b/journal/ui/main.py @@ -0,0 +1,680 @@ +import asyncio +import sys +import warnings + +if sys.platform == "win32" and hasattr(asyncio, "WindowsSelectorEventLoopPolicy"): + # Avoid noisy Proactor transport resets on long-running Windows sessions. + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + +from nicegui import ui, app, run +from typing import cast +from pathlib import Path +from functools import partial +from datetime import datetime + +# Add project root to sys.path to allow for absolute imports +# This is now handled by the run_desktop.py and cli/main.py scripts, but good for linters. +sys.path.append(str(Path(__file__).resolve().parent.parent.parent)) +from journal.core.entry import ( + create_daily_entry, + create_deep_entry, + create_fragment_entry, + create_recovery_entry, +) +from journal.core.storage import ( + save_entry_content, + load_all_vaults, + rebuild_all_vaults, + save_current_month_vault, + initialize_vault, + clear_data_directory, +) +from journal.core.config import ( + DATA_DIR, + VAULT_DIR, + SPEECH_RECOGNITION_ENGINE, + WHISPER_MODEL_SIZE, +) +from journal.core.parser import parse_journal_file +from journal.ai.analysis import summarize_entry, summarize_all_entries +from journal.ai.chat import get_cloud_ai_response +from journal.ui.components.speech import speech_to_text +from journal.ui.components.calendar import calendar_view +from journal.ui.components.editor import rich_text_editor +from journal.ui.components.settings import settings_dialog + +ui.dark_mode = True + + +# Global variables +selected_file_name: str = "" +drawer: ui.left_drawer | None = None +main_tab: ui.tabs | None = None +edit_tab: ui.tab | None = None +ai_tab: ui.tab | None = None +new_tab: ui.tab | None = None +entry_content_box: ui.editor | None = None +analysis_box: ui.textarea | None = None +new_entry_box: ui.editor | None = None +file_map: dict[str, str] = {} +vault_password: str | None = None # Global to store the password for the shutdown hook +sidebar_content: ui.column | None = None +content_container: ui.column | None = None +# Must be created inside page/layout scope for NiceGUI. +all_analysis_dialog: ui.dialog | None = None +vault_load_lock = asyncio.Lock() + + +def _is_client_active(client: object | None) -> bool: + return bool(client is not None and getattr(client, "has_socket_connection", False)) + + +def _is_deleted_client_error(error: RuntimeError) -> bool: + text = str(error).lower() + return "client this element belongs to has been deleted" in text or "parent slot of the element has been deleted" in text + + +def list_journal_files(): + # List files directly from the DATA_DIR (which is the decrypted workspace) + files = sorted(DATA_DIR.glob("*.md")) + print(f"list_journal_files found: {[f.name for f in files]}") + return [(f.name, str(f)) for f in files] + + +def save_entry(content: str, mode: str, password: str | None) -> str: + # Save to the DATA_DIR, then trigger a vault save + save_entry_content(content, mode=mode) + if password: + save_current_month_vault(password) + return "Entry saved." + + +def load_entry(file_path: str) -> str: + try: + entry = parse_journal_file(file_path) + return entry.raw_content + except Exception as e: + return f"Error loading file: {e}" + + +def save_existing_entry(file_path: str, content: str, password: str | None) -> str: + try: + # Overwriting is the correct behavior when editing a full, existing entry. + save_entry_content(content, file_path=Path(file_path), mode="Daily") + if password: + save_current_month_vault(password) + return f"Appended changes to {file_path}" + except Exception as e: + return f"Error saving file: {e}" + + +def analyze_entry(file_path: str) -> str: + try: + entry = parse_journal_file(file_path) + analysis = summarize_entry(entry) + return analysis + except Exception as e: + return f"Error analyzing entry: {e}" + + +def analyze_all_entries() -> str: + try: + journal_files = sorted(DATA_DIR.glob("*.md")) + if not journal_files: + return "No journal files found." + entries = [parse_journal_file(str(f)) for f in journal_files] + analysis = summarize_all_entries(entries) + return analysis + except Exception as e: + return f"Error analyzing all entries: {e}" + + +def update_sidebar(): + global \ + file_map, \ + drawer, \ + main_tab, \ + edit_tab, \ + entry_content_box, \ + analysis_box, \ + sidebar_content + print("update_sidebar called.") + if sidebar_content: + try: + sidebar_content.clear() + except RuntimeError as error: + if _is_deleted_client_error(error): + print("Skipping sidebar update because the client was deleted.") + return + raise + try: + with sidebar_content: + _ = ui.label("📅 Calendar").classes("text-md font-bold mb-4") + + def on_date_select(date: str): + global selected_file_name + selected_file_name = f"{date}.md" + if main_tab is not None and edit_tab is not None: + main_tab.set_value(edit_tab) + if entry_content_box is not None: + entry_content_box.set_value( + load_entry(file_map[selected_file_name]) + ) + if analysis_box is not None: + analysis_box.set_value("") + if drawer: + drawer.set_value(False) + + calendar_view(on_select=on_date_select) + _ = ui.separator().classes("my-4") + _ = ui.label("📁 Journal Files").classes("text-lg font-bold mb-4") + + files = list_journal_files() + file_names = [f[0] for f in files] + file_map = dict(files) + print(f"file_map in update_sidebar: {file_map}") + + def on_file_select(fname: str): + global selected_file_name + selected_file_name = fname + if main_tab is not None and edit_tab is not None: + main_tab.set_value(edit_tab) + if entry_content_box is not None: + entry_content_box.set_value(load_entry(file_map[fname])) + if analysis_box is not None: + analysis_box.set_value("") + if drawer: + drawer.set_value(False) + + for fname in file_names: + _ = ( + ui.button(fname, on_click=partial(on_file_select, fname)) + .classes("w-full justify-start mb-1") + .props("flat") + ) + + _ = ui.separator().classes("my-4") + + def open_new_tab(): + if main_tab is not None and new_tab is not None: + main_tab.set_value(new_tab) + if drawer: + drawer.set_value(False) + + _ = ( + ui.button("New Entry", on_click=open_new_tab) + .props("color=primary") + .classes("w-full mb-2") + ) + + def open_all_analysis_dialog(): + if all_analysis_dialog is not None: + all_analysis_dialog.open() + + _ = ( + ui.button("Analyze All Entries", on_click=open_all_analysis_dialog) + .props("color=accent") + .classes("w-full") + ) + except RuntimeError as error: + if _is_deleted_client_error(error): + print("Sidebar update aborted because client elements were deleted.") + return + raise + + +# --- Main UI Layout Function (Inner Content) --- +def journal_ui_layout(): + global \ + main_tab, \ + edit_tab, \ + ai_tab, \ + new_tab, \ + entry_content_box, \ + analysis_box, \ + new_entry_box + + # --- Main Content - Full Screen --- + with ui.element("div").style( + "width: 100vw; height: calc(100vh - 64px); overflow: hidden; padding: 0; margin: 0;" + ): + with ui.element("div").style( + "width: 100%; height: 100%; padding: 16px; box-sizing: border-box; display: flex; flex-direction: column;" + ): + # Tabs + with ui.tabs().classes("w-full mb-4") as main_tab_instance: + global main_tab + main_tab = main_tab_instance + edit_tab = ui.tab("Edit Entry") + ai_tab = ui.tab("AI Analysis") + chat_tab = ui.tab("AI Chat") + new_tab = ui.tab("New Entry") + speech_tab = ui.tab("Speech to Text") + + # Tab Panels - Full Height + with ui.tab_panels(main_tab, value=edit_tab).classes( + "w-full flex-grow bg-gray-800" + ): + # --- Edit Entry Tab --- + with ui.tab_panel(edit_tab).style( + "flex: 1; display: flex; flex-direction: column; padding: 0;" + ): + entry_content_box = rich_text_editor().classes("w-full h-full") + + # --- AI Analysis Tab --- + with ui.tab_panel(ai_tab).style( + "flex: 1; display: flex; flex-direction: column; padding: 16px;" + ): + _ = ( + ui.label("AI Analysis") + .classes("text-xl font-bold mb-4") + .style("flex-shrink: 0;") + ) + + analysis_box = ( + ui.textarea( + label="Analysis Results", + placeholder='Click "Analyze Entry" to get AI analysis of the selected journal entry...', + ) + .props("readonly outlined") + .style("flex: 1; width: 100%; min-height: 0;") + ) + + with ui.row().classes("gap-4 mt-4").style("flex-shrink: 0;"): + + async def analyze_selected_wrapper(): + client = ui.context.client + if ( + selected_file_name + and selected_file_name in file_map + and analysis_box is not None + ): + ui.notify("Analyzing entry...", type="info") + analysis = await run.io_bound( + analyze_entry, file_map[selected_file_name] + ) + if not _is_client_active(client): + return + analysis_box.set_value(analysis) + ui.notify("Analysis complete!", type="positive") + else: + ui.notify("No file selected", type="warning") + + _ = ui.button( + "Analyze Entry", on_click=analyze_selected_wrapper + ).props("color=primary size=lg") + + # --- AI Chat Tab --- + with ui.tab_panel(chat_tab).style( + "flex: 1; display: flex; flex-direction: column; padding: 16px;" + ): + _ = ( + ui.label("AI Chat") + .classes("text-xl font-bold mb-4") + .style("flex-shrink: 0;") + ) + + chat_output = ( + ui.textarea( + label="Chat History", + placeholder="Chat with the AI...", + ) + .props("readonly outlined") + .style("flex: 1; width: 100%; min-height: 0;") + ) + + chat_input = ui.input(placeholder="Enter your message").style( + "width: 100%;" + ) + + async def send_chat_message(): + client = ui.context.client + chat_value = cast(str | None, chat_input.value) + if chat_value and chat_output: + prompt = chat_value + chat_input.value = "" + response = await run.io_bound(get_cloud_ai_response, prompt) + if not _is_client_active(client): + return + current_chat = str( + cast(str | None, chat_output.value) or "" + ) + chat_output.value = ( + current_chat + f"> {prompt}\n{response}\n" + ) + + _ = chat_input.on("keydown.enter", send_chat_message) + + # --- New Entry Tab --- + with ui.tab_panel(new_tab).style( + "flex: 1; display: flex; flex-direction: column; padding: 16px;" + ): + _ = ( + ui.label("Create New Entry") + .classes("text-xl font-bold mb-4") + .style("flex-shrink: 0;") + ) + + mode = ( + ui.select( + ["Daily", "Deep Recovery", "Fragment", "Deep Entry"], + label="Entry Type", + value="Daily", + ) + .classes("mb-4 w-64") + .style("flex-shrink: 0;") + ) + + new_entry_box = rich_text_editor() + + def update_new_entry_box_on_mode_change(): + if new_entry_box is None: + return # Can't do anything if the box doesn't exist yet + + mode_val = cast(str | None, mode.value) + today_file_path = ( + DATA_DIR / f"{datetime.now().strftime('%Y-%m-%d')}.md" + ) + if ( + mode_val + and mode_val in ["Daily", "Deep Recovery", "Deep Entry"] + and today_file_path.exists() + ): + new_entry_box.set_value( + today_file_path.read_text(encoding="utf-8") + ) + ui.notify("Loaded today's existing entry.", type="info") + else: + new_entry_box.set_value("") + + _ = mode.on( + "update:model-value", update_new_entry_box_on_mode_change + ) + + with ui.row().classes("gap-4 mt-4").style("flex-shrink: 0;"): + + def fill_template(): + mode_val = cast(str | None, mode.value) + if new_entry_box is not None and mode_val is not None: + template_func = { + "Daily": create_daily_entry, + "Deep Recovery": create_recovery_entry, + "Fragment": create_fragment_entry, + "Deep Entry": create_deep_entry, + }[mode_val] + new_entry_box.set_value(template_func()) + ui.notify("Template loaded!", type="info") + + async def save_new_wrapper(): + client = ui.context.client + new_entry_value = cast( + str | None, + new_entry_box.value if new_entry_box else None, + ) + mode_val = cast(str, mode.value) + password = cast( + str | None, getattr(app.storage.user, "vault_password", None) + ) + if new_entry_value and new_entry_value.strip(): + # For new entries, always append to today's file + msg = await run.io_bound( + save_entry, new_entry_value, mode_val, password + ) + if not _is_client_active(client): + return + ui.notify(msg, type="positive") + if new_entry_box: + new_entry_box.set_value("") + update_sidebar() # Refresh the sidebar + else: + ui.notify("Entry content is empty", type="warning") + + _ = ui.button("Create Template", on_click=fill_template).props( + "color=secondary size=lg" + ) + _ = ui.button("Save Entry", on_click=save_new_wrapper).props( + "color=primary size=lg" + ) + # --- Speech to Text Tab --- + with ui.tab_panel(speech_tab).style( + "flex: 1; display: flex; flex-direction: column; padding: 16px;" + ): + # Add a text area to display the live transcription + speech_output_box = ( + ui.textarea(label="Live Transcription") + .props("outlined") + .classes("w-full flex-grow") + ) + + def on_speech_result(text: str): + # Append new transcribed text to the speech output box + current_text = cast(str, speech_output_box.value or "") + speech_output_box.set_value(current_text + text) + + # The speech component itself, which now updates the local text area + with ui.row().classes("w-full justify-between mt-4"): + speech_to_text(on_result=on_speech_result) + + def append_to_new_entry(): + # Safely get and cast the value from the speech box to satisfy pyright. + speech_text = cast(str, speech_output_box.value or "") + if new_entry_box is not None and speech_text: + # Append the transcribed text to the main new entry editor + current_entry_text = cast( + str, new_entry_box.value or "" + ) + new_entry_box.set_value( + current_entry_text + speech_text + ) + + # Clear the speech box and notify the user + speech_output_box.set_value("") + ui.notify( + "Text appended to 'New Entry' tab.", + type="positive", + ) + + _ = ui.button( + "Append to New Entry", on_click=append_to_new_entry + ).props("color=primary") + # --- Dialog for "Analyze All Entries" --- + global all_analysis_dialog + if all_analysis_dialog is None: + all_analysis_dialog = ui.dialog() + + with all_analysis_dialog, ui.card().classes("w-full max-w-5xl max-h-[90vh]"): + _ = ui.label("Comprehensive Journal Analysis").classes("text-xl font-bold mb-4") + all_analysis_output = ( + ui.textarea(label="Analysis Results") + .props("readonly outlined") + .classes("h-96 mb-4 w-full") + ) + + with ui.row().classes("gap-4"): + + async def do_analyze_all_wrapper(): + client = ui.context.client + ui.notify("Analyzing all entries...", type="info") + result: str = await run.io_bound(analyze_all_entries) + if not _is_client_active(client): + return + all_analysis_output.set_value(result) + ui.notify("Analysis complete!", type="positive") + + _ = ui.button("Run Analysis", on_click=do_analyze_all_wrapper).props( + "color=primary" + ) + _ = ui.button("Close", on_click=lambda: all_analysis_dialog.close() if all_analysis_dialog else None).props( + "color=secondary" + ) + + +@ui.page("/") +async def index_page(): + global drawer, sidebar_content, content_container + _ = ui.query("body").style("background-color: #111827") + # Top-level layout elements (always present) + with ( + ui.left_drawer(value=False) + .props("overlay") + .classes("w-64 bg-gray-900 text-white") as drawer_instance + ): + drawer = drawer_instance + sidebar_content = ui.column().classes("p-4 gap-2 h-full") + # Sidebar content will be updated after vault load + + with ui.header(elevated=True).classes( + "items-center justify-between px-4 bg-gray-900" + ): + # Add a None check inside the lambda to satisfy the type checker + _ = ui.button( + icon="menu", on_click=lambda: drawer.toggle() if drawer else None + ).props("flat color=white") + _ = ui.label("📓 Project Journal").classes("text-2xl font-bold") + _ = ui.space() + # Instantiate and open the settings dialog + settings = settings_dialog() + _ = ui.button(icon="settings", on_click=settings.open).props("flat color=white") + + # Main content area (dynamically populated) + with ui.column().classes( + "w-full h-full bg-gray-900 text-white" + ) as content_container_instance: + content_container = content_container_instance + if not getattr(app.storage.user, "authenticated", False): + with ui.card().classes("absolute-center bg-gray-800 text-white"): + _ = ui.label("Welcome to Your Journal").classes("text-2xl font-bold") + password_input = ( + ui.input( + "Enter Vault Password", + password=True, + placeholder="Type your password", + ) + .props("autofocus") + .on("keydown.enter", lambda: password_button.run_method("click")) + ) + password_button = ui.button( + "Load Vault", + on_click=lambda: process_password( + cast(str | None, password_input.value) + ), + ) + else: + # This branch will be executed if the page reloads and password is already set + # or if we navigate back to '/' after successful login. + journal_ui_layout() + update_sidebar() # Ensure sidebar is populated on reload + + +async def process_password(password: str | None): + global vault_password, content_container + client = ui.context.client + if not password: + if _is_client_active(client): + ui.notify("Password cannot be empty.", type="negative") + return + if cast(bool, app.storage.user.get("auth_in_progress", False)): + if _is_client_active(client): + ui.notify("Vault load already in progress. Please wait...", type="warning") + return + + if vault_load_lock.locked(): + if _is_client_active(client): + ui.notify("Another vault operation is already in progress. Please wait...", type="warning") + return + + app.storage.user["auth_in_progress"] = True + try: + async with vault_load_lock: + # Check if vault is empty to determine if we're setting a new password + is_new_vault = not any(VAULT_DIR.iterdir()) + + if is_new_vault: + await run.io_bound(initialize_vault, password) + if _is_client_active(client): + ui.notify("New vault initialized!", type="positive") + vault_password = password + # Load the newly initialized vault (which will be empty but sets up DATA_DIR) + _ = await run.io_bound(load_all_vaults, password) + else: + # Attempt to load existing vaults + load_success = await run.io_bound(load_all_vaults, password) + if not load_success: + if _is_client_active(client): + ui.notify("Incorrect password.", type="negative") + vault_password = None # Reset password if loading fails + return + + # Store password globally for shutdown hook, and in session for this client + if not _is_client_active(client): + return + + vault_password = password + app.storage.user["authenticated"] = True + app.storage.user["vault_password"] = password + # Initialize user settings in session storage + app.storage.user["speech_engine"] = SPEECH_RECOGNITION_ENGINE + app.storage.user["whisper_model"] = WHISPER_MODEL_SIZE + ui.notify("Vault loaded successfully!", type="positive") + + # Clear the password input area and render the main UI + if content_container is not None: + try: + content_container.clear() + with content_container: + journal_ui_layout() + except RuntimeError as error: + if _is_deleted_client_error(error): + print("Skipping layout update because the client was deleted.") + return + raise + update_sidebar() # Populate sidebar after vault is loaded + + except ValueError as e: + if _is_client_active(client): + ui.notify(f"Error: {e}", type="negative") + vault_password = None + except Exception as e: + if _is_client_active(client): + ui.notify(f"Error loading vault: {e}", type="negative") + vault_password = None # Reset password if loading fails + finally: + try: + app.storage.user["auth_in_progress"] = False + except RuntimeError: + # The page may be gone due to refresh/disconnect before callback completion. + pass + + +@app.get("/_health") +def healthcheck() -> dict[str, str]: + return {"status": "ok"} + + +def _shutdown_handler(): + """Handles saving and cleanup on application exit.""" + print("NiceGUI shutdown hook triggered.") + if vault_password: + rebuild_all_vaults(vault_password) + else: + print("Vault password is None, skipping save.") + clear_data_directory() + + +def main(): + ui.run( + title="Project Journal", + reload=False, + host="0.0.0.0", + port=8080, + show=False, + storage_secret="a_secret_key_for_session_storage", + ) + + +app.on_shutdown(_shutdown_handler) # pyright: ignore[reportUnknownMemberType] + +if __name__ in {"__main__", "__mp_main__"}: + main() diff --git a/originalprojectplan.md b/originalprojectplan.md new file mode 100644 index 0000000..493175f --- /dev/null +++ b/originalprojectplan.md @@ -0,0 +1,290 @@ +# 🧠🗂️ 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 new file mode 100644 index 0000000..52bd987 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,52 @@ +[project] +name = "journal-app" +version = "0.1.0" +description = "A personal journaling application with AI features." +authors = [ + { name = "Stan", email = "stan@example.com" } +] +dependencies = [ + "PyQt6>=6.9,<7", + "nicegui>=3.4,<4", + "requests>=2.32,<3", + "cryptography>=46,<47", + "python-multipart>=0.0.20,<0.1", + "uvicorn>=0.38,<1", + "pywebview>=6.1,<7; python_version < '3.14' or platform_system != 'Windows'", + "SpeechRecognition>=3.14,<4", + "pocketsphinx>=5,<6", + "soundfile>=0.13,<1", + "sounddevice>=0.5,<1", +] +requires-python = ">=3.14" +readme = "README.md" +license = { text = "Proprietary" } + +[project.optional-dependencies] +nlp = [ + "spacy>=3.8,<4; python_version < '3.14'", +] +cpu-ai = [ + "torch>=2.9,<3", + "openai-whisper>=20250625", +] +gpu-ai = [ + "torch>=2.9,<3", + "triton>=3,<4; platform_system != 'Windows'", + "openai-whisper>=20250625", +] + +[project.urls] +"Homepage" = "https://github.com/yourusername/journal-app" + +[project.scripts] +journal-cli = "journal.cli.main:main" +journal-ui = "journal.run_desktop:run" + +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["."] +include = ["journal*"] diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..e5d1af6 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,3 @@ +{ + "stubPath": "typings" +} \ No newline at end of file diff --git a/requirements_base.txt b/requirements_base.txt new file mode 100644 index 0000000..0643350 --- /dev/null +++ b/requirements_base.txt @@ -0,0 +1,18 @@ +# Core dependencies for Project_Journal (CPU-first baseline) +-r ../requirements-common.txt + +nicegui>=3.4,<4 +requests>=2.32,<3 +cryptography>=46,<47 +python-multipart>=0.0.20,<0.1 +uvicorn>=0.38,<1 +pywebview>=6.1,<7 ; python_version < "3.14" or platform_system != "Windows" + +# Database (sqlcipher preferred; app falls back to sqlite3 when unavailable) +sqlcipher3-binary>=0.5,<1 ; platform_system != "Windows" + +# Speech and audio +SpeechRecognition>=3.14,<4 +pocketsphinx>=5,<6 +soundfile>=0.13,<1 +sounddevice>=0.5,<1 diff --git a/requirements_cpu_only.txt b/requirements_cpu_only.txt new file mode 100644 index 0000000..c74d023 --- /dev/null +++ b/requirements_cpu_only.txt @@ -0,0 +1,9 @@ +# CPU-first dependency profile +-r requirements_base.txt + +# AI (CPU) +torch>=2.9,<3 +openai-whisper>=20250625 + +# Optional NLP backend: +# pip install -r requirements_nlp_optional.txt diff --git a/requirements_gpu.txt b/requirements_gpu.txt new file mode 100644 index 0000000..250c2a7 --- /dev/null +++ b/requirements_gpu.txt @@ -0,0 +1,10 @@ +# GPU-capable dependency profile +-r requirements_base.txt + +# AI (GPU) +torch>=2.9,<3 +triton>=3,<4 ; platform_system != "Windows" +openai-whisper>=20250625 + +# Optional NLP backend: +# pip install -r requirements_nlp_optional.txt diff --git a/requirements_nlp_optional.txt b/requirements_nlp_optional.txt new file mode 100644 index 0000000..803f40d --- /dev/null +++ b/requirements_nlp_optional.txt @@ -0,0 +1,4 @@ +# Optional NLP backend dependencies. +# Keep spaCy optional for Python 3.14 compatibility; the app auto-falls back +# when spaCy is not installed or not supported by the active interpreter. +spacy>=3.8,<4 ; python_version < "3.14" diff --git a/tests/test_ai_backend.py b/tests/test_ai_backend.py new file mode 100644 index 0000000..ac00703 --- /dev/null +++ b/tests/test_ai_backend.py @@ -0,0 +1,47 @@ +import importlib +import os +import sys +import unittest +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + + +def _reload_analysis(backend: str): + os.environ["JOURNAL_NLP_BACKEND"] = backend + import journal.core.config as config + import journal.ai.analysis as analysis + + importlib.reload(config) + importlib.reload(analysis) + return analysis + + +class AiBackendTests(unittest.TestCase): + def test_auto_backend_resolves_without_crashing(self): + analysis = _reload_analysis("auto") + backend = analysis.get_nlp_backend() + self.assertIn(backend, {"spacy", "fallback"}) + + def test_fallback_backend_forced(self): + analysis = _reload_analysis("fallback") + self.assertEqual(analysis.get_nlp_backend(), "fallback") + themes = analysis.extract_themes( + "rain rain rain over city streets and rain over old memories" + ) + self.assertIsInstance(themes, list) + + def test_spacy_forced_is_explicit(self): + analysis = _reload_analysis("spacy") + try: + backend = analysis.get_nlp_backend() + except RuntimeError: + return + self.assertEqual(backend, "spacy") + + +if __name__ == "__main__": + unittest.main() diff --git a/typings/speech_recognition/__init__.pyi b/typings/speech_recognition/__init__.pyi new file mode 100644 index 0000000..a4d1e84 --- /dev/null +++ b/typings/speech_recognition/__init__.pyi @@ -0,0 +1,55 @@ +from typing import Callable, Protocol +from types import TracebackType + +class Stoppable(Protocol): + def __call__(self, wait_for_stop: bool = True) -> None: ... + +class AudioData: + """Represents a chunk of audio data.""" + + ... + +class Microphone: + """Represents a physical microphone on the system.""" + + def __init__( + self, + device_index: int | None = None, + sample_rate: int | None = None, + chunk_size: int = 1024, + ) -> None: ... + def __enter__(self) -> "Microphone": ... + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: ... + @staticmethod + def list_microphone_names() -> list[str]: ... + +class Recognizer: + """Performs speech recognition, with multiple engines and API integrations.""" + + def __init__(self) -> None: ... + def adjust_for_ambient_noise( + self, source: Microphone, duration: float = 1 + ) -> None: ... + def listen_in_background( + self, + source: Microphone, + callback: Callable[["Recognizer", AudioData], None], + phrase_time_limit: float | None = ..., + ) -> Stoppable: ... + def recognize_sphinx( + self, audio_data: AudioData, language: str = "en-US" + ) -> str: ... + def recognize_google( + self, audio_data: AudioData, language: str = "en-US", show_all: bool = False + ) -> str: ... + def recognize_whisper( + self, audio_data: AudioData, model: str = "base", language: str | None = None + ) -> str: ... + +class UnknownValueError(Exception): ... +class RequestError(Exception): ... diff --git a/typings/sqlcipher3/dbapi2.pyi b/typings/sqlcipher3/dbapi2.pyi new file mode 100644 index 0000000..bf4678e --- /dev/null +++ b/typings/sqlcipher3/dbapi2.pyi @@ -0,0 +1,24 @@ +from typing import Iterable + +# This is a partial stub file for sqlcipher3.dbapi2. +# It provides basic type hints for the parts of the API used in this project. + +# Basic types +paramstyle: str +threadsafety: int +apilevel: str + +class Connection: + def __init__(self, database: str | bytes, timeout: float = 5.0) -> None: ... + def cursor(self) -> 'Cursor': ... + def execute(self, sql: str, parameters: Iterable[object] = ()) -> 'Cursor': ... + def commit(self) -> None: ... + def close(self) -> None: ... + +class Cursor: + def execute(self, sql: str, parameters: Iterable[object] = ()) -> 'Cursor': ... + def fetchone(self) -> tuple[object, ...] | None: ... + def fetchall(self) -> list[tuple[object, ...]]: ... + def close(self) -> None: ... + +def connect(database: str | bytes) -> Connection: ... \ No newline at end of file