main commit
This commit is contained in:
parent
40a0098f84
commit
7ba9c8c952
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal 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
73
installreqs.sh
Normal 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
0
journal/__init__.py
Normal file
0
journal/ai/__init__.py
Normal file
0
journal/ai/__init__.py
Normal file
373
journal/ai/analysis.py
Normal file
373
journal/ai/analysis.py
Normal 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
19
journal/ai/chat.py
Normal 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
0
journal/cli/__init__.py
Normal file
299
journal/cli/main.py
Normal file
299
journal/cli/main.py
Normal 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
0
journal/core/__init__.py
Normal file
62
journal/core/config.py
Normal file
62
journal/core/config.py
Normal 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
114
journal/core/database.py
Normal 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.")
|
||||||
61
journal/core/encryption.py
Normal file
61
journal/core/encryption.py
Normal 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
368
journal/core/entry.py
Normal 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: (0–10)
|
||||||
|
- Depression level: (0–10)
|
||||||
|
- Suicidal ideation: (Y/N, passive/active)
|
||||||
|
- Emotional state(s): Angry / Tired / Hopeful / Numb / Triggered / Overwhelmed / Energized / etc.
|
||||||
|
- Notes:
|
||||||
|
|
||||||
|
## ⚡ Memory / Mind Failures
|
||||||
|
|
||||||
|
- Forgot something mid-sentence?
|
||||||
|
- Lost train of thought?
|
||||||
|
- Couldn't speak thoughts?
|
||||||
|
- Time blindness / lost hours?
|
||||||
|
- Notes:
|
||||||
|
|
||||||
|
## 📜 Events / Triggers
|
||||||
|
|
||||||
|
- Interactions (e.g., with co-parent, child, officials)
|
||||||
|
- Flashbacks / trauma triggers
|
||||||
|
- Physical symptoms
|
||||||
|
- Legal / medical events
|
||||||
|
- Notes:
|
||||||
|
|
||||||
|
## 💬 Communication / Expression Log
|
||||||
|
|
||||||
|
- Messages I didn’t send
|
||||||
|
- Things I forgot to say
|
||||||
|
- Things I said that I didn’t mean
|
||||||
|
- Verbal conflicts / miscommunication
|
||||||
|
- Notes:
|
||||||
|
|
||||||
|
## 🧰 Coping / Tools Used
|
||||||
|
|
||||||
|
- Breathing
|
||||||
|
- Music
|
||||||
|
- Walking
|
||||||
|
- Writing
|
||||||
|
- AI journaling
|
||||||
|
- Hiding / Isolation
|
||||||
|
- Notes:
|
||||||
|
|
||||||
|
## 🧠 Reflection
|
||||||
|
|
||||||
|
- What do I wish I’d done differently?
|
||||||
|
- What patterns am I noticing?
|
||||||
|
- Is this getting better or worse?
|
||||||
|
- Notes:
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# endregion
|
||||||
|
|
||||||
|
|
||||||
|
# region Fragment Entry
|
||||||
|
def create_fragment_entry() -> str:
|
||||||
|
"""
|
||||||
|
Generates the full markdown for a new fragment journal entry.
|
||||||
|
"""
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
return f"""---
|
||||||
|
type: journal
|
||||||
|
mode: fragment
|
||||||
|
title: "Fragment Insert Format"
|
||||||
|
---
|
||||||
|
**Date:** {today}
|
||||||
|
# 🧠 Fragment Logging Mode
|
||||||
|
|
||||||
|
Use this mode for logging quick thoughts, triggers, shutdowns, or notable moments when filling out a full entry isn't possible.
|
||||||
|
|
||||||
|
## Syntax Format:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
!TYPE @time #tags
|
||||||
|
Description of the event, thought, or experience.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
!TRIGGER @16:45 #co-parent #shutdown
|
||||||
|
She texted "you don't care about her" instant stomach drop and fogged thinking.
|
||||||
|
|
||||||
|
!FORGOT @17:00 #memory #mindblank
|
||||||
|
Lost my train of thought mid-sentence with Person. Blank mind, embarrassed.
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# endregion
|
||||||
|
|
||||||
|
|
||||||
|
# region Recovery Entry
|
||||||
|
def create_recovery_entry() -> str:
|
||||||
|
"""
|
||||||
|
Generates the full markdown for a new trauma recovery journal entry.
|
||||||
|
"""
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
return f"""---
|
||||||
|
type: journal
|
||||||
|
mode: deep_recovery
|
||||||
|
title: "Trauma Recovery Log"
|
||||||
|
---
|
||||||
|
|
||||||
|
# 📓 Trauma Recovery Log — Entry #[#]
|
||||||
|
|
||||||
|
**Date:** {today}
|
||||||
|
**Title (Optional):** [Short phrase that helps you remember the theme]
|
||||||
|
|
||||||
|
## 🔍 Summary
|
||||||
|
|
||||||
|
> *(Briefly describe what happened or how you're feeling—1 to 3 paragraphs.)*
|
||||||
|
|
||||||
|
## 🧠 Cognitive State
|
||||||
|
|
||||||
|
- [ ] Masking
|
||||||
|
|
||||||
|
- [ ] Shutdown
|
||||||
|
|
||||||
|
- [ ] Meltdown
|
||||||
|
|
||||||
|
- [ ] Freeze
|
||||||
|
|
||||||
|
- [ ] Flow state
|
||||||
|
|
||||||
|
- Notes:
|
||||||
|
|
||||||
|
## 🧠 Core Events or Memories
|
||||||
|
|
||||||
|
- Significant event or memory (e.g., “Major change at work/school”)
|
||||||
|
- Notable pattern or experience (e.g., “Felt dismissed when expressing needs”)
|
||||||
|
- Impact (e.g., “Difficulty connecting with others”)
|
||||||
|
|
||||||
|
## 🧠 Mental / Emotional Snapshot
|
||||||
|
|
||||||
|
- Internal monologue or silence?
|
||||||
|
- Thought loops or rumination?
|
||||||
|
- Anxiety level: (0–10)
|
||||||
|
- Depression level: (0–10)
|
||||||
|
- Emotional state(s): (e.g., Angry / Tired / Hopeful / Numb / Triggered / Overwhelmed / Energized)
|
||||||
|
- Notes:
|
||||||
|
|
||||||
|
## 🧬 Autism/ADHD-Related Elements
|
||||||
|
|
||||||
|
- Masking to fit in or avoid negative attention
|
||||||
|
- Self-teaching or adapting due to lack of support
|
||||||
|
- Shutdowns or meltdowns misunderstood by others
|
||||||
|
- Sensory issues or communication differences
|
||||||
|
- Notes:
|
||||||
|
|
||||||
|
## 🧯 Emotional & Bodily Reactions (Then/Now)
|
||||||
|
|
||||||
|
| Then | Now |
|
||||||
|
| ------------------------ | -------------------------------- |
|
||||||
|
| [e.g., Fear, confusion] | [e.g., Anger, fatigue] |
|
||||||
|
| [e.g., Shame for crying] | [e.g., Still struggle to grieve] |
|
||||||
|
| [e.g., Helplessness] | [e.g., Determined, but tired] |
|
||||||
|
|
||||||
|
## ⚡ Memory / Mind Failures
|
||||||
|
|
||||||
|
- Forgetting details or losing train of thought?
|
||||||
|
- Difficulty expressing thoughts?
|
||||||
|
- Time blindness or lost hours?
|
||||||
|
- Notes:
|
||||||
|
|
||||||
|
## 📜 Events / Triggers
|
||||||
|
|
||||||
|
- Notes:
|
||||||
|
|
||||||
|
## 💬 Communication / Expression Log
|
||||||
|
|
||||||
|
- Notes:
|
||||||
|
|
||||||
|
## 🧰 Coping / Tools Used
|
||||||
|
|
||||||
|
- Notes:
|
||||||
|
|
||||||
|
## 🧠 Reflection & Therapy Prep
|
||||||
|
|
||||||
|
### What I want to bring up in session:
|
||||||
|
|
||||||
|
- [e.g., “Why do I struggle to trust others?”]
|
||||||
|
- [e.g., “How can I process past experiences safely?”]
|
||||||
|
- [e.g., “What helps me feel grounded?”]
|
||||||
|
|
||||||
|
### What helps me cope:
|
||||||
|
|
||||||
|
- [e.g., Talking to a supportive person or AI]
|
||||||
|
- [e.g., Writing or creative expression]
|
||||||
|
- [e.g., Learning new skills or information]
|
||||||
|
|
||||||
|
## 🧭 Truth to Anchor Myself To
|
||||||
|
|
||||||
|
> _“I am not broken—I am healing and growing.”_
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# endregion
|
||||||
|
|
||||||
|
|
||||||
|
# region Deep Entry
|
||||||
|
def create_deep_entry() -> str:
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
return f"""---
|
||||||
|
type: journal
|
||||||
|
mode: deep_recovery
|
||||||
|
title: "Trauma Recovery Log"
|
||||||
|
---
|
||||||
|
|
||||||
|
# 📓 Deep Log — Entry #[#]
|
||||||
|
|
||||||
|
**Date:** {today}
|
||||||
|
**Title (Optional):** [Short phrase that helps you remember the theme]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Summary
|
||||||
|
|
||||||
|
> *(Briefly describe what happened or how you're feeling—1 to 3 paragraphs.)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧠 Cognitive State
|
||||||
|
|
||||||
|
- [ ] Masking
|
||||||
|
|
||||||
|
- [ ] Shutdown
|
||||||
|
|
||||||
|
- [ ] Meltdown
|
||||||
|
|
||||||
|
- [ ] Freeze
|
||||||
|
|
||||||
|
- [ ] Flow state
|
||||||
|
|
||||||
|
- Notes:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧠 Core Events or Memories
|
||||||
|
|
||||||
|
- Significant event or memory (e.g., “Change in daily routine”)
|
||||||
|
- Notable pattern or experience (e.g., “Felt dismissed when expressing needs”)
|
||||||
|
- Impact (e.g., “Difficulty connecting with others”)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧠 Mental / Emotional Snapshot
|
||||||
|
|
||||||
|
- Internal monologue or silence?
|
||||||
|
- Thought loops or rumination?
|
||||||
|
- Anxiety level: (0–10)
|
||||||
|
- Depression level: (0–10)
|
||||||
|
- Emotional state(s): (e.g., Angry / Tired / Hopeful / Numb / Triggered / Overwhelmed / Energized)
|
||||||
|
- Notes:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧬 Autism/ADHD-Related Elements
|
||||||
|
|
||||||
|
- Masking to fit in or avoid negative attention
|
||||||
|
- Self-teaching or adapting due to lack of support
|
||||||
|
- Shutdowns or meltdowns misunderstood by others
|
||||||
|
- Sensory issues or communication differences
|
||||||
|
- Notes:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧯 Emotional & Bodily Reactions (Then/Now)
|
||||||
|
|
||||||
|
| Then | Now |
|
||||||
|
| ------------------------ | -------------------------------- |
|
||||||
|
| [e.g., Fear, confusion] | [e.g., Anger, fatigue] |
|
||||||
|
| [e.g., Shame for crying] | [e.g., Still struggle to grieve] |
|
||||||
|
| [e.g., Helplessness] | [e.g., Determined, but tired] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚡ Memory / Mind Failures
|
||||||
|
|
||||||
|
- Forgetting details or losing train of thought?
|
||||||
|
- Difficulty expressing thoughts?
|
||||||
|
- Time blindness or lost hours?
|
||||||
|
- Notes:
|
||||||
|
|
||||||
|
## 📜 Events / Triggers
|
||||||
|
|
||||||
|
- Interactions (e.g., with others, officials, etc.)
|
||||||
|
- Flashbacks or triggers
|
||||||
|
- Physical symptoms
|
||||||
|
- Notable events
|
||||||
|
- Notes:
|
||||||
|
|
||||||
|
## 💬 Communication / Expression Log
|
||||||
|
|
||||||
|
- Messages not sent
|
||||||
|
- Things forgotten to say
|
||||||
|
- Things said that weren't meant
|
||||||
|
- Verbal conflicts or miscommunication
|
||||||
|
|
||||||
|
## 🧰 Coping / Tools Used
|
||||||
|
|
||||||
|
- Breathing
|
||||||
|
- Music
|
||||||
|
- Walking
|
||||||
|
- Writing
|
||||||
|
- Journaling
|
||||||
|
- Hiding / Isolation
|
||||||
|
- Notes:
|
||||||
|
|
||||||
|
## 🧠 Reflection
|
||||||
|
|
||||||
|
- What do I wish I'd done differently?
|
||||||
|
- What patterns am I noticing?
|
||||||
|
- Is this getting better or worse?
|
||||||
|
- Notes:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧠 Reflection & Therapy Prep
|
||||||
|
|
||||||
|
### What I want to bring up in session:
|
||||||
|
|
||||||
|
- [e.g., “Why do I struggle to trust others?”]
|
||||||
|
- [e.g., “How can I process past experiences safely?”]
|
||||||
|
- [e.g., “What helps me feel grounded?”]
|
||||||
|
|
||||||
|
### What helps me cope:
|
||||||
|
|
||||||
|
- [e.g., Talking to a supportive person or AI]
|
||||||
|
- [e.g., Writing or creative expression]
|
||||||
|
- [e.g., Learning new skills or information]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧭 Truth to Anchor Myself To
|
||||||
|
|
||||||
|
> _“I am not broken—I am healing and growing.”_
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# endregion
|
||||||
10
journal/core/fragments.py
Normal file
10
journal/core/fragments.py
Normal 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
100
journal/core/models.py
Normal 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
96
journal/core/parser.py
Normal 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
82
journal/core/speech.py
Normal 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
310
journal/core/storage.py
Normal 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
308
journal/run_desktop.py
Normal 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
0
journal/ui/__init__.py
Normal file
0
journal/ui/components/__init__.py
Normal file
0
journal/ui/components/__init__.py
Normal file
13
journal/ui/components/calendar.py
Normal file
13
journal/ui/components/calendar.py
Normal 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")
|
||||||
138
journal/ui/components/editor.py
Normal file
138
journal/ui/components/editor.py
Normal 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
|
||||||
123
journal/ui/components/settings.py
Normal file
123
journal/ui/components/settings.py
Normal 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
|
||||||
82
journal/ui/components/speech.py
Normal file
82
journal/ui/components/speech.py
Normal 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
680
journal/ui/main.py
Normal 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
290
originalprojectplan.md
Normal 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: (0–10)
|
||||||
|
- Depression level: (0–10)
|
||||||
|
- Suicidal ideation: (Y/N, passive/active)
|
||||||
|
- Emotional state(s): Angry / Tired / Hopeful / Numb / Triggered / Overwhelmed / Energized / etc.
|
||||||
|
- Notes:
|
||||||
|
|
||||||
|
## ⚡ Memory / Mind Failures
|
||||||
|
|
||||||
|
- Forgot something mid-sentence?
|
||||||
|
- Lost train of thought?
|
||||||
|
- Couldn't speak thoughts?
|
||||||
|
- Time blindness / lost hours?
|
||||||
|
- Notes:
|
||||||
|
|
||||||
|
## 📜 Events / Triggers
|
||||||
|
|
||||||
|
- Interactions (e.g., with co-parent, child, officials)
|
||||||
|
- Flashbacks / trauma triggers
|
||||||
|
- Physical symptoms
|
||||||
|
- Legal / medical events
|
||||||
|
- Notes:
|
||||||
|
|
||||||
|
## 💬 Communication / Expression Log
|
||||||
|
|
||||||
|
- Messages I didn’t send
|
||||||
|
- Things I forgot to say
|
||||||
|
- Things I said that I didn’t mean
|
||||||
|
- Verbal conflicts / miscommunication
|
||||||
|
|
||||||
|
## 🧰 Coping / Tools Used
|
||||||
|
|
||||||
|
- Breathing
|
||||||
|
- Music
|
||||||
|
- Walking
|
||||||
|
- Writing
|
||||||
|
- AI journaling
|
||||||
|
- Hiding / Isolation
|
||||||
|
- Notes:
|
||||||
|
|
||||||
|
## 🧠 Reflection
|
||||||
|
|
||||||
|
- What do I wish I’d done differently?
|
||||||
|
- What patterns am I noticing?
|
||||||
|
- Is this getting better or worse?
|
||||||
|
- Notes:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Modular "Insert Blocks"
|
||||||
|
|
||||||
|
Quick journal fragments for when you're too overwhelmed to fill out a full template.
|
||||||
|
|
||||||
|
#### Example block types:
|
||||||
|
|
||||||
|
- `!FLASHBACK:` description of what triggered it
|
||||||
|
|
||||||
|
- `!FORGOT:` mid-thought freeze or sentence drop
|
||||||
|
|
||||||
|
- `!QUOTE:` something I wish I'd said
|
||||||
|
|
||||||
|
- `!TRIGGER:` encounter that caused a somatic or shutdown response
|
||||||
|
|
||||||
|
- `!LOOP:` thought pattern or obsession
|
||||||
|
|
||||||
|
- `!VIOLATION:` emotional harm from another person (e.g., co-parent)
|
||||||
|
|
||||||
|
- `!SOMATIC:` physical response (shaking, tears, tight chest)
|
||||||
|
|
||||||
|
These can be dropped into a daily log or used stand-alone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Indexing / Metadata System
|
||||||
|
|
||||||
|
To make logs searchable:
|
||||||
|
|
||||||
|
- **Tagging**: `#shutdown`, `#CPTSD`, `#co-parent`, `#legal`, etc.
|
||||||
|
|
||||||
|
- **Timestamps**: `@HH:MM`
|
||||||
|
|
||||||
|
- **Sources**: `> from text convo`, `> from therapy session`, `> from memory`, etc.
|
||||||
|
|
||||||
|
- **Priority markers**:
|
||||||
|
|
||||||
|
- `‼️` = urgent
|
||||||
|
|
||||||
|
- `🔁` = recurring pattern
|
||||||
|
|
||||||
|
- `🧩` = unexplained moment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Use Cases
|
||||||
|
|
||||||
|
This system supports:
|
||||||
|
|
||||||
|
- **Therapy**: structure logs showing memory gaps, trauma patterns, breakdowns
|
||||||
|
|
||||||
|
- **Legal**: document co-parenting issues and harmful behavior neutrally and time-stamped
|
||||||
|
|
||||||
|
- **Internal Growth**: recognize cycles, triggers, and patterns
|
||||||
|
|
||||||
|
- **Compensation**: catch memory failures before they damage communication or safety
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Capture Modes (Tools)
|
||||||
|
|
||||||
|
**Currently Available:**
|
||||||
|
|
||||||
|
- **Desktop UI** (NiceGUI)
|
||||||
|
|
||||||
|
- **Mobile browser access** via Tailscale
|
||||||
|
|
||||||
|
- **CLI tools**: `jfrag`, `vault`, `server`, `search`
|
||||||
|
|
||||||
|
- **ChatGPT log syntax** (can copy-paste into assistant)
|
||||||
|
|
||||||
|
- **Encrypted Vaults**: Journals saved as monthly `.vault` files
|
||||||
|
|
||||||
|
- **Automatic data cleanup**: Decrypted data auto-cleared on shutdown
|
||||||
|
|
||||||
|
|
||||||
|
- **Voice-to-text input**: (desktop + mobile)
|
||||||
|
|
||||||
|
- **Calendar and rich Markdown**: preview in UI
|
||||||
|
|
||||||
|
- **SQcypher backend**: Enrypted database backend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛍️ Syntax Format
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
!TYPE @time #tags
|
||||||
|
Description of the event, thought, or experience.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📦 Example Fragments:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
!FLASHBACK @15:20 #CPTSD #shutdown
|
||||||
|
Smelled her shampoo in the hallway and got hit with a memory of the hospital visit. Heart raced, froze completely.
|
||||||
|
|
||||||
|
!FORGOT @16:45 #aphantasia #mindblank
|
||||||
|
Mid-sentence memory drop while trying to explain Phaylynn’s school schedule. Just froze and couldn’t finish. Felt ashamed.
|
||||||
|
|
||||||
|
!TRIGGER @email #co-parent #legal
|
||||||
|
Kathryn’s message today saying “you never do anything for her” triggered a whole-body tension + tears. Completely false.
|
||||||
|
|
||||||
|
!QUOTE @walk #unsaid
|
||||||
|
What I *wanted* to say was: “You act like you want control more than peace.” Didn’t say it.
|
||||||
|
|
||||||
|
!LOOP #rumination
|
||||||
|
Keep repeating: “What if I’m the problem? What if it *is* all my fault?” over and over.
|
||||||
|
|
||||||
|
!SOMATIC @22:05 #CPTSD
|
||||||
|
Shaking in both arms, vision blurring, and that sharp ice-feeling in my chest. No obvious trigger identified yet.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔢 Fragment Insert Interfaces
|
||||||
|
|
||||||
|
### 📱 Mobile (Planned)
|
||||||
|
|
||||||
|
- Shortcut or PWA access
|
||||||
|
|
||||||
|
- Prompts: Type, Time, Tags, Description
|
||||||
|
|
||||||
|
- Appends to `YYYY-MM-DD.md` securely via NiceGUI interface
|
||||||
|
|
||||||
|
### 💻 CLI / Bash
|
||||||
|
|
||||||
|
```bash
|
||||||
|
jfrag "!TRIGGER" "Person texted me 'you don’t do anything for her'..." "#co-parent #shutdown"
|
||||||
|
```
|
||||||
|
|
||||||
|
Appends to journal or vault.
|
||||||
|
|
||||||
|
### 🤖 AI Session Logging
|
||||||
|
|
||||||
|
Say:
|
||||||
|
|
||||||
|
```
|
||||||
|
!QUOTE "I wish you would just work with me instead of against me."
|
||||||
|
```
|
||||||
|
|
||||||
|
Assistant logs it into today's entry using proper syntax.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Summary: Current & Future Tasks
|
||||||
|
|
||||||
|
### 🔹 Completed
|
||||||
|
|
||||||
|
- Vault encryption and cleanup
|
||||||
|
|
||||||
|
- UI (NiceGUI), cross-platform and Tailscale-accessible
|
||||||
|
|
||||||
|
- CLI tooling (vault, jfrag, search)
|
||||||
|
|
||||||
|
- Metadata and tag system
|
||||||
|
|
||||||
|
- AI summarization and pattern detection
|
||||||
|
|
||||||
|
- Documentation and structured templates
|
||||||
|
|
||||||
|
- Voice-to-text input (desktop & mobile)
|
||||||
|
|
||||||
|
- Calendar view + richer Markdown preview in UI
|
||||||
|
|
||||||
|
- Advanced NLP (sentiment, NER, topic modeling)
|
||||||
|
|
||||||
|
- SQLcypher backend for fast structured search
|
||||||
|
|
||||||
|
- Entry merging logic (into existing sections)
|
||||||
|
|
||||||
|
|
||||||
|
### 🔹 In Progress / Planned
|
||||||
|
|
||||||
|
- Export therapy-ready summaries
|
||||||
|
|
||||||
|
- Weekly/monthly summary generator
|
||||||
|
|
||||||
|
- AI tag suggestion
|
||||||
|
|
||||||
|
- In-memory decrypted vault reading (no full file extraction)
|
||||||
52
pyproject.toml
Normal file
52
pyproject.toml
Normal 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
3
pyrightconfig.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"stubPath": "typings"
|
||||||
|
}
|
||||||
18
requirements_base.txt
Normal file
18
requirements_base.txt
Normal 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
|
||||||
9
requirements_cpu_only.txt
Normal file
9
requirements_cpu_only.txt
Normal 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
10
requirements_gpu.txt
Normal 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
|
||||||
4
requirements_nlp_optional.txt
Normal file
4
requirements_nlp_optional.txt
Normal 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
47
tests/test_ai_backend.py
Normal 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()
|
||||||
55
typings/speech_recognition/__init__.pyi
Normal file
55
typings/speech_recognition/__init__.pyi
Normal 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): ...
|
||||||
24
typings/sqlcipher3/dbapi2.pyi
Normal file
24
typings/sqlcipher3/dbapi2.pyi
Normal 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: ...
|
||||||
Loading…
x
Reference in New Issue
Block a user