Add Docker deployment and per-player secret-link viewers.
Each player gets an isolated SQLite viewer via a unique URL without login, with landing page warnings to save the link and compose-based hosting for sharing with others. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
fantasyidler_save.json
|
||||||
|
*.md
|
||||||
|
.dockerignore
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
DATA_DIR=/data
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app.py db.py parser.py categories.py validation.py viewers.py ./
|
||||||
|
COPY templates/ templates/
|
||||||
|
COPY static/ static/
|
||||||
|
|
||||||
|
RUN mkdir -p /data/viewers /data/uploads
|
||||||
|
|
||||||
|
VOLUME ["/data"]
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--threads", "4", "app:app"]
|
||||||
@@ -8,7 +8,8 @@ Lokaler Web-Viewer für Backups des Android-Spiels **Idle Fantasy**. Parst `fant
|
|||||||
- **Inventar** mit Textsuche, Kategorie-Filtern, Sortierung und gruppierten Tabellen
|
- **Inventar** mit Textsuche, Kategorie-Filtern, Sortierung und gruppierten Tabellen
|
||||||
- **SQLite-Verlauf** – mehrere Backups importieren, Snapshots vergleichen, Coins-/Level-Charts
|
- **SQLite-Verlauf** – mehrere Backups importieren, Snapshots vergleichen, Coins-/Level-Charts
|
||||||
- **Import** per CLI oder Upload im Browser
|
- **Import** per CLI oder Upload im Browser
|
||||||
- Läuft nur lokal (`127.0.0.1`)
|
- **Multi-User** ohne Login – jeder Spieler erhält einen eigenen Viewer über einen geheimen Link
|
||||||
|
- **Docker** – für Betrieb auf einem Server
|
||||||
- **i18n** – Englisch als Standard/Fallback, Deutsch optional; automatische Browser-Sprache oder manuelle Auswahl in der Sidebar
|
- **i18n** – Englisch als Standard/Fallback, Deutsch optional; automatische Browser-Sprache oder manuelle Auswahl in der Sidebar
|
||||||
|
|
||||||
## Voraussetzungen
|
## Voraussetzungen
|
||||||
@@ -32,7 +33,31 @@ pip install -r requirements.txt
|
|||||||
python app.py fantasyidler_save.json
|
python app.py fantasyidler_save.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Der Browser öffnet sich automatisch unter `http://127.0.0.1:5000`.
|
Der Browser öffnet sich automatisch unter `http://127.0.0.1:5000/v/local/`.
|
||||||
|
|
||||||
|
### Docker (für andere Spieler hosten)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Viewer ist dann unter `http://localhost:5000` erreichbar:
|
||||||
|
|
||||||
|
1. Startseite → **Meinen Viewer erstellen**
|
||||||
|
2. Persönlichen Link speichern (Bookmark) – **ohne Link sind die Daten nicht wiederherstellbar** (kein Login)
|
||||||
|
3. Backups im Browser importieren
|
||||||
|
|
||||||
|
Daten liegen im Docker-Volume `viewer-data` (`/data/viewers/<id>.db`).
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Logs
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
# Stoppen
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
Umgebungsvariable `DATA_DIR` (Standard in Docker: `/data`) legt den Speicherort fest.
|
||||||
|
|
||||||
### Weitere Optionen
|
### Weitere Optionen
|
||||||
|
|
||||||
@@ -43,13 +68,31 @@ python app.py --import backup2.json
|
|||||||
# Anderen Port, Browser nicht öffnen
|
# Anderen Port, Browser nicht öffnen
|
||||||
python app.py fantasyidler_save.json --port 8080 --no-browser
|
python app.py fantasyidler_save.json --port 8080 --no-browser
|
||||||
|
|
||||||
# Eigene SQLite-Datenbank
|
# Eigene SQLite-Datenbank (Legacy, ein Datei-Modus)
|
||||||
python app.py --db data\meine_history.db fantasyidler_save.json
|
python app.py --db data\meine_history.db fantasyidler_save.json
|
||||||
|
|
||||||
|
# Server für Netzwerk/Docker binden
|
||||||
|
python app.py --host 0.0.0.0 --no-browser
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backups im Browser importieren
|
### Backups im Browser importieren
|
||||||
|
|
||||||
Im Tab **Inventar** (Sidebar unten): **Backup importieren** – wählt eine `.json`-Datei. Duplikate (gleicher Datei-Hash) werden übersprungen.
|
Sidebar unten: **Backup importieren** – wählt eine `.json`-Datei. Duplikate (gleicher Datei-Hash) werden übersprungen.
|
||||||
|
|
||||||
|
## Multi-User (ohne Login)
|
||||||
|
|
||||||
|
Jeder Viewer hat eine eigene SQLite-Datenbank unter `data/viewers/<viewer_id>.db`.
|
||||||
|
|
||||||
|
| Route | Beschreibung |
|
||||||
|
|-------|--------------|
|
||||||
|
| `GET /` | Startseite – neuen Viewer anlegen |
|
||||||
|
| `POST /api/viewers` | Erstellt Viewer, liefert `{ viewer_id, url }` |
|
||||||
|
| `GET /v/<viewer_id>/` | Persönliches Dashboard |
|
||||||
|
| `GET /v/<viewer_id>/api/...` | API für diesen Viewer |
|
||||||
|
|
||||||
|
Die `viewer_id` ist ein zufälliges Token (URL-safe). Wer den Link kennt, hat Zugriff – es gibt kein Passwort und keine Wiederherstellung bei verlorenem Link.
|
||||||
|
|
||||||
|
Lokale CLI-Nutzung nutzt standardmäßig den Viewer `local` (`/v/local/`).
|
||||||
|
|
||||||
## Sprache / i18n
|
## Sprache / i18n
|
||||||
|
|
||||||
@@ -64,28 +107,33 @@ Im Tab **Inventar** (Sidebar unten): **Backup importieren** – wählt eine `.js
|
|||||||
```
|
```
|
||||||
idle-fantasy-viewer/
|
idle-fantasy-viewer/
|
||||||
├── app.py # Flask-Server und CLI
|
├── app.py # Flask-Server und CLI
|
||||||
|
├── viewers.py # Viewer-IDs und Isolation
|
||||||
├── parser.py # Save parsen und normalisieren
|
├── parser.py # Save parsen und normalisieren
|
||||||
├── categories.py # Item-Kategorien (Heuristiken)
|
├── categories.py # Item-Kategorien (Heuristiken)
|
||||||
├── db.py # SQLite Snapshots, Diff, Timeline
|
├── db.py # SQLite Snapshots, Diff, Timeline
|
||||||
|
├── Dockerfile
|
||||||
|
├── docker-compose.yml
|
||||||
├── requirements.txt
|
├── requirements.txt
|
||||||
├── static/
|
├── static/
|
||||||
│ ├── i18n.js # Locale-Laden, t(), Fallback en
|
│ ├── i18n.js # Locale-Laden, t(), Fallback en
|
||||||
│ ├── locales/ # en.json, de.json
|
│ ├── locales/ # en.json, de.json
|
||||||
|
│ ├── landing.js # Startseite
|
||||||
│ └── app.js # Dashboard-UI
|
│ └── app.js # Dashboard-UI
|
||||||
├── templates/ # HTML
|
├── templates/ # HTML
|
||||||
└── data/ # history.db (wird angelegt, gitignored)
|
└── data/ # viewers/*.db (gitignored)
|
||||||
```
|
```
|
||||||
|
|
||||||
## API (lokal)
|
## API
|
||||||
|
|
||||||
| Endpunkt | Beschreibung |
|
| Endpunkt | Beschreibung |
|
||||||
|----------|--------------|
|
|----------|--------------|
|
||||||
| `GET /` | Dashboard |
|
| `GET /` | Startseite |
|
||||||
| `GET /api/snapshot/latest` | Neuester normalisierter Save |
|
| `POST /api/viewers` | Neuen Viewer erstellen |
|
||||||
| `GET /api/snapshots` | Alle Snapshots |
|
| `GET /v/<id>/api/snapshot/latest` | Neuester Save des Viewers |
|
||||||
| `GET /api/snapshots/<älter>/diff/<neuer>` | Vergleich zweier Backups |
|
| `GET /v/<id>/api/snapshots` | Alle Snapshots |
|
||||||
| `GET /api/timeline` | Zeitreihe für Charts |
|
| `GET /v/<id>/api/snapshots/<älter>/diff/<neuer>` | Vergleich |
|
||||||
| `POST /api/import` | JSON-Upload oder `{"path": "..."}` |
|
| `GET /v/<id>/api/timeline` | Zeitreihe für Charts |
|
||||||
|
| `POST /v/<id>/api/import` | JSON-Upload |
|
||||||
|
|
||||||
## Save-Format
|
## Save-Format
|
||||||
|
|
||||||
@@ -93,7 +141,7 @@ Die Backup-Datei enthält u. a. doppelt JSON-kodierte Felder (`skillLevels`, `in
|
|||||||
|
|
||||||
## Hinweise
|
## Hinweise
|
||||||
|
|
||||||
- `data/history.db` speichert importierte Snapshots lokal; nicht mit ins Repo committen (steht in `.gitignore`).
|
- `data/viewers/` speichert pro Spieler eine SQLite-Datei; nicht mit ins Repo committen (steht in `.gitignore`).
|
||||||
- Der Viewer ist ein inoffizielles Hilfstool, nicht mit dem Spiel verbunden.
|
- Der Viewer ist ein inoffizielles Hilfstool, nicht mit dem Spiel verbunden.
|
||||||
|
|
||||||
## Robustheit bei Spiel-Updates
|
## Robustheit bei Spiel-Updates
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Idle Fantasy Save Viewer – local Flask server."""
|
"""Idle Fantasy Save Viewer – Flask server with per-viewer secret URLs."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import os
|
||||||
import sys
|
import sys
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Flask, jsonify, render_template, request
|
from flask import Blueprint, Flask, abort, jsonify, render_template, request
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
from db import (
|
from db import (
|
||||||
DEFAULT_DB,
|
DEFAULT_DB,
|
||||||
@@ -22,59 +23,101 @@ from db import (
|
|||||||
get_connection,
|
get_connection,
|
||||||
timeline,
|
timeline,
|
||||||
)
|
)
|
||||||
|
from viewers import (
|
||||||
|
LOCAL_VIEWER_ID,
|
||||||
|
create_viewer,
|
||||||
|
ensure_local_viewer,
|
||||||
|
is_valid_viewer_id,
|
||||||
|
viewer_db_path,
|
||||||
|
)
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
DATA_DIR = Path(os.environ.get("DATA_DIR", Path(__file__).parent / "data"))
|
||||||
DB_PATH = DEFAULT_DB
|
DB_PATH = DEFAULT_DB
|
||||||
|
|
||||||
|
|
||||||
@app.route("/")
|
def get_data_dir() -> Path:
|
||||||
def index():
|
return DATA_DIR
|
||||||
return render_template("index.html")
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/snapshot/latest")
|
def _viewer_url(viewer_id: str) -> str:
|
||||||
def api_latest():
|
base = request.host_url.rstrip("/")
|
||||||
data = get_latest_snapshot(db_path=DB_PATH)
|
return f"{base}/v/{viewer_id}/"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_viewer_db(viewer_id: str) -> Path:
|
||||||
|
if not is_valid_viewer_id(viewer_id):
|
||||||
|
abort(404)
|
||||||
|
db_path = viewer_db_path(viewer_id, get_data_dir())
|
||||||
|
if not db_path.exists():
|
||||||
|
abort(404)
|
||||||
|
return db_path
|
||||||
|
|
||||||
|
|
||||||
|
viewer_bp = Blueprint("viewer", __name__, url_prefix="/v/<viewer_id>")
|
||||||
|
|
||||||
|
|
||||||
|
@viewer_bp.route("/")
|
||||||
|
def viewer_index(viewer_id: str):
|
||||||
|
_resolve_viewer_db(viewer_id)
|
||||||
|
return render_template("index.html", viewer_id=viewer_id)
|
||||||
|
|
||||||
|
|
||||||
|
@viewer_bp.route("/api/snapshot/latest")
|
||||||
|
def api_latest(viewer_id: str):
|
||||||
|
db_path = _resolve_viewer_db(viewer_id)
|
||||||
|
data = get_latest_snapshot(db_path=db_path)
|
||||||
if not data:
|
if not data:
|
||||||
return jsonify({"error": "No snapshots imported yet"}), 404
|
return jsonify({"error": "No snapshots imported yet"}), 404
|
||||||
return jsonify(data)
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/snapshot/<int:snapshot_id>")
|
@viewer_bp.route("/api/snapshot/<int:snapshot_id>")
|
||||||
def api_snapshot(snapshot_id: int):
|
def api_snapshot(viewer_id: str, snapshot_id: int):
|
||||||
data = get_snapshot(snapshot_id, db_path=DB_PATH)
|
db_path = _resolve_viewer_db(viewer_id)
|
||||||
|
data = get_snapshot(snapshot_id, db_path=db_path)
|
||||||
if not data:
|
if not data:
|
||||||
return jsonify({"error": "Snapshot not found"}), 404
|
return jsonify({"error": "Snapshot not found"}), 404
|
||||||
return jsonify(data)
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/snapshots")
|
@viewer_bp.route("/api/snapshots")
|
||||||
def api_snapshots():
|
def api_snapshots(viewer_id: str):
|
||||||
return jsonify(list_snapshots(db_path=DB_PATH))
|
db_path = _resolve_viewer_db(viewer_id)
|
||||||
|
return jsonify(list_snapshots(db_path=db_path))
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/snapshots/<int:older_id>/diff/<int:newer_id>")
|
@viewer_bp.route("/api/snapshots/<int:older_id>/diff/<int:newer_id>")
|
||||||
def api_diff(older_id: int, newer_id: int):
|
def api_diff(viewer_id: str, older_id: int, newer_id: int):
|
||||||
|
db_path = _resolve_viewer_db(viewer_id)
|
||||||
try:
|
try:
|
||||||
return jsonify(diff_snapshots(older_id, newer_id, db_path=DB_PATH))
|
return jsonify(diff_snapshots(older_id, newer_id, db_path=db_path))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({"error": str(e)}), 404
|
return jsonify({"error": str(e)}), 404
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/timeline")
|
@viewer_bp.route("/api/timeline")
|
||||||
def api_timeline():
|
def api_timeline(viewer_id: str):
|
||||||
return jsonify(timeline(db_path=DB_PATH))
|
db_path = _resolve_viewer_db(viewer_id)
|
||||||
|
return jsonify(timeline(db_path=db_path))
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/import", methods=["POST"])
|
@viewer_bp.route("/api/import", methods=["POST"])
|
||||||
def api_import():
|
def api_import(viewer_id: str):
|
||||||
|
db_path = _resolve_viewer_db(viewer_id)
|
||||||
|
|
||||||
if "file" in request.files:
|
if "file" in request.files:
|
||||||
f = request.files["file"]
|
f = request.files["file"]
|
||||||
if not f.filename:
|
if not f.filename:
|
||||||
return jsonify({"error": "No file selected"}), 400
|
return jsonify({"error": "No file selected"}), 400
|
||||||
tmp = Path(DB_PATH.parent) / f"_upload_{f.filename}"
|
upload_dir = get_data_dir() / "uploads"
|
||||||
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
safe_name = secure_filename(f.filename) or "upload.json"
|
||||||
|
tmp = upload_dir / f"_upload_{viewer_id}_{safe_name}"
|
||||||
f.save(tmp)
|
f.save(tmp)
|
||||||
try:
|
try:
|
||||||
result = import_save(tmp, db_path=DB_PATH)
|
result = import_save(tmp, db_path=db_path)
|
||||||
finally:
|
finally:
|
||||||
tmp.unlink(missing_ok=True)
|
tmp.unlink(missing_ok=True)
|
||||||
if result.get("error"):
|
if result.get("error"):
|
||||||
@@ -88,12 +131,29 @@ def api_import():
|
|||||||
path = Path(path)
|
path = Path(path)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return jsonify({"error": f"File not found: {path}"}), 404
|
return jsonify({"error": f"File not found: {path}"}), 404
|
||||||
result = import_save(path, db_path=DB_PATH)
|
result = import_save(path, db_path=db_path)
|
||||||
if result.get("error"):
|
if result.get("error"):
|
||||||
return jsonify(result), 422
|
return jsonify(result), 422
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
app.register_blueprint(viewer_bp)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def landing():
|
||||||
|
return render_template("landing.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/viewers")
|
||||||
|
def api_create_viewer():
|
||||||
|
viewer_id = create_viewer(get_data_dir())
|
||||||
|
return jsonify({
|
||||||
|
"viewer_id": viewer_id,
|
||||||
|
"url": _viewer_url(viewer_id),
|
||||||
|
}), 201
|
||||||
|
|
||||||
|
|
||||||
def _print_import_report(result: dict) -> None:
|
def _print_import_report(result: dict) -> None:
|
||||||
report = result.get("import_report") or []
|
report = result.get("import_report") or []
|
||||||
if not report:
|
if not report:
|
||||||
@@ -108,14 +168,31 @@ def main() -> int:
|
|||||||
parser.add_argument("save_file", nargs="?", help="Save JSON to import on start")
|
parser.add_argument("save_file", nargs="?", help="Save JSON to import on start")
|
||||||
parser.add_argument("--import", dest="import_file", metavar="FILE", help="Import save without starting server")
|
parser.add_argument("--import", dest="import_file", metavar="FILE", help="Import save without starting server")
|
||||||
parser.add_argument("--port", type=int, default=5000)
|
parser.add_argument("--port", type=int, default=5000)
|
||||||
|
parser.add_argument("--host", default="127.0.0.1", help="Bind host (use 0.0.0.0 in Docker)")
|
||||||
parser.add_argument("--no-browser", action="store_true")
|
parser.add_argument("--no-browser", action="store_true")
|
||||||
parser.add_argument("--db", type=Path, default=DEFAULT_DB, help="SQLite database path")
|
parser.add_argument("--db", type=Path, help="SQLite path (legacy single-file mode)")
|
||||||
|
parser.add_argument("--viewer", default=LOCAL_VIEWER_ID, help="Viewer id for CLI (default: local)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
global DB_PATH
|
global DATA_DIR, DB_PATH
|
||||||
DB_PATH = args.db
|
DATA_DIR = Path(os.environ.get("DATA_DIR", Path(__file__).parent / "data"))
|
||||||
|
|
||||||
conn = get_connection(DB_PATH)
|
if args.db:
|
||||||
|
DB_PATH = args.db
|
||||||
|
db_path = DB_PATH
|
||||||
|
viewer_id = None
|
||||||
|
else:
|
||||||
|
viewer_id = ensure_local_viewer(DATA_DIR) if args.viewer == LOCAL_VIEWER_ID else args.viewer
|
||||||
|
if not is_valid_viewer_id(viewer_id):
|
||||||
|
print(f"Error: invalid viewer id: {viewer_id}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
db_path = viewer_db_path(viewer_id, DATA_DIR)
|
||||||
|
if not db_path.exists():
|
||||||
|
conn = get_connection(db_path)
|
||||||
|
init_db(conn)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
conn = get_connection(db_path)
|
||||||
init_db(conn)
|
init_db(conn)
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -125,7 +202,7 @@ def main() -> int:
|
|||||||
if not path.exists():
|
if not path.exists():
|
||||||
print(f"Error: file not found: {path}", file=sys.stderr)
|
print(f"Error: file not found: {path}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
result = import_save(path, db_path=DB_PATH)
|
result = import_save(path, db_path=db_path)
|
||||||
if result.get("error"):
|
if result.get("error"):
|
||||||
print(f"Import failed: {result['error']}", file=sys.stderr)
|
print(f"Import failed: {result['error']}", file=sys.stderr)
|
||||||
_print_import_report(result)
|
_print_import_report(result)
|
||||||
@@ -146,11 +223,15 @@ def main() -> int:
|
|||||||
if args.import_file and not args.save_file:
|
if args.import_file and not args.save_file:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
url = f"http://127.0.0.1:{args.port}"
|
if viewer_id:
|
||||||
|
url = f"http://{args.host}:{args.port}/v/{viewer_id}/"
|
||||||
|
else:
|
||||||
|
url = f"http://{args.host}:{args.port}/"
|
||||||
print(f"Starting server at {url}")
|
print(f"Starting server at {url}")
|
||||||
if not args.no_browser:
|
if not args.no_browser and args.host in ("127.0.0.1", "localhost"):
|
||||||
webbrowser.open(url)
|
webbrowser.open(url)
|
||||||
app.run(host="127.0.0.1", port=args.port, debug=False)
|
app.run(host=args.host, port=args.port, debug=False)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
services:
|
||||||
|
viewer:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
environment:
|
||||||
|
DATA_DIR: /data
|
||||||
|
volumes:
|
||||||
|
- viewer-data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
viewer-data:
|
||||||
@@ -1 +1,2 @@
|
|||||||
flask>=3.0
|
flask>=3.0
|
||||||
|
gunicorn>=22.0
|
||||||
|
|||||||
+42
-6
@@ -38,15 +38,51 @@ const CATEGORY_I18N_KEYS = {
|
|||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", init);
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
|
||||||
|
function apiBase() {
|
||||||
|
const vid = window.VIEWER_ID;
|
||||||
|
return vid ? `/v/${vid}/api` : "/api";
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewerPageUrl() {
|
||||||
|
const vid = window.VIEWER_ID;
|
||||||
|
if (!vid) return window.location.href;
|
||||||
|
return `${window.location.origin}/v/${vid}/`;
|
||||||
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
await I18n.init();
|
await I18n.init();
|
||||||
applyStaticI18n();
|
applyStaticI18n();
|
||||||
setupLanguage();
|
setupLanguage();
|
||||||
|
setupViewerBanner();
|
||||||
setupNav();
|
setupNav();
|
||||||
setupUpload();
|
setupUpload();
|
||||||
await loadData();
|
await loadData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupViewerBanner() {
|
||||||
|
const vid = window.VIEWER_ID;
|
||||||
|
if (!vid || vid === "local") return;
|
||||||
|
|
||||||
|
const banner = document.getElementById("viewer-link-banner");
|
||||||
|
const urlEl = document.getElementById("viewer-link-url");
|
||||||
|
const copyBtn = document.getElementById("viewer-copy-link");
|
||||||
|
const url = viewerPageUrl();
|
||||||
|
|
||||||
|
banner.hidden = false;
|
||||||
|
urlEl.textContent = url;
|
||||||
|
|
||||||
|
copyBtn.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
const prev = copyBtn.textContent;
|
||||||
|
copyBtn.textContent = t("viewer.copied");
|
||||||
|
setTimeout(() => { copyBtn.textContent = prev; }, 2000);
|
||||||
|
} catch {
|
||||||
|
window.prompt(t("viewer.copyPrompt"), url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function categoryLabel(cat) {
|
function categoryLabel(cat) {
|
||||||
const key = CATEGORY_I18N_KEYS[cat];
|
const key = CATEGORY_I18N_KEYS[cat];
|
||||||
return key ? t(key) : cat;
|
return key ? t(key) : cat;
|
||||||
@@ -96,7 +132,7 @@ function setupUpload() {
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append("file", file);
|
fd.append("file", file);
|
||||||
const res = await fetch("/api/import", { method: "POST", body: fd });
|
const res = await fetch(`${apiBase()}/import`, { method: "POST", body: fd });
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
if (!res.ok || result.error) {
|
if (!res.ok || result.error) {
|
||||||
showImportFailure(result);
|
showImportFailure(result);
|
||||||
@@ -185,9 +221,9 @@ function renderImportReport(meta) {
|
|||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/snapshot/latest");
|
const res = await fetch(`${apiBase()}/snapshot/latest`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
showEmpty(t("empty.noSave"));
|
showEmpty(window.VIEWER_ID ? t("empty.noSaveWeb") : t("empty.noSave"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.data = await res.json();
|
state.data = await res.json();
|
||||||
@@ -629,8 +665,8 @@ async function loadHistoryTab() {
|
|||||||
panel.innerHTML = `<p class='loading'>${esc(t("history.loading"))}</p>`;
|
panel.innerHTML = `<p class='loading'>${esc(t("history.loading"))}</p>`;
|
||||||
|
|
||||||
const [snapRes, tlRes] = await Promise.all([
|
const [snapRes, tlRes] = await Promise.all([
|
||||||
fetch("/api/snapshots"),
|
fetch(`${apiBase()}/snapshots`),
|
||||||
fetch("/api/timeline"),
|
fetch(`${apiBase()}/timeline`),
|
||||||
]);
|
]);
|
||||||
state.snapshots = await snapRes.json();
|
state.snapshots = await snapRes.json();
|
||||||
state.timeline = await tlRes.json();
|
state.timeline = await tlRes.json();
|
||||||
@@ -756,7 +792,7 @@ async function runDiff() {
|
|||||||
}
|
}
|
||||||
const older = Math.min(h.olderId, h.newerId);
|
const older = Math.min(h.olderId, h.newerId);
|
||||||
const newer = Math.max(h.olderId, h.newerId);
|
const newer = Math.max(h.olderId, h.newerId);
|
||||||
const res = await fetch(`/api/snapshots/${older}/diff/${newer}`);
|
const res = await fetch(`${apiBase()}/snapshots/${older}/diff/${newer}`);
|
||||||
const diff = await res.json();
|
const diff = await res.json();
|
||||||
if (diff.error) {
|
if (diff.error) {
|
||||||
el.innerHTML = `<p class='empty-state'>${esc(diff.error)}</p>`;
|
el.innerHTML = `<p class='empty-state'>${esc(diff.error)}</p>`;
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
document.addEventListener("DOMContentLoaded", async () => {
|
||||||
|
await I18n.init();
|
||||||
|
applyStaticI18n();
|
||||||
|
setupLanguage();
|
||||||
|
setupCreate();
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyStaticI18n() {
|
||||||
|
document.querySelectorAll("[data-i18n]").forEach((el) => {
|
||||||
|
el.textContent = t(el.dataset.i18n);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupLanguage() {
|
||||||
|
const sel = document.getElementById("locale-select");
|
||||||
|
sel.value = I18n.getPreference();
|
||||||
|
sel.addEventListener("change", async (e) => {
|
||||||
|
await I18n.setPreference(e.target.value);
|
||||||
|
applyStaticI18n();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupCreate() {
|
||||||
|
const btn = document.getElementById("create-viewer");
|
||||||
|
const status = document.getElementById("create-status");
|
||||||
|
btn.addEventListener("click", async () => {
|
||||||
|
btn.disabled = true;
|
||||||
|
status.hidden = false;
|
||||||
|
status.textContent = t("viewer.creating");
|
||||||
|
status.className = "landing-hint";
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/viewers", { method: "POST" });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || t("viewer.createFailed"));
|
||||||
|
window.location.href = data.url;
|
||||||
|
} catch (err) {
|
||||||
|
status.textContent = err.message;
|
||||||
|
status.className = "landing-hint landing-hint-error";
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"noSave": "Kein Save importiert. Starte mit: python app.py fantasyidler_save.json",
|
"noSave": "Kein Save importiert. Starte mit: python app.py fantasyidler_save.json",
|
||||||
|
"noSaveWeb": "Noch kein Save importiert. Importiere ein Backup über den Button in der Sidebar.",
|
||||||
"loadError": "Fehler beim Laden: {message}",
|
"loadError": "Fehler beim Laden: {message}",
|
||||||
"unknown": "Unbekannt",
|
"unknown": "Unbekannt",
|
||||||
"none": "Keine",
|
"none": "Keine",
|
||||||
@@ -174,5 +175,21 @@
|
|||||||
"gems_jewelry": "Edelsteine & Schmuck",
|
"gems_jewelry": "Edelsteine & Schmuck",
|
||||||
"potions_brews": "Tränke & Brauerei",
|
"potions_brews": "Tränke & Brauerei",
|
||||||
"misc": "Sonstiges"
|
"misc": "Sonstiges"
|
||||||
|
},
|
||||||
|
"viewer": {
|
||||||
|
"landingLead": "Erstelle deinen persönlichen Save-Viewer. Kein Konto – nur ein privater Link zu deinen Daten.",
|
||||||
|
"featureDashboard": "Skills, Inventar, Quests und Verlauf",
|
||||||
|
"featureUpload": "Backups im Browser importieren",
|
||||||
|
"featurePrivate": "Deine Daten bleiben nur in deinem Viewer",
|
||||||
|
"create": "Meinen Viewer erstellen",
|
||||||
|
"creating": "Viewer wird erstellt…",
|
||||||
|
"createFailed": "Viewer konnte nicht erstellt werden",
|
||||||
|
"warningTitle": "Wichtig",
|
||||||
|
"warningBody": "Es gibt keinen Login. Dein Viewer ist nur über seinen einzigartigen Link erreichbar. Link speichern oder bookmarken – ohne ihn sind deine Daten nicht wiederherstellbar.",
|
||||||
|
"linkTitle": "Dein persönlicher Link",
|
||||||
|
"linkWarning": "Link speichern – es gibt keinen Login. Ohne Link sind deine Daten weg.",
|
||||||
|
"copyLink": "Link kopieren",
|
||||||
|
"copied": "Kopiert!",
|
||||||
|
"copyPrompt": "Viewer-Link kopieren:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"noSave": "No save imported. Start with: python app.py fantasyidler_save.json",
|
"noSave": "No save imported. Start with: python app.py fantasyidler_save.json",
|
||||||
|
"noSaveWeb": "No save imported yet. Import a backup using the sidebar button.",
|
||||||
"loadError": "Failed to load: {message}",
|
"loadError": "Failed to load: {message}",
|
||||||
"unknown": "Unknown",
|
"unknown": "Unknown",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
@@ -174,5 +175,21 @@
|
|||||||
"gems_jewelry": "Gems & Jewelry",
|
"gems_jewelry": "Gems & Jewelry",
|
||||||
"potions_brews": "Potions & Brews",
|
"potions_brews": "Potions & Brews",
|
||||||
"misc": "Misc"
|
"misc": "Misc"
|
||||||
|
},
|
||||||
|
"viewer": {
|
||||||
|
"landingLead": "Create your personal save viewer. No account – just a private link to your data.",
|
||||||
|
"featureDashboard": "Skills, inventory, quests and history",
|
||||||
|
"featureUpload": "Import backups in the browser",
|
||||||
|
"featurePrivate": "Your data stays in your viewer only",
|
||||||
|
"create": "Create my viewer",
|
||||||
|
"creating": "Creating viewer…",
|
||||||
|
"createFailed": "Could not create viewer",
|
||||||
|
"warningTitle": "Important",
|
||||||
|
"warningBody": "There is no login. Your viewer is only accessible via its unique link. Bookmark or save the link – without it, your data cannot be recovered.",
|
||||||
|
"linkTitle": "Your personal link",
|
||||||
|
"linkWarning": "Save this link – there is no login. Without it, your data is lost.",
|
||||||
|
"copyLink": "Copy link",
|
||||||
|
"copied": "Copied!",
|
||||||
|
"copyPrompt": "Copy your viewer link:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -597,6 +597,124 @@ tr:hover td { background: var(--bg-hover); }
|
|||||||
|
|
||||||
.list-compact li:last-child { border-bottom: none; }
|
.list-compact li:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
/* Landing page */
|
||||||
|
.landing-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-brand { margin-bottom: 20px; }
|
||||||
|
|
||||||
|
.landing-lead {
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-features {
|
||||||
|
margin: 0 0 24px;
|
||||||
|
padding-left: 1.2rem;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-actions { margin-bottom: 20px; }
|
||||||
|
|
||||||
|
.landing-create {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-hint {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-hint-error { color: #f87171; }
|
||||||
|
|
||||||
|
.landing-warning {
|
||||||
|
background: rgba(251, 191, 36, 0.08);
|
||||||
|
border: 1px solid rgba(251, 191, 36, 0.35);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-warning strong { color: #fbbf24; }
|
||||||
|
|
||||||
|
.landing-lang { margin-top: 8px; }
|
||||||
|
|
||||||
|
/* Viewer link banner */
|
||||||
|
.viewer-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid rgba(251, 191, 36, 0.35);
|
||||||
|
background: rgba(251, 191, 36, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-banner-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-banner-text strong {
|
||||||
|
display: block;
|
||||||
|
color: #fbbf24;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-banner-warning {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-link-url {
|
||||||
|
display: block;
|
||||||
|
word-break: break-all;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-copy-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-copy-btn:hover { background: var(--accent-dim); color: #fff; }
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.sidebar {
|
.sidebar {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" defer></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" defer></script>
|
||||||
<script src="/static/i18n.js" defer></script>
|
<script src="/static/i18n.js" defer></script>
|
||||||
|
<script>window.VIEWER_ID = {{ viewer_id|tojson }};</script>
|
||||||
<script src="/static/app.js" defer></script>
|
<script src="/static/app.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -46,6 +47,16 @@
|
|||||||
|
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
|
<div id="viewer-link-banner" class="viewer-banner" hidden>
|
||||||
|
<div class="viewer-banner-text">
|
||||||
|
<strong data-i18n="viewer.linkTitle">Your personal link</strong>
|
||||||
|
<p class="viewer-banner-warning" data-i18n="viewer.linkWarning">
|
||||||
|
Save this link – there is no login. Without it, your data is lost.
|
||||||
|
</p>
|
||||||
|
<code id="viewer-link-url" class="viewer-link-url"></code>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="viewer-copy-btn" id="viewer-copy-link" data-i18n="viewer.copyLink">Copy link</button>
|
||||||
|
</div>
|
||||||
<div id="import-report" class="import-report" hidden></div>
|
<div id="import-report" class="import-report" hidden></div>
|
||||||
<div id="character-header" class="character-header">
|
<div id="character-header" class="character-header">
|
||||||
<span class="loading" data-i18n="app.loading">Loading save…</span>
|
<span class="loading" data-i18n="app.loading">Loading save…</span>
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Idle Fantasy Viewer</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<script src="/static/i18n.js" defer></script>
|
||||||
|
<script src="/static/landing.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body class="landing-page">
|
||||||
|
<main class="landing-card">
|
||||||
|
<div class="brand landing-brand">
|
||||||
|
<span class="brand-icon">⚔</span>
|
||||||
|
<div>
|
||||||
|
<h1 data-i18n="app.title">Idle Fantasy</h1>
|
||||||
|
<p class="subtitle" data-i18n="app.subtitle">Save Viewer</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="landing-lead" data-i18n="viewer.landingLead">
|
||||||
|
Create your personal save viewer. No account – just a private link to your data.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="landing-features">
|
||||||
|
<li data-i18n="viewer.featureDashboard">Skills, inventory, quests and history</li>
|
||||||
|
<li data-i18n="viewer.featureUpload">Import backups in the browser</li>
|
||||||
|
<li data-i18n="viewer.featurePrivate">Your data stays in your viewer only</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="landing-actions">
|
||||||
|
<button type="button" class="upload-btn landing-create" id="create-viewer" data-i18n="viewer.create">
|
||||||
|
Create my viewer
|
||||||
|
</button>
|
||||||
|
<p class="landing-hint" id="create-status" hidden></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="landing-warning">
|
||||||
|
<strong data-i18n="viewer.warningTitle">Important</strong>
|
||||||
|
<p data-i18n="viewer.warningBody">
|
||||||
|
There is no login. Your viewer is only accessible via its unique link.
|
||||||
|
Bookmark or save the link – without it, your data cannot be recovered.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="lang-label landing-lang" for="locale-select">
|
||||||
|
<span data-i18n="settings.language">Language</span>
|
||||||
|
<select id="locale-select" class="select-input lang-select">
|
||||||
|
<option value="auto" data-i18n="settings.langAuto">Auto (browser)</option>
|
||||||
|
<option value="en" data-i18n="settings.langEn">English</option>
|
||||||
|
<option value="de" data-i18n="settings.langDe">Deutsch</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
"""Per-viewer isolation via secret URL tokens (no login)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_viewer_id() -> str:
|
||||||
|
return secrets.token_urlsafe(16)
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_viewer_id(viewer_id: str) -> bool:
|
||||||
|
if viewer_id == LOCAL_VIEWER_ID:
|
||||||
|
return True
|
||||||
|
return bool(viewer_id and VIEWER_ID_RE.match(viewer_id))
|
||||||
|
|
||||||
|
|
||||||
|
def viewers_dir(data_dir: Path) -> Path:
|
||||||
|
return data_dir / "viewers"
|
||||||
|
|
||||||
|
|
||||||
|
def viewer_db_path(viewer_id: str, data_dir: Path) -> Path:
|
||||||
|
return viewers_dir(data_dir) / f"{viewer_id}.db"
|
||||||
|
|
||||||
|
|
||||||
|
def viewer_exists(viewer_id: str, data_dir: Path) -> bool:
|
||||||
|
return viewer_db_path(viewer_id, data_dir).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def create_viewer(data_dir: Path) -> str:
|
||||||
|
"""Create a new viewer with an empty SQLite database."""
|
||||||
|
data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
viewers_dir(data_dir).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for _ in range(10):
|
||||||
|
viewer_id = generate_viewer_id()
|
||||||
|
db_path = viewer_db_path(viewer_id, data_dir)
|
||||||
|
if db_path.exists():
|
||||||
|
continue
|
||||||
|
conn = get_connection(db_path)
|
||||||
|
init_db(conn)
|
||||||
|
conn.close()
|
||||||
|
return viewer_id
|
||||||
|
|
||||||
|
raise RuntimeError("Could not allocate a unique viewer id")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_local_viewer(data_dir: Path) -> str:
|
||||||
|
"""CLI default viewer – not secret, for local single-user use."""
|
||||||
|
db_path = viewer_db_path(LOCAL_VIEWER_ID, data_dir)
|
||||||
|
if db_path.exists():
|
||||||
|
return LOCAL_VIEWER_ID
|
||||||
|
conn = get_connection(db_path)
|
||||||
|
init_db(conn)
|
||||||
|
conn.close()
|
||||||
|
return LOCAL_VIEWER_ID
|
||||||
Reference in New Issue
Block a user