Add i18n, save validation, and tolerant import handling.
Prepare the UI for English (default/fallback) and German with auto or manual locale selection, and report import issues with client-translated warnings instead of failing on minor save format changes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,6 +9,7 @@ Lokaler Web-Viewer für Backups des Android-Spiels **Idle Fantasy**. Parst `fant
|
||||
- **SQLite-Verlauf** – mehrere Backups importieren, Snapshots vergleichen, Coins-/Level-Charts
|
||||
- **Import** per CLI oder Upload im Browser
|
||||
- Läuft nur lokal (`127.0.0.1`)
|
||||
- **i18n** – Englisch als Standard/Fallback, Deutsch optional; automatische Browser-Sprache oder manuelle Auswahl in der Sidebar
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
@@ -50,6 +51,14 @@ python app.py --db data\meine_history.db fantasyidler_save.json
|
||||
|
||||
Im Tab **Inventar** (Sidebar unten): **Backup importieren** – wählt eine `.json`-Datei. Duplikate (gleicher Datei-Hash) werden übersprungen.
|
||||
|
||||
## Sprache / i18n
|
||||
|
||||
- **Standard:** Englisch (`en`) – auch Fallback, wenn ein Übersetzungsschlüssel fehlt
|
||||
- **Automatisch:** Sidebar → Sprache → *Automatisch (Browser)* – nutzt `navigator.language` (`de` → Deutsch, sonst Englisch)
|
||||
- **Manuell:** *English* oder *Deutsch* – Einstellung wird in `localStorage` gespeichert
|
||||
- Übersetzungsdateien: `static/locales/en.json`, `static/locales/de.json`
|
||||
- Import-Warnungen vom Server sind auf Englisch codiert (`code` + `params`); die UI übersetzt sie clientseitig
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
@@ -59,7 +68,10 @@ idle-fantasy-viewer/
|
||||
├── categories.py # Item-Kategorien (Heuristiken)
|
||||
├── db.py # SQLite Snapshots, Diff, Timeline
|
||||
├── requirements.txt
|
||||
├── static/ # CSS und JavaScript
|
||||
├── static/
|
||||
│ ├── i18n.js # Locale-Laden, t(), Fallback en
|
||||
│ ├── locales/ # en.json, de.json
|
||||
│ └── app.js # Dashboard-UI
|
||||
├── templates/ # HTML
|
||||
└── data/ # history.db (wird angelegt, gitignored)
|
||||
```
|
||||
@@ -84,6 +96,17 @@ Die Backup-Datei enthält u. a. doppelt JSON-kodierte Felder (`skillLevels`, `in
|
||||
- `data/history.db` speichert importierte Snapshots lokal; nicht mit ins Repo committen (steht in `.gitignore`).
|
||||
- Der Viewer ist ein inoffizielles Hilfstool, nicht mit dem Spiel verbunden.
|
||||
|
||||
## Robustheit bei Spiel-Updates
|
||||
|
||||
Das Spiel wird aktiv weiterentwickelt – Save-Dateien können neue Felder, Items oder Quest-Typen enthalten. Der Viewer:
|
||||
|
||||
- **Parst tolerant:** unbekannte Top-Level-Felder werden in `extensions` durchgereicht und als Info gemeldet
|
||||
- **Überspringt defekte Einträge** (z. B. einzelne Quests/Sessions) statt abzubrechen
|
||||
- **Meldet Warnungen** bei fehlenden Kernfeldern, nicht lesbarem JSON in verschachtelten Feldern oder ungültigen Zahlen
|
||||
- **Blockiert den Import** nur bei schwerwiegenden Problemen (keine gültige JSON-Datei, leeres Objekt)
|
||||
|
||||
Nach dem Import erscheinen Fehler und Warnungen als Banner im Dashboard; in der CLI unter stderr.
|
||||
|
||||
## Lizenz
|
||||
|
||||
Privates Projekt – Nutzung auf eigene Verantwortung.
|
||||
|
||||
@@ -77,6 +77,8 @@ def api_import():
|
||||
result = import_save(tmp, db_path=DB_PATH)
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
if result.get("error"):
|
||||
return jsonify(result), 422
|
||||
return jsonify(result)
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
@@ -86,7 +88,19 @@ def api_import():
|
||||
path = Path(path)
|
||||
if not path.exists():
|
||||
return jsonify({"error": f"File not found: {path}"}), 404
|
||||
return jsonify(import_save(path, db_path=DB_PATH))
|
||||
result = import_save(path, db_path=DB_PATH)
|
||||
if result.get("error"):
|
||||
return jsonify(result), 422
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
def _print_import_report(result: dict) -> None:
|
||||
report = result.get("import_report") or []
|
||||
if not report:
|
||||
return
|
||||
for item in report:
|
||||
level = item.get("level", "info").upper()
|
||||
print(f" [{level}] {item.get('message')}", file=sys.stderr)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -112,8 +126,20 @@ def main() -> int:
|
||||
print(f"Error: file not found: {path}", file=sys.stderr)
|
||||
return 1
|
||||
result = import_save(path, db_path=DB_PATH)
|
||||
if result.get("error"):
|
||||
print(f"Import failed: {result['error']}", file=sys.stderr)
|
||||
_print_import_report(result)
|
||||
return 1
|
||||
if result.get("imported"):
|
||||
print(f"Imported snapshot #{result['snapshot_id']} from {path.name}")
|
||||
summary = result.get("import_summary") or {}
|
||||
if summary.get("warnings") or summary.get("infos"):
|
||||
print(
|
||||
f"Notes: {summary.get('warnings', 0)} warning(s), "
|
||||
f"{summary.get('infos', 0)} info(s)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
_print_import_report(result)
|
||||
else:
|
||||
print(f"Skipped duplicate: {path.name} (snapshot #{result['snapshot_id']})")
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from parser import normalize_save, load_save
|
||||
from parser import SaveParseError, normalize_save, load_save
|
||||
|
||||
DEFAULT_DB = Path(__file__).parent / "data" / "history.db"
|
||||
|
||||
@@ -88,15 +88,31 @@ def import_save(
|
||||
"SELECT id FROM snapshots WHERE file_hash = ?", (digest,)
|
||||
).fetchone()
|
||||
if existing:
|
||||
result = {"imported": False, "snapshot_id": existing["id"], "reason": "duplicate"}
|
||||
result = {
|
||||
"imported": False,
|
||||
"snapshot_id": existing["id"],
|
||||
"reason": "duplicate",
|
||||
"import_report": [],
|
||||
}
|
||||
if own_conn:
|
||||
conn.close()
|
||||
return result
|
||||
|
||||
raw = load_save(path)
|
||||
normalized = normalize_save(raw, source_file=path.name)
|
||||
meta = normalized["meta"]
|
||||
try:
|
||||
raw, failures = load_save(path)
|
||||
normalized = normalize_save(raw, source_file=path.name, nested_failures=failures)
|
||||
except SaveParseError as exc:
|
||||
if own_conn:
|
||||
conn.close()
|
||||
return {
|
||||
"imported": False,
|
||||
"error": str(exc),
|
||||
"import_report": exc.issues,
|
||||
}
|
||||
|
||||
import_report = normalized["meta"].get("import_report", [])
|
||||
character = normalized["character"]
|
||||
meta = normalized["meta"]
|
||||
|
||||
cur = conn.execute(
|
||||
"""
|
||||
@@ -130,7 +146,12 @@ def import_save(
|
||||
if own_conn:
|
||||
conn.close()
|
||||
|
||||
return {"imported": True, "snapshot_id": snapshot_id}
|
||||
return {
|
||||
"imported": True,
|
||||
"snapshot_id": snapshot_id,
|
||||
"import_report": import_report,
|
||||
"import_summary": meta.get("import_summary"),
|
||||
}
|
||||
|
||||
|
||||
def list_snapshots(conn: sqlite3.Connection | None = None, db_path: Path | str = DEFAULT_DB) -> list[dict]:
|
||||
|
||||
@@ -7,29 +7,96 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from categories import categorize_item
|
||||
from validation import Issue, analyze_save, has_errors, issue
|
||||
|
||||
|
||||
def _maybe_parse_json(value: Any) -> Any:
|
||||
class SaveParseError(Exception):
|
||||
"""Save cannot be imported."""
|
||||
|
||||
def __init__(self, message: str, issues: list[Issue] | None = None):
|
||||
super().__init__(message)
|
||||
self.issues = issues or []
|
||||
|
||||
|
||||
def _maybe_parse_json(value: Any, field_path: str, failures: list[str]) -> Any:
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
if stripped.startswith(("{", "[")):
|
||||
try:
|
||||
return json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
failures.append(field_path)
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def _deep_parse(obj: Any) -> Any:
|
||||
def _deep_parse(obj: Any, path: str = "", failures: list[str] | None = None) -> Any:
|
||||
failures = failures if failures is not None else []
|
||||
if isinstance(obj, dict):
|
||||
return {k: _deep_parse(_maybe_parse_json(v)) for k, v in obj.items()}
|
||||
return {
|
||||
k: _deep_parse(_maybe_parse_json(v, f"{path}.{k}" if path else k, failures), f"{path}.{k}" if path else k, failures)
|
||||
for k, v in obj.items()
|
||||
}
|
||||
if isinstance(obj, list):
|
||||
return [_deep_parse(_maybe_parse_json(v)) for v in obj]
|
||||
return [
|
||||
_deep_parse(_maybe_parse_json(v, f"{path}[{i}]", failures), f"{path}[{i}]", failures)
|
||||
for i, v in enumerate(obj)
|
||||
]
|
||||
return obj
|
||||
|
||||
|
||||
def _ensure_dict(value: Any, field: str, issues: list[Issue]) -> dict[str, Any]:
|
||||
if value is None:
|
||||
return {}
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
issues.append(issue(
|
||||
"warning", "coerced_empty_dict",
|
||||
f'Field "{field}" is not an object – treated as empty.',
|
||||
field=field,
|
||||
params={"field": field},
|
||||
))
|
||||
return {}
|
||||
|
||||
|
||||
def _ensure_list(value: Any, field: str, issues: list[Issue]) -> list[Any]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
issues.append(issue(
|
||||
"warning", "coerced_empty_list",
|
||||
f'Field "{field}" is not a list – skipped.',
|
||||
field=field,
|
||||
params={"field": field},
|
||||
))
|
||||
return []
|
||||
|
||||
|
||||
def _safe_int(value: Any, field: str, issues: list[Issue], default: int = 0) -> int:
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
issues.append(issue(
|
||||
"warning", "invalid_number",
|
||||
f'Invalid number in "{field}".',
|
||||
field=field,
|
||||
params={"field": field, "detail": ""},
|
||||
))
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
issues.append(issue(
|
||||
"warning", "invalid_number",
|
||||
f'Invalid number in "{field}": {value!r}.',
|
||||
field=field,
|
||||
params={"field": field, "detail": f": {value!r}"},
|
||||
))
|
||||
return default
|
||||
|
||||
|
||||
def xp_for_level(level: int) -> int:
|
||||
"""Approximate cumulative XP threshold (OSRS-style curve)."""
|
||||
if level <= 1:
|
||||
return 0
|
||||
total = 0
|
||||
@@ -38,7 +105,7 @@ def xp_for_level(level: int) -> int:
|
||||
return total // 4
|
||||
|
||||
|
||||
def xp_to_next_level(level: int, xp: int) -> dict[str, int]:
|
||||
def xp_to_next_level(level: int, xp: int) -> dict[str, int | float]:
|
||||
current_threshold = xp_for_level(level)
|
||||
next_threshold = xp_for_level(level + 1)
|
||||
span = max(next_threshold - current_threshold, 1)
|
||||
@@ -51,50 +118,77 @@ def xp_to_next_level(level: int, xp: int) -> dict[str, int]:
|
||||
|
||||
|
||||
def format_item_name(key: str) -> str:
|
||||
return key.replace("_", " ").title()
|
||||
return str(key).replace("_", " ").title()
|
||||
|
||||
|
||||
def format_key(key: str) -> str:
|
||||
return key.replace("_", " ").title()
|
||||
return str(key).replace("_", " ").title()
|
||||
|
||||
|
||||
def load_save(path: str | Path) -> dict[str, Any]:
|
||||
def load_save(path: str | Path) -> tuple[dict[str, Any], list[str]]:
|
||||
path = Path(path)
|
||||
with path.open(encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
return _deep_parse(raw)
|
||||
try:
|
||||
with path.open(encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise SaveParseError(f"Invalid JSON file: {exc}") from exc
|
||||
except OSError as exc:
|
||||
raise SaveParseError(f"Could not read file: {exc}") from exc
|
||||
|
||||
failures: list[str] = []
|
||||
parsed = _deep_parse(raw, failures=failures)
|
||||
if not isinstance(parsed, dict):
|
||||
raise SaveParseError("The file must contain a JSON object.")
|
||||
return parsed, failures
|
||||
|
||||
|
||||
def normalize_save(raw: dict[str, Any], source_file: str = "") -> dict[str, Any]:
|
||||
flags = raw.get("flags") or {}
|
||||
skill_levels = raw.get("skillLevels") or {}
|
||||
skill_xp = raw.get("skillXp") or {}
|
||||
inventory = raw.get("inventory") or {}
|
||||
equipped = raw.get("equipped") or {}
|
||||
def normalize_save(
|
||||
raw: dict[str, Any],
|
||||
source_file: str = "",
|
||||
issues: list[Issue] | None = None,
|
||||
nested_failures: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
issues = list(issues or [])
|
||||
issues.extend(analyze_save(raw, nested_failures))
|
||||
|
||||
if has_errors(issues):
|
||||
fatal = next(i for i in issues if i["level"] == "error")
|
||||
raise SaveParseError(fatal["message"], issues)
|
||||
|
||||
flags = _ensure_dict(raw.get("flags"), "flags", issues)
|
||||
skill_levels = _ensure_dict(raw.get("skillLevels"), "skillLevels", issues)
|
||||
skill_xp = _ensure_dict(raw.get("skillXp"), "skillXp", issues)
|
||||
inventory = _ensure_dict(raw.get("inventory"), "inventory", issues)
|
||||
equipped = _ensure_dict(raw.get("equipped"), "equipped", issues)
|
||||
equipped_values = {v for v in equipped.values() if v}
|
||||
|
||||
inventory_items = []
|
||||
for key, qty in sorted(inventory.items()):
|
||||
for key, qty_raw in sorted(inventory.items()):
|
||||
qty = _safe_int(qty_raw, f"inventory.{key}", issues, default=-1)
|
||||
if qty <= 0:
|
||||
if qty < 0:
|
||||
continue
|
||||
continue
|
||||
item_key = str(key)
|
||||
inventory_items.append({
|
||||
"key": key,
|
||||
"name": format_item_name(key),
|
||||
"key": item_key,
|
||||
"name": format_item_name(item_key),
|
||||
"qty": qty,
|
||||
"category": categorize_item(key),
|
||||
"equipped": key in equipped_values,
|
||||
"category": categorize_item(item_key),
|
||||
"equipped": item_key in equipped_values,
|
||||
})
|
||||
|
||||
all_skill_keys = set(skill_levels) | set(skill_xp)
|
||||
skills = []
|
||||
total_level = 0
|
||||
for key in sorted(skill_levels.keys()):
|
||||
level = int(skill_levels[key])
|
||||
xp = int(skill_xp.get(key, 0))
|
||||
for key in sorted(all_skill_keys):
|
||||
level = _safe_int(skill_levels.get(key, 1), f"skillLevels.{key}", issues, default=1)
|
||||
xp = _safe_int(skill_xp.get(key, 0), f"skillXp.{key}", issues, default=0)
|
||||
total_level += level
|
||||
prog = xp_to_next_level(level, xp)
|
||||
skills.append({
|
||||
"key": key,
|
||||
"name": format_key(key),
|
||||
"key": str(key),
|
||||
"name": format_key(str(key)),
|
||||
"level": level,
|
||||
"xp": xp,
|
||||
**prog,
|
||||
@@ -103,43 +197,73 @@ def normalize_save(raw: dict[str, Any], source_file: str = "") -> dict[str, Any]
|
||||
equipment = []
|
||||
for slot, item_key in equipped.items():
|
||||
equipment.append({
|
||||
"slot": slot,
|
||||
"slot_name": format_key(slot),
|
||||
"slot": str(slot),
|
||||
"slot_name": format_key(str(slot)),
|
||||
"key": item_key,
|
||||
"name": format_item_name(item_key) if item_key else None,
|
||||
"name": format_item_name(str(item_key)) if item_key else None,
|
||||
})
|
||||
|
||||
story_quests = []
|
||||
for q in raw.get("questProgress") or []:
|
||||
for idx, q in enumerate(_ensure_list(raw.get("questProgress"), "questProgress", issues)):
|
||||
if not isinstance(q, dict):
|
||||
issues.append(issue(
|
||||
"warning", "invalid_quest_entry",
|
||||
f"Quest entry #{idx + 1} is not an object and was skipped.",
|
||||
field="questProgress",
|
||||
params={"index": idx + 1},
|
||||
))
|
||||
continue
|
||||
quest_id = q.get("questId") or q.get("id") or f"unknown_{idx}"
|
||||
story_quests.append({
|
||||
"id": q.get("questId"),
|
||||
"name": format_key(q.get("questId", "")),
|
||||
"progress": q.get("progress", 0),
|
||||
"id": quest_id,
|
||||
"name": format_key(str(quest_id)),
|
||||
"progress": _safe_int(q.get("progress"), f"questProgress[{idx}].progress", issues),
|
||||
"completed": bool(q.get("completed")),
|
||||
"completed_at": q.get("completedAt"),
|
||||
})
|
||||
|
||||
daily_quests = _build_flag_quests(
|
||||
flags.get("daily_quest_ids") or [],
|
||||
flags.get("daily_quest_progress") or {},
|
||||
flags.get("daily_quest_claimed") or [],
|
||||
flags.get("daily_quest_ids"),
|
||||
flags.get("daily_quest_progress"),
|
||||
flags.get("daily_quest_claimed"),
|
||||
"daily_quest", issues,
|
||||
)
|
||||
weekly_quests = _build_flag_quests(
|
||||
flags.get("weekly_quest_ids") or [],
|
||||
flags.get("weekly_quest_progress") or {},
|
||||
flags.get("weekly_quest_claimed") or [],
|
||||
flags.get("weekly_quest_ids"),
|
||||
flags.get("weekly_quest_progress"),
|
||||
flags.get("weekly_quest_claimed"),
|
||||
"weekly_quest", issues,
|
||||
)
|
||||
guild_quests = _build_flag_quests(
|
||||
flags.get("guild_daily_ids") or [],
|
||||
flags.get("guild_daily_progress") or {},
|
||||
flags.get("guild_daily_claimed") or [],
|
||||
flags.get("guild_daily_ids"),
|
||||
flags.get("guild_daily_progress"),
|
||||
flags.get("guild_daily_claimed"),
|
||||
"guild_daily", issues,
|
||||
)
|
||||
|
||||
sessions = []
|
||||
for s in raw.get("sessions") or []:
|
||||
frames = s.get("frames") or []
|
||||
total_xp = sum(f.get("xp_gain", 0) for f in frames) if isinstance(frames, list) else 0
|
||||
total_kills = sum(f.get("kills", 0) for f in frames) if isinstance(frames, list) else 0
|
||||
for idx, s in enumerate(_ensure_list(raw.get("sessions"), "sessions", issues)):
|
||||
if not isinstance(s, dict):
|
||||
issues.append(issue(
|
||||
"warning", "invalid_session_entry",
|
||||
f"Session entry #{idx + 1} is not an object and was skipped.",
|
||||
field="sessions",
|
||||
params={"index": idx + 1},
|
||||
))
|
||||
continue
|
||||
frames = s.get("frames")
|
||||
if isinstance(frames, str):
|
||||
issues.append(issue(
|
||||
"warning", "unparsed_session_frames",
|
||||
f"Session #{idx + 1}: activity frames could not be read.",
|
||||
field="sessions",
|
||||
params={"index": idx + 1},
|
||||
))
|
||||
frames = []
|
||||
elif not isinstance(frames, list):
|
||||
frames = []
|
||||
total_xp = sum(_safe_int(f.get("xp_gain"), f"sessions[{idx}].xp", issues) for f in frames if isinstance(f, dict))
|
||||
total_kills = sum(_safe_int(f.get("kills"), f"sessions[{idx}].kills", issues) for f in frames if isinstance(f, dict))
|
||||
sessions.append({
|
||||
"id": s.get("session_id"),
|
||||
"skill": s.get("skill_name"),
|
||||
@@ -149,9 +273,47 @@ def normalize_save(raw: dict[str, Any], source_file: str = "") -> dict[str, Any]
|
||||
"completed": s.get("completed"),
|
||||
"total_xp": total_xp,
|
||||
"total_kills": total_kills,
|
||||
"frame_count": len(frames) if isinstance(frames, list) else 0,
|
||||
"frame_count": len(frames),
|
||||
})
|
||||
|
||||
pets_raw = raw.get("pets")
|
||||
pets: list[Any] = []
|
||||
if isinstance(pets_raw, list):
|
||||
pets = pets_raw
|
||||
elif pets_raw is not None:
|
||||
issues.append(issue("warning", "invalid_pets", 'Field "pets" is not a list.', field="pets"))
|
||||
|
||||
farming = []
|
||||
for idx, patch in enumerate(_ensure_list(raw.get("farmingPatches"), "farmingPatches", issues)):
|
||||
if isinstance(patch, dict):
|
||||
farming.append(patch)
|
||||
else:
|
||||
issues.append(issue(
|
||||
"warning", "invalid_farming_patch",
|
||||
f"Farming patch #{idx + 1} was skipped.",
|
||||
field="farmingPatches",
|
||||
params={"index": idx + 1},
|
||||
))
|
||||
|
||||
if not flags.get("character_name"):
|
||||
issues.append(issue(
|
||||
"warning", "missing_character_name",
|
||||
"No character name found in save.",
|
||||
field="flags.character_name",
|
||||
))
|
||||
|
||||
# Deduplizieren (gleicher code+field+message)
|
||||
seen = set()
|
||||
unique_issues: list[Issue] = []
|
||||
for item in issues:
|
||||
key = (item["level"], item["code"], item.get("field"), item["message"])
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique_issues.append(item)
|
||||
|
||||
warning_count = sum(1 for i in unique_issues if i["level"] == "warning")
|
||||
info_count = sum(1 for i in unique_issues if i["level"] == "info")
|
||||
|
||||
return {
|
||||
"character": {
|
||||
"name": flags.get("character_name"),
|
||||
@@ -175,50 +337,85 @@ def normalize_save(raw: dict[str, Any], source_file: str = "") -> dict[str, Any]
|
||||
"guild": guild_quests,
|
||||
},
|
||||
"combat": {
|
||||
"enemy_kills": flags.get("enemy_kills") or {},
|
||||
"dungeon_runs": flags.get("dungeon_runs") or {},
|
||||
"slayer_task": flags.get("active_slayer_task"),
|
||||
"slayer_points": flags.get("slayer_points", 0),
|
||||
"enemy_kills": _ensure_dict(flags.get("enemy_kills"), "flags.enemy_kills", issues),
|
||||
"dungeon_runs": _ensure_dict(flags.get("dungeon_runs"), "flags.dungeon_runs", issues),
|
||||
"slayer_task": flags.get("active_slayer_task") if isinstance(flags.get("active_slayer_task"), dict) else None,
|
||||
"slayer_points": _safe_int(flags.get("slayer_points"), "flags.slayer_points", issues),
|
||||
},
|
||||
"guild_reputation": flags.get("guild_reputation") or {},
|
||||
"pets": raw.get("pets") or [],
|
||||
"farming": raw.get("farmingPatches") or [],
|
||||
"farming_fertilizer": flags.get("farming_fertilizer") or {},
|
||||
"session_queue": flags.get("session_queue") or [],
|
||||
"recent_sessions": flags.get("recent_sessions") or [],
|
||||
"guild_reputation": _ensure_dict(flags.get("guild_reputation"), "flags.guild_reputation", issues),
|
||||
"pets": pets,
|
||||
"farming": farming,
|
||||
"farming_fertilizer": _ensure_dict(flags.get("farming_fertilizer"), "flags.farming_fertilizer", issues),
|
||||
"session_queue": _ensure_list(flags.get("session_queue"), "flags.session_queue", issues),
|
||||
"recent_sessions": _ensure_list(flags.get("recent_sessions"), "flags.recent_sessions", issues),
|
||||
"sessions": sessions,
|
||||
"town_buildings": flags.get("town_building_tiers") or {},
|
||||
"town_buildings": _ensure_dict(flags.get("town_building_tiers"), "flags.town_building_tiers", issues),
|
||||
"meta": {
|
||||
"source_file": source_file,
|
||||
"exported_at": raw.get("exported_at"),
|
||||
"coins": raw.get("coins", 0),
|
||||
"inventory_coins": inventory.get("coins", 0),
|
||||
"coins": _safe_int(raw.get("coins"), "coins", issues),
|
||||
"inventory_coins": _safe_int(inventory.get("coins"), "inventory.coins", issues),
|
||||
"total_level": total_level,
|
||||
"item_count": len(inventory_items),
|
||||
"total_items": sum(i["qty"] for i in inventory_items),
|
||||
"version_code": flags.get("last_seen_version_code"),
|
||||
"import_report": unique_issues,
|
||||
"import_summary": {
|
||||
"ok": True,
|
||||
"warnings": warning_count,
|
||||
"infos": info_count,
|
||||
},
|
||||
},
|
||||
# Unbekannte Top-Level-Felder für spätere Auswertung durchreichen
|
||||
"extensions": {
|
||||
k: raw[k] for k in raw if k not in {
|
||||
"skillLevels", "skillXp", "inventory", "equipped", "flags",
|
||||
"pets", "coins", "questProgress", "farmingPatches", "sessions", "exported_at",
|
||||
}
|
||||
} if isinstance(raw, dict) else {},
|
||||
}
|
||||
|
||||
|
||||
def _build_flag_quests(
|
||||
ids: list[str],
|
||||
progress: dict[str, int],
|
||||
claimed: list[str],
|
||||
ids: Any,
|
||||
progress: Any,
|
||||
claimed: Any,
|
||||
label: str,
|
||||
issues: list[Issue],
|
||||
) -> list[dict[str, Any]]:
|
||||
claimed_set = set(claimed or [])
|
||||
id_list = ids if isinstance(ids, list) else []
|
||||
if ids is not None and not isinstance(ids, list):
|
||||
issues.append(issue(
|
||||
"warning", "invalid_quest_ids",
|
||||
f"Quest IDs ({label}) are not a list.",
|
||||
field=label,
|
||||
params={"label": label},
|
||||
))
|
||||
progress_map = progress if isinstance(progress, dict) else {}
|
||||
if progress is not None and not isinstance(progress, dict):
|
||||
issues.append(issue(
|
||||
"warning", "invalid_quest_progress",
|
||||
f"Quest progress ({label}) is not an object.",
|
||||
field=label,
|
||||
params={"label": label},
|
||||
))
|
||||
claimed_list = claimed if isinstance(claimed, list) else []
|
||||
claimed_set = set(claimed_list)
|
||||
|
||||
result = []
|
||||
for qid in ids:
|
||||
for qid in id_list:
|
||||
qid_str = str(qid)
|
||||
result.append({
|
||||
"id": qid,
|
||||
"name": format_key(qid),
|
||||
"progress": progress.get(qid, 0),
|
||||
"claimed": qid in claimed_set,
|
||||
"id": qid_str,
|
||||
"name": format_key(qid_str),
|
||||
"progress": _safe_int(progress_map.get(qid), f"{label}.{qid_str}", issues),
|
||||
"claimed": qid_str in claimed_set or qid in claimed_set,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def parse_save_file(path: str | Path) -> dict[str, Any]:
|
||||
def parse_save_file(path: str | Path) -> tuple[dict[str, Any], list[Issue]]:
|
||||
path = Path(path)
|
||||
raw = load_save(path)
|
||||
return normalize_save(raw, source_file=path.name)
|
||||
raw, failures = load_save(path)
|
||||
data = normalize_save(raw, source_file=path.name, nested_failures=failures)
|
||||
return data, data["meta"]["import_report"]
|
||||
|
||||
+377
-176
@@ -4,7 +4,7 @@ let state = {
|
||||
data: null,
|
||||
snapshots: [],
|
||||
timeline: [],
|
||||
inventory: { search: "", categories: new Set(), sort: "category", highlightEquipped: false },
|
||||
inventory: { search: "", categories: new Set(), sort: "category", highlightEquipped: false, collapsedGroups: new Set() },
|
||||
skills: { search: "", sort: "level", sortAsc: false },
|
||||
quests: { tab: "story", filter: "all" },
|
||||
history: { olderId: null, newerId: null, diff: null },
|
||||
@@ -17,14 +17,67 @@ const CATEGORY_ORDER = [
|
||||
"Magic", "Armor", "Bones & Hides", "Gems & Jewelry", "Potions & Brews", "Misc",
|
||||
];
|
||||
|
||||
const CATEGORY_I18N_KEYS = {
|
||||
"Currency": "category.currency",
|
||||
"Ores & Mining": "category.ores_mining",
|
||||
"Bars & Smithing": "category.bars_smithing",
|
||||
"Wood & Planks": "category.wood_planks",
|
||||
"Runes": "category.runes",
|
||||
"Raw Food": "category.raw_food",
|
||||
"Cooked Food": "category.cooked_food",
|
||||
"Seeds & Farming": "category.seeds_farming",
|
||||
"Melee Weapons": "category.melee_weapons",
|
||||
"Ranged": "category.ranged",
|
||||
"Magic": "category.magic",
|
||||
"Armor": "category.armor",
|
||||
"Bones & Hides": "category.bones_hides",
|
||||
"Gems & Jewelry": "category.gems_jewelry",
|
||||
"Potions & Brews": "category.potions_brews",
|
||||
"Misc": "category.misc",
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
|
||||
async function init() {
|
||||
await I18n.init();
|
||||
applyStaticI18n();
|
||||
setupLanguage();
|
||||
setupNav();
|
||||
setupUpload();
|
||||
await loadData();
|
||||
}
|
||||
|
||||
function categoryLabel(cat) {
|
||||
const key = CATEGORY_I18N_KEYS[cat];
|
||||
return key ? t(key) : cat;
|
||||
}
|
||||
|
||||
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();
|
||||
resetLocaleDependentPanels();
|
||||
if (state.data) renderAll();
|
||||
if (document.getElementById("tab-history").classList.contains("active")) {
|
||||
loadHistoryTab();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function resetLocaleDependentPanels() {
|
||||
["tab-skills", "tab-inventory"].forEach((id) => {
|
||||
document.getElementById(id).innerHTML = "";
|
||||
});
|
||||
}
|
||||
|
||||
function setupNav() {
|
||||
document.querySelectorAll(".nav-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
@@ -45,27 +98,102 @@ function setupUpload() {
|
||||
fd.append("file", file);
|
||||
const res = await fetch("/api/import", { method: "POST", body: fd });
|
||||
const result = await res.json();
|
||||
if (result.imported || result.snapshot_id) {
|
||||
if (!res.ok || result.error) {
|
||||
showImportFailure(result);
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
if (result.imported) {
|
||||
await loadData();
|
||||
alert(result.imported ? `Importiert: Snapshot #${result.snapshot_id}` : "Backup bereits vorhanden (Duplikat).");
|
||||
} else {
|
||||
alert(result.error || "Import fehlgeschlagen");
|
||||
notifyImportSuccess(result);
|
||||
} else if (result.reason === "duplicate") {
|
||||
alert(t("import.duplicate"));
|
||||
}
|
||||
e.target.value = "";
|
||||
});
|
||||
}
|
||||
|
||||
function showImportFailure(result) {
|
||||
const lines = [result.error || t("import.failed")];
|
||||
for (const item of result.import_report || []) {
|
||||
lines.push(`• ${I18n.translateIssue(item)}`);
|
||||
}
|
||||
alert(lines.join("\n"));
|
||||
}
|
||||
|
||||
function notifyImportSuccess(result) {
|
||||
const summary = result.import_summary || {};
|
||||
const warnings = summary.warnings || 0;
|
||||
const infos = summary.infos || 0;
|
||||
if (warnings || infos) {
|
||||
alert(t("import.successWithNotes", {
|
||||
id: result.snapshot_id,
|
||||
warnings,
|
||||
infos,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function renderImportReport(meta) {
|
||||
const el = document.getElementById("import-report");
|
||||
const report = meta?.import_report || [];
|
||||
const visible = report.filter((i) => i.level === "error" || i.level === "warning");
|
||||
const infos = report.filter((i) => i.level === "info");
|
||||
|
||||
if (!report.length) {
|
||||
el.hidden = true;
|
||||
el.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const errors = report.filter((i) => i.level === "error");
|
||||
const warnings = report.filter((i) => i.level === "warning");
|
||||
const level = errors.length ? "error" : warnings.length ? "warning" : "info";
|
||||
const title = errors.length
|
||||
? t("import.titleError")
|
||||
: warnings.length
|
||||
? t("import.titleWarning")
|
||||
: t("import.titleInfo");
|
||||
|
||||
el.hidden = false;
|
||||
el.className = `import-report import-report-${level}`;
|
||||
el.innerHTML = `
|
||||
<div class="import-report-header">
|
||||
<strong>${esc(title)}</strong>
|
||||
<span class="import-report-counts">
|
||||
${errors.length ? t("import.countErrors", { count: errors.length }) : ""}
|
||||
${warnings.length ? t("import.countWarnings", { count: warnings.length }) : ""}
|
||||
${infos.length ? t("import.countInfos", { count: infos.length }) : ""}
|
||||
</span>
|
||||
<button type="button" class="import-report-dismiss" title="${esc(t("actions.dismiss"))}">×</button>
|
||||
</div>
|
||||
<ul class="import-report-list">
|
||||
${visible.map((i) => `<li class="import-issue import-issue-${i.level}">${esc(I18n.translateIssue(i))}</li>`).join("")}
|
||||
${infos.length ? `
|
||||
<li class="import-issue import-issue-info-collapsed">
|
||||
<details>
|
||||
<summary>${esc(t("import.newFieldsSummary", { count: infos.length }))}</summary>
|
||||
<ul>${infos.map((i) => `<li>${esc(I18n.translateIssue(i))}</li>`).join("")}</ul>
|
||||
</details>
|
||||
</li>` : ""}
|
||||
</ul>`;
|
||||
|
||||
el.querySelector(".import-report-dismiss").addEventListener("click", () => {
|
||||
el.hidden = true;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const res = await fetch("/api/snapshot/latest");
|
||||
if (!res.ok) {
|
||||
showEmpty("Kein Save importiert. Starte mit: python app.py fantasyidler_save.json");
|
||||
showEmpty(t("empty.noSave"));
|
||||
return;
|
||||
}
|
||||
state.data = await res.json();
|
||||
renderAll();
|
||||
} catch (err) {
|
||||
showEmpty(`Fehler beim Laden: ${err.message}`);
|
||||
showEmpty(t("empty.loadError", { message: err.message }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +206,7 @@ function renderAll() {
|
||||
if (!d) return;
|
||||
|
||||
renderHeader(d);
|
||||
renderImportReport(d.meta);
|
||||
renderOverview(d);
|
||||
renderSkills(d);
|
||||
renderInventory(d);
|
||||
@@ -90,16 +219,16 @@ function renderHeader(d) {
|
||||
const c = d.character;
|
||||
const m = d.meta;
|
||||
document.getElementById("character-header").innerHTML = `
|
||||
<h2>${esc(c.name || "Unbekannt")}</h2>
|
||||
<h2>${esc(c.name || t("empty.unknown"))}</h2>
|
||||
<div class="character-meta">
|
||||
${esc(c.race || "")} · ${esc(c.gender || "")} · Export: ${formatTs(m.exported_at)}
|
||||
${esc(c.race || "")} · ${esc(c.gender || "")} · ${t("meta.export")}: ${formatTs(m.exported_at)}
|
||||
</div>`;
|
||||
|
||||
document.getElementById("kpi-row").innerHTML = `
|
||||
<div class="kpi"><div class="kpi-label">Coins</div><div class="kpi-value">${fmt(m.coins)}</div></div>
|
||||
<div class="kpi"><div class="kpi-label">Gesamt-Level</div><div class="kpi-value">${m.total_level}</div></div>
|
||||
<div class="kpi"><div class="kpi-label">Items</div><div class="kpi-value">${m.item_count}</div></div>
|
||||
<div class="kpi"><div class="kpi-label">Stückzahl</div><div class="kpi-value">${fmt(m.total_items)}</div></div>`;
|
||||
<div class="kpi"><div class="kpi-label">${esc(t("kpi.coins"))}</div><div class="kpi-value">${fmt(m.coins)}</div></div>
|
||||
<div class="kpi"><div class="kpi-label">${esc(t("kpi.totalLevel"))}</div><div class="kpi-value">${m.total_level}</div></div>
|
||||
<div class="kpi"><div class="kpi-label">${esc(t("kpi.items"))}</div><div class="kpi-value">${m.item_count}</div></div>
|
||||
<div class="kpi"><div class="kpi-label">${esc(t("kpi.totalQty"))}</div><div class="kpi-value">${fmt(m.total_items)}</div></div>`;
|
||||
}
|
||||
|
||||
function renderOverview(d) {
|
||||
@@ -110,47 +239,47 @@ function renderOverview(d) {
|
||||
|
||||
const slayer = d.combat.slayer_task;
|
||||
const slayerHtml = slayer
|
||||
? `<p><strong>${esc(slayer.display_name)}</strong>: ${slayer.kills_completed}/${slayer.target_kills} (${d.combat.slayer_points} Punkte)</p>`
|
||||
: "<p class='empty-state'>Kein Slayer-Task aktiv</p>";
|
||||
? `<p><strong>${esc(slayer.display_name)}</strong>: ${slayer.kills_completed}/${slayer.target_kills} (${d.combat.slayer_points} ${esc(t("meta.points"))})</p>`
|
||||
: `<p class='empty-state'>${esc(t("overview.noSlayerTask"))}</p>`;
|
||||
|
||||
const pets = (d.pets || []).map((p) =>
|
||||
`<li><span>${esc(p.id.replace(/_/g, " "))}</span><span>+${p.boost_percent}%</span></li>`
|
||||
).join("");
|
||||
|
||||
const farming = (d.farming || []).map((p) =>
|
||||
`<li><span>Feld ${p.patchNumber}</span><span>${esc(p.cropType || "—")}</span></li>`
|
||||
`<li><span>${esc(t("overview.patch", { n: p.patchNumber }))}</span><span>${esc(p.cropType || "—")}</span></li>`
|
||||
).join("");
|
||||
|
||||
document.getElementById("tab-overview").innerHTML = `
|
||||
<div class="grid-2">
|
||||
<div class="card">
|
||||
<h3>Charakter</h3>
|
||||
<h3>${esc(t("overview.character"))}</h3>
|
||||
<ul class="list-compact">
|
||||
<li><span>HP</span><span>${c.hp ?? "—"}</span></li>
|
||||
<li><span>Aktiver Trank</span><span>${esc(c.active_potion || "—")}</span></li>
|
||||
<li><span>Aktiver Zauber</span><span>${esc(c.active_spell || "—")}</span></li>
|
||||
<li><span>Waffen-Slot</span><span>${esc(c.active_weapon_slot || "—")}</span></li>
|
||||
<li><span>Segen</span><span>${esc(c.active_blessing || "—")}</span></li>
|
||||
<li><span>${esc(t("overview.hp"))}</span><span>${c.hp ?? "—"}</span></li>
|
||||
<li><span>${esc(t("overview.activePotion"))}</span><span>${esc(c.active_potion || "—")}</span></li>
|
||||
<li><span>${esc(t("overview.activeSpell"))}</span><span>${esc(c.active_spell || "—")}</span></li>
|
||||
<li><span>${esc(t("overview.weaponSlot"))}</span><span>${esc(c.active_weapon_slot || "—")}</span></li>
|
||||
<li><span>${esc(t("overview.blessing"))}</span><span>${esc(c.active_blessing || "—")}</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Session-Queue</h3>
|
||||
<ul class="list-compact">${queue || "<li><span>Leer</span></li>"}</ul>
|
||||
<h3>${esc(t("overview.sessionQueue"))}</h3>
|
||||
<ul class="list-compact">${queue || `<li><span>${esc(t("empty.empty"))}</span></li>`}</ul>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Slayer</h3>
|
||||
<h3>${esc(t("overview.slayer"))}</h3>
|
||||
${slayerHtml}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Pets</h3>
|
||||
<ul class="list-compact">${pets || "<li><span>Keine</span></li>"}</ul>
|
||||
<h3>${esc(t("overview.pets"))}</h3>
|
||||
<ul class="list-compact">${pets || `<li><span>${esc(t("empty.none"))}</span></li>`}</ul>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Farming</h3>
|
||||
<ul class="list-compact">${farming || "<li><span>Keine Felder</span></li>"}</ul>
|
||||
<h3>${esc(t("overview.farming"))}</h3>
|
||||
<ul class="list-compact">${farming || `<li><span>${esc(t("empty.none"))}</span></li>`}</ul>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Gilden-Ruf</h3>
|
||||
<h3>${esc(t("overview.guildRep"))}</h3>
|
||||
<ul class="list-compact">${Object.entries(d.guild_reputation || {}).map(([k, v]) =>
|
||||
`<li><span>${esc(k)}</span><span>${fmt(v)}</span></li>`).join("")}</ul>
|
||||
</div>
|
||||
@@ -160,6 +289,62 @@ function renderOverview(d) {
|
||||
function renderSkills(d) {
|
||||
const panel = document.getElementById("tab-skills");
|
||||
const s = state.skills;
|
||||
|
||||
if (!panel.querySelector("#skill-search")) {
|
||||
panel.innerHTML = `
|
||||
<div class="toolbar">
|
||||
<input class="search-input" id="skill-search" placeholder="" value="">
|
||||
<select class="select-input" id="skill-sort">
|
||||
<option value="level"></option>
|
||||
<option value="xp"></option>
|
||||
<option value="name"></option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th data-sort="name"></th>
|
||||
<th data-sort="level"></th>
|
||||
<th data-sort="xp">XP</th>
|
||||
<th></th>
|
||||
</tr></thead>
|
||||
<tbody id="skill-tbody"></tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
|
||||
document.getElementById("skill-search").addEventListener("input", (e) => {
|
||||
state.skills.search = e.target.value;
|
||||
renderSkillsBody(state.data);
|
||||
});
|
||||
document.getElementById("skill-sort").addEventListener("change", (e) => {
|
||||
state.skills.sort = e.target.value;
|
||||
renderSkillsBody(state.data);
|
||||
});
|
||||
panel.querySelectorAll("th[data-sort]").forEach((th) => {
|
||||
th.addEventListener("click", () => {
|
||||
const key = th.dataset.sort;
|
||||
if (state.skills.sort === key) state.skills.sortAsc = !state.skills.sortAsc;
|
||||
else { state.skills.sort = key; state.skills.sortAsc = false; }
|
||||
renderSkillsBody(state.data);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("skill-search").placeholder = t("skills.search");
|
||||
document.getElementById("skill-sort").options[0].textContent = t("skills.sortLevel");
|
||||
document.getElementById("skill-sort").options[1].textContent = t("skills.sortXp");
|
||||
document.getElementById("skill-sort").options[2].textContent = t("skills.sortName");
|
||||
panel.querySelector('th[data-sort="name"]').textContent = t("skills.skill");
|
||||
panel.querySelector('th[data-sort="level"]').textContent = t("skills.level");
|
||||
panel.querySelector("thead tr th:last-child").textContent = t("skills.progress");
|
||||
|
||||
document.getElementById("skill-search").value = s.search;
|
||||
document.getElementById("skill-sort").value = s.sort;
|
||||
renderSkillsBody(d);
|
||||
}
|
||||
|
||||
function renderSkillsBody(d) {
|
||||
const s = state.skills;
|
||||
let items = [...d.skills];
|
||||
if (s.search) {
|
||||
const q = s.search.toLowerCase();
|
||||
@@ -173,62 +358,19 @@ function renderSkills(d) {
|
||||
return s.sortAsc ? cmp : -cmp;
|
||||
});
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="toolbar">
|
||||
<input class="search-input" id="skill-search" placeholder="Skill suchen…" value="${esc(s.search)}">
|
||||
<select class="select-input" id="skill-sort">
|
||||
<option value="level" ${s.sort === "level" ? "selected" : ""}>Nach Level</option>
|
||||
<option value="xp" ${s.sort === "xp" ? "selected" : ""}>Nach XP</option>
|
||||
<option value="name" ${s.sort === "name" ? "selected" : ""}>Nach Name</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th data-sort="name">Skill</th>
|
||||
<th data-sort="level">Level</th>
|
||||
<th data-sort="xp">XP</th>
|
||||
<th>Fortschritt</th>
|
||||
</tr></thead>
|
||||
<tbody>${items.map((sk) => `
|
||||
<tr>
|
||||
<td>${esc(sk.name)}</td>
|
||||
<td>${sk.level}</td>
|
||||
<td>${fmt(sk.xp)}</td>
|
||||
<td style="min-width:140px">
|
||||
${sk.progress_pct}%
|
||||
<div class="progress-bar"><div class="progress-fill" style="width:${sk.progress_pct}%"></div></div>
|
||||
</td>
|
||||
</tr>`).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
|
||||
document.getElementById("skill-search").addEventListener("input", (e) => {
|
||||
state.skills.search = e.target.value;
|
||||
renderSkills(state.data);
|
||||
});
|
||||
document.getElementById("skill-sort").addEventListener("change", (e) => {
|
||||
state.skills.sort = e.target.value;
|
||||
renderSkills(state.data);
|
||||
});
|
||||
panel.querySelectorAll("th[data-sort]").forEach((th) => {
|
||||
th.addEventListener("click", () => {
|
||||
const key = th.dataset.sort;
|
||||
if (state.skills.sort === key) state.skills.sortAsc = !state.skills.sortAsc;
|
||||
else { state.skills.sort = key; state.skills.sortAsc = false; }
|
||||
renderSkills(state.data);
|
||||
});
|
||||
});
|
||||
document.getElementById("skill-tbody").innerHTML = items.map((sk) => `
|
||||
<tr>
|
||||
<td>${esc(sk.name)}</td>
|
||||
<td>${sk.level}</td>
|
||||
<td>${fmt(sk.xp)}</td>
|
||||
<td style="min-width:140px">
|
||||
${sk.progress_pct}%
|
||||
<div class="progress-bar"><div class="progress-fill" style="width:${sk.progress_pct}%"></div></div>
|
||||
</td>
|
||||
</tr>`).join("");
|
||||
}
|
||||
|
||||
function renderInventory(d) {
|
||||
const panel = document.getElementById("tab-inventory");
|
||||
const inv = state.inventory;
|
||||
const categories = [...new Set(d.inventory.map((i) => i.category))].sort(
|
||||
(a, b) => CATEGORY_ORDER.indexOf(a) - CATEGORY_ORDER.indexOf(b)
|
||||
);
|
||||
|
||||
function getFilteredInventoryItems(d, inv) {
|
||||
let items = [...d.inventory];
|
||||
if (inv.search) {
|
||||
const q = inv.search.toLowerCase();
|
||||
@@ -244,7 +386,11 @@ function renderInventory(d) {
|
||||
const cb = CATEGORY_ORDER.indexOf(b.category);
|
||||
return ca - cb || a.name.localeCompare(b.name);
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
function renderInventoryTable(d, inv) {
|
||||
const items = getFilteredInventoryItems(d, inv);
|
||||
const grouped = {};
|
||||
for (const item of items) {
|
||||
if (!grouped[item.category]) grouped[item.category] = [];
|
||||
@@ -253,25 +399,33 @@ function renderInventory(d) {
|
||||
|
||||
const groupRows = Object.entries(grouped).map(([cat, catItems]) => {
|
||||
const totalQty = catItems.reduce((s, i) => s + i.qty, 0);
|
||||
const expanded = !inv.collapsedGroups.has(cat);
|
||||
const catLabel = categoryLabel(cat);
|
||||
const header = `
|
||||
<tr class="inv-group-row" data-group="${esc(cat)}">
|
||||
<td colspan="3">
|
||||
<button type="button" class="inv-group-toggle" data-group="${esc(cat)}" aria-expanded="true">
|
||||
<span class="inv-group-title">${esc(cat)}</span>
|
||||
<span class="inv-group-meta">${catItems.length} Items · ${fmt(totalQty)} Stück</span>
|
||||
<button type="button" class="inv-group-toggle" data-group="${esc(cat)}" aria-expanded="${expanded}">
|
||||
<span class="inv-group-title">${esc(catLabel)}</span>
|
||||
<span class="inv-group-meta">${esc(t("inventory.groupMeta", { count: catItems.length, qty: fmt(totalQty) }))}</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
const rows = catItems.map((i) => `
|
||||
<tr class="inv-item-row ${i.equipped && inv.highlightEquipped ? "item-equipped" : ""}" data-group="${esc(cat)}">
|
||||
<td class="col-name">${esc(i.name)}${i.equipped ? '<span class="equipped-mark" title="Ausgerüstet">⚡</span>' : ""}</td>
|
||||
<tr class="inv-item-row ${i.equipped && inv.highlightEquipped ? "item-equipped" : ""} ${expanded ? "" : "collapsed"}" data-group="${esc(cat)}">
|
||||
<td class="col-name">${esc(i.name)}${i.equipped ? `<span class="equipped-mark" title="${esc(t("inventory.equipped"))}">⚡</span>` : ""}</td>
|
||||
<td class="col-qty">${fmt(i.qty)}</td>
|
||||
<td class="col-key"><code>${esc(i.key)}</code></td>
|
||||
</tr>`).join("");
|
||||
return header + rows;
|
||||
}).join("");
|
||||
|
||||
const tableHtml = groupRows ? `
|
||||
const results = document.getElementById("inv-results");
|
||||
if (!groupRows) {
|
||||
results.innerHTML = `<p class='empty-state'>${esc(t("empty.noItems"))}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
results.innerHTML = `
|
||||
<div class="inv-table-wrap">
|
||||
<table class="inv-table">
|
||||
<colgroup>
|
||||
@@ -281,72 +435,99 @@ function renderInventory(d) {
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-name">Item</th>
|
||||
<th class="col-qty">Menge</th>
|
||||
<th class="col-key">ID</th>
|
||||
<th class="col-name">${esc(t("inventory.item"))}</th>
|
||||
<th class="col-qty">${esc(t("inventory.qty"))}</th>
|
||||
<th class="col-key">${esc(t("inventory.id"))}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${groupRows}</tbody>
|
||||
</table>
|
||||
</div>` : "<p class='empty-state'>Keine Items gefunden</p>";
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="toolbar">
|
||||
<input class="search-input" id="inv-search" placeholder="Item suchen…" value="${esc(inv.search)}">
|
||||
<select class="select-input" id="inv-sort">
|
||||
<option value="category" ${inv.sort === "category" ? "selected" : ""}>Nach Kategorie</option>
|
||||
<option value="name" ${inv.sort === "name" ? "selected" : ""}>Nach Name</option>
|
||||
<option value="qty" ${inv.sort === "qty" ? "selected" : ""}>Nach Menge</option>
|
||||
</select>
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="inv-equipped" ${inv.highlightEquipped ? "checked" : ""}>
|
||||
Ausgerüstet hervorheben
|
||||
</label>
|
||||
</div>
|
||||
<div class="chip-row" id="inv-chips">
|
||||
${categories.map((c) => `
|
||||
<span class="chip ${inv.categories.has(c) ? "active" : ""}" data-cat="${esc(c)}">${esc(c)}</span>`).join("")}
|
||||
</div>
|
||||
<div class="card inv-card">
|
||||
${tableHtml}
|
||||
</div>`;
|
||||
|
||||
document.getElementById("inv-search").addEventListener("input", (e) => {
|
||||
state.inventory.search = e.target.value;
|
||||
renderInventory(state.data);
|
||||
});
|
||||
document.getElementById("inv-sort").addEventListener("change", (e) => {
|
||||
state.inventory.sort = e.target.value;
|
||||
renderInventory(state.data);
|
||||
});
|
||||
document.getElementById("inv-equipped").addEventListener("change", (e) => {
|
||||
state.inventory.highlightEquipped = e.target.checked;
|
||||
renderInventory(state.data);
|
||||
});
|
||||
panel.querySelectorAll(".chip").forEach((chip) => {
|
||||
chip.addEventListener("click", () => {
|
||||
const cat = chip.dataset.cat;
|
||||
if (state.inventory.categories.has(cat)) state.inventory.categories.delete(cat);
|
||||
else state.inventory.categories.add(cat);
|
||||
renderInventory(state.data);
|
||||
});
|
||||
});
|
||||
panel.querySelectorAll(".inv-group-toggle").forEach((btn) => {
|
||||
results.querySelectorAll(".inv-group-toggle").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const group = btn.dataset.group;
|
||||
const expanded = btn.getAttribute("aria-expanded") === "true";
|
||||
btn.setAttribute("aria-expanded", expanded ? "false" : "true");
|
||||
panel.querySelectorAll(`.inv-item-row[data-group="${group}"]`).forEach((row) => {
|
||||
if (expanded) inv.collapsedGroups.add(group);
|
||||
else inv.collapsedGroups.delete(group);
|
||||
results.querySelectorAll(`.inv-item-row[data-group="${group}"]`).forEach((row) => {
|
||||
row.classList.toggle("collapsed", expanded);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderInventoryChips(d, inv) {
|
||||
const categories = [...new Set(d.inventory.map((i) => i.category))].sort(
|
||||
(a, b) => CATEGORY_ORDER.indexOf(a) - CATEGORY_ORDER.indexOf(b)
|
||||
);
|
||||
const chips = document.getElementById("inv-chips");
|
||||
chips.innerHTML = categories.map((c) => `
|
||||
<span class="chip ${inv.categories.has(c) ? "active" : ""}" data-cat="${esc(c)}">${esc(categoryLabel(c))}</span>`).join("");
|
||||
chips.querySelectorAll(".chip").forEach((chip) => {
|
||||
chip.addEventListener("click", () => {
|
||||
const cat = chip.dataset.cat;
|
||||
if (inv.categories.has(cat)) inv.categories.delete(cat);
|
||||
else inv.categories.add(cat);
|
||||
renderInventoryChips(d, inv);
|
||||
renderInventoryTable(d, inv);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderInventory(d) {
|
||||
const panel = document.getElementById("tab-inventory");
|
||||
const inv = state.inventory;
|
||||
|
||||
if (!panel.querySelector("#inv-search")) {
|
||||
panel.innerHTML = `
|
||||
<div class="toolbar">
|
||||
<input class="search-input" id="inv-search" placeholder="" value="">
|
||||
<select class="select-input" id="inv-sort">
|
||||
<option value="category"></option>
|
||||
<option value="name"></option>
|
||||
<option value="qty"></option>
|
||||
</select>
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="inv-equipped">
|
||||
<span id="inv-equipped-label"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="chip-row" id="inv-chips"></div>
|
||||
<div class="card inv-card" id="inv-results"></div>`;
|
||||
|
||||
document.getElementById("inv-search").addEventListener("input", (e) => {
|
||||
state.inventory.search = e.target.value;
|
||||
renderInventoryTable(state.data, state.inventory);
|
||||
});
|
||||
document.getElementById("inv-sort").addEventListener("change", (e) => {
|
||||
state.inventory.sort = e.target.value;
|
||||
renderInventoryTable(state.data, state.inventory);
|
||||
});
|
||||
document.getElementById("inv-equipped").addEventListener("change", (e) => {
|
||||
state.inventory.highlightEquipped = e.target.checked;
|
||||
renderInventoryTable(state.data, state.inventory);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("inv-search").placeholder = t("inventory.search");
|
||||
document.getElementById("inv-sort").options[0].textContent = t("inventory.sortCategory");
|
||||
document.getElementById("inv-sort").options[1].textContent = t("inventory.sortName");
|
||||
document.getElementById("inv-sort").options[2].textContent = t("inventory.sortQty");
|
||||
document.getElementById("inv-equipped-label").textContent = t("inventory.highlightEquipped");
|
||||
|
||||
document.getElementById("inv-search").value = inv.search;
|
||||
document.getElementById("inv-sort").value = inv.sort;
|
||||
document.getElementById("inv-equipped").checked = inv.highlightEquipped;
|
||||
renderInventoryChips(d, inv);
|
||||
renderInventoryTable(d, inv);
|
||||
}
|
||||
|
||||
function renderEquipment(d) {
|
||||
document.getElementById("tab-equipment").innerHTML = `
|
||||
<div class="card">
|
||||
<h3>Ausrüstung</h3>
|
||||
<h3>${esc(t("equipment.title"))}</h3>
|
||||
<div class="equip-grid">
|
||||
${d.equipment.map((eq) => `
|
||||
<div class="equip-slot ${eq.key ? "" : "empty"}">
|
||||
@@ -361,10 +542,10 @@ function renderQuests(d) {
|
||||
const panel = document.getElementById("tab-quests");
|
||||
const q = state.quests;
|
||||
const tabs = [
|
||||
{ key: "story", label: "Story" },
|
||||
{ key: "daily", label: "Daily" },
|
||||
{ key: "weekly", label: "Weekly" },
|
||||
{ key: "guild", label: "Gilde" },
|
||||
{ key: "story", label: t("quests.story") },
|
||||
{ key: "daily", label: t("quests.daily") },
|
||||
{ key: "weekly", label: t("quests.weekly") },
|
||||
{ key: "guild", label: t("quests.guild") },
|
||||
];
|
||||
|
||||
let items = d.quests[q.tab] || [];
|
||||
@@ -379,28 +560,28 @@ function renderQuests(d) {
|
||||
const isStory = q.tab === "story";
|
||||
panel.innerHTML = `
|
||||
<div class="quest-tabs">
|
||||
${tabs.map((t) => `<button class="quest-tab ${q.tab === t.key ? "active" : ""}" data-tab="${t.key}">${t.label}</button>`).join("")}
|
||||
${tabs.map((tab) => `<button class="quest-tab ${q.tab === tab.key ? "active" : ""}" data-tab="${tab.key}">${esc(tab.label)}</button>`).join("")}
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<select class="select-input" id="quest-filter">
|
||||
<option value="all" ${q.filter === "all" ? "selected" : ""}>Alle</option>
|
||||
<option value="open" ${q.filter === "open" ? "selected" : ""}>Offen</option>
|
||||
<option value="done" ${q.filter === "done" ? "selected" : ""}>Abgeschlossen</option>
|
||||
<option value="all" ${q.filter === "all" ? "selected" : ""}>${esc(t("quests.filterAll"))}</option>
|
||||
<option value="open" ${q.filter === "open" ? "selected" : ""}>${esc(t("quests.filterOpen"))}</option>
|
||||
<option value="done" ${q.filter === "done" ? "selected" : ""}>${esc(t("quests.filterDone"))}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Quest</th>
|
||||
<th>Fortschritt</th>
|
||||
<th>Status</th>
|
||||
<th>${esc(t("quests.quest"))}</th>
|
||||
<th>${esc(t("quests.progress"))}</th>
|
||||
<th>${esc(t("quests.status"))}</th>
|
||||
</tr></thead>
|
||||
<tbody>${items.map((quest) => {
|
||||
const done = isStory ? quest.completed : quest.claimed;
|
||||
return `<tr>
|
||||
<td>${esc(quest.name)}</td>
|
||||
<td>${fmt(quest.progress)}</td>
|
||||
<td><span class="badge ${done ? "badge-success" : "badge-warning"}">${done ? "Erledigt" : "Offen"}</span></td>
|
||||
<td><span class="badge ${done ? "badge-success" : "badge-warning"}">${esc(done ? t("quests.done") : t("quests.open"))}</span></td>
|
||||
</tr>`;
|
||||
}).join("")}</tbody>
|
||||
</table>
|
||||
@@ -425,26 +606,27 @@ function renderCombat(d) {
|
||||
|
||||
const dungeons = Object.entries(d.combat.dungeon_runs || {})
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([k, v]) => `<li><span>${esc(k.replace(/_/g, " "))}</span><span>${fmt(v)} Runs</span></li>`).join("");
|
||||
.map(([k, v]) => `<li><span>${esc(k.replace(/_/g, " "))}</span><span>${esc(t("combat.runs", { count: fmt(v) }))}</span></li>`).join("");
|
||||
|
||||
const recent = (d.recent_sessions || [])
|
||||
.map((s) => `<li><span>${esc(s.activity_display_name || s.activity_key)}</span><span>${esc(s.skill_name)}</span></li>`).join("");
|
||||
|
||||
const active = (d.sessions || [])
|
||||
.map((s) => `<li><span>${esc(s.activity)}</span><span>${esc(s.skill)} · ${s.completed ? "fertig" : "läuft"}</span></li>`).join("");
|
||||
.map((s) => `<li><span>${esc(s.activity)}</span><span>${esc(s.skill)} · ${esc(s.completed ? t("combat.sessionDone") : t("combat.sessionRunning"))}</span></li>`).join("");
|
||||
|
||||
const none = `<li>${esc(t("empty.none"))}</li>`;
|
||||
document.getElementById("tab-combat").innerHTML = `
|
||||
<div class="grid-2">
|
||||
<div class="card"><h3>Feind-Kills</h3><ul class="list-compact">${kills || "<li>Keine</li>"}</ul></div>
|
||||
<div class="card"><h3>Dungeon-Runs</h3><ul class="list-compact">${dungeons || "<li>Keine</li>"}</ul></div>
|
||||
<div class="card"><h3>Letzte Aktivitäten</h3><ul class="list-compact">${recent || "<li>Keine</li>"}</ul></div>
|
||||
<div class="card"><h3>Aktive Sessions</h3><ul class="list-compact">${active || "<li>Keine</li>"}</ul></div>
|
||||
<div class="card"><h3>${esc(t("combat.enemyKills"))}</h3><ul class="list-compact">${kills || none}</ul></div>
|
||||
<div class="card"><h3>${esc(t("combat.dungeonRuns"))}</h3><ul class="list-compact">${dungeons || none}</ul></div>
|
||||
<div class="card"><h3>${esc(t("combat.recentActivity"))}</h3><ul class="list-compact">${recent || none}</ul></div>
|
||||
<div class="card"><h3>${esc(t("combat.activeSessions"))}</h3><ul class="list-compact">${active || none}</ul></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function loadHistoryTab() {
|
||||
const panel = document.getElementById("tab-history");
|
||||
panel.innerHTML = "<p class='loading'>Lade Verlauf…</p>";
|
||||
panel.innerHTML = `<p class='loading'>${esc(t("history.loading"))}</p>`;
|
||||
|
||||
const [snapRes, tlRes] = await Promise.all([
|
||||
fetch("/api/snapshots"),
|
||||
@@ -454,7 +636,7 @@ async function loadHistoryTab() {
|
||||
state.timeline = await tlRes.json();
|
||||
|
||||
if (state.snapshots.length === 0) {
|
||||
panel.innerHTML = "<p class='empty-state'>Noch keine Snapshots. Importiere ein Backup.</p>";
|
||||
panel.innerHTML = `<p class='empty-state'>${esc(t("empty.noSnapshots"))}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -465,16 +647,16 @@ async function loadHistoryTab() {
|
||||
panel.innerHTML = `
|
||||
<div class="grid-2">
|
||||
<div class="card">
|
||||
<h3>Coins-Verlauf</h3>
|
||||
<h3>${esc(t("history.coinsChart"))}</h3>
|
||||
<div class="chart-wrap"><canvas id="chart-coins"></canvas></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Gesamt-Level-Verlauf</h3>
|
||||
<h3>${esc(t("history.levelChart"))}</h3>
|
||||
<div class="chart-wrap"><canvas id="chart-level"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Snapshot-Vergleich</h3>
|
||||
<h3>${esc(t("history.snapshotCompare"))}</h3>
|
||||
<div class="toolbar">
|
||||
<select class="select-input" id="diff-older">
|
||||
${state.snapshots.map((s) => option(s, h.olderId)).join("")}
|
||||
@@ -483,14 +665,21 @@ async function loadHistoryTab() {
|
||||
<select class="select-input" id="diff-newer">
|
||||
${state.snapshots.map((s) => option(s, h.newerId)).join("")}
|
||||
</select>
|
||||
<button class="select-input" id="diff-run" style="background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600">Vergleichen</button>
|
||||
<button class="select-input" id="diff-run" style="background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600">${esc(t("actions.compare"))}</button>
|
||||
</div>
|
||||
<div id="diff-result"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Alle Snapshots</h3>
|
||||
<h3>${esc(t("history.allSnapshots"))}</h3>
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>Charakter</th><th>Coins</th><th>Level</th><th>Export</th><th>Datei</th></tr></thead>
|
||||
<thead><tr>
|
||||
<th>ID</th>
|
||||
<th>${esc(t("history.character"))}</th>
|
||||
<th>${esc(t("kpi.coins"))}</th>
|
||||
<th>${esc(t("kpi.totalLevel"))}</th>
|
||||
<th>${esc(t("meta.export"))}</th>
|
||||
<th>${esc(t("history.file"))}</th>
|
||||
</tr></thead>
|
||||
<tbody>${state.snapshots.map((s) => `
|
||||
<tr>
|
||||
<td>${s.id}</td>
|
||||
@@ -529,12 +718,12 @@ function renderTimelineCharts() {
|
||||
|
||||
state.charts.coins = new Chart(document.getElementById("chart-coins"), {
|
||||
type: "line",
|
||||
data: { labels, datasets: [{ label: "Coins", data: coins, borderColor: "#6c8cff", tension: 0.3, fill: false }] },
|
||||
data: { labels, datasets: [{ label: t("kpi.coins"), data: coins, borderColor: "#6c8cff", tension: 0.3, fill: false }] },
|
||||
options: chartOpts(),
|
||||
});
|
||||
state.charts.level = new Chart(document.getElementById("chart-level"), {
|
||||
type: "line",
|
||||
data: { labels, datasets: [{ label: "Gesamt-Level", data: levels, borderColor: "#4ade80", tension: 0.3, fill: false }] },
|
||||
data: { labels, datasets: [{ label: t("kpi.totalLevel"), data: levels, borderColor: "#4ade80", tension: 0.3, fill: false }] },
|
||||
options: chartOpts(),
|
||||
});
|
||||
}
|
||||
@@ -562,7 +751,7 @@ async function runDiff() {
|
||||
const h = state.history;
|
||||
const el = document.getElementById("diff-result");
|
||||
if (!h.olderId || !h.newerId || h.olderId === h.newerId) {
|
||||
el.innerHTML = "<p class='empty-state'>Wähle zwei verschiedene Snapshots.</p>";
|
||||
el.innerHTML = `<p class='empty-state'>${esc(t("empty.pickTwoSnapshots"))}</p>`;
|
||||
return;
|
||||
}
|
||||
const older = Math.min(h.olderId, h.newerId);
|
||||
@@ -576,6 +765,7 @@ async function runDiff() {
|
||||
|
||||
const coinDelta = diff.summary.coins_delta;
|
||||
const coinCls = coinDelta >= 0 ? "delta-pos" : "delta-neg";
|
||||
const levelDelta = diff.summary.total_level_delta;
|
||||
|
||||
const invRows = diff.inventory_changes.slice(0, 50).map((i) => `
|
||||
<tr>
|
||||
@@ -594,26 +784,37 @@ async function runDiff() {
|
||||
<td class="${s.xp_delta >= 0 ? "delta-pos" : "delta-neg"}">${s.xp_delta >= 0 ? "+" : ""}${fmt(s.xp_delta)} XP</td>
|
||||
</tr>`).join("");
|
||||
|
||||
const noChanges = `<tr><td colspan='3'>${esc(t("empty.noChanges"))}</td></tr>`;
|
||||
el.innerHTML = `
|
||||
<p>Coins: <span class="${coinCls}">${coinDelta >= 0 ? "+" : ""}${fmt(coinDelta)}</span>
|
||||
· Gesamt-Level: ${diff.summary.total_level_delta >= 0 ? "+" : ""}${diff.summary.total_level_delta}</p>
|
||||
<h4 style="margin-top:16px">Inventar-Änderungen (${diff.inventory_changes.length})</h4>
|
||||
<table><thead><tr><th>Item</th><th>Menge</th><th>Delta</th></tr></thead>
|
||||
<tbody>${invRows || "<tr><td colspan='3'>Keine Änderungen</td></tr>"}</tbody></table>
|
||||
<h4 style="margin-top:16px">Skill-Änderungen (${diff.skill_changes.length})</h4>
|
||||
<table><thead><tr><th>Skill</th><th>Level</th><th>XP-Delta</th></tr></thead>
|
||||
<tbody>${skRows || "<tr><td colspan='3'>Keine Änderungen</td></tr>"}</tbody></table>`;
|
||||
<p>${esc(t("history.coinsSummary", {
|
||||
delta: `${coinDelta >= 0 ? "+" : ""}${fmt(coinDelta)}`,
|
||||
levelDelta: `${levelDelta >= 0 ? "+" : ""}${levelDelta}`,
|
||||
}))}</p>
|
||||
<h4 style="margin-top:16px">${esc(t("history.inventoryChanges", { count: diff.inventory_changes.length }))}</h4>
|
||||
<table><thead><tr>
|
||||
<th>${esc(t("inventory.item"))}</th>
|
||||
<th>${esc(t("inventory.qty"))}</th>
|
||||
<th>${esc(t("history.delta"))}</th>
|
||||
</tr></thead>
|
||||
<tbody>${invRows || noChanges}</tbody></table>
|
||||
<h4 style="margin-top:16px">${esc(t("history.skillChanges", { count: diff.skill_changes.length }))}</h4>
|
||||
<table><thead><tr>
|
||||
<th>${esc(t("skills.skill"))}</th>
|
||||
<th>${esc(t("skills.level"))}</th>
|
||||
<th>${esc(t("history.xpDelta"))}</th>
|
||||
</tr></thead>
|
||||
<tbody>${skRows || noChanges}</tbody></table>`;
|
||||
}
|
||||
|
||||
function fmt(n) {
|
||||
if (n == null) return "—";
|
||||
return Number(n).toLocaleString("de-DE");
|
||||
return Number(n).toLocaleString(I18n.localeTag());
|
||||
}
|
||||
|
||||
function formatTs(ts) {
|
||||
if (!ts) return "—";
|
||||
const d = new Date(Number(ts));
|
||||
return d.toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" });
|
||||
return d.toLocaleString(I18n.localeTag(), { dateStyle: "short", timeStyle: "short" });
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/* Idle Fantasy Viewer – i18n (English default / fallback) */
|
||||
|
||||
const I18n = (() => {
|
||||
const STORAGE_KEY = "locale";
|
||||
const SUPPORTED = ["en", "de"];
|
||||
let locale = "en";
|
||||
let preference = "auto";
|
||||
let messages = {};
|
||||
let fallback = {};
|
||||
|
||||
function getNested(obj, path) {
|
||||
return path.split(".").reduce((o, k) => (o && o[k] !== undefined ? o[k] : undefined), obj);
|
||||
}
|
||||
|
||||
async function loadMessages(code) {
|
||||
const res = await fetch(`/static/locales/${code}.json`);
|
||||
if (!res.ok) throw new Error(`Locale not found: ${code}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function resolveLocale(pref) {
|
||||
if (pref === "en" || pref === "de") return pref;
|
||||
const browser = (navigator.language || "en").split("-")[0].toLowerCase();
|
||||
return SUPPORTED.includes(browser) ? browser : "en";
|
||||
}
|
||||
|
||||
async function init() {
|
||||
preference = localStorage.getItem(STORAGE_KEY) || "auto";
|
||||
locale = resolveLocale(preference);
|
||||
fallback = await loadMessages("en");
|
||||
messages = locale === "en" ? fallback : await loadMessages(locale);
|
||||
document.documentElement.lang = locale;
|
||||
return locale;
|
||||
}
|
||||
|
||||
async function setPreference(pref) {
|
||||
preference = pref;
|
||||
localStorage.setItem(STORAGE_KEY, pref);
|
||||
locale = resolveLocale(pref);
|
||||
messages = locale === "en" ? fallback : await loadMessages(locale);
|
||||
document.documentElement.lang = locale;
|
||||
return locale;
|
||||
}
|
||||
|
||||
function t(key, params = {}) {
|
||||
let str = getNested(messages, key) ?? getNested(fallback, key) ?? key;
|
||||
if (typeof str !== "string") return key;
|
||||
return str.replace(/\{(\w+)\}/g, (_, k) => (params[k] !== undefined ? String(params[k]) : `{${k}}`));
|
||||
}
|
||||
|
||||
function translateIssue(item) {
|
||||
const params = { ...(item.params || {}), field: item.field || "" };
|
||||
const translated = t(`import.${item.code}`, params);
|
||||
if (translated !== `import.${item.code}`) return translated;
|
||||
return item.message || translated;
|
||||
}
|
||||
|
||||
function localeTag() {
|
||||
return locale === "de" ? "de-DE" : "en-US";
|
||||
}
|
||||
|
||||
function getLocale() { return locale; }
|
||||
function getPreference() { return preference; }
|
||||
|
||||
return { init, setPreference, t, translateIssue, localeTag, getLocale, getPreference, SUPPORTED };
|
||||
})();
|
||||
|
||||
window.I18n = I18n;
|
||||
window.t = (...args) => I18n.t(...args);
|
||||
@@ -0,0 +1,178 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "Idle Fantasy",
|
||||
"subtitle": "Save Viewer",
|
||||
"loading": "Lade Save…"
|
||||
},
|
||||
"nav": {
|
||||
"overview": "Übersicht",
|
||||
"skills": "Skills",
|
||||
"inventory": "Inventar",
|
||||
"equipment": "Ausrüstung",
|
||||
"quests": "Quests",
|
||||
"combat": "Kampf",
|
||||
"history": "Verlauf"
|
||||
},
|
||||
"settings": {
|
||||
"language": "Sprache",
|
||||
"langAuto": "Automatisch (Browser)",
|
||||
"langEn": "English",
|
||||
"langDe": "Deutsch"
|
||||
},
|
||||
"actions": {
|
||||
"importBackup": "Backup importieren",
|
||||
"compare": "Vergleichen",
|
||||
"dismiss": "Schließen"
|
||||
},
|
||||
"empty": {
|
||||
"noSave": "Kein Save importiert. Starte mit: python app.py fantasyidler_save.json",
|
||||
"loadError": "Fehler beim Laden: {message}",
|
||||
"unknown": "Unbekannt",
|
||||
"none": "Keine",
|
||||
"empty": "Leer",
|
||||
"noItems": "Keine Items gefunden",
|
||||
"noSnapshots": "Noch keine Snapshots. Importiere ein Backup.",
|
||||
"noChanges": "Keine Änderungen",
|
||||
"pickTwoSnapshots": "Wähle zwei verschiedene Snapshots."
|
||||
},
|
||||
"import": {
|
||||
"failed": "Import fehlgeschlagen",
|
||||
"duplicate": "Backup bereits vorhanden (Duplikat).",
|
||||
"success": "Importiert: Snapshot #{id}",
|
||||
"successWithNotes": "Importiert: Snapshot #{id}\n\n{warnings} Warnung(en), {infos} Hinweis(e) – Details im Dashboard-Banner.",
|
||||
"titleError": "Import-Fehler",
|
||||
"titleWarning": "Import-Warnungen",
|
||||
"titleInfo": "Import-Hinweise",
|
||||
"countErrors": "{count} Fehler",
|
||||
"countWarnings": "{count} Warnung(en)",
|
||||
"countInfos": "{count} Hinweis(e)",
|
||||
"newFieldsSummary": "{count} neue/unbekannte Feld(er) aus dem Spiel",
|
||||
"invalid_root": "Die Datei ist kein JSON-Objekt – kein gültiges Idle-Fantasy-Backup.",
|
||||
"empty_save": "Die Save-Datei ist leer.",
|
||||
"unknown_top_level": "Unbekanntes Feld im Backup: „{field}“ (Spiel-Update?).",
|
||||
"missing_field": "Erwartetes Feld fehlt: „{field}“ – zugehörige Daten werden leer angezeigt.",
|
||||
"nested_json_invalid": "Feld „{field}“ konnte nicht als JSON gelesen werden – Rohwert ignoriert.",
|
||||
"invalid_coins": "Feld „coins“ ist nicht numerisch.",
|
||||
"invalid_exported_at": "Feld „exported_at“ ist kein gültiger Zeitstempel.",
|
||||
"missing_exported_at": "Kein Export-Zeitstempel – Verlaufsvergleiche können ungenau sein.",
|
||||
"skill_xp_mismatch": "{count} Skill(s) ohne XP-Eintrag (z. B. {examples}).",
|
||||
"skill_level_mismatch": "{count} XP-Einträge ohne Skill-Level.",
|
||||
"unparsed_nested_json": "Feld „{field}“ ist noch Text – JSON-Inhalt konnte nicht gelesen werden.",
|
||||
"invalid_type": "Feld „{field}“ hat unerwarteten Typ ({type}).",
|
||||
"coerced_empty_dict": "Feld „{field}“ ist kein Objekt – wird als leer behandelt.",
|
||||
"coerced_empty_list": "Feld „{field}“ ist keine Liste – wird übersprungen.",
|
||||
"invalid_number": "Ungültiger Zahlenwert in „{field}“{detail}.",
|
||||
"invalid_quest_entry": "Quest-Eintrag #{index} ist kein Objekt und wurde übersprungen.",
|
||||
"invalid_session_entry": "Session-Eintrag #{index} ist kein Objekt und wurde übersprungen.",
|
||||
"unparsed_session_frames": "Session #{index}: Aktivitäts-Frames konnten nicht gelesen werden.",
|
||||
"invalid_pets": "Feld „pets“ ist keine Liste.",
|
||||
"invalid_farming_patch": "Farming-Patch #{index} wurde übersprungen.",
|
||||
"missing_character_name": "Kein Charaktername im Save gefunden.",
|
||||
"invalid_quest_ids": "Quest-IDs ({label}) sind keine Liste.",
|
||||
"invalid_quest_progress": "Quest-Fortschritt ({label}) ist kein Objekt."
|
||||
},
|
||||
"meta": {
|
||||
"export": "Export",
|
||||
"points": "Punkte"
|
||||
},
|
||||
"kpi": {
|
||||
"coins": "Münzen",
|
||||
"totalLevel": "Gesamtlevel",
|
||||
"items": "Items",
|
||||
"totalQty": "Gesamtmenge"
|
||||
},
|
||||
"overview": {
|
||||
"character": "Charakter",
|
||||
"hp": "HP",
|
||||
"activePotion": "Aktiver Trank",
|
||||
"activeSpell": "Aktiver Zauber",
|
||||
"weaponSlot": "Waffenslot",
|
||||
"blessing": "Segen",
|
||||
"sessionQueue": "Session-Warteschlange",
|
||||
"slayer": "Slayer",
|
||||
"noSlayerTask": "Keine aktive Slayer-Aufgabe",
|
||||
"pets": "Haustiere",
|
||||
"farming": "Farming",
|
||||
"patch": "Patch {n}",
|
||||
"guildRep": "Gilden-Ruf"
|
||||
},
|
||||
"skills": {
|
||||
"search": "Skill suchen…",
|
||||
"sortLevel": "Nach Level",
|
||||
"sortXp": "Nach XP",
|
||||
"sortName": "Nach Name",
|
||||
"skill": "Skill",
|
||||
"level": "Level",
|
||||
"progress": "Fortschritt"
|
||||
},
|
||||
"inventory": {
|
||||
"search": "Item suchen…",
|
||||
"sortCategory": "Nach Kategorie",
|
||||
"sortName": "Nach Name",
|
||||
"sortQty": "Nach Menge",
|
||||
"highlightEquipped": "Ausgerüstete hervorheben",
|
||||
"item": "Item",
|
||||
"qty": "Menge",
|
||||
"id": "ID",
|
||||
"equipped": "Ausgerüstet",
|
||||
"groupMeta": "{count} Items · {qty} Stk."
|
||||
},
|
||||
"equipment": {
|
||||
"title": "Ausrüstung"
|
||||
},
|
||||
"quests": {
|
||||
"story": "Story",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"guild": "Gilde",
|
||||
"filterAll": "Alle",
|
||||
"filterOpen": "Offen",
|
||||
"filterDone": "Erledigt",
|
||||
"quest": "Quest",
|
||||
"progress": "Fortschritt",
|
||||
"status": "Status",
|
||||
"done": "Erledigt",
|
||||
"open": "Offen"
|
||||
},
|
||||
"combat": {
|
||||
"enemyKills": "Gegner-Kills",
|
||||
"dungeonRuns": "Dungeon-Läufe",
|
||||
"runs": "{count} Läufe",
|
||||
"recentActivity": "Letzte Aktivität",
|
||||
"activeSessions": "Aktive Sessions",
|
||||
"sessionDone": "fertig",
|
||||
"sessionRunning": "läuft"
|
||||
},
|
||||
"history": {
|
||||
"loading": "Lade Verlauf…",
|
||||
"coinsChart": "Münzen über Zeit",
|
||||
"levelChart": "Gesamtlevel über Zeit",
|
||||
"snapshotCompare": "Snapshot-Vergleich",
|
||||
"allSnapshots": "Alle Snapshots",
|
||||
"character": "Charakter",
|
||||
"file": "Datei",
|
||||
"inventoryChanges": "Inventar-Änderungen ({count})",
|
||||
"skillChanges": "Skill-Änderungen ({count})",
|
||||
"delta": "Delta",
|
||||
"xpDelta": "XP-Delta",
|
||||
"coinsSummary": "Münzen: {delta} · Gesamtlevel: {levelDelta}"
|
||||
},
|
||||
"category": {
|
||||
"currency": "Währung",
|
||||
"ores_mining": "Erze & Mining",
|
||||
"bars_smithing": "Barren & Schmieden",
|
||||
"wood_planks": "Holz & Bretter",
|
||||
"runes": "Runen",
|
||||
"raw_food": "Rohkost",
|
||||
"cooked_food": "Gekochtes",
|
||||
"seeds_farming": "Samen & Farming",
|
||||
"melee_weapons": "Nahkampfwaffen",
|
||||
"ranged": "Fernkampf",
|
||||
"magic": "Magie",
|
||||
"armor": "Rüstung",
|
||||
"bones_hides": "Knochen & Felle",
|
||||
"gems_jewelry": "Edelsteine & Schmuck",
|
||||
"potions_brews": "Tränke & Brauerei",
|
||||
"misc": "Sonstiges"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "Idle Fantasy",
|
||||
"subtitle": "Save Viewer",
|
||||
"loading": "Loading save…"
|
||||
},
|
||||
"nav": {
|
||||
"overview": "Overview",
|
||||
"skills": "Skills",
|
||||
"inventory": "Inventory",
|
||||
"equipment": "Equipment",
|
||||
"quests": "Quests",
|
||||
"combat": "Combat",
|
||||
"history": "History"
|
||||
},
|
||||
"settings": {
|
||||
"language": "Language",
|
||||
"langAuto": "Auto (browser)",
|
||||
"langEn": "English",
|
||||
"langDe": "Deutsch"
|
||||
},
|
||||
"actions": {
|
||||
"importBackup": "Import backup",
|
||||
"compare": "Compare",
|
||||
"dismiss": "Dismiss"
|
||||
},
|
||||
"empty": {
|
||||
"noSave": "No save imported. Start with: python app.py fantasyidler_save.json",
|
||||
"loadError": "Failed to load: {message}",
|
||||
"unknown": "Unknown",
|
||||
"none": "None",
|
||||
"empty": "Empty",
|
||||
"noItems": "No items found",
|
||||
"noSnapshots": "No snapshots yet. Import a backup.",
|
||||
"noChanges": "No changes",
|
||||
"pickTwoSnapshots": "Select two different snapshots."
|
||||
},
|
||||
"import": {
|
||||
"failed": "Import failed",
|
||||
"duplicate": "Backup already exists (duplicate).",
|
||||
"success": "Imported: Snapshot #{id}",
|
||||
"successWithNotes": "Imported: Snapshot #{id}\n\n{warnings} warning(s), {infos} note(s) – see dashboard banner for details.",
|
||||
"titleError": "Import errors",
|
||||
"titleWarning": "Import warnings",
|
||||
"titleInfo": "Import notes",
|
||||
"countErrors": "{count} error(s)",
|
||||
"countWarnings": "{count} warning(s)",
|
||||
"countInfos": "{count} info(s)",
|
||||
"newFieldsSummary": "{count} new/unknown field(s) from the game",
|
||||
"invalid_root": "The file is not a JSON object – not a valid Idle Fantasy backup.",
|
||||
"empty_save": "The save file is empty.",
|
||||
"unknown_top_level": "Unknown field in backup: \"{field}\" (added by a game update?).",
|
||||
"missing_field": "Expected field missing: \"{field}\" – related data will be shown empty.",
|
||||
"nested_json_invalid": "Field \"{field}\" could not be read as JSON – raw value ignored.",
|
||||
"invalid_coins": "Field \"coins\" is not numeric.",
|
||||
"invalid_exported_at": "Field \"exported_at\" is not a valid timestamp.",
|
||||
"missing_exported_at": "No export timestamp – history comparisons may be inaccurate.",
|
||||
"skill_xp_mismatch": "{count} skill(s) without XP entry (e.g. {examples}).",
|
||||
"skill_level_mismatch": "{count} XP entries without skill level.",
|
||||
"unparsed_nested_json": "Field \"{field}\" is still a text string – JSON content could not be read.",
|
||||
"invalid_type": "Field \"{field}\" has unexpected type ({type}).",
|
||||
"coerced_empty_dict": "Field \"{field}\" is not an object – treated as empty.",
|
||||
"coerced_empty_list": "Field \"{field}\" is not a list – skipped.",
|
||||
"invalid_number": "Invalid number in \"{field}\"{detail}.",
|
||||
"invalid_quest_entry": "Quest entry #{index} is not an object and was skipped.",
|
||||
"invalid_session_entry": "Session entry #{index} is not an object and was skipped.",
|
||||
"unparsed_session_frames": "Session #{index}: activity frames could not be read.",
|
||||
"invalid_pets": "Field \"pets\" is not a list.",
|
||||
"invalid_farming_patch": "Farming patch #{index} was skipped.",
|
||||
"missing_character_name": "No character name found in save.",
|
||||
"invalid_quest_ids": "Quest IDs ({label}) are not a list.",
|
||||
"invalid_quest_progress": "Quest progress ({label}) is not an object."
|
||||
},
|
||||
"meta": {
|
||||
"export": "Export",
|
||||
"points": "points"
|
||||
},
|
||||
"kpi": {
|
||||
"coins": "Coins",
|
||||
"totalLevel": "Total level",
|
||||
"items": "Items",
|
||||
"totalQty": "Total quantity"
|
||||
},
|
||||
"overview": {
|
||||
"character": "Character",
|
||||
"hp": "HP",
|
||||
"activePotion": "Active potion",
|
||||
"activeSpell": "Active spell",
|
||||
"weaponSlot": "Weapon slot",
|
||||
"blessing": "Blessing",
|
||||
"sessionQueue": "Session queue",
|
||||
"slayer": "Slayer",
|
||||
"noSlayerTask": "No active slayer task",
|
||||
"pets": "Pets",
|
||||
"farming": "Farming",
|
||||
"patch": "Patch {n}",
|
||||
"guildRep": "Guild reputation"
|
||||
},
|
||||
"skills": {
|
||||
"search": "Search skills…",
|
||||
"sortLevel": "By level",
|
||||
"sortXp": "By XP",
|
||||
"sortName": "By name",
|
||||
"skill": "Skill",
|
||||
"level": "Level",
|
||||
"progress": "Progress"
|
||||
},
|
||||
"inventory": {
|
||||
"search": "Search items…",
|
||||
"sortCategory": "By category",
|
||||
"sortName": "By name",
|
||||
"sortQty": "By quantity",
|
||||
"highlightEquipped": "Highlight equipped",
|
||||
"item": "Item",
|
||||
"qty": "Qty",
|
||||
"id": "ID",
|
||||
"equipped": "Equipped",
|
||||
"groupMeta": "{count} items · {qty} pcs"
|
||||
},
|
||||
"equipment": {
|
||||
"title": "Equipment"
|
||||
},
|
||||
"quests": {
|
||||
"story": "Story",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"guild": "Guild",
|
||||
"filterAll": "All",
|
||||
"filterOpen": "Open",
|
||||
"filterDone": "Completed",
|
||||
"quest": "Quest",
|
||||
"progress": "Progress",
|
||||
"status": "Status",
|
||||
"done": "Done",
|
||||
"open": "Open"
|
||||
},
|
||||
"combat": {
|
||||
"enemyKills": "Enemy kills",
|
||||
"dungeonRuns": "Dungeon runs",
|
||||
"runs": "{count} runs",
|
||||
"recentActivity": "Recent activity",
|
||||
"activeSessions": "Active sessions",
|
||||
"sessionDone": "done",
|
||||
"sessionRunning": "running"
|
||||
},
|
||||
"history": {
|
||||
"loading": "Loading history…",
|
||||
"coinsChart": "Coins over time",
|
||||
"levelChart": "Total level over time",
|
||||
"snapshotCompare": "Snapshot comparison",
|
||||
"allSnapshots": "All snapshots",
|
||||
"character": "Character",
|
||||
"file": "File",
|
||||
"inventoryChanges": "Inventory changes ({count})",
|
||||
"skillChanges": "Skill changes ({count})",
|
||||
"delta": "Delta",
|
||||
"xpDelta": "XP delta",
|
||||
"coinsSummary": "Coins: {delta} · Total level: {levelDelta}"
|
||||
},
|
||||
"category": {
|
||||
"currency": "Currency",
|
||||
"ores_mining": "Ores & Mining",
|
||||
"bars_smithing": "Bars & Smithing",
|
||||
"wood_planks": "Wood & Planks",
|
||||
"runes": "Runes",
|
||||
"raw_food": "Raw Food",
|
||||
"cooked_food": "Cooked Food",
|
||||
"seeds_farming": "Seeds & Farming",
|
||||
"melee_weapons": "Melee Weapons",
|
||||
"ranged": "Ranged",
|
||||
"magic": "Magic",
|
||||
"armor": "Armor",
|
||||
"bones_hides": "Bones & Hides",
|
||||
"gems_jewelry": "Gems & Jewelry",
|
||||
"potions_brews": "Potions & Brews",
|
||||
"misc": "Misc"
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,22 @@ body {
|
||||
.sidebar-footer {
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.lang-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.lang-select {
|
||||
width: 100%;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
@@ -121,6 +137,72 @@ body {
|
||||
|
||||
.topbar { margin-bottom: 24px; }
|
||||
|
||||
.import-report {
|
||||
margin-bottom: 16px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.import-report-error {
|
||||
border-color: var(--danger);
|
||||
background: rgba(248, 113, 113, 0.08);
|
||||
}
|
||||
|
||||
.import-report-warning {
|
||||
border-color: var(--warning);
|
||||
background: rgba(251, 191, 36, 0.08);
|
||||
}
|
||||
|
||||
.import-report-info {
|
||||
border-color: var(--accent-dim);
|
||||
background: rgba(108, 140, 255, 0.08);
|
||||
}
|
||||
|
||||
.import-report-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.import-report-counts {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.import-report-dismiss {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.import-report-list {
|
||||
margin: 0;
|
||||
padding-left: 1.2rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.import-issue-warning { color: var(--warning); }
|
||||
.import-issue-error { color: var(--danger); }
|
||||
|
||||
.import-issue-info-collapsed details {
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.import-issue-info-collapsed ul {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 1.2rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.character-header h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 1.5rem;
|
||||
|
||||
+22
-12
@@ -1,11 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<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="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/app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -14,22 +15,30 @@
|
||||
<div class="brand">
|
||||
<span class="brand-icon">⚔</span>
|
||||
<div>
|
||||
<h1>Idle Fantasy</h1>
|
||||
<p class="subtitle">Save Viewer</p>
|
||||
<h1 data-i18n="app.title">Idle Fantasy Viewer</h1>
|
||||
<p class="subtitle" data-i18n="app.subtitle">Save Viewer</p>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="nav" id="nav">
|
||||
<button class="nav-btn active" data-tab="overview">Übersicht</button>
|
||||
<button class="nav-btn" data-tab="skills">Skills</button>
|
||||
<button class="nav-btn" data-tab="inventory">Inventar</button>
|
||||
<button class="nav-btn" data-tab="equipment">Ausrüstung</button>
|
||||
<button class="nav-btn" data-tab="quests">Quests</button>
|
||||
<button class="nav-btn" data-tab="combat">Kampf</button>
|
||||
<button class="nav-btn" data-tab="history">Verlauf</button>
|
||||
<button class="nav-btn active" data-tab="overview" data-i18n="nav.overview">Overview</button>
|
||||
<button class="nav-btn" data-tab="skills" data-i18n="nav.skills">Skills</button>
|
||||
<button class="nav-btn" data-tab="inventory" data-i18n="nav.inventory">Inventory</button>
|
||||
<button class="nav-btn" data-tab="equipment" data-i18n="nav.equipment">Equipment</button>
|
||||
<button class="nav-btn" data-tab="quests" data-i18n="nav.quests">Quests</button>
|
||||
<button class="nav-btn" data-tab="combat" data-i18n="nav.combat">Combat</button>
|
||||
<button class="nav-btn" data-tab="history" data-i18n="nav.history">History</button>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<label class="lang-label" 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>
|
||||
<label class="upload-btn">
|
||||
Backup importieren
|
||||
<span data-i18n="actions.importBackup">Import backup</span>
|
||||
<input type="file" id="file-upload" accept=".json" hidden>
|
||||
</label>
|
||||
</div>
|
||||
@@ -37,8 +46,9 @@
|
||||
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<div id="import-report" class="import-report" hidden></div>
|
||||
<div id="character-header" class="character-header">
|
||||
<span class="loading">Lade Save…</span>
|
||||
<span class="loading" data-i18n="app.loading">Loading save…</span>
|
||||
</div>
|
||||
<div class="kpi-row" id="kpi-row"></div>
|
||||
</header>
|
||||
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
"""Save validation and import issue reporting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# Known top-level fields (viewer baseline) – new fields are OK, reported as info.
|
||||
KNOWN_TOP_LEVEL_KEYS = frozenset({
|
||||
"skillLevels", "skillXp", "inventory", "equipped", "flags", "pets", "coins",
|
||||
"questProgress", "farmingPatches", "sessions", "exported_at",
|
||||
})
|
||||
|
||||
IMPORTANT_TOP_LEVEL_KEYS = frozenset({
|
||||
"skillLevels", "skillXp", "inventory", "flags",
|
||||
})
|
||||
|
||||
Issue = dict[str, Any]
|
||||
|
||||
|
||||
def issue(
|
||||
level: str,
|
||||
code: str,
|
||||
message: str,
|
||||
field: str | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> Issue:
|
||||
out: Issue = {"level": level, "code": code, "message": message, "field": field}
|
||||
if params:
|
||||
out["params"] = params
|
||||
return out
|
||||
|
||||
|
||||
def has_errors(issues: list[Issue]) -> bool:
|
||||
return any(i["level"] == "error" for i in issues)
|
||||
|
||||
|
||||
def analyze_save(raw: Any, nested_parse_failures: list[str] | None = None) -> list[Issue]:
|
||||
"""Structural checks before normalization."""
|
||||
issues: list[Issue] = []
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
issues.append(issue(
|
||||
"error", "invalid_root",
|
||||
"The file is not a JSON object – not a valid Idle Fantasy backup.",
|
||||
))
|
||||
return issues
|
||||
|
||||
if not raw:
|
||||
issues.append(issue("error", "empty_save", "The save file is empty."))
|
||||
return issues
|
||||
|
||||
present = set(raw.keys())
|
||||
for key in sorted(present - KNOWN_TOP_LEVEL_KEYS):
|
||||
issues.append(issue(
|
||||
"info", "unknown_top_level",
|
||||
f'Unknown field in backup: "{key}" (added by a game update?).',
|
||||
field=key,
|
||||
params={"field": key},
|
||||
))
|
||||
|
||||
for key in sorted(IMPORTANT_TOP_LEVEL_KEYS - present):
|
||||
issues.append(issue(
|
||||
"warning", "missing_field",
|
||||
f'Expected field missing: "{key}" – related data will be shown empty.',
|
||||
field=key,
|
||||
params={"field": key},
|
||||
))
|
||||
|
||||
for field in nested_parse_failures or []:
|
||||
issues.append(issue(
|
||||
"warning", "nested_json_invalid",
|
||||
f'Field "{field}" could not be read as JSON – raw value ignored.',
|
||||
field=field,
|
||||
params={"field": field},
|
||||
))
|
||||
|
||||
_check_dict_field(raw, "skillLevels", issues)
|
||||
_check_dict_field(raw, "skillXp", issues)
|
||||
_check_dict_field(raw, "inventory", issues)
|
||||
_check_dict_field(raw, "equipped", issues)
|
||||
_check_dict_field(raw, "flags", issues)
|
||||
_check_list_field(raw, "questProgress", issues)
|
||||
_check_list_field(raw, "farmingPatches", issues)
|
||||
_check_list_field(raw, "sessions", issues)
|
||||
|
||||
if "coins" in raw and not _is_number(raw["coins"]):
|
||||
issues.append(issue(
|
||||
"warning", "invalid_coins",
|
||||
'Field "coins" is not numeric.',
|
||||
field="coins",
|
||||
))
|
||||
|
||||
if "exported_at" in raw and not _is_number(raw["exported_at"]):
|
||||
issues.append(issue(
|
||||
"warning", "invalid_exported_at",
|
||||
'Field "exported_at" is not a valid timestamp.',
|
||||
field="exported_at",
|
||||
))
|
||||
elif "exported_at" not in raw:
|
||||
issues.append(issue(
|
||||
"warning", "missing_exported_at",
|
||||
"No export timestamp – history comparisons may be inaccurate.",
|
||||
field="exported_at",
|
||||
))
|
||||
|
||||
skill_levels = raw.get("skillLevels")
|
||||
skill_xp = raw.get("skillXp")
|
||||
if isinstance(skill_levels, dict) and isinstance(skill_xp, dict):
|
||||
only_levels = set(skill_levels) - set(skill_xp)
|
||||
only_xp = set(skill_xp) - set(skill_levels)
|
||||
if only_levels:
|
||||
examples = ", ".join(sorted(only_levels)[:3])
|
||||
issues.append(issue(
|
||||
"info", "skill_xp_mismatch",
|
||||
f"{len(only_levels)} skill(s) without XP entry (e.g. {examples}).",
|
||||
field="skillXp",
|
||||
params={"count": len(only_levels), "examples": examples},
|
||||
))
|
||||
if only_xp:
|
||||
issues.append(issue(
|
||||
"info", "skill_level_mismatch",
|
||||
f"{len(only_xp)} XP entries without skill level.",
|
||||
field="skillLevels",
|
||||
params={"count": len(only_xp)},
|
||||
))
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def _check_dict_field(raw: dict, field: str, issues: list[Issue]) -> None:
|
||||
if field not in raw:
|
||||
return
|
||||
value = raw[field]
|
||||
if value is None:
|
||||
return
|
||||
if isinstance(value, str):
|
||||
issues.append(issue(
|
||||
"warning", "unparsed_nested_json",
|
||||
f'Field "{field}" is still a text string – JSON content could not be read.',
|
||||
field=field,
|
||||
params={"field": field},
|
||||
))
|
||||
elif not isinstance(value, dict):
|
||||
issues.append(issue(
|
||||
"warning", "invalid_type",
|
||||
f'Field "{field}" has unexpected type ({type(value).__name__}).',
|
||||
field=field,
|
||||
params={"field": field, "type": type(value).__name__},
|
||||
))
|
||||
|
||||
|
||||
def _check_list_field(raw: dict, field: str, issues: list[Issue]) -> None:
|
||||
if field not in raw:
|
||||
return
|
||||
value = raw[field]
|
||||
if value is None:
|
||||
return
|
||||
if isinstance(value, str):
|
||||
issues.append(issue(
|
||||
"warning", "unparsed_nested_json",
|
||||
f'Field "{field}" is still a text string – JSON content could not be read.',
|
||||
field=field,
|
||||
params={"field": field},
|
||||
))
|
||||
elif not isinstance(value, list):
|
||||
issues.append(issue(
|
||||
"warning", "invalid_type",
|
||||
f'Field "{field}" has unexpected type ({type(value).__name__}).',
|
||||
field=field,
|
||||
params={"field": field, "type": type(value).__name__},
|
||||
))
|
||||
|
||||
|
||||
def _is_number(value: Any) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
Reference in New Issue
Block a user