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('