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 = `
+
+
+ ${visible.map((i) => `- ${esc(I18n.translateIssue(i))}
`).join("")}
+ ${infos.length ? `
+ -
+
+ ${esc(t("import.newFieldsSummary", { count: infos.length }))}
+ ${infos.map((i) => `- ${esc(I18n.translateIssue(i))}
`).join("")}
+
+ ` : ""}
+
`;
+
+ 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 = `
-
- Gesamt-Level
${m.total_level}
-
- 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"))}
- - HP${c.hp ?? "—"}
- - Aktiver Trank${esc(c.active_potion || "—")}
- - Aktiver Zauber${esc(c.active_spell || "—")}
- - Waffen-Slot${esc(c.active_weapon_slot || "—")}
- - Segen${esc(c.active_blessing || "—")}
+ - ${esc(t("overview.hp"))}${c.hp ?? "—"}
+ - ${esc(t("overview.activePotion"))}${esc(c.active_potion || "—")}
+ - ${esc(t("overview.activeSpell"))}${esc(c.active_spell || "—")}
+ - ${esc(t("overview.weaponSlot"))}${esc(c.active_weapon_slot || "—")}
+ - ${esc(t("overview.blessing"))}${esc(c.active_blessing || "—")}
-
Session-Queue
-
+
${esc(t("overview.sessionQueue"))}
+
${queue || `- ${esc(t("empty.empty"))}
`}
-
Slayer
+ ${esc(t("overview.slayer"))}
${slayerHtml}
-
Pets
-
+
${esc(t("overview.pets"))}
+
${pets || `- ${esc(t("empty.none"))}
`}
-
Farming
-
${farming || "- Keine Felder
"}
+
${esc(t("overview.farming"))}
+
${farming || `- ${esc(t("empty.none"))}
`}
-
Gilden-Ruf
+
${esc(t("overview.guildRep"))}
${Object.entries(d.guild_reputation || {}).map(([k, v]) =>
`- ${esc(k)}${fmt(v)}
`).join("")}
@@ -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 = `
+
+
+
+
+
`;
+
+ 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 = `
-
-
-
-
-
-
-
- | Skill |
- Level |
- XP |
- Fortschritt |
-
- ${items.map((sk) => `
-
- | ${esc(sk.name)} |
- ${sk.level} |
- ${fmt(sk.xp)} |
-
- ${sk.progress_pct}%
-
- |
-
`).join("")}
-
-
-
`;
-
- 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) {
- | Item |
- Menge |
- ID |
+ ${esc(t("inventory.item"))} |
+ ${esc(t("inventory.qty"))} |
+ ${esc(t("inventory.id"))} |
${groupRows}
-
` : "
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) => `${t.label}`).join("")}
+ ${tabs.map((tab) => `${esc(tab.label)}`).join("")}
- | Quest |
- Fortschritt |
- Status |
+ ${esc(t("quests.quest"))} |
+ ${esc(t("quests.progress"))} |
+ ${esc(t("quests.status"))} |
${items.map((quest) => {
const done = isStory ? quest.completed : quest.claimed;
return `
| ${esc(quest.name)} |
${fmt(quest.progress)} |
- ${done ? "Erledigt" : "Offen"} |
+ ${esc(done ? t("quests.done") : t("quests.open"))} |
`;
}).join("")}
@@ -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 = `
-
-
-
-
+
${esc(t("combat.enemyKills"))}
+
${esc(t("combat.dungeonRuns"))}
+
${esc(t("combat.recentActivity"))}
+
${esc(t("combat.activeSessions"))}
`;
}
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"))}
-
Alle Snapshots
+
${esc(t("history.allSnapshots"))}
- | ID | Charakter | Coins | Level | Export | Datei |
+
+ | ID |
+ ${esc(t("history.character"))} |
+ ${esc(t("kpi.coins"))} |
+ ${esc(t("kpi.totalLevel"))} |
+ ${esc(t("meta.export"))} |
+ ${esc(t("history.file"))} |
+
${state.snapshots.map((s) => `
| ${s.id} |
@@ -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() {
| ${s.xp_delta >= 0 ? "+" : ""}${fmt(s.xp_delta)} XP |
`).join("");
+ const noChanges = `| ${esc(t("empty.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})
- | Item | Menge | Delta |
- ${invRows || "| Keine Änderungen |
"}
- Skill-Änderungen (${diff.skill_changes.length})
- | Skill | Level | XP-Delta |
- ${skRows || "| Keine Änderungen |
"}
`;
+ ${esc(t("history.coinsSummary", {
+ delta: `${coinDelta >= 0 ? "+" : ""}${fmt(coinDelta)}`,
+ levelDelta: `${levelDelta >= 0 ? "+" : ""}${levelDelta}`,
+ }))}
+ ${esc(t("history.inventoryChanges", { count: diff.inventory_changes.length }))}
+
+ | ${esc(t("inventory.item"))} |
+ ${esc(t("inventory.qty"))} |
+ ${esc(t("history.delta"))} |
+
+ ${invRows || noChanges}
+ ${esc(t("history.skillChanges", { count: diff.skill_changes.length }))}
+
+ | ${esc(t("skills.skill"))} |
+ ${esc(t("skills.level"))} |
+ ${esc(t("history.xpDelta"))} |
+
+ ${skRows || noChanges}
`;
}
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 @@
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)