diff --git a/README.md b/README.md index ce4181b..4405010 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app.py b/app.py index 2910b67..3eac56a 100644 --- a/app.py +++ b/app.py @@ -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']})") diff --git a/db.py b/db.py index c285529..2445f7f 100644 --- a/db.py +++ b/db.py @@ -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]: diff --git a/parser.py b/parser.py index 20fc77b..35307eb 100644 --- a/parser.py +++ b/parser.py @@ -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"] diff --git a/static/app.js b/static/app.js index 955387d..03ee940 100644 --- a/static/app.js +++ b/static/app.js @@ -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 = ` +
+ ${esc(title)} + + ${errors.length ? t("import.countErrors", { count: errors.length }) : ""} + ${warnings.length ? t("import.countWarnings", { count: warnings.length }) : ""} + ${infos.length ? t("import.countInfos", { count: infos.length }) : ""} + + +
+ `; + + 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 = ` -

${esc(c.name || "Unbekannt")}

+

${esc(c.name || t("empty.unknown"))}

- ${esc(c.race || "")} · ${esc(c.gender || "")} · Export: ${formatTs(m.exported_at)} + ${esc(c.race || "")} · ${esc(c.gender || "")} · ${t("meta.export")}: ${formatTs(m.exported_at)}
`; document.getElementById("kpi-row").innerHTML = ` -
Coins
${fmt(m.coins)}
-
Gesamt-Level
${m.total_level}
-
Items
${m.item_count}
-
Stückzahl
${fmt(m.total_items)}
`; +
${esc(t("kpi.coins"))}
${fmt(m.coins)}
+
${esc(t("kpi.totalLevel"))}
${m.total_level}
+
${esc(t("kpi.items"))}
${m.item_count}
+
${esc(t("kpi.totalQty"))}
${fmt(m.total_items)}
`; } function renderOverview(d) { @@ -110,47 +239,47 @@ function renderOverview(d) { const slayer = d.combat.slayer_task; const slayerHtml = slayer - ? `

${esc(slayer.display_name)}: ${slayer.kills_completed}/${slayer.target_kills} (${d.combat.slayer_points} Punkte)

` - : "

Kein Slayer-Task aktiv

"; + ? `

${esc(slayer.display_name)}: ${slayer.kills_completed}/${slayer.target_kills} (${d.combat.slayer_points} ${esc(t("meta.points"))})

` + : `

${esc(t("overview.noSlayerTask"))}

`; const pets = (d.pets || []).map((p) => `
  • ${esc(p.id.replace(/_/g, " "))}+${p.boost_percent}%
  • ` ).join(""); const farming = (d.farming || []).map((p) => - `
  • Feld ${p.patchNumber}${esc(p.cropType || "—")}
  • ` + `
  • ${esc(t("overview.patch", { n: p.patchNumber }))}${esc(p.cropType || "—")}
  • ` ).join(""); document.getElementById("tab-overview").innerHTML = `
    -

    Charakter

    +

    ${esc(t("overview.character"))}

    -

    Session-Queue

    - +

    ${esc(t("overview.sessionQueue"))}

    +
    -

    Slayer

    +

    ${esc(t("overview.slayer"))}

    ${slayerHtml}
    -

    Pets

    - +

    ${esc(t("overview.pets"))}

    +
    -

    Farming

    - +

    ${esc(t("overview.farming"))}

    +
    -

    Gilden-Ruf

    +

    ${esc(t("overview.guildRep"))}

    @@ -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 = ` +
    + + +
    +
    + + + + + + + + +
    XP
    +
    `; + + 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 = ` -
    - - -
    -
    - - - - - - - - ${items.map((sk) => ` - - - - - - `).join("")} - -
    SkillLevelXPFortschritt
    ${esc(sk.name)}${sk.level}${fmt(sk.xp)} - ${sk.progress_pct}% -
    -
    -
    `; - - 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) => ` + + ${esc(sk.name)} + ${sk.level} + ${fmt(sk.xp)} + + ${sk.progress_pct}% +
    + + `).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 = ` - `; const rows = catItems.map((i) => ` - - ${esc(i.name)}${i.equipped ? '' : ""} + + ${esc(i.name)}${i.equipped ? `` : ""} ${fmt(i.qty)} ${esc(i.key)} `).join(""); return header + rows; }).join(""); - const tableHtml = groupRows ? ` + const results = document.getElementById("inv-results"); + if (!groupRows) { + results.innerHTML = `

    ${esc(t("empty.noItems"))}

    `; + return; + } + + results.innerHTML = `
    @@ -281,72 +435,99 @@ function renderInventory(d) { - - - + + + ${groupRows}
    ItemMengeID${esc(t("inventory.item"))}${esc(t("inventory.qty"))}${esc(t("inventory.id"))}
    -
    ` : "

    Keine Items gefunden

    "; - - panel.innerHTML = ` -
    - - - -
    -
    - ${categories.map((c) => ` - ${esc(c)}`).join("")} -
    -
    - ${tableHtml}
    `; - 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) => ` + ${esc(categoryLabel(c))}`).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 = ` +
    + + + +
    +
    +
    `; + + 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 = `
    -

    Ausrüstung

    +

    ${esc(t("equipment.title"))}

    ${d.equipment.map((eq) => `
    @@ -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 = `
    - ${tabs.map((t) => ``).join("")} + ${tabs.map((tab) => ``).join("")}
    - - - + + + ${items.map((quest) => { const done = isStory ? quest.completed : quest.claimed; return ` - + `; }).join("")}
    QuestFortschrittStatus${esc(t("quests.quest"))}${esc(t("quests.progress"))}${esc(t("quests.status"))}
    ${esc(quest.name)} ${fmt(quest.progress)}${done ? "Erledigt" : "Offen"}${esc(done ? t("quests.done") : t("quests.open"))}
    @@ -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]) => `
  • ${esc(k.replace(/_/g, " "))}${fmt(v)} Runs
  • `).join(""); + .map(([k, v]) => `
  • ${esc(k.replace(/_/g, " "))}${esc(t("combat.runs", { count: fmt(v) }))}
  • `).join(""); const recent = (d.recent_sessions || []) .map((s) => `
  • ${esc(s.activity_display_name || s.activity_key)}${esc(s.skill_name)}
  • `).join(""); const active = (d.sessions || []) - .map((s) => `
  • ${esc(s.activity)}${esc(s.skill)} · ${s.completed ? "fertig" : "läuft"}
  • `).join(""); + .map((s) => `
  • ${esc(s.activity)}${esc(s.skill)} · ${esc(s.completed ? t("combat.sessionDone") : t("combat.sessionRunning"))}
  • `).join(""); + const none = `
  • ${esc(t("empty.none"))}
  • `; document.getElementById("tab-combat").innerHTML = `
    -

    Feind-Kills

      ${kills || "
    • Keine
    • "}
    -

    Dungeon-Runs

      ${dungeons || "
    • Keine
    • "}
    -

    Letzte Aktivitäten

      ${recent || "
    • Keine
    • "}
    -

    Aktive Sessions

      ${active || "
    • Keine
    • "}
    +

    ${esc(t("combat.enemyKills"))}

      ${kills || none}
    +

    ${esc(t("combat.dungeonRuns"))}

      ${dungeons || none}
    +

    ${esc(t("combat.recentActivity"))}

      ${recent || none}
    +

    ${esc(t("combat.activeSessions"))}

      ${active || none}
    `; } async function loadHistoryTab() { const panel = document.getElementById("tab-history"); - panel.innerHTML = "

    Lade Verlauf…

    "; + panel.innerHTML = `

    ${esc(t("history.loading"))}

    `; 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 = "

    Noch keine Snapshots. Importiere ein Backup.

    "; + panel.innerHTML = `

    ${esc(t("empty.noSnapshots"))}

    `; return; } @@ -465,16 +647,16 @@ async function loadHistoryTab() { panel.innerHTML = `
    -

    Coins-Verlauf

    +

    ${esc(t("history.coinsChart"))}

    -

    Gesamt-Level-Verlauf

    +

    ${esc(t("history.levelChart"))}

    -

    Snapshot-Vergleich

    +

    ${esc(t("history.snapshotCompare"))}

    ${state.snapshots.map((s) => option(s, h.newerId)).join("")} - +
    -

    Alle Snapshots

    +

    ${esc(t("history.allSnapshots"))}

    - + + + + + + + + ${state.snapshots.map((s) => ` @@ -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 = "

    Wähle zwei verschiedene Snapshots.

    "; + el.innerHTML = `

    ${esc(t("empty.pickTwoSnapshots"))}

    `; 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) => ` @@ -594,26 +784,37 @@ async function runDiff() { `).join(""); + const noChanges = ``; el.innerHTML = ` -

    Coins: ${coinDelta >= 0 ? "+" : ""}${fmt(coinDelta)} - · Gesamt-Level: ${diff.summary.total_level_delta >= 0 ? "+" : ""}${diff.summary.total_level_delta}

    -

    Inventar-Änderungen (${diff.inventory_changes.length})

    -
    IDCharakterCoinsLevelExportDatei
    ID${esc(t("history.character"))}${esc(t("kpi.coins"))}${esc(t("kpi.totalLevel"))}${esc(t("meta.export"))}${esc(t("history.file"))}
    ${s.id}
    ${s.xp_delta >= 0 ? "+" : ""}${fmt(s.xp_delta)} XP
    ${esc(t("empty.noChanges"))}
    - ${invRows || ""}
    ItemMengeDelta
    Keine Änderungen
    -

    Skill-Änderungen (${diff.skill_changes.length})

    - - ${skRows || ""}
    SkillLevelXP-Delta
    Keine Änderungen
    `; +

    ${esc(t("history.coinsSummary", { + delta: `${coinDelta >= 0 ? "+" : ""}${fmt(coinDelta)}`, + levelDelta: `${levelDelta >= 0 ? "+" : ""}${levelDelta}`, + }))}

    +

    ${esc(t("history.inventoryChanges", { count: diff.inventory_changes.length }))}

    + + + + + + ${invRows || noChanges}
    ${esc(t("inventory.item"))}${esc(t("inventory.qty"))}${esc(t("history.delta"))}
    +

    ${esc(t("history.skillChanges", { count: diff.skill_changes.length }))}

    + + + + + + ${skRows || noChanges}
    ${esc(t("skills.skill"))}${esc(t("skills.level"))}${esc(t("history.xpDelta"))}
    `; } 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) { diff --git a/static/i18n.js b/static/i18n.js new file mode 100644 index 0000000..4c48147 --- /dev/null +++ b/static/i18n.js @@ -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); diff --git a/static/locales/de.json b/static/locales/de.json new file mode 100644 index 0000000..75a5920 --- /dev/null +++ b/static/locales/de.json @@ -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" + } +} diff --git a/static/locales/en.json b/static/locales/en.json new file mode 100644 index 0000000..8d3300d --- /dev/null +++ b/static/locales/en.json @@ -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" + } +} diff --git a/static/style.css b/static/style.css index 9a40036..e07ce1d 100644 --- a/static/style.css +++ b/static/style.css @@ -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; diff --git a/templates/index.html b/templates/index.html index 7212d09..e090060 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,11 +1,12 @@ - + Idle Fantasy Viewer + @@ -14,22 +15,30 @@
    -

    Idle Fantasy

    -

    Save Viewer

    +

    Idle Fantasy Viewer

    +

    Save Viewer

    @@ -37,8 +46,9 @@
    +
    - Lade Save… + Loading save…
    diff --git a/validation.py b/validation.py new file mode 100644 index 0000000..9a5fcc3 --- /dev/null +++ b/validation.py @@ -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)