diff --git a/README.md b/README.md index 3f9ab56..5617c42 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ A web viewer for backups of the Android game **Idle Fantasy**. Parses `fantasyid - **Global search** across items, skills, and goals; deep links to tabs (`#overview`, `#goals`, …) - **SQLite history** — import multiple backups, compare snapshots, coins/level/skill charts, delete snapshots - **Import summary** — dashboard card with changes since the previous snapshot (coins, level, top deltas) -- **Viewer export** — download the viewer SQLite database from the sidebar +- **Viewer backup** — export/import the viewer SQLite database (snapshots, history, goals) - **Import** via CLI or browser upload - **Multi-user** without login — each player gets their own viewer via a secret link - **Docker** — ready to run behind nginx Proxy Manager @@ -98,9 +98,19 @@ Create targets from the **Inventory** or **Skills** tab (+ button per row), or m Tabs support URL hashes for bookmarking, e.g. `http://127.0.0.1:5000/v//#goals`. The global search field above the KPI row jumps to matching items, skills, or goals. -### Export viewer database +### Export / import viewer database -Sidebar: **Export viewer** — downloads `idle-fantasy-viewer-.db` (SQLite backup of all snapshots and goals). +The sidebar section **Viewer backup** is separate from **Import backup** (game `.json`): + +| Action | File | Effect | +|--------|------|--------| +| **Import backup** | `.json` from Idle Fantasy | Adds a new snapshot to the current viewer | +| **Export viewer** | `.db` download | Full backup of snapshots, history, and goals | +| **Import viewer** | `.db` from a previous export | **Replaces** all data in the current viewer | + +Use export/import to move your history and goals to another machine, keep an offline backup, or recover after data loss — as long as you still have your personal viewer link (or use the same viewer id locally). + +The `.db` file contains all stored save data; treat it as private. Import asks for confirmation because it overwrites the current viewer database. ## Multi-user (no login) @@ -230,6 +240,7 @@ idle-fantasy-viewer/ | `PATCH /v//api/goal-groups/` | Rename a goal group | | `DELETE /v//api/goal-groups/` | Delete a goal group (goals become ungrouped) | | `GET /v//api/export` | Download viewer SQLite database | +| `POST /v//api/import-viewer` | Restore viewer from exported `.db` (replaces all data, rate limited) | | `POST /v//api/import` | JSON file upload (`.json` only, rate limited) | ## Save format diff --git a/app.py b/app.py index 083008c..1fd42ae 100644 --- a/app.py +++ b/app.py @@ -49,6 +49,7 @@ from viewers import ( ensure_local_viewer, get_or_create_cli_viewer, is_valid_viewer_id, + restore_viewer_db, viewer_db_path, ) @@ -270,6 +271,37 @@ def api_delete_goal(viewer_id: str, goal_id: int): return jsonify({"deleted": True}) +@viewer_bp.route("/api/import-viewer", methods=["POST"]) +@limiter.limit(IMPORT_LIMIT) +def api_import_viewer(viewer_id: str): + db_path = _resolve_viewer_db(viewer_id) + + if "file" not in request.files: + return jsonify({"error": "No file uploaded"}), 400 + f = request.files["file"] + if not f.filename: + return jsonify({"error": "No file selected"}), 400 + + safe_name = secure_filename(f.filename) or "viewer.db" + if not safe_name.lower().endswith(".db"): + return jsonify({"error": "Only .db files are accepted"}), 400 + + upload_dir = get_data_dir() / "uploads" + upload_dir.mkdir(parents=True, exist_ok=True) + tmp = upload_dir / f"_viewer_db_{viewer_id}_{safe_name}" + f.save(tmp) + try: + stats = restore_viewer_db(tmp, db_path) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + except OSError: + return jsonify({"error": "Could not restore viewer database"}), 500 + finally: + tmp.unlink(missing_ok=True) + + return jsonify({"restored": True, **stats}) + + @viewer_bp.route("/api/import", methods=["POST"]) @limiter.limit(IMPORT_LIMIT) def api_import(viewer_id: str): diff --git a/static/app.js b/static/app.js index c1b9301..49c317a 100644 --- a/static/app.js +++ b/static/app.js @@ -74,6 +74,7 @@ async function init() { setupNav(); setupUpload(); setupExport(); + setupViewerDbImport(); setupGlobalSearch(); setupGoalModal(); await loadData(); @@ -163,9 +164,47 @@ function setupExport() { const el = document.getElementById("export-viewer"); if (!el) return; el.href = `${apiBase()}/export`; + el.title = t("viewerDb.exportHint"); el.addEventListener("click", () => trackEvent("Viewer Export")); } +function setupViewerDbImport() { + const input = document.getElementById("viewer-db-upload"); + if (!input) return; + input.addEventListener("change", async (e) => { + const file = e.target.files[0]; + e.target.value = ""; + if (!file) return; + if (!file.name.toLowerCase().endsWith(".db")) { + alert(t("viewerDb.invalidFile")); + return; + } + if (!confirm(t("viewerDb.importConfirm"))) return; + + const fd = new FormData(); + fd.append("file", file); + const res = await fetch(`${apiBase()}/import-viewer`, { method: "POST", body: fd }); + const result = await res.json(); + if (!res.ok || result.error) { + alert(result.error || t("viewerDb.importFailed")); + return; + } + + trackEvent("Viewer Import", { + snapshots: String(result.snapshots || 0), + goals: String(result.goals || 0), + }); + state.lastImportChanges = null; + state.inventoryTimeline = null; + state.skillTimeline = null; + await loadData(); + alert(t("viewerDb.importSuccess", { + snapshots: result.snapshots || 0, + goals: result.goals || 0, + })); + }); +} + function setupGlobalSearch() { const input = document.getElementById("global-search"); if (!input) return; diff --git a/static/locales/de.json b/static/locales/de.json index de9c212..f05036c 100644 --- a/static/locales/de.json +++ b/static/locales/de.json @@ -21,8 +21,9 @@ "langDe": "Deutsch" }, "actions": { - "importBackup": "Backup importieren", + "importBackup": "Spiel-Backup importieren", "exportViewer": "Viewer exportieren", + "importViewer": "Viewer importieren", "compare": "Vergleichen", "dismiss": "Schließen" }, @@ -224,6 +225,16 @@ "copied": "Kopiert!", "copyPrompt": "Viewer-Link kopieren:" }, + "viewerDb": { + "title": "Viewer-Backup", + "helpTitle": "Was ist das?", + "helpBody": "Der Viewer speichert importierte Spiel-Backups, Verlaufsdiagramme und Ziele in einer SQLite-Datenbank auf dem Server. Export lädt diese Datenbank als Backup herunter. Import stellt sie wieder her und ersetzt alle aktuellen Viewer-Daten. Das ist nicht dasselbe wie ein Spiel-Backup (.json) importieren.", + "exportHint": "SQLite-Datenbank mit allen Snapshots, Verläufen und Zielen herunterladen", + "importConfirm": "Alle aktuellen Viewer-Daten (Snapshots, Verlauf, Ziele) werden durch die importierte .db-Datei ersetzt. Fortfahren?", + "importSuccess": "Viewer wiederhergestellt: {snapshots} Snapshot(s), {goals} Ziel(e).", + "importFailed": "Viewer konnte nicht importiert werden", + "invalidFile": "Bitte eine .db-Datei auswählen (Viewer-Export)." + }, "goals": { "filterAll": "Alle", "filterOpen": "Offen", diff --git a/static/locales/en.json b/static/locales/en.json index caeb0aa..03a6b3d 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -21,8 +21,9 @@ "langDe": "Deutsch" }, "actions": { - "importBackup": "Import backup", + "importBackup": "Import game backup", "exportViewer": "Export viewer", + "importViewer": "Import viewer", "compare": "Compare", "dismiss": "Dismiss" }, @@ -224,6 +225,16 @@ "copied": "Copied!", "copyPrompt": "Copy your viewer link:" }, + "viewerDb": { + "title": "Viewer backup", + "helpTitle": "What is this?", + "helpBody": "The viewer stores imported game backups, history charts, and goals in a SQLite database on the server. Export downloads that database as a backup. Import restores it and replaces all current viewer data. This is not the same as importing a game save (.json).", + "exportHint": "Download the SQLite database with all snapshots, history, and goals", + "importConfirm": "All current viewer data (snapshots, history, goals) will be replaced by the imported .db file. Continue?", + "importSuccess": "Viewer restored: {snapshots} snapshot(s), {goals} goal(s).", + "importFailed": "Could not import viewer database", + "invalidFile": "Please select a .db file (viewer export)." + }, "goals": { "filterAll": "All", "filterOpen": "Open", diff --git a/static/style.css b/static/style.css index a169ff7..652288b 100644 --- a/static/style.css +++ b/static/style.css @@ -1029,6 +1029,34 @@ tr.has-goal td:first-child { font-weight: 600; } margin-top: 8px; } +.sidebar-backup-block { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border); +} +.sidebar-backup-label { + margin: 0 0 8px; + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); +} +.sidebar-backup-help { + margin-top: 10px; + font-size: 0.78rem; + color: var(--text-muted); +} +.sidebar-backup-help summary { + cursor: pointer; + color: var(--accent); + font-weight: 600; +} +.sidebar-backup-help p { + margin: 8px 0 0; + line-height: 1.45; +} + .goal-modal { position: fixed; inset: 0; diff --git a/templates/index.html b/templates/index.html index bed020a..a180c32 100644 --- a/templates/index.html +++ b/templates/index.html @@ -40,10 +40,21 @@ - Export viewer + diff --git a/test_db_goals.py b/test_db_goals.py index c05096d..8f04cae 100644 --- a/test_db_goals.py +++ b/test_db_goals.py @@ -20,6 +20,7 @@ from db import ( skill_timeline, summarize_import_changes, ) +from viewers import inspect_viewer_db, restore_viewer_db def _minimal_save(coins: int = 100, level: int = 5) -> dict: @@ -78,6 +79,13 @@ def test_relative_and_skill_goals() -> None: changes = summarize_import_changes(snaps, db_path=db) assert changes["has_previous"] is False + db2 = Path(td) / "restored.db" + stats = restore_viewer_db(db, db2) + assert stats["snapshots"] >= 1 + assert stats["goals"] == 2 + inspected = inspect_viewer_db(db2) + assert inspected["goal_groups"] == 1 + print("all tests passed") diff --git a/viewers.py b/viewers.py index eaafe2a..392cbd6 100644 --- a/viewers.py +++ b/viewers.py @@ -2,14 +2,24 @@ from __future__ import annotations +import os import re import secrets +import shutil +import sqlite3 from pathlib import Path from db import get_connection, init_db VIEWER_ID_RE = re.compile(r"^[A-Za-z0-9_-]{16,64}$") LOCAL_VIEWER_ID = "local" +VIEWER_DB_TABLES = frozenset({ + "snapshots", + "inventory_snapshots", + "skill_snapshots", + "goals", + "goal_groups", +}) def generate_viewer_id() -> str: @@ -79,3 +89,49 @@ def get_or_create_cli_viewer(data_dir: Path) -> str: viewer_id = create_viewer(data_dir) marker.write_text(viewer_id, encoding="utf-8") return viewer_id + + +def inspect_viewer_db(db_path: Path) -> dict[str, int]: + """Validate a viewer SQLite file and apply schema migrations.""" + if not db_path.is_file(): + raise ValueError("Database file not found") + try: + conn = get_connection(db_path) + except sqlite3.DatabaseError as exc: + raise ValueError("Not a valid SQLite database") from exc + try: + tables = { + row[0] + for row in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ) + } + missing = VIEWER_DB_TABLES - tables + if missing: + raise ValueError("Not a valid Idle Fantasy viewer database") + init_db(conn) + conn.commit() + snapshots = conn.execute("SELECT COUNT(*) AS c FROM snapshots").fetchone()["c"] + goals = conn.execute("SELECT COUNT(*) AS c FROM goals").fetchone()["c"] + goal_groups = conn.execute("SELECT COUNT(*) AS c FROM goal_groups").fetchone()["c"] + return { + "snapshots": snapshots, + "goals": goals, + "goal_groups": goal_groups, + } + finally: + conn.close() + + +def restore_viewer_db(source: Path, target: Path) -> dict[str, int]: + """Replace a viewer database with a validated copy.""" + stats = inspect_viewer_db(source) + target.parent.mkdir(parents=True, exist_ok=True) + staging = target.with_suffix(".db.importing") + shutil.copy2(source, staging) + try: + os.replace(staging, target) + except OSError: + staging.unlink(missing_ok=True) + raise + return stats