"""SQLite-Datenbank (data/p42.db): Schema, Verbindungen und Übernahme der alten JSON-Daten.""" import html import json import re import sqlite3 from contextlib import contextmanager from datetime import datetime, timezone from .config import DATA_DIR DB_FILE = DATA_DIR / "p42.db" # Vorrat und Einkauf; key = Name in casefold() für den Vergleich. # Jeder Eintrag gehört zu einer Liste (shopping_lists, z. B. "Zuhause", "Urlaub") mit eigenem Vorrat und Einkauf. # Pro Liste, Produkt, Bereich und Einheit eine Zeile ("Mehl 1 kg" und "Mehl 2 Stück" dürfen nebeneinander stehen). HOUSEHOLD_TABLE = """( id INTEGER PRIMARY KEY, key TEXT NOT NULL, name TEXT NOT NULL, list TEXT NOT NULL CHECK (list IN ('vorrat', 'einkauf')), list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE, quantity REAL NOT NULL CHECK (quantity > 0), unit TEXT NOT NULL DEFAULT 'stk' CHECK (unit IN ('stk', 'kg', 'l')), checked INTEGER NOT NULL DEFAULT 0, -- Einkauf: im Wagen, wandert erst mit "Einkauf beenden" in den Vorrat updated_at TEXT NOT NULL, updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL, UNIQUE (list_id, key, list, unit) )""" # Kanban-Projekte (je ein Board), gemeinsam für alle Konten KANBAN_PROJECTS_TABLE = """( id INTEGER PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', position REAL NOT NULL, completed_at TEXT, created_at TEXT NOT NULL, created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, updated_at TEXT NOT NULL, updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL )""" # Aufgaben eines Kanban-Projekts; position sortiert innerhalb einer Spalte KANBAN_TASKS_TABLE = """( id INTEGER PRIMARY KEY, project_id INTEGER NOT NULL REFERENCES kanban_projects(id) ON DELETE CASCADE, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL CHECK (status IN ('offen', 'arbeit', 'wartet', 'erledigt')), assignee INTEGER REFERENCES users(id) ON DELETE SET NULL, priority INTEGER NOT NULL DEFAULT 2 CHECK (priority IN (1, 2, 3)), -- 1 hoch, 2 mittel, 3 niedrig position REAL NOT NULL, created_at TEXT NOT NULL, created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, updated_at TEXT NOT NULL, updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL )""" SCHEMA = f""" CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY, username TEXT NOT NULL UNIQUE COLLATE NOCASE, display_name TEXT NOT NULL, password_hash TEXT NOT NULL, is_admin INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS sessions ( token_hash TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at REAL NOT NULL ); CREATE TABLE IF NOT EXISTS user_links ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, key TEXT NOT NULL, url TEXT NOT NULL, PRIMARY KEY (user_id, key) ); CREATE TABLE IF NOT EXISTS projects ( id TEXT PRIMARY KEY, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL, cover TEXT, content TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, updated_at TEXT NOT NULL, updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS monitors ( id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, type TEXT NOT NULL, target TEXT NOT NULL, token TEXT, -- nur Typ "agent" (P42-S.M.); wird nie an den Browser gegeben created_at TEXT NOT NULL ); -- Verlauf der Server-Auslastung (Typ "agent"): ein Mittelwert pro Minute, 2 Tage lang CREATE TABLE IF NOT EXISTS monitor_samples ( monitor_id INTEGER NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, ts INTEGER NOT NULL, -- Unix-Zeit, auf die Minute gerundet cpu REAL, mem REAL, swap REAL, load1 REAL, rx REAL, tx REAL, rd REAL, wr REAL, temp REAL, PRIMARY KEY (monitor_id, ts) ) WITHOUT ROWID; -- Listen mit je eigenem Vorrat und Einkauf (z. B. "Zuhause", "Urlaub"); -- everyone = 1: alle Konten sehen sie, sonst nur Ersteller und Mitglieder CREATE TABLE IF NOT EXISTS shopping_lists ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, everyone INTEGER NOT NULL DEFAULT 1, position REAL NOT NULL, created_at TEXT NOT NULL, created_by INTEGER REFERENCES users(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS shopping_list_members ( list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, PRIMARY KEY (list_id, user_id) ); CREATE TABLE IF NOT EXISTS household_items {HOUSEHOLD_TABLE}; -- Produktkatalog für die Vorschläge CREATE TABLE IF NOT EXISTS products ( key TEXT PRIMARY KEY, name TEXT NOT NULL, uses INTEGER NOT NULL DEFAULT 0, last_used TEXT NOT NULL ); -- Gelernte Zuordnungen Rezeptzutat → Produkt (z. B. "margarine" → "Butter") CREATE TABLE IF NOT EXISTS ingredient_aliases ( key TEXT PRIMARY KEY, product TEXT NOT NULL ); -- Reise-Karte pro Konto: besuchte Städte (GeoNames-ID) und von Hand markierte Länder (ISO-Code) CREATE TABLE IF NOT EXISTS travel_cities ( id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, geoname_id INTEGER NOT NULL, name TEXT NOT NULL, admin1 TEXT NOT NULL DEFAULT '', country TEXT NOT NULL, lat REAL NOT NULL, lon REAL NOT NULL, population INTEGER, elevation REAL, timezone TEXT, created_at TEXT NOT NULL, UNIQUE (user_id, geoname_id) ); CREATE TABLE IF NOT EXISTS travel_countries ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, code TEXT NOT NULL, PRIMARY KEY (user_id, code) ); -- Zwischenspeicher für Infos von Wikipedia/Wikidata CREATE TABLE IF NOT EXISTS info_cache ( key TEXT PRIMARY KEY, value TEXT NOT NULL, fetched_at REAL NOT NULL ); -- Kanban: Projekte (je ein Board) → Aufgaben; gemeinsam für alle Konten CREATE TABLE IF NOT EXISTS kanban_projects {KANBAN_PROJECTS_TABLE}; -- Verknüpfung Kanban-Projekt ↔ Artikel aus "P42 Projekte" CREATE TABLE IF NOT EXISTS kanban_project_links ( project_id INTEGER NOT NULL REFERENCES kanban_projects(id) ON DELETE CASCADE, article_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, PRIMARY KEY (project_id, article_id) ); CREATE TABLE IF NOT EXISTS kanban_tasks {KANBAN_TASKS_TABLE}; CREATE TABLE IF NOT EXISTS meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); """ def now_iso() -> str: return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") def connect() -> sqlite3.Connection: conn = sqlite3.connect(DB_FILE, timeout=10) conn.row_factory = sqlite3.Row conn.execute("PRAGMA foreign_keys = ON") return conn @contextmanager def transaction(): """Verbindung für einen Arbeitsschritt; bei Erfolg wird gespeichert, bei Fehlern zurückgerollt.""" conn = connect() try: with conn: yield conn finally: conn.close() def get_meta(conn, key: str): row = conn.execute("SELECT value FROM meta WHERE key = ?", (key,)).fetchone() return row["value"] if row else None # ---------- Übernahme der alten JSON-Dateien ---------- def _posts_to_html(posts: list) -> str: """Ganz alte Daten mit Update-Feed in Artikeltext umwandeln.""" parts = [] for post in reversed(posts): date = datetime.fromisoformat(post["createdAt"].replace("Z", "+00:00")).strftime("%d.%m.%Y") parts.append(f"

{date}

") for para in filter(None, re.split(r"\n{2,}", post.get("text") or "")): parts.append(f"

{html.escape(para).replace(chr(10), '
')}

") parts += [f'

' for img in post.get("images", [])] return "".join(parts) def _import_legacy(conn) -> None: projects_json = DATA_DIR / "projects.json" if projects_json.exists(): for p in json.loads(projects_json.read_text(encoding="utf-8")).get("projects", []): conn.execute( """INSERT OR IGNORE INTO projects (id, title, description, status, cover, content, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", ( p["id"], p["title"], p.get("description", ""), p.get("status", "aktiv"), p.get("cover"), p.get("content") or _posts_to_html(p.get("posts", [])), p["createdAt"], p["updatedAt"], ), ) projects_json.rename(projects_json.with_name("projects.json.importiert")) # Links aus der Zeit vor den Konten bekommt das erste Konto links_json = DATA_DIR / "links.json" if links_json.exists(): conn.execute("INSERT OR REPLACE INTO meta VALUES ('legacy_links', ?)", (links_json.read_text(encoding="utf-8"),)) links_json.rename(links_json.with_name("links.json.importiert")) def _migrate(conn) -> None: """Vorrat/Einkauf aus früheren Versionen (ohne Einheiten, ohne Listen oder Vorrat ohne Liste) neu aufbauen. Alles, was noch keiner Liste gehört, kommt in die erste Liste bzw. in eine neue Liste "Zuhause", die alle Konten sehen. """ info = {r["name"]: r for r in conn.execute("PRAGMA table_info(household_items)")} if "list_id" in info and info["list_id"]["notnull"]: return row = conn.execute("SELECT id FROM shopping_lists ORDER BY position, id LIMIT 1").fetchone() if row: default = row["id"] # Die Zwischenversion nannte die gemeinsame Liste "Einkaufsliste"; jetzt enthält sie auch den Vorrat conn.execute("UPDATE shopping_lists SET name = 'Zuhause' WHERE id = ? AND name = 'Einkaufsliste' AND created_by IS NULL", (default,)) else: default = conn.execute( "INSERT INTO shopping_lists (name, everyone, position, created_at) VALUES ('Zuhause', 1, 1, ?)", (now_iso(),) ).lastrowid unit = "unit" if "unit" in info else "'stk'" list_id = "COALESCE(list_id, ?)" if "list_id" in info else "?" conn.execute("DROP INDEX IF EXISTS household_vorrat") conn.execute("DROP INDEX IF EXISTS household_einkauf") conn.execute("ALTER TABLE household_items RENAME TO household_items_alt") conn.execute("CREATE TABLE household_items " + HOUSEHOLD_TABLE) conn.execute( f"""INSERT INTO household_items (id, key, name, list, list_id, quantity, unit, updated_at, updated_by) SELECT id, key, name, list, {list_id}, quantity, {unit}, updated_at, updated_by FROM household_items_alt""", (default,), ) conn.execute("DROP TABLE household_items_alt") def _migrate_checked(conn) -> None: # Einkauf ohne Haken-Spalte: ergänzen, bestehende Einträge sind nicht abgehakt columns = {r["name"] for r in conn.execute("PRAGMA table_info(household_items)")} if "checked" not in columns: conn.execute("ALTER TABLE household_items ADD COLUMN checked INTEGER NOT NULL DEFAULT 0") def _migrate_travel(conn) -> None: # Reise-Städte aus einer Vorversion ohne Höhe/Zeitzone: Spalten ergänzen columns = {r["name"] for r in conn.execute("PRAGMA table_info(travel_cities)")} for column, kind in (("elevation", "REAL"), ("timezone", "TEXT")): if column not in columns: conn.execute(f"ALTER TABLE travel_cities ADD COLUMN {column} {kind}") def _migrate_kanban_categories(conn) -> None: """Kategorien wieder entfernt: kanban_projects ohne category_id neu aufbauen. Aufgaben und Artikel-Links verweisen per ON DELETE CASCADE auf kanban_projects. Ein normales DROP würde sie mitlöschen, deshalb das SQLite-Verfahren: Fremdschlüssel aus, neue Tabelle anlegen, kopieren, alte löschen, umbenennen, Verweise prüfen. """ columns = {r["name"] for r in conn.execute("PRAGMA table_info(kanban_projects)")} if "category_id" not in columns: conn.execute("DROP TABLE IF EXISTS kanban_categories") return conn.commit() conn.execute("PRAGMA foreign_keys = OFF") # wirkt nur außerhalb einer Transaktion try: conn.execute("BEGIN") conn.execute("CREATE TABLE kanban_projects_neu " + KANBAN_PROJECTS_TABLE) conn.execute( """INSERT INTO kanban_projects_neu (id, name, description, position, created_at, created_by, updated_at, updated_by) SELECT id, name, description, position, created_at, created_by, updated_at, updated_by FROM kanban_projects""" ) conn.execute("DROP TABLE kanban_projects") conn.execute("ALTER TABLE kanban_projects_neu RENAME TO kanban_projects") conn.execute("DROP TABLE kanban_categories") if conn.execute("PRAGMA foreign_key_check").fetchall(): raise RuntimeError("Kanban-Migration: ungültige Verweise, Änderungen werden zurückgenommen.") conn.commit() except Exception: conn.rollback() raise finally: conn.execute("PRAGMA foreign_keys = ON") def _migrate_kanban_priority(conn) -> None: # Aufgaben ohne Priorität: Spalte ergänzen, bestehende Aufgaben werden "mittel" columns = {r["name"] for r in conn.execute("PRAGMA table_info(kanban_tasks)")} if "priority" not in columns: conn.execute( "ALTER TABLE kanban_tasks ADD COLUMN priority INTEGER NOT NULL DEFAULT 2 CHECK (priority IN (1, 2, 3))" ) def _migrate_monitor_token(conn) -> None: # Monitore aus der Zeit vor P42-S.M.: Spalte für den Token ergänzen columns = {r["name"] for r in conn.execute("PRAGMA table_info(monitors)")} if "token" not in columns: conn.execute("ALTER TABLE monitors ADD COLUMN token TEXT") def _migrate_kanban_completed(conn) -> None: # Projekte "abgeschlossen": Spalte ergänzen, falls sie fehlt columns = {r["name"] for r in conn.execute("PRAGMA table_info(kanban_projects)")} if "completed_at" not in columns: conn.execute("ALTER TABLE kanban_projects ADD COLUMN completed_at TEXT") def _migrate_kanban(conn) -> None: # Aufgaben aus der Zeit vor den Projekten: in "Allgemeines Board" übernehmen columns = {r["name"] for r in conn.execute("PRAGMA table_info(kanban_tasks)")} if "project_id" in columns: return conn.execute("ALTER TABLE kanban_tasks RENAME TO kanban_tasks_alt") conn.execute("CREATE TABLE kanban_tasks " + KANBAN_TASKS_TABLE) if conn.execute("SELECT COUNT(*) FROM kanban_tasks_alt").fetchone()[0]: now = now_iso() project = conn.execute( "INSERT INTO kanban_projects (name, position, created_at, updated_at) VALUES ('Allgemeines Board', 1, ?, ?)", (now, now), ).lastrowid conn.execute( """INSERT INTO kanban_tasks (id, project_id, title, description, status, assignee, position, created_at, created_by, updated_at, updated_by) SELECT id, ?, title, description, status, assignee, position, created_at, created_by, updated_at, updated_by FROM kanban_tasks_alt""", (project,), ) conn.execute("DROP TABLE kanban_tasks_alt") def init() -> None: DATA_DIR.mkdir(parents=True, exist_ok=True) with transaction() as conn: conn.execute("PRAGMA journal_mode = WAL") conn.executescript(SCHEMA) _migrate(conn) _migrate_checked(conn) _migrate_travel(conn) _migrate_monitor_token(conn) _migrate_kanban_categories(conn) _migrate_kanban_completed(conn) _migrate_kanban(conn) _migrate_kanban_priority(conn) _import_legacy(conn) init()