Analysiere den Code der PrintVault App. Lies beide Dateien vollständig:\n\n1. /media/HDD_1TB/PrintVault/main.py\n2. /media/HDD_1TB/PrintVault/frontend/index.html\n\nDie App ist eine STL-Bibliothek für 3D-Drucker-Dateien (FastAPI Backend + Single-Page HTML Frontend mit Three.js 3D-Viewer). Docker-Container auf ZimaOS Port 3001. SQLite WAL-mode, matplotlib Thumbnails, Dark Mode, Tags/Suche/Sortierung, Backup/Restore ZIP, Soft-Delete in /trash.\n\nGib mir 10 konkrete Verbesserungsideen — MINDESTENS 5 für UI/Frontend. Für jede Idee: kurze Begründung + Aufwandschätzung (S/M/L). Antwort auf Deutsch.


- update-config: Use this skill to configure the Claude Code harness via settings.json. Automated behaviors ("from now on when X", "each time X", "whenever X", "before/after X") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions ("allow X", "add permission", "move permission to"), env vars ("set X=Y"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: "allow npm commands", "add bq permission to global settings", "move permission to user settings", "set DEBUG=true", "when claude stops show X". For simple settings like theme/model, suggest the /config command.
- keybindings-help: Use when the user wants to customize keyboard shortcuts, rebind keys, add chord bindings, or modify ~/.claude/keybindings.json. Examples: "rebind ctrl+s", "add a chord shortcut", "change the submit key", "customize keybindings".
- verify: Verify that a code change actually does what it's supposed to by running the app and observing behavior. Use when asked to verify a PR, confirm a fix works, test a change manually, check that a feature works, or validate local changes before pushing.
- simplify: Review changed code for reuse, quality, and efficiency, then fix any issues found.
- fewer-permission-prompts: Scan your transcripts for common read-only Bash and MCP tool calls, then add a prioritized allowlist to project .claude/settings.json to reduce permission prompts.
- loop: Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo, defaults to 10m) - When the user wants to set up a recurring task, poll for status, or run something repeatedly on an interval (e.g. "check the deploy every 5 minutes", "keep running /babysit-prs"). Do NOT invoke for one-off tasks.
- claude-api: Build, debug, and optimize Claude API / Anthropic SDK apps. Apps built with this skill should include prompt caching. Also handles migrating existing Claude API code between Claude model versions (4.5 → 4.6, 4.6 → 4.7, retired-model replacements).
TRIGGER when: code imports `anthropic`/`@anthropic-ai/sdk`; user asks for the Claude API, Anthropic SDK, or Managed Agents; user adds/modifies/tunes a Claude feature (caching, thinking, compaction, tool use, batch, files, citations, memory) or model (Opus/Sonnet/Haiku) in a file; questions about prompt caching / cache hit rate in an Anthropic SDK project.
SKIP: file imports `openai`/other-provider SDK, filename like `*-openai.py`/`*-generic.py`, provider-neutral code, general programming/ML.
- run: Launch and drive this project's app to see a change working. Use when asked to run, start, or screenshot the app, or to confirm a change works in the real app (not just tests). First looks for a project skill that already covers launching the app; otherwise falls back to built-in patterns per project type (CLI, server, TUI, Electron, browser-driven, library).
- init: Initialize a new CLAUDE.md file with codebase documentation
- review: Review a pull request
- security-review: Complete a security review of the pending changes on the current branch


1	"""
2	PrintVault — STL-Bibliothek für ZimaOS
3	FastAPI Backend + Static Frontend
4	"""
5
6	import json
7	import os
8	import io
9	import uuid
10	import shutil
11	import sqlite3
12	import zipfile
13	import tempfile
14	import time
15	import asyncio
16	from pathlib import Path
17	from datetime import datetime
18	from typing import Optional, List
19
20	from fastapi import FastAPI, File, UploadFile, HTTPException, Query, Request, BackgroundTasks
21	from fastapi.responses import FileResponse, JSONResponse, HTMLResponse, StreamingResponse
22	from fastapi.staticfiles import StaticFiles
23	from fastapi.middleware.cors import CORSMiddleware
24	import trimesh
25	import numpy as np
26
27	# ── Konfiguration ──────────────────────────────────────────
28
29	BASE_DIR = Path(os.environ.get("DATA_DIR", "/data"))
30	STL_DIR = BASE_DIR / "stls"
31	THUMB_DIR = BASE_DIR / "thumbs"
32	TRASH_DIR = BASE_DIR / "trash"
33	BACKUP_DIR = BASE_DIR / "backups"
34	META_FILE = BASE_DIR / "metadata.json"       # Legacy
35	DB_FILE = BASE_DIR / "printvault.db"
36
37	for d in (BASE_DIR, STL_DIR, THUMB_DIR, TRASH_DIR, BACKUP_DIR):
38	    d.mkdir(parents=True, exist_ok=True)
39
40	MAX_BACKUPS = 10
41
42	app = FastAPI(title="PrintVault", version="2.0.0")
43
44	app.add_middleware(
45	    CORSMiddleware,
46	    allow_origins=["*"],
47	    allow_methods=["*"],
48	    allow_headers=["*"],
49	)
50
51
52	@app.middleware("http")
53	async def add_frame_options(request: Request, call_next):
54	    response = await call_next(request)
55	    response.headers["X-Frame-Options"] = "ALLOWALL"
56	    response.headers["Content-Security-Policy"] = "frame-ancestors 'self' http://*:* https://*:*;"
57	    return response
58
59
60	# ── Atomic helpers ─────────────────────────────────────────
61
62	def atomic_write_bytes(path: Path, data: bytes):
63	    tmp = path.with_suffix(path.suffix + ".tmp")
64	    tmp.write_bytes(data)
65	    os.replace(tmp, path)
66
67
68	def atomic_write_text(path: Path, text: str):
69	    atomic_write_bytes(path, text.encode("utf-8"))
70
71
72	# ── Database ───────────────────────────────────────────────
73
74	def db_connect() -> sqlite3.Connection:
75	    conn = sqlite3.connect(DB_FILE, timeout=15.0)
76	    conn.row_factory = sqlite3.Row
77	    conn.execute("PRAGMA foreign_keys = ON")
78	    conn.execute("PRAGMA journal_mode = WAL")
79	    conn.execute("PRAGMA synchronous = NORMAL")
80	    return conn
81
82
83	def init_db():
84	    with db_connect() as conn:
85	        conn.execute("""
86	            CREATE TABLE IF NOT EXISTS files (
87	                id              TEXT PRIMARY KEY,
88	                name            TEXT NOT NULL,
89	                note            TEXT NOT NULL DEFAULT '',
90	                tags_json       TEXT NOT NULL DEFAULT '[]',
91	                analysis_json   TEXT NOT NULL DEFAULT '{}',
92	                analysis_status TEXT NOT NULL DEFAULT 'done',
93	                uploaded_at     REAL NOT NULL
94	            )
95	        """)
96	        conn.execute("CREATE INDEX IF NOT EXISTS idx_files_uploaded_at ON files(uploaded_at DESC)")
97
98
99	def row_to_dict(row: sqlite3.Row, include_path_info: bool = True) -> dict:
100	    d = {
101	        "id": row["id"],
102	        "name": row["name"],
103	        "note": row["note"],
104	        "tags": json.loads(row["tags_json"] or "[]"),
105	        "analysis": json.loads(row["analysis_json"] or "{}"),
106	        "analysis_status": row["analysis_status"],
107	        "uploaded_at": datetime.fromtimestamp(row["uploaded_at"]).isoformat(),
108	    }
109	    if include_path_info:
110	        stl_path = STL_DIR / f"{row['id']}.stl"
111	        d["size_kb"] = round(stl_path.stat().st_size / 1024, 1) if stl_path.exists() else 0
112	        d["has_thumb"] = (THUMB_DIR / f"{row['id']}.png").exists()
113	        d["original_name"] = stl_path.name if stl_path.exists() else ""
114	    return d
115
116
117	# ── Migration: metadata.json → SQLite ─────────────────────
118
119	def migrate_legacy_json():
120	    if not META_FILE.exists():
121	        return
122	    try:
123	        meta = json.loads(META_FILE.read_text() or "{}")
124	    except Exception:
125	        return
126	    if not meta:
127	        return
128	    imported = 0
129	    with db_connect() as conn:
130	        existing = {r["id"] for r in conn.execute("SELECT id FROM files")}
131	        for fid, entry in meta.items():
132	            if fid in existing:
133	                continue
134	            stl_path = STL_DIR / f"{fid}.stl"
135	            uploaded_at = stl_path.stat().st_mtime if stl_path.exists() else time.time()
136	            conn.execute(
137	                "INSERT INTO files (id,name,note,tags_json,analysis_json,analysis_status,uploaded_at) "
138	                "VALUES (?,?,?,?,?,?,?)",
139	                (
140	                    fid,
141	                    entry.get("name", fid),
142	                    entry.get("note", ""),
143	                    json.dumps(entry.get("tags", [])),
144	                    json.dumps(entry.get("analysis", {})),
145	                    "done" if entry.get("analysis") else "pending",
146	                    uploaded_at,
147	                ),
148	            )
149	            imported += 1
150	        conn.commit()
151	    archive = META_FILE.with_name("metadata.migrated.json")
152	    META_FILE.rename(archive)
153	    print(f"[migrate] {imported} Einträge aus metadata.json nach SQLite importiert. Original → {archive.name}")
154
155
156	def import_orphan_stls():
157	    """STLs auf der Platte ohne DB-Eintrag automatisch erfassen."""
158	    with db_connect() as conn:
159	        existing = {r["id"] for r in conn.execute("SELECT id FROM files")}
160	        for stl_file in STL_DIR.glob("*.stl"):
161	            fid = stl_file.stem
162	            if fid in existing:
163	                continue
164	            conn.execute(
165	                "INSERT INTO files (id,name,tags_json,analysis_json,analysis_status,uploaded_at) "
166	                "VALUES (?,?,?,?,?,?)",
167	                (
168	                    fid,
169	                    stl_file.stem.replace("_", " "),
170	                    "[]", "{}", "pending",
171	                    stl_file.stat().st_mtime,
172	                ),
173	            )
174	        conn.commit()
175
176
177	# ── Backups ────────────────────────────────────────────────
178
179	def snapshot_db(reason: str = "auto"):
180	    if not DB_FILE.exists():
181	        return
182	    ts = datetime.now().strftime("%Y%m%d-%H%M%S")
183	    target = BACKUP_DIR / f"printvault-{ts}-{reason}.db"
184	    try:
185	        with db_connect() as src, sqlite3.connect(target) as dst:
186	            src.backup(dst)
187	    except Exception as e:
188	        print(f"[backup] Snapshot fehlgeschlagen: {e}")
189	        return
190	    rotate_backups()
191
192
193	def rotate_backups():
194	    backups = sorted(BACKUP_DIR.glob("printvault-*.db"))
195	    for old in backups[:-MAX_BACKUPS]:
196	        try:
197	            old.unlink()
198	        except Exception:
199	            pass
200
201
202	# ── STL-Analyse ────────────────────────────────────────────
203
204	def analyze_stl(path: Path) -> dict:
205	    try:
206	        mesh = trimesh.load(str(path))
207	        extent = mesh.bounding_box.extents
208	        x, y, z = float(extent[0]), float(extent[1]), float(extent[2])
209	        dims = sorted([x, y, z], reverse=True)
210
211	        volume_mm3 = float(mesh.volume) if hasattr(mesh, "volume") and mesh.volume else 0
212	        volume_cm3 = volume_mm3 / 1000.0
213	        filament_grams = round(volume_cm3 * 1.24, 1)
214	        faces = len(mesh.faces) if hasattr(mesh, "faces") and mesh.faces is not None else 0
215	        watertight = bool(mesh.is_watertight) if hasattr(mesh, "is_watertight") else False
216
217	        is_manifold = False
218	        try:
219	            from manifold3d import Manifold
220	            Manifold(*trimesh_to_manifold(mesh))
221	            is_manifold = True
222	        except Exception:
223	            is_manifold = watertight
224
225	        return {
226	            "width_mm": round(dims[0], 1),
227	            "depth_mm": round(dims[1], 1),
228	            "height_mm": round(dims[2], 1),
229	            "volume_cm3": round(volume_cm3, 2),
230	            "filament_grams": filament_grams,
231	            "faces": faces,
232	            "watertight": watertight,
233	            "manifold": is_manifold,
234	        }
235	    except Exception as e:
236	        return {
237	            "width_mm": 0, "depth_mm": 0, "height_mm": 0,
238	            "volume_cm3": 0, "faces": 0,
239	            "watertight": False, "manifold": False,
240	            "error": str(e),
241	        }
242
243
244	def trimesh_to_manifold(mesh):
245	    verts = np.array(mesh.vertices, dtype=np.float64)
246	    faces = np.array(mesh.faces, dtype=np.int32)
247	    return verts, faces
248
249
250	def generate_thumbnail(stl_path: Path, thumb_path: Path) -> bool:
251	    """Schönes Diffuse-Shaded PNG mit transparentem Hintergrund."""
252	    try:
253	        import matplotlib
254	        matplotlib.use("Agg")
255	        import matplotlib.pyplot as plt
256	        from mpl_toolkits.mplot3d.art3d import Poly3DCollection
257
258	        mesh = trimesh.load(str(stl_path))
259
260	        # Auf den Ursprung zentrieren
261	        mesh.apply_translation(-mesh.centroid)
262
263	        verts = np.asarray(mesh.vertices, dtype=np.float32)
264	        faces = np.asarray(mesh.faces, dtype=np.int32)
265	        polys = verts[faces]  # (N, 3, 3)
266
267	        # Face-Normalen für Diffuse-Lighting
268	        normals = np.asarray(mesh.face_normals, dtype=np.float32)
269	        light_dir = np.array([0.35, -0.55, 0.75], dtype=np.float32)
270	        light_dir /= np.linalg.norm(light_dir)
271
272	        # Lambert-Shading + Ambient + sanfter Rim-Light
273	        diffuse = np.clip(normals @ light_dir, 0.0, 1.0)
274	        rim = np.clip(1.0 - np.abs(normals @ np.array([0, 0, 1], dtype=np.float32)), 0, 1) ** 3
275	        shade = 0.28 + 0.72 * diffuse + 0.08 * rim
276	        shade = np.clip(shade, 0.0, 1.05)
277
278	        # Markenfarbe (#3B82F6 → #60A5FA an hellen Stellen)
279	        base = np.array([0.231, 0.510, 0.965], dtype=np.float32)
280	        highlight = np.array([0.376, 0.647, 0.980], dtype=np.float32)
281	        # Mix zwischen base und highlight je nach Helligkeit
282	        mix = np.clip((shade - 0.4) / 0.6, 0, 1)[:, None]
283	        colors = base * (1 - mix) + highlight * mix
284	        colors *= shade[:, None]
285	        colors = np.clip(colors, 0, 1)
286	        face_colors = np.hstack([colors, np.full((len(colors), 1), 0.98, dtype=np.float32)])
287
288	        fig = plt.figure(figsize=(6, 6), dpi=140)
289	        ax = fig.add_subplot(111, projection="3d")
290	        fig.patch.set_alpha(0.0)
291	        ax.set_facecolor((1, 1, 1, 0))
292
293	        coll = Poly3DCollection(polys, facecolors=face_colors, edgecolors="none", linewidths=0)
294	        coll.set_zsort("average")
295	        ax.add_collection3d(coll)
296
297	        extent = float(mesh.extents.max()) * 0.58
298	        ax.set_xlim(-extent, extent)
299	        ax.set_ylim(-extent, extent)
300	        ax.set_zlim(-extent, extent)
301	        try:
302	            ax.set_box_aspect((1, 1, 1))
303	        except Exception:
304	            pass
305	        ax.view_init(elev=28, azim=-55)
306	        ax.axis("off")
307	        ax.set_proj_type("ortho")
308	        for pane in (ax.xaxis, ax.yaxis, ax.zaxis):
309	            pane.pane.fill = False
310	            pane.pane.set_edgecolor((0, 0, 0, 0))
311
312	        buf = io.BytesIO()
313	        plt.savefig(buf, format="png", bbox_inches="tight", pad_inches=0.05, transparent=True, dpi=140)
314	        buf.seek(0)
315	        atomic_write_bytes(thumb_path, buf.read())
316	        plt.close(fig)
317	        return True
318	    except Exception as e:
319	        print(f"Thumbnail error: {e}")
320	        return False
321
322
323	def process_stl_background(file_id: str):
324	    """Analyse + Thumbnail im Hintergrund."""
325	    stl_path = STL_DIR / f"{file_id}.stl"
326	    if not stl_path.exists():
327	        return
328	    analysis = analyze_stl(stl_path)
329	    thumb_path = THUMB_DIR / f"{file_id}.png"
330	    generate_thumbnail(stl_path, thumb_path)
331	    with db_connect() as conn:
332	        conn.execute(
333	            "UPDATE files SET analysis_json = ?, analysis_status = ? WHERE id = ?",
334	            (json.dumps(analysis), "error" if "error" in analysis else "done", file_id),
335	        )
336	        conn.commit()
337
338
339	# ── API: Files ─────────────────────────────────────────────
340
341	@app.get("/api/files")
342	def list_files(
343	    sort: str = Query("date", regex="^(name|date|size|volume)$"),
344	    search: str = Query(""),
345	    tags: str = Query(""),
346	):
347	    with db_connect() as conn:
348	        rows = conn.execute("SELECT * FROM files").fetchall()
349
350	    results = [row_to_dict(r) for r in rows]
351
352	    if search:
353	        q = search.lower()
354	        results = [
355	            r for r in results
356	            if q in r["name"].lower()
357	            or q in (r.get("note") or "").lower()
358	            or any(q in t.lower() for t in r.get("tags", []))
359	        ]
360
361	    if tags:
362	        required = {t.strip().lower() for t in tags.split(",") if t.strip()}
363	        if required:
364	            results = [
365	                r for r in results
366	                if required.issubset({t.lower() for t in r.get("tags", [])})
367	            ]
368
369	    if sort == "name":
370	        results.sort(key=lambda r: r["name"].lower())
371	    elif sort == "size":
372	        results.sort(key=lambda r: r["size_kb"], reverse=True)
373	    elif sort == "volume":
374	        results.sort(key=lambda r: r.get("analysis", {}).get("volume_cm3", 0), reverse=True)
375	    else:
376	        results.sort(key=lambda r: r["uploaded_at"], reverse=True)
377
378	    return results
379
380
381	@app.get("/api/files/{file_id}")
382	def get_file_detail(file_id: str):
383	    with db_connect() as conn:
384	        row = conn.execute("SELECT * FROM files WHERE id = ?", (file_id,)).fetchone()
385	    if not row:
386	        raise HTTPException(404, "Datei nicht gefunden")
387	    return row_to_dict(row)
388
389
390	@app.post("/api/upload")
391	async def upload_stl(background: BackgroundTasks, file: UploadFile = File(...)):
392	    if not file.filename.lower().endswith(".stl"):
393	        raise HTTPException(400, "Nur .stl-Dateien erlaubt")
394
395	    fid = str(uuid.uuid4())[:8]
396	    stl_path = STL_DIR / f"{fid}.stl"
397	    content = await file.read()
398	    if not content:
399	        raise HTTPException(400, "Leere Datei")
400	    atomic_write_bytes(stl_path, content)
401
402	    name = file.filename.rsplit(".", 1)[0].replace("_", " ")
403	    uploaded_at = time.time()
404
405	    with db_connect() as conn:
406	        conn.execute(
407	            "INSERT INTO files (id,name,tags_json,analysis_json,analysis_status,uploaded_at) "
408	            "VALUES (?,?,?,?,?,?)",
409	            (fid, name, "[]", "{}", "pending", uploaded_at),
410	        )
411	        conn.commit()
412
413	    background.add_task(process_stl_background, fid)
414
415	    return {
416	        "id": fid,
417	        "name": name,
418	        "size_kb": round(stl_path.stat().st_size / 1024, 1),
419	        "analysis": {},
420	        "analysis_status": "pending",
421	        "has_thumb": False,
422	    }
423
424
425	@app.patch("/api/files/{file_id}")
426	async def update_file(file_id: str, body: dict):
427	    with db_connect() as conn:
428	        row = conn.execute("SELECT * FROM files WHERE id = ?", (file_id,)).fetchone()
429	        if not row:
430	            raise HTTPException(404, "Datei nicht gefunden")
431
432	        fields, values = [], []
433	        if "name" in body:
434	            fields.append("name = ?"); values.append(str(body["name"]))
435	        if "note" in body:
436	            fields.append("note = ?"); values.append(str(body["note"]))
437	        if "tags" in body:
438	            tags = body["tags"] if isinstance(body["tags"], list) else []
439	            tags = [str(t).strip() for t in tags if str(t).strip()]
440	            fields.append("tags_json = ?"); values.append(json.dumps(tags))
441
442	        if not fields:
443	            return {"ok": True}
444
445	        values.append(file_id)
446	        conn.execute(f"UPDATE files SET {', '.join(fields)} WHERE id = ?", values)
447	        conn.commit()
448	    return {"ok": True}
449
450
451	@app.delete("/api/files/{file_id}")
452	def delete_file(file_id: str):
453	    """Soft-Delete: STL + Thumbnail in /trash verschieben, DB-Eintrag löschen."""
454	    stl_path = STL_DIR / f"{file_id}.stl"
455	    thumb_path = THUMB_DIR / f"{file_id}.png"
456
457	    ts = datetime.now().strftime("%Y%m%d-%H%M%S")
458	    if stl_path.exists():
459	        shutil.move(str(stl_path), str(TRASH_DIR / f"{file_id}-{ts}.stl"))
460	    if thumb_path.exists():
461	        shutil.move(str(thumb_path), str(TRASH_DIR / f"{file_id}-{ts}.png"))
462
463	    with db_connect() as conn:
464	        conn.execute("DELETE FROM files WHERE id = ?", (file_id,))
465	        conn.commit()
466
467	    return {"ok": True}
468
469
470	@app.get("/api/thumbs/{file_id}.png")
471	def get_thumbnail(file_id: str):
472	    thumb_path = THUMB_DIR / f"{file_id}.png"
473	    if not thumb_path.exists():
474	        stl_path = STL_DIR / f"{file_id}.stl"
475	        if not stl_path.exists():
476	            raise HTTPException(404, "Kein Thumbnail")
477	        generate_thumbnail(stl_path, thumb_path)
478	    return FileResponse(thumb_path, media_type="image/png")
479
480
481	@app.get("/api/stats")
482	def get_stats():
483	    with db_connect() as conn:
484	        rows = conn.execute("SELECT analysis_json FROM files").fetchall()
485	    total = len(rows)
486	    watertight = 0
487	    total_volume = 0.0
488	    total_filament = 0.0
489	    for r in rows:
490	        try:
491	            a = json.loads(r["analysis_json"] or "{}")
492	        except Exception:
493	            a = {}
494	        if a.get("watertight"):
495	            watertight += 1
496	        total_volume += float(a.get("volume_cm3") or 0)
497	        total_filament += float(a.get("filament_grams") or 0)
498
499	    total_size = sum(f.stat().st_size for f in STL_DIR.glob("*.stl"))
500
501	    return {
502	        "total_files": total,
503	        "total_size_mb": round(total_size / 1024 / 1024, 1),
504	        "watertight": watertight,
505	        "not_watertight": total - watertight,
506	        "total_volume_cm3": round(total_volume, 1),
507	        "total_filament_grams": round(total_filament, 1),
508	    }
509
510
511	@app.get("/api/tags")
512	def list_tags():
513	    with db_connect() as conn:
514	        rows = conn.execute("SELECT tags_json FROM files").fetchall()
515	    counts: dict = {}
516	    for r in rows:
517	        try:
518	            for t in json.loads(r["tags_json"] or "[]"):
519	                counts[t] = counts.get(t, 0) + 1
520	        except Exception:
521	            pass
522	    return [{"tag": t, "count": c} for t, c in sorted(counts.items(), key=lambda x: -x[1])]
523
524
525	@app.get("/api/download/{file_id}")
526	def download_stl(file_id: str):
527	    stl_path = STL_DIR / f"{file_id}.stl"
528	    if not stl_path.exists():
529	        raise HTTPException(404, "Datei nicht gefunden")
530	    with db_connect() as conn:
531	        row = conn.execute("SELECT name FROM files WHERE id = ?", (file_id,)).fetchone()
532	    name = (row["name"] if row else file_id).replace(" ", "_") + ".stl"
533	    return FileResponse(stl_path, media_type="application/octet-stream", filename=name)
534
535
536	# ── Backup / Restore ──────────────────────────────────────
537
538	@app.get("/api/backup")
539	def backup_zip():
540	    """Komplette Bibliothek als ZIP herunterladen (DB + STLs + Thumbnails)."""
541	    snapshot_db("manual")
542
543	    buf = io.BytesIO()
544	    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
545	        if DB_FILE.exists():
546	            zf.write(DB_FILE, "printvault.db")
547	        for p in STL_DIR.glob("*.stl"):
548	            zf.write(p, f"stls/{p.name}")
549	        for p in THUMB_DIR.glob("*.png"):
550	            zf.write(p, f"thumbs/{p.name}")
551	        manifest = {
552	            "version": "2.0.0",
553	            "created_at": datetime.now().isoformat(),
554	            "files": len(list(STL_DIR.glob("*.stl"))),
555	        }
556	        zf.writestr("manifest.json", json.dumps(manifest, indent=2))
557	    buf.seek(0)
558
559	    fname = f"printvault-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip"
560	    return StreamingResponse(
561	        buf,
562	        media_type="application/zip",
563	        headers={"Content-Disposition": f'attachment; filename="{fname}"'},
564	    )
565
566
567	@app.post("/api/restore")
568	async def restore_zip(file: UploadFile = File(...)):
569	    """Backup-ZIP einspielen. Vorher wird automatisch ein Sicherheits-Snapshot angelegt."""
570	    if not file.filename.lower().endswith(".zip"):
571	        raise HTTPException(400, "Nur .zip erlaubt")
572
573	    snapshot_db("pre-restore")
574
575	    data = await file.read()
576	    with tempfile.TemporaryDirectory() as tmpdir:
577	        tmp = Path(tmpdir)
578	        try:
579	            with zipfile.ZipFile(io.BytesIO(data)) as zf:
580	                # Validierung
581	                names = zf.namelist()
582	                if "printvault.db" not in names:
583	                    raise HTTPException(400, "ZIP enthält keine printvault.db")
584	                zf.extractall(tmp)
585	        except zipfile.BadZipFile:
586	            raise HTTPException(400, "Ungültige ZIP-Datei")
587
588	        # DB validieren bevor wir sie übernehmen
589	        try:
590	            test_conn = sqlite3.connect(tmp / "printvault.db")
591	            test_conn.execute("SELECT COUNT(*) FROM files").fetchone()
592	            test_conn.close()
593	        except Exception as e:
594	            raise HTTPException(400, f"DB im Backup ungültig: {e}")
595
596	        # STL & Thumbs einspielen (überschreiben)
597	        if (tmp / "stls").exists():
598	            for src in (tmp / "stls").glob("*.stl"):
599	                atomic_write_bytes(STL_DIR / src.name, src.read_bytes())
600	        if (tmp / "thumbs").exists():
601	            for src in (tmp / "thumbs").glob("*.png"):
602	                atomic_write_bytes(THUMB_DIR / src.name, src.read_bytes())
603
604	        # DB atomar ersetzen
605	        atomic_write_bytes(DB_FILE, (tmp / "printvault.db").read_bytes())
606
607	    return {"ok": True}
608
609
610	@app.get("/api/backups")
611	def list_backups():
612	    return [
613	        {
614	            "name": p.name,
615	            "size_kb": round(p.stat().st_size / 1024, 1),
616	            "created_at": datetime.fromtimestamp(p.stat().st_mtime).isoformat(),
617	        }
618	        for p in sorted(BACKUP_DIR.glob("printvault-*.db"), reverse=True)
619	    ]
620
621
622	# ── Static Frontend ────────────────────────────────────────
623
624	FRONTEND_DIR = Path(__file__).parent / "frontend"
625	FRONTEND_DIR.mkdir(exist_ok=True)
626
627
628	@app.get("/")
629	def serve_frontend():
630	    return FileResponse(FRONTEND_DIR / "index.html")
631
632
633	if (FRONTEND_DIR / "assets").exists():
634	    app.mount("/assets", StaticFiles(directory=FRONTEND_DIR / "assets"), name="assets")
635
636
637	@app.get("/icon.svg")
638	def serve_icon():
639	    return FileResponse(FRONTEND_DIR / "icon.svg", media_type="image/svg+xml")
640
641
642	# ── Startup ────────────────────────────────────────────────
643
644	@app.on_event("startup")
645	def on_startup():
646	    init_db()
647	    migrate_legacy_json()
648	    import_orphan_stls()
649	    snapshot_db("startup")
650
651
652	if __name__ == "__main__":
653	    import uvicorn
654	    uvicorn.run(app, host="0.0.0.0", port=3001)
655

"""
PrintVault — STL-Bibliothek für ZimaOS
FastAPI Backend + Static Frontend
"""

import json
import os
import io
import uuid
import shutil
import sqlite3
import zipfile
import tempfile
import time
import asyncio
from pathlib import Path
from datetime import datetime
from typing import Optional, List

from fastapi import FastAPI, File, UploadFile, HTTPException, Query, Request, BackgroundTasks
from fastapi.responses import FileResponse, JSONResponse, HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
import trimesh
import numpy as np

# ── Konfiguration ──────────────────────────────────────────

BASE_DIR = Path(os.environ.get("DATA_DIR", "/data"))
STL_DIR = BASE_DIR / "stls"
THUMB_DIR = BASE_DIR / "thumbs"
TRASH_DIR = BASE_DIR / "trash"
BACKUP_DIR = BASE_DIR / "backups"
META_FILE = BASE_DIR / "metadata.json"       # Legacy
DB_FILE = BASE_DIR / "printvault.db"

for d in (BASE_DIR, STL_DIR, THUMB_DIR, TRASH_DIR, BACKUP_DIR):
    d.mkdir(parents=True, exist_ok=True)

MAX_BACKUPS = 10

app = FastAPI(title="PrintVault", version="2.0.0")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)

@app.middleware("http")
async def add_frame_options(request: Request, call_next):
    response = await call_next(request)
    response.headers["X-Frame-Options"] = "ALLOWALL"
    response.headers["Content-Security-Policy"] = "frame-ancestors 'self' http://*:* https://*:*;"
    return response

# ── Atomic helpers ─────────────────────────────────────────

def atomic_write_bytes(path: Path, data: bytes):
    tmp = path.with_suffix(path.suffix + ".tmp")
    tmp.write_bytes(data)
    os.replace(tmp, path)

def atomic_write_text(path: Path, text: str):
    atomic_write_bytes(path, text.encode("utf-8"))

# ── Database ───────────────────────────────────────────────

def db_connect() -> sqlite3.Connection:
    conn = sqlite3.connect(DB_FILE, timeout=15.0)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA foreign_keys = ON")
    conn.execute("PRAGMA journal_mode = WAL")
    conn.execute("PRAGMA synchronous = NORMAL")
    return conn

def init_db():
    with db_connect() as conn:
        conn.execute("""
            CREATE TABLE IF NOT EXISTS files (
                id              TEXT PRIMARY KEY,
                name            TEXT NOT NULL,
                note            TEXT NOT NULL DEFAULT '',
                tags_json       TEXT NOT NULL DEFAULT '[]',
                analysis_json   TEXT NOT NULL DEFAULT '{}',
                analysis_status TEXT NOT NULL DEFAULT 'done',
                uploaded_at     REAL NOT NULL
            )
        """)
        conn.execute("CREATE INDEX IF NOT EXISTS idx_files_uploaded_at ON files(uploaded_at DESC)")

def row_to_dict(row: sqlite3.Row, include_path_info: bool = True) -> dict:
    d = {
        "id": row["id"],
        "name": row["name"],
        "note": row["note"],
        "tags": json.loads(row["tags_json"] or "[]"),
        "analysis": json.loads(row["analysis_json"] or "{}"),
        "analysis_status": row["analysis_status"],
        "uploaded_at": datetime.fromtimestamp(row["uploaded_at"]).isoformat(),
    }
    if include_path_info:
        stl_path = STL_DIR / f"{row['id']}.stl"
        d["size_kb"] = round(stl_path.stat().st_size / 1024, 1) if stl_path.exists() else 0
        d["has_thumb"] = (THUMB_DIR / f"{row['id']}.png").exists()
        d["original_name"] = stl_path.name if stl_path.exists() else ""
    return d

# ── Migration: metadata.json → SQLite ─────────────────────

def migrate_legacy_json():
    if not META_FILE.exists():
        return
    try:
        meta = json.loads(META_FILE.read_text() or "{}")
    except Exception:
        return
    if not meta:
        return
    imported = 0
    with db_connect() as conn:
        existing = {r["id"] for r in conn.execute("SELECT id FROM files")}
        for fid, entry in meta.items():
            if fid in existing:
                continue
            stl_path = STL_DIR / f"{fid}.stl"
            uploaded_at = stl_path.stat().st_mtime if stl_path.exists() else time.time()
            conn.execute(
                "INSERT INTO files (id,name,note,tags_json,analysis_json,analysis_status,uploaded_at) "
                "VALUES (?,?,?,?,?,?,?)",
                (
                    fid,
                    entry.get("name", fid),
                    entry.get("note", ""),
                    json.dumps(entry.get("tags", [])),
                    json.dumps(entry.get("analysis", {})),
                    "done" if entry.get("analysis") else "pending",
                    uploaded_at,
                ),
            )
            imported += 1
        conn.commit()
    archive = META_FILE.with_name("metadata.migrated.json")
    META_FILE.rename(archive)
    print(f"[migrate] {imported} Einträge aus metadata.json nach SQLite importiert. Original → {archive.name}")

def import_orphan_stls():
    """STLs auf der Platte ohne DB-Eintrag automatisch erfassen."""
    with db_connect() as conn:
        existing = {r["id"] for r in conn.execute("SELECT id FROM files")}
        for stl_file in STL_DIR.glob("*.stl"):
            fid = stl_file.stem
            if fid in existing:
                continue
            conn.execute(
                "INSERT INTO files (id,name,tags_json,analysis_json,analysis_status,uploaded_at) "
                "VALUES (?,?,?,?,?,?)",
                (
                    fid,
                    stl_file.stem.replace("_", " "),
                    "[]", "{}", "pending",
                    stl_file.stat().st_mtime,
                ),
            )
        conn.commit()

# ── Backups ────────────────────────────────────────────────

def snapshot_db(reason: str = "auto"):
    if not DB_FILE.exists():
        return
    ts = datetime.now().strftime("%Y%m%d-%H%M%S")
    target = BACKUP_DIR / f"printvault-{ts}-{reason}.db"
    try:
        with db_connect() as src, sqlite3.connect(target) as dst:
            src.backup(dst)
    except Exception as e:
        print(f"[backup] Snapshot fehlgeschlagen: {e}")
        return
    rotate_backups()

def rotate_backups():
    backups = sorted(BACKUP_DIR.glob("printvault-*.db"))
    for old in backups[:-MAX_BACKUPS]:
        try:
            old.unlink()
        except Exception:
            pass

# ── STL-Analyse ────────────────────────────────────────────

def analyze_stl(path: Path) -> dict:
    try:
        mesh = trimesh.load(str(path))
        extent = mesh.bounding_box.extents
        x, y, z = float(extent[0]), float(extent[1]), float(extent[2])
        dims = sorted([x, y, z], reverse=True)

        volume_mm3 = float(mesh.volume) if hasattr(mesh, "volume") and mesh.volume else 0
        volume_cm3 = volume_mm3 / 1000.0
        filament_grams = round(volume_cm3 * 1.24, 1)
        faces = len(mesh.faces) if hasattr(mesh, "faces") and mesh.faces is not None else 0
        watertight = bool(mesh.is_watertight) if hasattr(mesh, "is_watertight") else False

        is_manifold = False
        try:
            from manifold3d import Manifold
            Manifold(*trimesh_to_manifold(mesh))
            is_manifold = True
        except Exception:
            is_manifold = watertight

        return {
            "width_mm": round(dims[0], 1),
            "depth_mm": round(dims[1], 1),
            "height_mm": round(dims[2], 1),
            "volume_cm3": round(volume_cm3, 2),
            "filament_grams": filament_grams,
            "faces": faces,
            "watertight": watertight,
            "manifold": is_manifold,
        }
    except Exception as e:
        return {
            "width_mm": 0, "depth_mm": 0, "height_mm": 0,
            "volume_cm3": 0, "faces": 0,
            "watertight": False, "manifold": False,
            "error": str(e),
        }

def trimesh_to_manifold(mesh):
    verts = np.array(mesh.vertices, dtype=np.float64)
    faces = np.array(mesh.faces, dtype=np.int32)
    return verts, faces

def generate_thumbnail(stl_path: Path, thumb_path: Path) -> bool:
    """Schönes Diffuse-Shaded PNG mit transparentem Hintergrund."""
    try:
        import matplotlib
        matplotlib.use("Agg")
        import matplotlib.pyplot as plt
        from mpl_toolkits.mplot3d.art3d import Poly3DCollection

        mesh = trimesh.load(str(stl_path))

        # Auf den Ursprung zentrieren
        mesh.apply_translation(-mesh.centroid)

        verts = np.asarray(mesh.vertices, dtype=np.float32)
        faces = np.asarray(mesh.faces, dtype=np.int32)
        polys = verts[faces]  # (N, 3, 3)

        # Face-Normalen für Diffuse-Lighting
        normals = np.asarray(mesh.face_normals, dtype=np.float32)
        light_dir = np.array([0.35, -0.55, 0.75], dtype=np.float32)
        light_dir /= np.linalg.norm(light_dir)

        # Lambert-Shading + Ambient + sanfter Rim-Light
        diffuse = np.clip(normals @ light_dir, 0.0, 1.0)
        rim = np.clip(1.0 - np.abs(normals @ np.array([0, 0, 1], dtype=np.float32)), 0, 1) ** 3
        shade = 0.28 + 0.72 * diffuse + 0.08 * rim
        shade = np.clip(shade, 0.0, 1.05)

        # Markenfarbe (#3B82F6 → #60A5FA an hellen Stellen)
        base = np.array([0.231, 0.510, 0.965], dtype=np.float32)
        highlight = np.array([0.376, 0.647, 0.980], dtype=np.float32)
        # Mix zwischen base und highlight je nach Helligkeit
        mix = np.clip((shade - 0.4) / 0.6, 0, 1)[:, None]
        colors = base * (1 - mix) + highlight * mix
        colors *= shade[:, None]
        colors = np.clip(colors, 0, 1)
        face_colors = np.hstack([colors, np.full((len(colors), 1), 0.98, dtype=np.float32)])

        fig = plt.figure(figsize=(6, 6), dpi=140)
        ax = fig.add_subplot(111, projection="3d")
        fig.patch.set_alpha(0.0)
        ax.set_facecolor((1, 1, 1, 0))

        coll = Poly3DCollection(polys, facecolors=face_colors, edgecolors="none", linewidths=0)
        coll.set_zsort("average")
        ax.add_collection3d(coll)

        extent = float(mesh.extents.max()) * 0.58
        ax.set_xlim(-extent, extent)
        ax.set_ylim(-extent, extent)
        ax.set_zlim(-extent, extent)
        try:
            ax.set_box_aspect((1, 1, 1))
        except Exception:
            pass
        ax.view_init(elev=28, azim=-55)
        ax.axis("off")
        ax.set_proj_type("ortho")
        for pane in (ax.xaxis, ax.yaxis, ax.zaxis):
            pane.pane.fill = False
            pane.pane.set_edgecolor((0, 0, 0, 0))

        buf = io.BytesIO()
        plt.savefig(buf, format="png", bbox_inches="tight", pad_inches=0.05, transparent=True, dpi=140)
        buf.seek(0)
        atomic_write_bytes(thumb_path, buf.read())
        plt.close(fig)
        return True
    except Exception as e:
        print(f"Thumbnail error: {e}")
        return False

def process_stl_background(file_id: str):
    """Analyse + Thumbnail im Hintergrund."""
    stl_path = STL_DIR / f"{file_id}.stl"
    if not stl_path.exists():
        return
    analysis = analyze_stl(stl_path)
    thumb_path = THUMB_DIR / f"{file_id}.png"
    generate_thumbnail(stl_path, thumb_path)
    with db_connect() as conn:
        conn.execute(
            "UPDATE files SET analysis_json = ?, analysis_status = ? WHERE id = ?",
            (json.dumps(analysis), "error" if "error" in analysis else "done", file_id),
        )
        conn.commit()

# ── API: Files ─────────────────────────────────────────────

@app.get("/api/files")
def list_files(
    sort: str = Query("date", regex="^(name|date|size|volume)$"),
    search: str = Query(""),
    tags: str = Query(""),
):
    with db_connect() as conn:
        rows = conn.execute("SELECT * FROM files").fetchall()

    results = [row_to_dict(r) for r in rows]

    if search:
        q = search.lower()
        results = [
            r for r in results
            if q in r["name"].lower()
            or q in (r.get("note") or "").lower()
            or any(q in t.lower() for t in r.get("tags", []))
        ]

    if tags:
        required = {t.strip().lower() for t in tags.split(",") if t.strip()}
        if required:
            results = [
                r for r in results
                if required.issubset({t.lower() for t in r.get("tags", [])})
            ]

    if sort == "name":
        results.sort(key=lambda r: r["name"].lower())
    elif sort == "size":
        results.sort(key=lambda r: r["size_kb"], reverse=True)
    elif sort == "volume":
        results.sort(key=lambda r: r.get("analysis", {}).get("volume_cm3", 0), reverse=True)
    else:
        results.sort(key=lambda r: r["uploaded_at"], reverse=True)

    return results

@app.get("/api/files/{file_id}")
def get_file_detail(file_id: str):
    with db_connect() as conn:
        row = conn.execute("SELECT * FROM files WHERE id = ?", (file_id,)).fetchone()
    if not row:
        raise HTTPException(404, "Datei nicht gefunden")
    return row_to_dict(row)

@app.post("/api/upload")
async def upload_stl(background: BackgroundTasks, file: UploadFile = File(...)):
    if not file.filename.lower().endswith(".stl"):
        raise HTTPException(400, "Nur .stl-Dateien erlaubt")

    fid = str(uuid.uuid4())[:8]
    stl_path = STL_DIR / f"{fid}.stl"
    content = await file.read()
    if not content:
        raise HTTPException(400, "Leere Datei")
    atomic_write_bytes(stl_path, content)

    name = file.filename.rsplit(".", 1)[0].replace("_", " ")
    uploaded_at = time.time()

    with db_connect() as conn:
        conn.execute(
            "INSERT INTO files (id,name,tags_json,analysis_json,analysis_status,uploaded_at) "
            "VALUES (?,?,?,?,?,?)",
            (fid, name, "[]", "{}", "pending", uploaded_at),
        )
        conn.commit()

    background.add_task(process_stl_background, fid)

    return {
        "id": fid,
        "name": name,
        "size_kb": round(stl_path.stat().st_size / 1024, 1),
        "analysis": {},
        "analysis_status": "pending",
        "has_thumb": False,
    }

@app.patch("/api/files/{file_id}")
async def update_file(file_id: str, body: dict):
    with db_connect() as conn:
        row = conn.execute("SELECT * FROM files WHERE id = ?", (file_id,)).fetchone()
        if not row:
            raise HTTPException(404, "Datei nicht gefunden")

        fields, values = [], []
        if "name" in body:
            fields.append("name = ?"); values.append(str(body["name"]))
        if "note" in body:
            fields.append("note = ?"); values.append(str(body["note"]))
        if "tags" in body:
            tags = body["tags"] if isinstance(body["tags"], list) else []
            tags = [str(t).strip() for t in tags if str(t).strip()]
            fields.append("tags_json = ?"); values.append(json.dumps(tags))

        if not fields:
            return {"ok": True}

        values.append(file_id)
        conn.execute(f"UPDATE files SET {', '.join(fields)} WHERE id = ?", values)
        conn.commit()
    return {"ok": True}

@app.delete("/api/files/{file_id}")
def delete_file(file_id: str):
    """Soft-Delete: STL + Thumbnail in /trash verschieben, DB-Eintrag löschen."""
    stl_path = STL_DIR / f"{file_id}.stl"
    thumb_path = THUMB_DIR / f"{file_id}.png"

    ts = datetime.now().strftime("%Y%m%d-%H%M%S")
    if stl_path.exists():
        shutil.move(str(stl_path), str(TRASH_DIR / f"{file_id}-{ts}.stl"))
    if thumb_path.exists():
        shutil.move(str(thumb_path), str(TRASH_DIR / f"{file_id}-{ts}.png"))

    with db_connect() as conn:
        conn.execute("DELETE FROM files WHERE id = ?", (file_id,))
        conn.commit()

    return {"ok": True}

@app.get("/api/thumbs/{file_id}.png")
def get_thumbnail(file_id: str):
    thumb_path = THUMB_DIR / f"{file_id}.png"
    if not thumb_path.exists():
        stl_path = STL_DIR / f"{file_id}.stl"
        if not stl_path.exists():
            raise HTTPException(404, "Kein Thumbnail")
        generate_thumbnail(stl_path, thumb_path)
    return FileResponse(thumb_path, media_type="image/png")

@app.get("/api/stats")
def get_stats():
    with db_connect() as conn:
        rows = conn.execute("SELECT analysis_json FROM files").fetchall()
    total = len(rows)
    watertight = 0
    total_volume = 0.0
    total_filament = 0.0
    for r in rows:
        try:
            a = json.loads(r["analysis_json"] or "{}")
        except Exception:
            a = {}
        if a.get("watertight"):
            watertight += 1
        total_volume += float(a.get("volume_cm3") or 0)
        total_filament += float(a.get("filament_grams") or 0)

    total_size = sum(f.stat().st_size for f in STL_DIR.glob("*.stl"))

    return {
        "total_files": total,
        "total_size_mb": round(total_size / 1024 / 1024, 1),
        "watertight": watertight,
        "not_watertight": total - watertight,
        "total_volume_cm3": round(total_volume, 1),
        "total_filament_grams": round(total_filament, 1),
    }

@app.get("/api/tags")
def list_tags():
    with db_connect() as conn:
        rows = conn.execute("SELECT tags_json FROM files").fetchall()
    counts: dict = {}
    for r in rows:
        try:
            for t in json.loads(r["tags_json"] or "[]"):
                counts[t] = counts.get(t, 0) + 1
        except Exception:
            pass
    return [{"tag": t, "count": c} for t, c in sorted(counts.items(), key=lambda x: -x[1])]

@app.get("/api/download/{file_id}")
def download_stl(file_id: str):
    stl_path = STL_DIR / f"{file_id}.stl"
    if not stl_path.exists():
        raise HTTPException(404, "Datei nicht gefunden")
    with db_connect() as conn:
        row = conn.execute("SELECT name FROM files WHERE id = ?", (file_id,)).fetchone()
    name = (row["name"] if row else file_id).replace(" ", "_") + ".stl"
    return FileResponse(stl_path, media_type="application/octet-stream", filename=name)

# ── Backup / Restore ──────────────────────────────────────

@app.get("/api/backup")
def backup_zip():
    """Komplette Bibliothek als ZIP herunterladen (DB + STLs + Thumbnails)."""
    snapshot_db("manual")

    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
        if DB_FILE.exists():
            zf.write(DB_FILE, "printvault.db")
        for p in STL_DIR.glob("*.stl"):
            zf.write(p, f"stls/{p.name}")
        for p in THUMB_DIR.glob("*.png"):
            zf.write(p, f"thumbs/{p.name}")
        manifest = {
            "version": "2.0.0",
            "created_at": datetime.now().isoformat(),
            "files": len(list(STL_DIR.glob("*.stl"))),
        }
        zf.writestr("manifest.json", json.dumps(manifest, indent=2))
    buf.seek(0)

    fname = f"printvault-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip"
    return StreamingResponse(
        buf,
        media_type="application/zip",
        headers={"Content-Disposition": f'attachment; filename="{fname}"'},
    )

@app.post("/api/restore")
async def restore_zip(file: UploadFile = File(...)):
    """Backup-ZIP einspielen. Vorher wird automatisch ein Sicherheits-Snapshot angelegt."""
    if not file.filename.lower().endswith(".zip"):
        raise HTTPException(400, "Nur .zip erlaubt")

    snapshot_db("pre-restore")

    data = await file.read()
    with tempfile.TemporaryDirectory() as tmpdir:
        tmp = Path(tmpdir)
        try:
            with zipfile.ZipFile(io.BytesIO(data)) as zf:
                # Validierung
                names = zf.namelist()
                if "printvault.db" not in names:
                    raise HTTPException(400, "ZIP enthält keine printvault.db")
                zf.extractall(tmp)
        except zipfile.BadZipFile:
            raise HTTPException(400, "Ungültige ZIP-Datei")

        # DB validieren bevor wir sie übernehmen
        try:
            test_conn = sqlite3.connect(tmp / "printvault.db")
            test_conn.execute("SELECT COUNT(*) FROM files").fetchone()
            test_conn.close()
        except Exception as e:
            raise HTTPException(400, f"DB im Backup ungültig: {e}")

        # STL & Thumbs einspielen (überschreiben)
        if (tmp / "stls").exists():
            for src in (tmp / "stls").glob("*.stl"):
                atomic_write_bytes(STL_DIR / src.name, src.read_bytes())
        if (tmp / "thumbs").exists():
            for src in (tmp / "thumbs").glob("*.png"):
                atomic_write_bytes(THUMB_DIR / src.name, src.read_bytes())

        # DB atomar ersetzen
        atomic_write_bytes(DB_FILE, (tmp / "printvault.db").read_bytes())

    return {"ok": True}

@app.get("/api/backups")
def list_backups():
    return [
        {
            "name": p.name,
            "size_kb": round(p.stat().st_size / 1024, 1),
            "created_at": datetime.fromtimestamp(p.stat().st_mtime).isoformat(),
        }
        for p in sorted(BACKUP_DIR.glob("printvault-*.db"), reverse=True)
    ]

# ── Static Frontend ────────────────────────────────────────

FRONTEND_DIR = Path(__file__).parent / "frontend"
FRONTEND_DIR.mkdir(exist_ok=True)

@app.get("/")
def serve_frontend():
    return FileResponse(FRONTEND_DIR / "index.html")

if (FRONTEND_DIR / "assets").exists():
    app.mount("/assets", StaticFiles(directory=FRONTEND_DIR / "assets"), name="assets")

@app.get("/icon.svg")
def serve_icon():
    return FileResponse(FRONTEND_DIR / "icon.svg", media_type="image/svg+xml")

# ── Startup ────────────────────────────────────────────────

@app.on_event("startup")
def on_startup():
    init_db()
    migrate_legacy_json()
    import_orphan_stls()
    snapshot_db("startup")

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=3001)