diff --git a/app/backup.py b/app/backup.py new file mode 100644 index 0000000..e33d920 --- /dev/null +++ b/app/backup.py @@ -0,0 +1,105 @@ +"""CSV-/SQL-Export und SQL-Import der eigenen Links (Backup & Restore).""" + +import csv +import io +import sqlite3 + +from .models import Link + +SQL_COLUMNS = [ + "url", "title", "summary", "category", "manufacturer", + "tags", "status", "needs_review", "created_at", +] + + +def to_csv(links: list[Link]) -> str: + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow( + ["url", "title", "summary", "category", "manufacturer", "tags", "status", "created_at"] + ) + for link in links: + writer.writerow( + [ + link.url, + link.title, + link.summary, + link.category, + link.manufacturer, + ", ".join(link.tag_list), + link.status, + link.created_at.isoformat(), + ] + ) + return buf.getvalue() + + +def _sql_quote(value: str) -> str: + return "'" + value.replace("'", "''") + "'" + + +def to_sql_dump(links: list[Link]) -> str: + lines = [ + "-- LinkVault-Export – Links des angemeldeten Kontos.", + "-- Kann über 'Einstellungen -> SQL-Import' wieder eingespielt werden.", + f"-- Zeilen: {len(links)}", + "", + ] + for link in links: + values = ", ".join( + [ + _sql_quote(link.url), + _sql_quote(link.title), + _sql_quote(link.summary), + _sql_quote(link.category), + _sql_quote(link.manufacturer), + _sql_quote(link.tags), + _sql_quote(link.status), + "1" if link.needs_review else "0", + _sql_quote(link.created_at.isoformat()), + ] + ) + lines.append(f"INSERT INTO links ({', '.join(SQL_COLUMNS)}) VALUES ({values});") + return "\n".join(lines) + "\n" + + +def parse_sql_dump(sql_text: str, max_rows: int = 5000) -> list[dict]: + """Liest die von to_sql_dump() erzeugten INSERT-Zeilen sicher ein. + + Läuft bewusst gegen eine isolierte In-Memory-SQLite-Datenbank statt gegen + die echte App-Datenbank: nur Zeilen, die (nach Trimmen) mit + 'insert into links' beginnen, werden ausgeführt, und sqlite3.execute() + lässt ohnehin nur je eine einzelne Anweisung zu. Dadurch können weder + andere Tabellen berührt noch mehrere Anweisungen aneinandergehängt werden. + Das Ergebnis wird anschließend vom Aufrufer kontrolliert per ORM in die + echte Datenbank übernommen (mit fest gesetzter user_id). + """ + conn = sqlite3.connect(":memory:") + try: + conn.execute( + "CREATE TABLE links (" + "url TEXT, title TEXT, summary TEXT, category TEXT, manufacturer TEXT, " + "tags TEXT, status TEXT, needs_review INTEGER, created_at TEXT)" + ) + statements = 0 + for raw_line in sql_text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("--"): + continue + if not line.lower().startswith("insert into links"): + continue + if statements >= max_rows: + raise ValueError(f"Zu viele Zeilen (Limit: {max_rows}).") + try: + conn.execute(line) + except sqlite3.Error as e: + raise ValueError(f"Ungültige Zeile: {e}") from e + statements += 1 + + if statements == 0: + raise ValueError("Keine gültigen INSERT-Zeilen für 'links' gefunden.") + + cur = conn.execute(f"SELECT {', '.join(SQL_COLUMNS)} FROM links") + return [dict(zip(SQL_COLUMNS, row)) for row in cur.fetchall()] + finally: + conn.close() diff --git a/app/main.py b/app/main.py index b643e77..3769e52 100644 --- a/app/main.py +++ b/app/main.py @@ -1,18 +1,18 @@ import json from datetime import datetime, timezone from pathlib import Path -from urllib.parse import urlparse +from urllib.parse import quote, urlparse -from fastapi import Depends, FastAPI, Form, Request +from fastapi import Depends, FastAPI, File, Form, Request, UploadFile from fastapi.responses import HTMLResponse, RedirectResponse, Response from fastapi.templating import Jinja2Templates from sqlalchemy import func, select, text from sqlalchemy.orm import Session from starlette.middleware.sessions import SessionMiddleware -from . import ai, config, scraper, search +from . import ai, backup, config, scraper, search from .database import Base, engine, get_db -from .models import Link, User +from .models import Link, User, utcnow from .security import hash_password, verify_password Base.metadata.create_all(bind=engine) @@ -529,3 +529,126 @@ def reanalyze_link( return templates.TemplateResponse( "_link_card_edit.html", _edit_context(request, db, user, link) ) + + +# --------------------------------------------------------------------------- +# Einstellungen: Export / Import +# --------------------------------------------------------------------------- +MAX_IMPORT_BYTES = 2 * 1024 * 1024 + + +@app.get("/settings", response_class=HTMLResponse) +def settings_page(request: Request, db: Session = Depends(get_db)): + user = current_user(request, db) + if not user: + return RedirectResponse("/login", status_code=303) + link_count = db.scalar( + select(func.count()).select_from(Link).where(Link.user_id == user.id) + ) + return templates.TemplateResponse( + "settings.html", + { + "request": request, + "user": user, + "link_count": link_count or 0, + "imported": request.query_params.get("imported"), + "import_error": request.query_params.get("import_error"), + }, + ) + + +@app.get("/settings/export.csv") +def export_csv(request: Request, db: Session = Depends(get_db)): + user = current_user(request, db) + if not user: + return RedirectResponse("/login", status_code=303) + links = list( + db.scalars( + select(Link).where(Link.user_id == user.id).order_by(Link.created_at) + ).all() + ) + return Response( + backup.to_csv(links), + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": "attachment; filename=linkvault-links.csv"}, + ) + + +@app.get("/settings/export.sql") +def export_sql(request: Request, db: Session = Depends(get_db)): + user = current_user(request, db) + if not user: + return RedirectResponse("/login", status_code=303) + links = list( + db.scalars( + select(Link).where(Link.user_id == user.id).order_by(Link.created_at) + ).all() + ) + return Response( + backup.to_sql_dump(links), + media_type="application/sql; charset=utf-8", + headers={"Content-Disposition": "attachment; filename=linkvault-links.sql"}, + ) + + +@app.post("/settings/import-sql") +async def import_sql( + request: Request, + file: UploadFile = File(...), + db: Session = Depends(get_db), +): + user = current_user(request, db) + if not user: + return RedirectResponse("/login", status_code=303) + + raw = await file.read(MAX_IMPORT_BYTES + 1) + if len(raw) > MAX_IMPORT_BYTES: + return RedirectResponse( + f"/settings?import_error={quote('Datei ist größer als 2 MB.')}", + status_code=303, + ) + try: + sql_text = raw.decode("utf-8") + except UnicodeDecodeError: + return RedirectResponse( + f"/settings?import_error={quote('Datei ist keine gültige UTF-8-Textdatei.')}", + status_code=303, + ) + + try: + rows = backup.parse_sql_dump(sql_text) + except ValueError as e: + return RedirectResponse(f"/settings?import_error={quote(str(e))}", status_code=303) + + imported = 0 + for row in rows: + url = (row.get("url") or "").strip()[:2000] + if not url: + continue + created_at = utcnow() + if row.get("created_at"): + try: + created_at = datetime.fromisoformat(row["created_at"]) + except ValueError: + pass + try: + tags = json.dumps(json.loads(row.get("tags") or "[]"), ensure_ascii=False) + except (ValueError, TypeError): + tags = "[]" + db.add( + Link( + user_id=user.id, + url=url, + title=(row.get("title") or "").strip()[:300], + summary=(row.get("summary") or "").strip()[:1000], + category=(row.get("category") or "").strip()[:120], + manufacturer=(row.get("manufacturer") or "").strip()[:120], + tags=tags, + status=(row.get("status") or "ok").strip()[:20] or "ok", + needs_review=bool(row.get("needs_review")), + created_at=created_at, + ) + ) + imported += 1 + db.commit() + return RedirectResponse(f"/settings?imported={imported}", status_code=303) diff --git a/app/templates/_icons.html b/app/templates/_icons.html index e3c0373..ea21d28 100644 --- a/app/templates/_icons.html +++ b/app/templates/_icons.html @@ -74,6 +74,14 @@ {{ base('', size) }} {%- endmacro %} +{% macro download(size=16) -%} +{{ base('', size) }} +{%- endmacro %} + +{% macro upload(size=16) -%} +{{ base('', size) }} +{%- endmacro %} + {% macro tag(size=16) -%} {{ base('', size) }} {%- endmacro %} diff --git a/app/templates/base.html b/app/templates/base.html index 0abdd11..88c97c8 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -59,6 +59,15 @@ background: var(--panel2); border: 1px solid var(--border); color: var(--text); border-radius: 8px; padding: 10px 12px; font-size: .95rem; width: 100%; } + .file-input { + position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; + overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; + } + .file-label { + display: inline-flex; align-items: center; gap: 6px; cursor: pointer; + white-space: nowrap; + } + .file-label:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; } input:focus, select:focus { outline: none; border-color: var(--accent); } select { background: var(--panel2); border: 1px solid var(--border); color: var(--text); diff --git a/app/templates/index.html b/app/templates/index.html index 33effd1..f86ccf5 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -27,9 +27,9 @@ - + + {{ icons.sliders(size=15) }} Einstellungen +
diff --git a/app/templates/settings.html b/app/templates/settings.html new file mode 100644 index 0000000..26d2938 --- /dev/null +++ b/app/templates/settings.html @@ -0,0 +1,84 @@ +{% extends "base.html" %} +{% import "_icons.html" as icons %} +{% block title %}Einstellungen · LinkVault{% endblock %} +{% block body %} +
+ LinkVault + +
+ +
+
+

Einstellungen

+

+ Angemeldet als {{ user.email }} · {{ link_count }} Link{{ '' if link_count == 1 else 'e' }} +

+ + {% if imported is not none %} +
+ {{ icons.sparkles(size=15) }} {{ imported }} Link{{ '' if imported == '1' else 'e' }} erfolgreich importiert. +
+ {% endif %} + {% if import_error %} +
{{ import_error }}
+ {% endif %} + +
+

CSV-Export

+

+ Alle deine Links als CSV-Tabelle (Titel, Zusammenfassung, Kategorie, + Hersteller, Tags, Datum) – zum Öffnen in Excel/Numbers/Sheets. +

+ +
+ +
+

Datenbank-Export (SQL-Dump)

+

+ Alle deine Links als SQL-INSERT-Anweisungen – als Backup + oder zum späteren Wiedereinspielen über den SQL-Import unten. + Enthält keine Embeddings (semantische Suche wird beim nächsten + „KI neu beschreiben lassen" pro Link neu erzeugt). +

+ +
+ +
+

SQL-Import

+

+ Eine mit obigem SQL-Export erzeugte Datei wieder einspielen. Die + Links werden deinem Konto hinzugefügt (keine + bestehenden Links werden überschrieben oder gelöscht). Nur Dateien, + die dem Export-Format dieser App entsprechen, werden akzeptiert + (max. 2 MB). +

+ + + + Keine Datei ausgewählt + + +
+
+
+ +{% endblock %}