777 lines
28 KiB
Python
777 lines
28 KiB
Python
"""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
|
|
return _attach_sources(conn, "journal_draft_source_refs", "draft_id", row["id"], row)
|
|
|
|
|
|
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],
|
|
) -> 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)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
draft_id,
|
|
profile_id,
|
|
journal_day_id,
|
|
clean_title(title),
|
|
body or "",
|
|
"[]",
|
|
"[]",
|
|
as_of,
|
|
),
|
|
)
|
|
_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,
|
|
}
|