"""Journal Day, Draft, Entry and Space product store. Source messages stay in dialogue_store.""" from __future__ import annotations import json import re import uuid from datetime import datetime, timezone from pathlib import Path from dialogue_store import ( StoreError, create_conversation, create_space, get_space, list_conversations_for_day, list_spaces, rename_space, start_usage_session, ) from db import _insert_source_refs, get_db, row_to_dict from journal_policy import consolidation_offer, require_origin from journal_body import clean_title, to_markdown DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") SCRATCH_MAX_ITEMS = 40 SCRATCH_MAX_TEXT = 400 def _now() -> str: return datetime.now(timezone.utc).replace(microsecond=0).isoformat() def _parse_ids(raw: str | None) -> list[str]: if not raw: return [] try: data = json.loads(raw) except json.JSONDecodeError: return [] if not isinstance(data, list): return [] return [str(item) for item in data] def _load_source_ids(conn, table: str, owner_col: str, owner_id: str, row: dict | None) -> tuple[list[str], list[str]]: refs = conn.execute( f""" SELECT source_kind, source_id, sort_order FROM {table} WHERE {owner_col} = ? ORDER BY source_kind, sort_order """, (owner_id,), ).fetchall() conversations: list[str] = [] messages: list[str] = [] for item in refs: kind = item["source_kind"] source_id = item["source_id"] if kind == "conversation": conversations.append(source_id) elif kind == "message": messages.append(source_id) if conversations or messages: return conversations, messages row = row or {} return _parse_ids(row.get("source_conversation_ids")), _parse_ids(row.get("source_message_ids")) def _attach_sources(conn, table: str, owner_col: str, owner_id: str, row: dict | None) -> dict | None: if not row: return None conversations, messages = _load_source_ids(conn, table, owner_col, owner_id, row) row["source_conversation_ids"] = conversations row["source_message_ids"] = messages row["title"] = clean_title(row.get("title") or "") return row def _owned(conn, table: str, record_id: str, profile_id: str) -> dict | None: return row_to_dict( conn.execute( f"SELECT * FROM {table} WHERE id = ? AND profile_id = ?", (record_id, profile_id), ).fetchone() ) def _decode_draft(conn, row: dict | None) -> dict | None: if not row: return None payload = _attach_sources(conn, "journal_draft_source_refs", "draft_id", row["id"], row) raw = payload.get("generation_snapshot") or "{}" try: snapshot = json.loads(raw) if isinstance(raw, str) else (raw or {}) except json.JSONDecodeError: snapshot = {} if not isinstance(snapshot, dict): snapshot = {} payload["generation_snapshot"] = snapshot from journal_generation_policy import snapshot_summary payload["generation_summary"] = snapshot_summary(snapshot) return payload def _decode_version(conn, row: dict | None) -> dict | None: if not row: return None owner_id = row.get("current_version_id") or row.get("id") return _attach_sources(conn, "journal_entry_version_source_refs", "version_id", owner_id, row) def create_user_space(profile_id: str, title: str) -> dict: if not (title or "").strip(): raise StoreError("empty_title", "Space-Name darf nicht leer sein") return create_space(profile_id, title.strip(), visibility="user") def list_user_spaces(profile_id: str) -> list[dict]: return list_spaces(profile_id, visibility="user") def get_user_space(profile_id: str, space_id: str) -> dict: space = get_space(profile_id, space_id) if space.get("visibility") != "user": raise StoreError("not_found", "Reflection Space nicht gefunden", 404) return space def rename_user_space(profile_id: str, space_id: str, title: str) -> dict: get_user_space(profile_id, space_id) return rename_space(profile_id, space_id, title) def get_or_create_day(profile_id: str, space_id: str, calendar_date: str) -> dict: get_user_space(profile_id, space_id) if not DATE_RE.match(calendar_date or ""): raise StoreError("invalid_date", "calendar_date muss YYYY-MM-DD sein") with get_db() as conn: existing = row_to_dict( conn.execute( """ SELECT * FROM journal_days WHERE profile_id = ? AND space_id = ? AND calendar_date = ? """, (profile_id, space_id, calendar_date), ).fetchone() ) if existing: return existing day_id = str(uuid.uuid4()) conn.execute( """ INSERT INTO journal_days (id, profile_id, space_id, calendar_date) VALUES (?, ?, ?, ?) """, (day_id, profile_id, space_id, calendar_date), ) return row_to_dict(conn.execute("SELECT * FROM journal_days WHERE id = ?", (day_id,)).fetchone()) def get_day(profile_id: str, journal_day_id: str) -> dict: with get_db() as conn: day = _owned(conn, "journal_days", journal_day_id, profile_id) if not day: raise StoreError("not_found", "Journal Day nicht gefunden", 404) return day def list_days(profile_id: str, space_id: str) -> list[dict]: get_user_space(profile_id, space_id) with get_db() as conn: rows = conn.execute( """ SELECT d.*, (SELECT COUNT(*) FROM conversations c WHERE c.journal_day_id = d.id AND c.profile_id = d.profile_id) AS conversation_count, (SELECT COUNT(*) FROM journal_entries e WHERE e.journal_day_id = d.id AND e.profile_id = d.profile_id AND e.deleted_at IS NULL ) AS entry_count FROM journal_days d WHERE d.profile_id = ? AND d.space_id = ? ORDER BY d.calendar_date DESC """, (profile_id, space_id), ).fetchall() result = [] for row in rows: item = row_to_dict(row) entry = current_entries(profile_id, item["id"]) item["entry_title"] = (entry[-1].get("title") if entry else "") or "" item["entry_id"] = entry[-1]["id"] if entry else None result.append(item) return result def create_day_conversation(profile_id: str, journal_day_id: str, title: str = "") -> dict: day = get_day(profile_id, journal_day_id) usage = start_usage_session(profile_id, intent="journal") return create_conversation( profile_id, usage_session_id=usage["id"], title=title or "Gespräch", space_id=day["space_id"], journal_day_id=day["id"], ) def current_draft(profile_id: str, journal_day_id: str) -> dict | None: get_day(profile_id, journal_day_id) with get_db() as conn: row = row_to_dict( conn.execute( """ SELECT * FROM journal_drafts WHERE profile_id = ? AND journal_day_id = ? AND superseded_at IS NULL ORDER BY created DESC LIMIT 1 """, (profile_id, journal_day_id), ).fetchone() ) return _decode_draft(conn, row) def insert_draft( profile_id: str, journal_day_id: str, title: str, body: str, source_conversation_ids: list[str], source_message_ids: list[str], generation_snapshot: dict | None = None, ) -> dict: get_day(profile_id, journal_day_id) draft_id = str(uuid.uuid4()) as_of = _now() with get_db() as conn: conn.execute( """ UPDATE journal_drafts SET superseded_at = ? WHERE profile_id = ? AND journal_day_id = ? AND superseded_at IS NULL """, (as_of, profile_id, journal_day_id), ) conn.execute( """ INSERT INTO journal_drafts (id, profile_id, journal_day_id, title, body, source_conversation_ids, source_message_ids, as_of, generation_snapshot) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( draft_id, profile_id, journal_day_id, clean_title(title), body or "", "[]", "[]", as_of, json.dumps(generation_snapshot or {}, ensure_ascii=False), ), ) _insert_source_refs( conn, "journal_draft_source_refs", "draft_id", draft_id, profile_id, source_conversation_ids or [], source_message_ids or [], ) row = row_to_dict(conn.execute("SELECT * FROM journal_drafts WHERE id = ?", (draft_id,)).fetchone()) return _decode_draft(conn, row) def current_entries(profile_id: str, journal_day_id: str) -> list[dict]: with get_db() as conn: rows = conn.execute( """ SELECT e.*, v.title, v.body, v.origin, v.source_conversation_ids, v.source_message_ids, v.created AS version_created FROM journal_entries e LEFT JOIN journal_entry_versions v ON v.id = e.current_version_id WHERE e.profile_id = ? AND e.journal_day_id = ? AND e.deleted_at IS NULL ORDER BY e.created, e.rowid """, (profile_id, journal_day_id), ).fetchall() return [_decode_version(conn, row_to_dict(row)) for row in rows] def get_entry(profile_id: str, entry_id: str, include_deleted: bool = False) -> dict: with get_db() as conn: entry = _owned(conn, "journal_entries", entry_id, profile_id) if not entry: raise StoreError("not_found", "Journal Entry nicht gefunden", 404) if entry.get("deleted_at") and not include_deleted: raise StoreError("not_found", "Journal Entry nicht gefunden", 404) version = None if entry.get("current_version_id"): version = _decode_version( conn, row_to_dict( conn.execute( "SELECT * FROM journal_entry_versions WHERE id = ? AND profile_id = ?", (entry["current_version_id"], profile_id), ).fetchone() ), ) entry["version"] = version if version: entry["title"] = version.get("title") or "" entry["body"] = version.get("body") or "" entry["origin"] = version.get("origin") entry["source_conversation_ids"] = version.get("source_conversation_ids") or [] entry["source_message_ids"] = version.get("source_message_ids") or [] return entry def list_versions(profile_id: str, entry_id: str) -> list[dict]: get_entry(profile_id, entry_id, include_deleted=True) with get_db() as conn: rows = conn.execute( """ SELECT * FROM journal_entry_versions WHERE profile_id = ? AND entry_id = ? ORDER BY created """, (profile_id, entry_id), ).fetchall() return [_decode_version(conn, row_to_dict(row)) for row in rows] def save_entry( profile_id: str, journal_day_id: str, title: str, body: str, origin: str, entry_id: str | None = None, source_conversation_ids: list[str] | None = None, source_message_ids: list[str] | None = None, ) -> dict: require_origin(origin) day = get_day(profile_id, journal_day_id) version_id = str(uuid.uuid4()) with get_db() as conn: if entry_id: entry = _owned(conn, "journal_entries", entry_id, profile_id) if not entry or entry.get("deleted_at"): raise StoreError("not_found", "Journal Entry nicht gefunden", 404) if entry["journal_day_id"] != journal_day_id: raise StoreError("assignment_mismatch", "Entry gehört zu einem anderen Journal Day") else: entry_id = str(uuid.uuid4()) conn.execute( """ INSERT INTO journal_entries (id, profile_id, journal_day_id, space_id) VALUES (?, ?, ?, ?) """, (entry_id, profile_id, journal_day_id, day["space_id"]), ) conn.execute( """ INSERT INTO journal_entry_versions (id, profile_id, entry_id, title, body, source_conversation_ids, source_message_ids, origin, created) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( version_id, profile_id, entry_id, clean_title(title), to_markdown(body or ""), "[]", "[]", origin, _now(), ), ) _insert_source_refs( conn, "journal_entry_version_source_refs", "version_id", version_id, profile_id, source_conversation_ids or [], source_message_ids or [], ) conn.execute( """ UPDATE journal_entries SET current_version_id = ?, updated = datetime('now'), deleted_at = NULL WHERE id = ? AND profile_id = ? """, (version_id, entry_id, profile_id), ) return get_entry(profile_id, entry_id) def restore_entry(profile_id: str, entry_id: str, version_id: str) -> dict: entry = get_entry(profile_id, entry_id) with get_db() as conn: version = row_to_dict( conn.execute( """ SELECT * FROM journal_entry_versions WHERE id = ? AND entry_id = ? AND profile_id = ? """, (version_id, entry_id, profile_id), ).fetchone() ) if not version: raise StoreError("not_found", "Version nicht gefunden", 404) decoded = _decode_version(conn, version) return save_entry( profile_id, entry["journal_day_id"], decoded.get("title") or "", decoded.get("body") or "", origin="restore", entry_id=entry_id, source_conversation_ids=decoded.get("source_conversation_ids") or [], source_message_ids=decoded.get("source_message_ids") or [], ) def soft_delete_entry(profile_id: str, entry_id: str) -> dict: get_entry(profile_id, entry_id) with get_db() as conn: conn.execute( "UPDATE journal_entries SET deleted_at = ?, updated = datetime('now') WHERE id = ? AND profile_id = ?", (_now(), entry_id, profile_id), ) return {"id": entry_id, "deleted": True} def _parse_scratch(raw) -> list[dict]: if isinstance(raw, list): data = raw else: try: data = json.loads(raw or "[]") except json.JSONDecodeError: data = [] if not isinstance(data, list): return [] items: list[dict] = [] for item in data[:SCRATCH_MAX_ITEMS]: if not isinstance(item, dict): continue text = str(item.get("text") or "")[:SCRATCH_MAX_TEXT] items.append( { "id": str(item.get("id") or uuid.uuid4()), "text": text, "done": bool(item.get("done")), } ) return items def save_day_scratch(profile_id: str, journal_day_id: str, items: list[dict]) -> list[dict]: get_day(profile_id, journal_day_id) cleaned = _parse_scratch(items) with get_db() as conn: conn.execute( "UPDATE journal_days SET scratch_json = ?, updated = datetime('now') WHERE id = ? AND profile_id = ?", (json.dumps(cleaned, ensure_ascii=False), journal_day_id, profile_id), ) return cleaned def day_payload(profile_id: str, journal_day_id: str) -> dict: day = get_day(profile_id, journal_day_id) space = get_user_space(profile_id, day["space_id"]) conversations = list_conversations_for_day(profile_id, journal_day_id) return { "day": {key: value for key, value in day.items() if key != "scratch_json"}, "space": space, "conversations": conversations, "current_draft": current_draft(profile_id, journal_day_id), "entries": current_entries(profile_id, journal_day_id), "scratch": _parse_scratch(day.get("scratch_json")), "consolidation_offer": consolidation_offer(conversations), } def _entry_list_row(conn, row) -> dict: item = row_to_dict(row) item["title"] = clean_title(item.get("title") or "") item["version_count"] = int(item.get("version_count") or 0) return item def list_space_entries(profile_id: str, space_id: str) -> list[dict]: """Chronological journal entries of a space. Deleted entries are excluded.""" get_user_space(profile_id, space_id) with get_db() as conn: rows = conn.execute( """ SELECT e.id, e.profile_id, e.space_id, e.journal_day_id, e.current_version_id, e.created, e.updated, e.deleted_at, d.calendar_date, v.title, v.origin, (SELECT COUNT(*) FROM journal_entry_versions ver WHERE ver.entry_id = e.id AND ver.profile_id = e.profile_id) AS version_count, (SELECT r.source_id FROM journal_entry_version_source_refs r WHERE r.version_id = e.current_version_id AND r.source_kind = 'conversation' ORDER BY r.sort_order LIMIT 1) AS source_conversation_id FROM journal_entries e JOIN journal_days d ON d.id = e.journal_day_id LEFT JOIN journal_entry_versions v ON v.id = e.current_version_id WHERE e.profile_id = ? AND e.space_id = ? AND e.deleted_at IS NULL ORDER BY d.calendar_date DESC, e.created ASC, e.rowid ASC """, (profile_id, space_id), ).fetchall() return [_entry_list_row(conn, row) for row in rows] def list_deleted_entries(profile_id: str, space_id: str | None = None) -> list[dict]: if space_id: get_user_space(profile_id, space_id) params: list = [profile_id] space_sql = "" if space_id: space_sql = "AND e.space_id = ?" params.append(space_id) with get_db() as conn: rows = conn.execute( f""" SELECT e.id, e.profile_id, e.space_id, e.journal_day_id, e.current_version_id, e.created, e.updated, e.deleted_at, d.calendar_date, v.title, v.origin, (SELECT COUNT(*) FROM journal_entry_versions ver WHERE ver.entry_id = e.id AND ver.profile_id = e.profile_id) AS version_count, (SELECT r.source_id FROM journal_entry_version_source_refs r WHERE r.version_id = e.current_version_id AND r.source_kind = 'conversation' ORDER BY r.sort_order LIMIT 1) AS source_conversation_id FROM journal_entries e JOIN journal_days d ON d.id = e.journal_day_id LEFT JOIN journal_entry_versions v ON v.id = e.current_version_id WHERE e.profile_id = ? {space_sql} AND e.deleted_at IS NOT NULL ORDER BY e.deleted_at DESC, e.id ASC """, params, ).fetchall() return [_entry_list_row(conn, row) for row in rows] def undelete_entry(profile_id: str, entry_id: str) -> dict: """Return a soft-deleted entry to the space list. Not a version restore.""" entry = get_entry(profile_id, entry_id, include_deleted=True) if not entry.get("deleted_at"): raise StoreError("not_deleted", "Eintrag ist nicht im Papierkorb.") with get_db() as conn: conn.execute( """ UPDATE journal_entries SET deleted_at = NULL, updated = datetime('now') WHERE id = ? AND profile_id = ? """, (entry_id, profile_id), ) return get_entry(profile_id, entry_id) def continuable_journal_day(profile_id: str) -> dict | None: """Most recently active journal day that already has a stored message.""" with get_db() as conn: row = row_to_dict( conn.execute( """ SELECT d.id AS journal_day_id, d.space_id, d.calendar_date, s.title AS space_title, c.id AS conversation_id, m.created AS last_message_at FROM messages m JOIN conversations c ON c.id = m.conversation_id AND c.profile_id = m.profile_id JOIN journal_days d ON d.id = c.journal_day_id AND d.profile_id = m.profile_id JOIN spaces s ON s.id = d.space_id AND s.profile_id = m.profile_id WHERE m.profile_id = ? AND s.visibility = 'user' ORDER BY m.created DESC, m.seq DESC, m.id DESC LIMIT 1 """, (profile_id,), ).fetchone() ) if not row: return None return { "space_id": row["space_id"], "space_title": row.get("space_title") or "", "journal_day_id": row["journal_day_id"], "calendar_date": row["calendar_date"], "conversation_id": row["conversation_id"], "last_message_at": row.get("last_message_at"), "reason": "recent_dialogue", } def start_payload(profile_id: str) -> dict: spaces = list_user_spaces(profile_id) return { "continuable": continuable_journal_day(profile_id), "has_spaces": bool(spaces), "space_count": len(spaces), "spaces": [{"id": item["id"], "title": item.get("title") or ""} for item in spaces], } def _bodies_referencing_media(conn, profile_id: str, media_id: str, exclude_entry_id: str) -> list[str]: from journal_body import media_ids rows = conn.execute( """ SELECT v.entry_id, v.body FROM journal_entry_versions v WHERE v.profile_id = ? AND v.entry_id != ? """, (profile_id, exclude_entry_id), ).fetchall() owners: list[str] = [] seen: set[str] = set() for row in rows: if media_id in media_ids(row["body"] or "") and row["entry_id"] not in seen: seen.add(row["entry_id"]) owners.append(row["entry_id"]) return owners def inventory_entry_for_purge(profile_id: str, entry_id: str) -> dict: entry = get_entry(profile_id, entry_id, include_deleted=True) with get_db() as conn: versions = [ row_to_dict(row) for row in conn.execute( "SELECT id, origin, created FROM journal_entry_versions WHERE profile_id = ? AND entry_id = ? ORDER BY created", (profile_id, entry_id), ).fetchall() ] version_ids = [item["id"] for item in versions] source_refs = 0 if version_ids: placeholders = ",".join("?" * len(version_ids)) source_refs = conn.execute( f""" SELECT COUNT(*) AS n FROM journal_entry_version_source_refs WHERE version_id IN ({placeholders}) AND profile_id = ? """, (*version_ids, profile_id), ).fetchone()["n"] writing_sources = [ row_to_dict(row) for row in conn.execute( "SELECT id, kind FROM writing_profile_sources WHERE profile_id = ? AND entry_id = ?", (profile_id, entry_id), ).fetchall() ] evidence = [ row_to_dict(row) for row in conn.execute( "SELECT id FROM writing_profile_evidence WHERE profile_id = ? AND source_id = ?", (profile_id, entry_id), ).fetchall() ] media_rows = [ row_to_dict(row) for row in conn.execute( "SELECT * FROM media_assets WHERE profile_id = ? AND entry_id = ?", (profile_id, entry_id), ).fetchall() ] media = [] from media_store import media_root root = media_root() for row in media_rows: owners = _bodies_referencing_media(conn, profile_id, row["id"], entry_id) path = root / row["rel_path"] media.append( { "id": row["id"], "rel_path": row["rel_path"], "path_exists": path.is_file(), "referenced_by_entries": owners, "keep_file": bool(owners), "reassign_to": owners[0] if owners else None, } ) return { "entry": {"id": entry["id"], "space_id": entry["space_id"], "deleted_at": entry.get("deleted_at")}, "versions": versions, "source_ref_count": int(source_refs or 0), "writing_profile_sources": writing_sources, "writing_profile_evidence": evidence, "media": media, "conversations_untouched": True, } def purge_entry(profile_id: str, entry_id: str, *, confirm: bool) -> dict: """Irreversible delete after explicit confirm. Source dialogues stay.""" if not confirm: raise StoreError("confirm_required", "Endgültiges Löschen braucht eine ausdrückliche Bestätigung.") entry = get_entry(profile_id, entry_id, include_deleted=True) if not entry.get("deleted_at"): raise StoreError("not_in_trash", "Endgültiges Löschen nur aus dem Papierkorb.") inventory = inventory_entry_for_purge(profile_id, entry_id) from media_store import media_root root = media_root() files_to_delete: list[Path] = [] try: with get_db() as conn: for item in inventory["media"]: if item.get("keep_file") and item.get("reassign_to"): other = row_to_dict( conn.execute( "SELECT id FROM journal_entries WHERE id = ? AND profile_id = ?", (item["reassign_to"], profile_id), ).fetchone() ) if not other: raise StoreError("purge_conflict", "Geteiltes Medium hat keinen sicheren neuen Träger.") conn.execute( "UPDATE media_assets SET entry_id = ? WHERE id = ? AND profile_id = ?", (item["reassign_to"], item["id"], profile_id), ) elif not item.get("keep_file"): files_to_delete.append(root / item["rel_path"]) conn.execute( "DELETE FROM writing_profile_sources WHERE profile_id = ? AND entry_id = ?", (profile_id, entry_id), ) conn.execute( "DELETE FROM writing_profile_evidence WHERE profile_id = ? AND source_id = ?", (profile_id, entry_id), ) conn.execute( "DELETE FROM journal_entries WHERE id = ? AND profile_id = ?", (entry_id, profile_id), ) leftover = conn.execute( "SELECT id FROM journal_entries WHERE id = ? AND profile_id = ?", (entry_id, profile_id), ).fetchone() if leftover: raise StoreError("purge_failed", "Eintrag konnte nicht vollständig gelöscht werden.", 500) leftover_media = conn.execute( "SELECT id FROM media_assets WHERE profile_id = ? AND entry_id = ?", (profile_id, entry_id), ).fetchone() if leftover_media: raise StoreError("purge_failed", "Medienverweise konnten nicht aufgelöst werden.", 500) leftover_sources = conn.execute( "SELECT id FROM writing_profile_sources WHERE profile_id = ? AND entry_id = ?", (profile_id, entry_id), ).fetchone() if leftover_sources: raise StoreError("purge_failed", "Writing-Profile-Quellen konnten nicht gelöscht werden.", 500) except StoreError: raise except Exception as exc: raise StoreError("purge_failed", "Endgültiges Löschen wurde abgebrochen, nichts wurde entfernt.", 500) from exc removed_files: list[str] = [] missing_files: list[str] = [] for path in files_to_delete: if not path.is_file(): missing_files.append(str(path)) continue try: path.unlink() removed_files.append(str(path)) except OSError as exc: raise StoreError( "purge_failed", "Datenbank entfernt, Mediendatei konnte nicht gelöscht werden. Vorgang fail-closed gemeldet.", 500, ) from exc return { "id": entry_id, "purged": True, "inventory": inventory, "removed_files": len(removed_files), "missing_files": missing_files, "conversations_untouched": True, }