"""Search translated WolfDawn JSON for text to fix wrapping. Paste in-game text, open the matching line, then wrap that line or every overflowing line in the same group (database sheet, names category, map / CommonEvent file, or Game.dat). """ from __future__ import annotations import json import re from dataclasses import dataclass, field from pathlib import Path from typing import Any, Sequence import util.dazedwrap as dazedwrap from util.wolfdawn.db_classify import group_key, json_file_from_doc from util.wolfdawn.selective_wrap import line_needs_wrap, wrap_line_text WRAP_PROFILE_NAME = "wrap_profile.json" DEFAULT_WRAP_WIDTH = 36 _APOSTROPHE_NORMALIZE = str.maketrans( { "\u2019": "'", "\u2018": "'", "\u02bc": "'", "`": "'", } ) @dataclass class WrapHit: """One searchable line in translated JSON.""" json_file: str kind: str sheet_name: str row: int | None field_name: str text: str max_line_len: int scene_index: int | None = None line_index: int | None = None map_file: str | None = None @property def hit_id(self) -> dict[str, Any]: """Stable locator stored in the UI.""" loc: dict[str, Any] = { "json_file": self.json_file, "kind": self.kind, "sheet_name": self.sheet_name, "field_name": self.field_name, } if self.row is not None: loc["row"] = self.row if self.kind == "names" and self.row is not None: loc["name_index"] = self.row if self.scene_index is not None: loc["scene_index"] = self.scene_index if self.line_index is not None: loc["line_index"] = self.line_index return loc def summary(self, width: int = 0) -> str: overflow = f" longest line: {self.max_line_len} chars" if width > 0 and self.max_line_len > width: overflow += " (overflow)" preview = self.text.replace("\n", " ")[:90] if len(self.text) > 90: preview += "…" if self.kind == "db": loc = f"row {self.row} · {self.field_name}" elif self.kind == "names": loc = f"entry #{self.row} · {self.field_name[:60]}" elif self.kind == "gamedat": loc = self.field_name or f"line {self.line_index}" else: loc = self.field_name or self.map_file or self.json_file return ( f"Sheet: {self.sheet_name} · {self.json_file} · {loc}\n" f" {preview}{overflow}" ) @dataclass class SheetOverflowSummary: json_file: str sheet_name: str line_count: int overflow_count: int tier: str = "" kind: str = "db" @property def key(self) -> str: if self.kind == "names": return f"{self.json_file}|names|{self.sheet_name}" return group_key(self.json_file, self.sheet_name) def wrap_profile_path(work_dir: str | Path) -> Path: return Path(work_dir) / WRAP_PROFILE_NAME def load_wrap_profile(work_dir: str | Path) -> dict[str, Any]: path = wrap_profile_path(work_dir) if not path.is_file(): return {"default_width": DEFAULT_WRAP_WIDTH, "sheets": {}} try: data = json.loads(path.read_text(encoding="utf-8")) if not isinstance(data, dict): return {"default_width": DEFAULT_WRAP_WIDTH, "sheets": {}} data.setdefault("default_width", DEFAULT_WRAP_WIDTH) data.setdefault("sheets", {}) return data except Exception: return {"default_width": DEFAULT_WRAP_WIDTH, "sheets": {}} def save_wrap_profile(work_dir: str | Path, profile: dict[str, Any]) -> None: path = wrap_profile_path(work_dir) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(profile, ensure_ascii=False, indent=4) + "\n", encoding="utf-8") def get_sheet_width( profile: dict[str, Any], sheet_name: str, *, default: int | None = None, ) -> int: sheets = profile.get("sheets") or {} entry = sheets.get(sheet_name) if isinstance(entry, dict) and entry.get("width"): try: return int(entry["width"]) except (TypeError, ValueError): pass if default is not None: return default try: return int(profile.get("default_width") or DEFAULT_WRAP_WIDTH) except (TypeError, ValueError): return DEFAULT_WRAP_WIDTH def set_sheet_width( work_dir: str | Path, sheet_name: str, width: int, *, json_file: str, ) -> None: profile = load_wrap_profile(work_dir) sheets = profile.setdefault("sheets", {}) sheets[sheet_name] = {"width": int(width), "json_file": json_file} profile["default_width"] = profile.get("default_width", DEFAULT_WRAP_WIDTH) save_wrap_profile(work_dir, profile) def _load_json(path: Path) -> dict[str, Any] | None: try: return json.loads(path.read_text(encoding="utf-8-sig")) except Exception: return None def _normalize_query(query: str) -> str: return " ".join(query.lower().translate(_APOSTROPHE_NORMALIZE).split()) def _normalize_match_text(text: str) -> str: return text.lower().translate(_APOSTROPHE_NORMALIZE) def _text_matches(text: str, query_norm: str) -> bool: if not query_norm or not isinstance(text, str): return False hay = _normalize_match_text(text) if query_norm in hay: return True # In-game UI often hides line breaks; allow matching across newlines. collapsed = " ".join(hay.split()) return query_norm in collapsed def _iter_search_dirs( translated_dir: Path, files_dir: Path | None, extra_dirs: Sequence[str | Path] | None = None, ) -> list[Path]: dirs: list[Path] = [] for candidate in (translated_dir, files_dir, *(extra_dirs or ())): if candidate is None: continue p = Path(candidate) if p.is_dir() and p not in dirs: dirs.append(p) return dirs def search_translated_text( query: str, translated_dir: str | Path, *, files_dir: str | Path | None = None, extra_dirs: Sequence[str | Path] | None = None, limit: int = 50, ) -> list[WrapHit]: """Find lines containing *query* in translated, files/, and optional extra dirs.""" if not query.strip(): return [] q = _normalize_query(query) hits: list[WrapHit] = [] tdir = Path(translated_dir) fdir = Path(files_dir) if files_dir else tdir.parent / "files" seen: set[tuple] = set() for base in _iter_search_dirs(tdir, fdir, extra_dirs): for path in sorted(base.glob("*.json")): doc = _load_json(path) if not doc: continue kind = doc.get("kind") jf = path.name if kind == "db": for group in doc.get("groups") or []: sheet = str(group.get("typeName") or "") for line in group.get("lines") or []: text = line.get("text") if not _text_matches(text, q): continue row = line.get("row") field = str(line.get("fieldName") or "") key = (jf, sheet, row, field) if key in seen: continue seen.add(key) hits.append( WrapHit( json_file=jf, kind="db", sheet_name=sheet, row=int(row) if row is not None else None, field_name=field, text=str(text), max_line_len=dazedwrap.max_line_visible_length(str(text)), ) ) elif kind == "names": for idx, entry in enumerate(doc.get("names") or []): if not isinstance(entry, dict): continue text = entry.get("text") if not _text_matches(text, q): continue note = str(entry.get("note") or "names.json") source = str(entry.get("source") or "")[:80] key = (jf, "names", idx) if key in seen: continue seen.add(key) hits.append( WrapHit( json_file=jf, kind="names", sheet_name=note, row=idx, field_name=source or f"entry {idx}", text=str(text), max_line_len=dazedwrap.max_line_visible_length(str(text)), ) ) elif kind == "gamedat": for li, line in enumerate(doc.get("lines") or []): if not isinstance(line, dict): continue text = line.get("text") if not _text_matches(text, q): continue key = (jf, "gamedat", li) if key in seen: continue seen.add(key) field = str(line.get("key") or line.get("fieldName") or f"line {li}") hits.append( WrapHit( json_file=jf, kind="gamedat", sheet_name="Game.dat", row=li, field_name=field, text=str(text), max_line_len=dazedwrap.max_line_visible_length(str(text)), line_index=li, ) ) elif kind in ("map", "common"): label = doc.get("file") or jf sheet = "CommonEvent" if kind == "common" else str(label) for si, scene in enumerate(doc.get("scenes") or []): for li, line in enumerate(scene.get("lines") or []): text = line.get("text") if not _text_matches(text, q): continue key = (jf, si, li) if key in seen: continue seen.add(key) speaker = str(line.get("speaker") or "") hits.append( WrapHit( json_file=jf, kind=kind, sheet_name=sheet, row=scene.get("event"), field_name=speaker or f"scene {si} line {li}", text=str(text), max_line_len=dazedwrap.max_line_visible_length(str(text)), scene_index=si, line_index=li, map_file=str(label) if kind == "map" else None, ) ) if len(hits) >= limit: return hits return hits def wrap_preview_info(text: str, width: int) -> dict[str, Any]: """Return wrapped text and metrics for the Step 7 live preview.""" if not isinstance(text, str) or not text.strip() or width <= 0: return { "wrapped": text if isinstance(text, str) else "", "needs_wrap": False, "longest": 0, "input_line_count": 0, "output_line_count": 0, "line_stats": [], } wrapped = wrap_line_text(text, width) norm_in = text.replace("\r\n", "\n").replace("\r", "\n") norm_out = wrapped.replace("\r\n", "\n").replace("\r", "\n") in_lines = norm_in.split("\n") if norm_in else [""] out_lines = norm_out.split("\n") if norm_out else [""] line_stats: list[dict[str, Any]] = [] for i, line in enumerate(in_lines): vis = dazedwrap.max_line_visible_length(line) line_stats.append( { "line": i + 1, "visible": vis, "overflow": max(0, vis - width), "needs_wrap": vis > width, } ) longest = max((s["visible"] for s in line_stats), default=0) needs_wrap = wrapped != text or any(s["needs_wrap"] for s in line_stats) return { "wrapped": wrapped, "needs_wrap": needs_wrap, "longest": longest, "input_line_count": len(in_lines), "output_line_count": len(out_lines), "line_stats": line_stats, } def format_wrap_preview(text: str, width: int) -> str: """Multiline preview with per-line visible character counts.""" info = wrap_preview_info(text, width) if not info["wrapped"]: return "" lines = info["wrapped"].replace("\r\n", "\n").replace("\r", "\n").split("\n") parts: list[str] = [] for i, line in enumerate(lines): vis = dazedwrap.max_line_visible_length(line) marker = "⚠" if vis > width else " " parts.append(f"{marker} {i + 1:2d} ({vis:2d}) {line}") return "\n".join(parts) def wrap_preview_summary(text: str, width: int) -> str: """One-line status for the preview header.""" info = wrap_preview_info(text, width) if not isinstance(text, str) or not text.strip(): return "" longest = int(info["longest"]) if not info["needs_wrap"]: return f"Fits at width {width} (longest line {longest} visible chars)." return ( f"Will wrap to {info['output_line_count']} line(s) at width {width} " f"(longest input line {longest} visible chars)." ) def split_line_at_visible_width(line: str, width: int) -> tuple[int, int]: """Return ``(fit_end, line_len)`` char indices in *line* for UI highlighting.""" line_len = len(line) if not line or width <= 0: return (line_len, line_len) fit_end = dazedwrap.visible_word_wrap_end_index(line, width) return (min(fit_end, line_len), line_len) def locate_line(doc: dict[str, Any], hit_id: dict[str, Any]) -> dict[str, Any] | None: """Return the live line dict for *hit_id* inside *doc*.""" kind = hit_id.get("kind") if kind == "db": sheet = hit_id.get("sheet_name") row = hit_id.get("row") field = hit_id.get("field_name") for group in doc.get("groups") or []: if str(group.get("typeName") or "") != sheet: continue for line in group.get("lines") or []: if line.get("row") == row and str(line.get("fieldName") or "") == field: return line return None if kind in ("map", "common"): si = hit_id.get("scene_index") li = hit_id.get("line_index") scenes = doc.get("scenes") or [] if si is None or li is None or si >= len(scenes): return None lines = scenes[si].get("lines") or [] if li >= len(lines): return None return lines[li] if kind == "names": idx = hit_id.get("name_index", hit_id.get("row")) names = doc.get("names") or [] if idx is None or not isinstance(idx, int) or idx < 0 or idx >= len(names): return None entry = names[idx] return entry if isinstance(entry, dict) else None if kind == "gamedat": li = hit_id.get("line_index", hit_id.get("row")) lines = doc.get("lines") or [] if li is None or not isinstance(li, int) or li < 0 or li >= len(lines): return None line = lines[li] return line if isinstance(line, dict) else None return None def load_hit_from_id( translated_dir: str | Path, hit_id: dict[str, Any], *, files_dir: str | Path | None = None, extra_dirs: Sequence[str | Path] | None = None, ) -> tuple[Path | None, dict[str, Any] | None, dict[str, Any] | None]: """Load JSON path, document, and line dict for *hit_id*.""" jf = hit_id.get("json_file") if not jf: return None, None, None tdir = Path(translated_dir) fdir = Path(files_dir) if files_dir else None for base in _iter_search_dirs(tdir, fdir, extra_dirs): path = base / str(jf) if not path.is_file(): continue doc = _load_json(path) if not doc: continue line = locate_line(doc, hit_id) if line is not None: return path, doc, line return None, None, None def save_document(path: Path, doc: dict[str, Any]) -> None: path.write_text( json.dumps(doc, ensure_ascii=False, indent=4) + "\n", encoding="utf-8", ) def wrap_line_in_doc(line: dict[str, Any], width: int) -> bool: """Wrap one line's ``text``; return True if changed.""" text = line.get("text") if not isinstance(text, str) or not text.strip() or width <= 0: return False new_text = wrap_line_text(text, width) if new_text == text: return False line["text"] = new_text return True @dataclass class ScopeStats: """Line counts for the wrap group containing one search hit.""" label: str total: int overflow: int def scope_label(hit: WrapHit | dict[str, Any]) -> str: """Human-readable group name for status messages.""" if isinstance(hit, WrapHit): kind, sheet, jf = hit.kind, hit.sheet_name, hit.json_file else: kind = str(hit.get("kind") or "") sheet = str(hit.get("sheet_name") or "") jf = str(hit.get("json_file") or "") if kind == "db": return f"sheet {sheet}" if kind == "names": return f"category {sheet}" if kind == "gamedat": return "Game.dat" if kind == "common": return "CommonEvent" if kind == "map": return sheet or jf return sheet or jf def scope_stats( doc: dict[str, Any], hit: WrapHit | dict[str, Any], width: int, ) -> ScopeStats: """Count lines and overflows in the same group as *hit*.""" if isinstance(hit, WrapHit): kind, sheet_name = hit.kind, hit.sheet_name else: kind = str(hit.get("kind") or doc.get("kind") or "") sheet_name = str(hit.get("sheet_name") or "") total = 0 overflow = 0 if kind == "db": for group in doc.get("groups") or []: if str(group.get("typeName") or "") != sheet_name: continue for line in group.get("lines") or []: text = line.get("text") if not isinstance(text, str) or not text.strip(): continue total += 1 if line_needs_wrap(text, width): overflow += 1 break elif kind == "names": for entry in doc.get("names") or []: if not isinstance(entry, dict): continue if str(entry.get("note") or "") != sheet_name: continue text = entry.get("text") if not isinstance(text, str) or not text.strip(): continue total += 1 if line_needs_wrap(text, width): overflow += 1 elif kind in ("map", "common"): for scene in doc.get("scenes") or []: for line in scene.get("lines") or []: text = line.get("text") if not isinstance(text, str) or not text.strip(): continue total += 1 if line_needs_wrap(text, width): overflow += 1 elif kind == "gamedat": for line in doc.get("lines") or []: if not isinstance(line, dict): continue text = line.get("text") if not isinstance(text, str) or not text.strip(): continue total += 1 if line_needs_wrap(text, width): overflow += 1 return ScopeStats(label=scope_label(hit), total=total, overflow=overflow) def _wrap_overflow_in_event_file(path: Path, doc: dict[str, Any], width: int) -> int: """Wrap overflowing dialogue lines in one map or CommonEvent JSON file.""" if doc.get("kind") not in ("map", "common"): return 0 changed = 0 for scene in doc.get("scenes") or []: for line in scene.get("lines") or []: text = line.get("text") if not isinstance(text, str) or not text.strip(): continue if not line_needs_wrap(text, width): continue if wrap_line_in_doc(line, width): changed += 1 if changed: save_document(path, doc) return changed def _wrap_overflow_in_gamedat(path: Path, doc: dict[str, Any], width: int) -> int: if doc.get("kind") != "gamedat": return 0 changed = 0 for line in doc.get("lines") or []: if not isinstance(line, dict): continue text = line.get("text") if not isinstance(text, str) or not text.strip(): continue if not line_needs_wrap(text, width): continue if wrap_line_in_doc(line, width): changed += 1 if changed: save_document(path, doc) return changed def wrap_overflow_in_scope( path: Path, doc: dict[str, Any], hit: WrapHit | dict[str, Any], width: int, ) -> int: """Wrap every overflowing line in the same group as *hit*.""" if isinstance(hit, WrapHit): kind, sheet_name = hit.kind, hit.sheet_name else: kind = str(hit.get("kind") or doc.get("kind") or "") sheet_name = str(hit.get("sheet_name") or "") if kind == "names": return _wrap_overflow_in_names_category(path, doc, sheet_name, width) if kind == "db": return wrap_overflow_in_sheet(path, doc, sheet_name, width, kind="db") if kind in ("map", "common"): return _wrap_overflow_in_event_file(path, doc, width) if kind == "gamedat": return _wrap_overflow_in_gamedat(path, doc, width) return 0 def wrap_hit_in_file( path: Path, doc: dict[str, Any], hit_id: dict[str, Any], width: int, ) -> bool: line = locate_line(doc, hit_id) if line is None: return False if not wrap_line_in_doc(line, width): return False save_document(path, doc) return True def wrap_overflow_in_sheet( path: Path, doc: dict[str, Any], sheet_name: str, width: int, *, kind: str | None = None, ) -> int: """Wrap every overflowing line in one DB sheet or names.json category.""" doc_kind = kind or doc.get("kind") if doc_kind == "names": return _wrap_overflow_in_names_category(path, doc, sheet_name, width) if doc.get("kind") != "db": return 0 changed = 0 for group in doc.get("groups") or []: if str(group.get("typeName") or "") != sheet_name: continue for line in group.get("lines") or []: text = line.get("text") if not isinstance(text, str) or not text.strip(): continue if not line_needs_wrap(text, width): continue if wrap_line_in_doc(line, width): changed += 1 if changed: save_document(path, doc) return changed def _wrap_overflow_in_names_category( path: Path, doc: dict[str, Any], category: str, width: int, ) -> int: """Wrap overflowing ``names[]`` entries sharing one ``note`` (category).""" if doc.get("kind") != "names": return 0 changed = 0 for entry in doc.get("names") or []: if not isinstance(entry, dict): continue if str(entry.get("note") or "") != category: continue text = entry.get("text") if not isinstance(text, str) or not text.strip(): continue if not line_needs_wrap(text, width): continue if wrap_line_in_doc(entry, width): changed += 1 if changed: save_document(path, doc) return changed def _names_category_summaries( doc: dict[str, Any], *, json_file: str, width: int, ) -> list[SheetOverflowSummary]: counts: dict[str, int] = {} overflow: dict[str, int] = {} for entry in doc.get("names") or []: if not isinstance(entry, dict): continue note = str(entry.get("note") or "names.json") counts[note] = counts.get(note, 0) + 1 text = entry.get("text") if isinstance(text, str) and line_needs_wrap(text, width): overflow[note] = overflow.get(note, 0) + 1 return [ SheetOverflowSummary( json_file=json_file, sheet_name=note, line_count=counts[note], overflow_count=overflow.get(note, 0), tier="names", kind="names", ) for note in sorted(counts) ] def sheet_overflow_summaries( translated_dir: str | Path, width: int, *, files_dir: str | Path | None = None, ) -> list[SheetOverflowSummary]: """Per DB sheet overflow counts at *width*.""" from util.wolfdawn.db_classify import analyze_content_distribution, classify_db_document base = Path(translated_dir) if not base.is_dir(): base = Path(files_dir) if files_dir else base dist = analyze_content_distribution(base) summaries: list[SheetOverflowSummary] = [] for path in sorted(base.glob("*.project.json")): doc = _load_json(path) if not doc or doc.get("kind") != "db": continue jf = path.name for group in classify_db_document(doc, json_file=jf): overflow = 0 for g in doc.get("groups") or []: if str(g.get("typeName") or "") != group.type_name: continue for line in g.get("lines") or []: text = line.get("text") if isinstance(text, str) and line_needs_wrap(text, width): overflow += 1 summaries.append( SheetOverflowSummary( json_file=jf, sheet_name=group.type_name, line_count=group.line_count, overflow_count=overflow, tier=group.tier, ) ) if not summaries: summaries = [ SheetOverflowSummary( json_file=g.json_file, sheet_name=g.type_name, line_count=g.line_count, overflow_count=0, tier=g.tier, ) for g in dist.groups ] seen_names: set[Path] = set() for search_base in _iter_search_dirs(base, Path(files_dir) if files_dir else None): names_path = search_base / "names.json" if not names_path.is_file() or names_path in seen_names: continue seen_names.add(names_path) doc = _load_json(names_path) if doc and doc.get("kind") == "names": summaries.extend( _names_category_summaries(doc, json_file="names.json", width=width) ) return summaries