main commit

This commit is contained in:
stan44 2026-02-21 18:35:20 -06:00
parent 40a0098f84
commit 7ba9c8c952
36 changed files with 3852 additions and 0 deletions

29
.gitignore vendored Normal file
View File

@ -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

73
installreqs.sh Normal file
View File

@ -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"

0
journal/__init__.py Normal file
View File

0
journal/ai/__init__.py Normal file
View File

373
journal/ai/analysis.py Normal file
View File

@ -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)

19
journal/ai/chat.py Normal file
View File

@ -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}"

0
journal/cli/__init__.py Normal file
View File

299
journal/cli/main.py Normal file
View File

@ -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()

0
journal/core/__init__.py Normal file
View File

62
journal/core/config.py Normal file
View File

@ -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)

114
journal/core/database.py Normal file
View File

@ -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.")

View File

@ -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()

368
journal/core/entry.py Normal file
View File

@ -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: (010)
- Depression level: (010)
- 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 didnt send
- Things I forgot to say
- Things I said that I didnt mean
- Verbal conflicts / miscommunication
- Notes:
## 🧰 Coping / Tools Used
- Breathing
- Music
- Walking
- Writing
- AI journaling
- Hiding / Isolation
- Notes:
## 🧠 Reflection
- What do I wish Id 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: (010)
- Depression level: (010)
- 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 brokenI 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: (010)
- Depression level: (010)
- 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 brokenI am healing and growing._
"""
# endregion

10
journal/core/fragments.py Normal file
View File

@ -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)

100
journal/core/models.py Normal file
View File

@ -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

96
journal/core/parser.py Normal file
View File

@ -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
)

82
journal/core/speech.py Normal file
View File

@ -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

310
journal/core/storage.py Normal file
View File

@ -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)

308
journal/run_desktop.py Normal file
View File

@ -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()

0
journal/ui/__init__.py Normal file
View File

View File

View File

@ -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")

View File

@ -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

View File

@ -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

View File

@ -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"
)

680
journal/ui/main.py Normal file
View File

@ -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()

290
originalprojectplan.md Normal file
View File

@ -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: (010)
- Depression level: (010)
- 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 didnt send
- Things I forgot to say
- Things I said that I didnt mean
- Verbal conflicts / miscommunication
## 🧰 Coping / Tools Used
- Breathing
- Music
- Walking
- Writing
- AI journaling
- Hiding / Isolation
- Notes:
## 🧠 Reflection
- What do I wish Id 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 Phaylynns school schedule. Just froze and couldnt finish. Felt ashamed.
!TRIGGER @email #co-parent #legal
Kathryns 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.” Didnt say it.
!LOOP #rumination
Keep repeating: “What if Im 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 dont 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)

52
pyproject.toml Normal file
View File

@ -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*"]

3
pyrightconfig.json Normal file
View File

@ -0,0 +1,3 @@
{
"stubPath": "typings"
}

18
requirements_base.txt Normal file
View File

@ -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

View File

@ -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

10
requirements_gpu.txt Normal file
View File

@ -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

View File

@ -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"

47
tests/test_ai_backend.py Normal file
View File

@ -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()

View File

@ -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): ...

View File

@ -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: ...