Add viewer database import and clarify backup actions.

Pairs export with validated .db restore, documents the difference from game
save imports, and renames the JSON upload button to Import game backup.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-19 23:06:59 +02:00
parent defc98dec2
commit 7656578b72
9 changed files with 214 additions and 7 deletions
+14 -3
View File
@@ -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/<id>/#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-<id>.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/<id>/api/goal-groups/<id>` | Rename a goal group |
| `DELETE /v/<id>/api/goal-groups/<id>` | Delete a goal group (goals become ungrouped) |
| `GET /v/<id>/api/export` | Download viewer SQLite database |
| `POST /v/<id>/api/import-viewer` | Restore viewer from exported `.db` (replaces all data, rate limited) |
| `POST /v/<id>/api/import` | JSON file upload (`.json` only, rate limited) |
## Save format
+32
View File
@@ -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):
+39
View File
@@ -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;
+12 -1
View File
@@ -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",
+12 -1
View File
@@ -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",
+28
View File
@@ -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;
+13 -2
View File
@@ -40,10 +40,21 @@
</select>
</label>
<label class="upload-btn">
<span data-i18n="actions.importBackup">Import backup</span>
<span data-i18n="actions.importBackup">Import game backup</span>
<input type="file" id="file-upload" accept=".json" hidden>
</label>
<a class="upload-btn export-btn" id="export-viewer" href="#" data-i18n="actions.exportViewer">Export viewer</a>
<div class="sidebar-backup-block">
<p class="sidebar-backup-label" data-i18n="viewerDb.title">Viewer backup</p>
<a class="upload-btn export-btn" id="export-viewer" href="#" data-i18n="actions.exportViewer">Export viewer</a>
<label class="upload-btn">
<span data-i18n="actions.importViewer">Import viewer</span>
<input type="file" id="viewer-db-upload" accept=".db,application/octet-stream" hidden>
</label>
<details class="sidebar-backup-help">
<summary data-i18n="viewerDb.helpTitle">What is this?</summary>
<p data-i18n="viewerDb.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).</p>
</details>
</div>
</div>
</aside>
+8
View File
@@ -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")
+56
View File
@@ -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