CatStation/Tools/localize/localize.py
MetalSage b10036943e
Глобальное реформатирование локализации №2 (#3543)
Co-authored-by: MetalSage <metalsage.official@gmail.com>
2026-03-30 01:49:26 +07:00

1275 lines
48 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# Автор инструмента discord (metalsage <@494907100376596480>)
import argparse
import logging
import multiprocessing
import os
import re
import sys
from concurrent.futures import ProcessPoolExecutor, as_completed
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple
try:
import yaml
try:
from yaml import CSafeLoader as SafeLoader
except ImportError:
from yaml import SafeLoader
except ImportError:
print("Ошибка: Библиотека 'pyyaml' не установлена, но она обязательна для работы.")
print("Установите её с помощью команды: pip install pyyaml")
sys.exit(1)
class SS14YamlLoader(SafeLoader):
pass
def construct_undefined(loader, tag_suffix, node):
if isinstance(node, yaml.ScalarNode):
return loader.construct_scalar(node)
elif isinstance(node, yaml.SequenceNode):
return loader.construct_sequence(node)
elif isinstance(node, yaml.MappingNode):
return loader.construct_mapping(node)
return None
SS14YamlLoader.add_multi_constructor("!", construct_undefined)
@dataclass(slots=True)
class FtlAttribute:
name: str
value: str
key_space: str = " "
val_space: str = " "
indent: str = " "
@dataclass(slots=True)
class FtlEntry:
key: str
value: str
attributes: Dict[str, FtlAttribute] = field(default_factory=dict)
ignore_untranslated: bool = False
variables: Set[str] = field(default_factory=set)
preceding_text: List[str] = field(default_factory=list)
key_space: str = " "
val_space: str = " "
@dataclass(slots=True)
class PrototypeEntry:
id: str
parents: List[str]
name: Optional[str] = None
desc: Optional[str] = None
suffix: Optional[str] = None
name_parent: Optional[str] = None
desc_parent: Optional[str] = None
suffix_parent: Optional[str] = None
resolved_name: Optional[str] = None
resolved_desc: Optional[str] = None
resolved_suffix: Optional[str] = None
filepath: str = ""
file_index: int = 0
class RunContext:
def __init__(
self,
ignore_paths: Optional[List[Path]] = None,
ignore_untranslated_paths: Optional[List[Path]] = None,
):
self._processed_files: Set[str] = set()
self.parsed_ftl_cache: Dict[
Path, Tuple[Dict[str, FtlEntry], List[str], bool]
] = {}
self.ignore_paths: List[str] = [
os.path.normcase(os.path.abspath(p)) for p in (ignore_paths or [])
]
self.ignore_untranslated_paths: List[str] = [
os.path.normcase(os.path.abspath(p))
for p in (ignore_untranslated_paths or [])
]
def mark_processed(self, filepath: Path) -> bool:
path_str = str(filepath.resolve())
if path_str in self._processed_files:
return True
self._processed_files.add(path_str)
return False
def is_ignored(self, filepath: Path) -> bool:
if not self.ignore_paths:
return False
file_abs = os.path.normcase(os.path.abspath(filepath))
for ign in self.ignore_paths:
if file_abs == ign or file_abs.startswith(ign + os.sep):
return True
return False
def is_untranslated_ignored(self, filepath: Path) -> bool:
if not self.ignore_untranslated_paths:
return False
file_abs = os.path.normcase(os.path.abspath(filepath))
for ign in self.ignore_untranslated_paths:
if file_abs == ign or file_abs.startswith(ign + os.sep):
return True
return False
class FtlParser:
VAR_REGEX = re.compile(r"\$([a-zA-Z0-9_-]+)")
REF_REGEX = re.compile(r"\{[^}]+\}")
TAG_REGEX = re.compile(r"\[[^\]]+\]")
MSG_DEF_RE = re.compile(r"^-?[a-zA-Z][a-zA-Z0-9_-]*\s*=")
ATTR_DEF_RE = re.compile(r"^\s+\.[a-zA-Z][a-zA-Z0-9_-]*\s*=")
COMMENT_RE = re.compile(r"^\s*#")
@classmethod
def parse_file(cls, filepath: Path) -> Tuple[Dict[str, FtlEntry], List[str], bool]:
entries: Dict[str, FtlEntry] = {}
buffer: List[str] = []
pending_blanks: List[str] = []
ends_with_newline = False
try:
with open(filepath, "r", encoding="utf-8-sig") as f:
current_entry: Optional[FtlEntry] = None
current_attr: Optional[FtlAttribute] = None
for line in f:
raw_line = line.replace("\ufeff", "")
ends_with_newline = raw_line.endswith("\n")
stripped = raw_line.strip()
if not stripped:
pending_blanks.append(raw_line.rstrip("\n"))
continue
if cls.COMMENT_RE.match(raw_line):
current_entry, current_attr = None, None
buffer.extend(pending_blanks)
pending_blanks.clear()
buffer.append(raw_line.rstrip("\n"))
continue
if cls.MSG_DEF_RE.match(raw_line):
current_entry, current_attr = None, None
buffer.extend(pending_blanks)
pending_blanks.clear()
key_part, val_part = raw_line.split("=", 1)
key = key_part.strip()
key_space_match = re.search(r"([ \t]+)$", key_part)
key_space = key_space_match.group(1) if key_space_match else ""
val_str = val_part.rstrip("\n")
val_space_match = re.match(r"^([ \t]+)", val_part)
val_space = val_space_match.group(1) if val_space_match else ""
if val_space:
val_str = val_str[len(val_space) :]
ignore_next = any(
"ignore-untranslated" in b.lower() for b in buffer
)
current_entry = FtlEntry(
key=key,
value=val_str,
ignore_untranslated=ignore_next,
variables=set(cls.VAR_REGEX.findall(val_str)),
preceding_text=buffer.copy(),
key_space=key_space,
val_space=val_space,
)
entries[key] = current_entry
buffer.clear()
continue
if current_entry and cls.ATTR_DEF_RE.match(raw_line):
append_str = ""
for b in pending_blanks:
append_str += "\n" + b.rstrip("\n")
if current_attr:
current_attr.value += append_str
else:
current_entry.value += append_str
pending_blanks.clear()
attr_parts = raw_line.split("=", 1)
attr_key_part = attr_parts[0]
attr_val_part = attr_parts[1]
indent_match = re.match(r"^([ \t]+)", attr_key_part)
indent = indent_match.group(1) if indent_match else " "
attr_name_full = attr_key_part.strip()
attr_name = attr_name_full[1:]
key_space_match = re.search(r"([ \t]+)$", attr_key_part)
key_space = key_space_match.group(1) if key_space_match else ""
attr_val_str = attr_val_part.rstrip("\n")
val_space_match = re.match(r"^([ \t]+)", attr_val_part)
val_space = val_space_match.group(1) if val_space_match else ""
if val_space:
attr_val_str = attr_val_str[len(val_space) :]
current_attr = FtlAttribute(
attr_name, attr_val_str, key_space, val_space, indent
)
current_entry.attributes[attr_name] = current_attr
current_entry.variables.update(
cls.VAR_REGEX.findall(attr_val_str)
)
continue
if current_entry:
append_str = ""
for b in pending_blanks:
append_str += "\n" + b.rstrip("\n")
append_str += "\n" + raw_line.rstrip("\n")
pending_blanks.clear()
target = current_attr if current_attr else current_entry
target.value += append_str
current_entry.variables.update(cls.VAR_REGEX.findall(raw_line))
continue
current_entry, current_attr = None, None
buffer.extend(pending_blanks)
pending_blanks.clear()
buffer.append(raw_line.rstrip("\n"))
except OSError as e:
logging.error(f"Ошибка чтения fluent файла {filepath}: {e}")
buffer.extend(pending_blanks)
return entries, buffer, ends_with_newline
@classmethod
def _format_value(
cls, key: str, value: str, indent: str, key_space: str, val_space: str
) -> str:
if value is None:
value = ""
val_str = str(value)
lines = val_str.split("\n")
result_lines = []
first_line = lines[0]
if not first_line.strip() and len(lines) > 1:
result_lines.append(f"{indent}{key}{key_space}={val_space}")
else:
result_lines.append(f"{indent}{key}{key_space}={val_space}{first_line}")
for line in lines[1:]:
if not line.strip():
result_lines.append("")
else:
if (
not line.startswith(" ")
and not line.startswith("\t")
and not line.startswith("}")
):
result_lines.append(f"{indent} {line}")
else:
result_lines.append(line)
return "\n".join(result_lines)
@classmethod
def write_file(
cls,
filepath: Path,
entries: Dict[str, FtlEntry],
trailing_text: List[str],
ends_with_newline: bool = True,
) -> None:
try:
filepath.parent.mkdir(parents=True, exist_ok=True)
lines_to_write = []
for entry in entries.values():
has_value = bool(entry.value and str(entry.value).strip())
has_attrs = any(
attr.value and str(attr.value).strip()
for attr in entry.attributes.values()
)
if not has_value and not has_attrs:
continue
lines_to_write.extend(entry.preceding_text)
val_str = cls._format_value(
entry.key, entry.value, "", entry.key_space, entry.val_space
)
lines_to_write.append(val_str)
for attr in entry.attributes.values():
if attr.name == "desc" or (attr.value and str(attr.value).strip()):
attr_str = cls._format_value(
"." + attr.name,
attr.value,
attr.indent,
attr.key_space,
attr.val_space,
)
lines_to_write.append(attr_str)
lines_to_write.extend(trailing_text)
content = "\n".join(lines_to_write)
if not content.strip():
if filepath.exists():
try:
filepath.unlink()
except OSError:
pass
return
if ends_with_newline:
content += "\n"
with open(filepath, "w", encoding="utf-8") as f:
f.write(content)
except OSError as e:
logging.error(f"Ошибка записи fluent файла {filepath}: {e}")
class LocalizationManager:
CYRILLIC_PATTERN = re.compile(r"[А-Яа-яЁё]")
LATIN_PATTERN = re.compile(r"[a-zA-Z]")
RE_INNER_DASH = re.compile(r"(?<=\s)-(?=\s)")
def __init__(self, root_dir: Path, format_level: str, config_file: str):
self.root_dir = root_dir
self.format_level = format_level
self.resources_dir = self.root_dir / "Resources"
self.locales_dir = self.resources_dir / "Locale"
self.en_us_dir = self.locales_dir / "en-US"
self.ru_ru_dir = self.locales_dir / "ru-RU"
self.robust_en_dir = (
self.root_dir / "RobustToolbox" / "Resources" / "Locale" / "en-US"
)
self.robust_ru_dir = self.ru_ru_dir / "robust-toolbox"
self.prototypes_ru_dir = self.ru_ru_dir / "ss14-ru" / "prototypes"
self.prototypes_src_dir = self.resources_dir / "Prototypes"
ignore_paths, ignore_untranslated = self._load_config(config_file)
self.context = RunContext(ignore_paths, ignore_untranslated)
def _load_config(self, config_file: str) -> Tuple[List[Path], List[Path]]:
ignore_paths: List[Path] = []
ignore_untranslated_paths: List[Path] = []
config_path = Path(config_file).resolve()
if not config_path.exists():
config_path = (self.root_dir / config_file).resolve()
if config_path.exists():
try:
with open(config_path, "r", encoding="utf-8") as f:
config_data = yaml.safe_load(f)
if config_data and isinstance(config_data, dict):
for p in config_data.get("ignored_paths", []):
ignore_paths.append((self.root_dir / p).resolve())
for p in config_data.get("ignored_untranslated_paths", []):
ignore_untranslated_paths.append(
(self.root_dir / p).resolve()
)
logging.info(f"Конфигурация загружена из {config_path.name}")
except Exception as e:
logging.error(f"Ошибка чтения конфигурации {config_path}: {e}")
else:
logging.info(
f"Конфигурационный файл {config_file} не найден. "
"Игнорирование путей отключено."
)
return ignore_paths, ignore_untranslated_paths
def _get_parsed_ftl(
self, filepath: Path
) -> Tuple[Dict[str, FtlEntry], List[str], bool]:
if filepath not in self.context.parsed_ftl_cache:
self.context.parsed_ftl_cache[filepath] = FtlParser.parse_file(filepath)
return self.context.parsed_ftl_cache[filepath]
def sync_systems(self) -> None:
global_memory = self._load_translation_memory()
valid_ru_files = self._sync_directories(
self.en_us_dir, self.ru_ru_dir, global_memory
)
ignore_dirs_ru = [self.robust_ru_dir, self.ru_ru_dir / "ss14-ru"]
self._cleanup_orphans(
self.ru_ru_dir, valid_ru_files, ignore_dirs=ignore_dirs_ru
)
valid_robust_files = self._sync_directories(
self.robust_en_dir, self.robust_ru_dir, global_memory
)
self._cleanup_orphans(self.robust_ru_dir, valid_robust_files)
def _load_translation_memory(self) -> Dict[str, FtlEntry]:
memory: Dict[str, FtlEntry] = {}
for target_dir in (self.ru_ru_dir, self.robust_ru_dir):
if not target_dir.exists():
continue
for ru_file in target_dir.rglob("*.ftl"):
if self.context.is_ignored(ru_file):
continue
entries, _, _ = self._get_parsed_ftl(ru_file)
memory.update(entries)
return memory
@staticmethod
def _enforce_inline_braces_spacing(text: str) -> str:
if not text:
return text
def replacer(match: re.Match) -> str:
content = match.group(1).strip()
return f"{{ {content} }}"
return re.sub(r"\{([^{}\n]+)\}", replacer, text)
@staticmethod
def _align_string_to_template(template: str, target: str) -> str:
if not template or not target:
return target
target = re.sub(
r"((?:^|\n)[ \t]*\*?\[[^\]\n]+\][^\n{]*?)[ \t]*\n[ \t]*(\{)",
lambda m: f"{m.group(1).rstrip()} {m.group(2)}",
target,
)
template_lines = template.split("\n")
target_lines = target.split("\n")
if len(template_lines) <= 1 and len(target_lines) <= 1:
return target
temp_has_text_on_first = bool(template_lines[0].strip())
targ_has_text_on_first = bool(target_lines[0].strip())
if temp_has_text_on_first and not targ_has_text_on_first:
first_idx = next((i for i, l in enumerate(target_lines) if l.strip()), -1)
if first_idx != -1:
target_lines = target_lines[first_idx:]
target_lines[0] = target_lines[0].lstrip()
elif not temp_has_text_on_first and targ_has_text_on_first:
target_lines.insert(0, "")
is_inline = bool(target_lines[0].strip())
formatted_lines = [target_lines[0].rstrip()] if is_inline else [""]
has_selectors = "->" in template or "->" in target
if not has_selectors:
for i in range(1, len(target_lines)):
stripped = target_lines[i].strip()
if not stripped:
formatted_lines.append("")
continue
temp_idx = i if i < len(template_lines) else len(template_lines) - 1
leading_ws = ""
for j in range(temp_idx, 0, -1):
if j < len(template_lines) and template_lines[j].strip():
leading_ws = template_lines[j][
: len(template_lines[j]) - len(template_lines[j].lstrip())
]
break
if not leading_ws:
leading_ws = " "
formatted_lines.append(leading_ws + stripped)
return "\n".join(formatted_lines)
depth = target_lines[0].count("->") if is_inline else 0
for i in range(1, len(target_lines)):
stripped = target_lines[i].strip()
if not stripped:
formatted_lines.append("")
continue
is_closing = stripped.startswith("}")
closes_selector = 1 if is_closing else 0
open_selectors = stripped.count("->")
current_line_depth = max(0, depth - closes_selector)
if stripped.startswith("[") or stripped.startswith("*["):
if is_inline:
indent = 4 + max(0, current_line_depth - 1) * 8
else:
indent = 8 + max(0, current_line_depth - 1) * 8
elif is_closing:
if is_inline:
indent = current_line_depth * 8
else:
indent = 4 + current_line_depth * 8
else:
if is_inline:
indent = 8 + max(0, current_line_depth - 1) * 8
if current_line_depth == 0:
indent = 0
else:
indent = 12 + max(0, current_line_depth - 1) * 8
if current_line_depth == 0:
indent = 4
formatted_lines.append((" " * indent) + stripped)
depth = depth - closes_selector + open_selectors
return "\n".join(formatted_lines)
@staticmethod
def _sanitize_yaml_string(s: str, indent: str) -> str:
if not s:
return s
lines = s.split("\n")
res = [lines[0]]
for line in lines[1:]:
if line.strip() and not line.startswith(" ") and not line.startswith("\t"):
res.append(indent + line)
else:
res.append(line)
return "\n".join(res)
@classmethod
def _replace_dashes_in_text(cls, text: str) -> str:
if not text:
return text
lines = text.split("\n")
new_lines = []
changed = False
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith("-") or stripped.endswith("-"):
new_lines.append(line)
continue
new_line = cls.RE_INNER_DASH.sub("—", line)
new_lines.append(new_line)
if new_line != line:
changed = True
return "\n".join(new_lines) if changed else text
def _get_best_translation(
self,
key: str,
en_entry: FtlEntry,
ru_entries: Dict[str, FtlEntry],
global_memory: Dict[str, FtlEntry],
keep_ru_format: bool = False,
) -> Tuple[str, Dict[str, FtlAttribute]]:
source_entry = ru_entries.get(key) or global_memory.get(key)
if source_entry:
attrs = {}
for attr_name, en_attr in en_entry.attributes.items():
if attr_name in source_entry.attributes:
ru_attr = source_entry.attributes[attr_name]
if keep_ru_format:
aligned_val = ru_attr.value
else:
aligned_val = self._align_string_to_template(
en_attr.value, ru_attr.value
)
aligned_val = self._enforce_inline_braces_spacing(aligned_val)
attrs[attr_name] = FtlAttribute(
en_attr.name,
aligned_val,
key_space=ru_attr.key_space
if keep_ru_format
else en_attr.key_space,
val_space=ru_attr.val_space
if keep_ru_format
else en_attr.val_space,
indent=ru_attr.indent if keep_ru_format else en_attr.indent,
)
else:
attrs[attr_name] = FtlAttribute(
en_attr.name,
self._enforce_inline_braces_spacing(en_attr.value),
key_space=en_attr.key_space,
val_space=en_attr.val_space,
indent=en_attr.indent,
)
if keep_ru_format:
aligned_ru_val = source_entry.value
else:
aligned_ru_val = self._align_string_to_template(
en_entry.value, source_entry.value
)
aligned_ru_val = self._enforce_inline_braces_spacing(aligned_ru_val)
return aligned_ru_val, attrs
attrs = {
k: FtlAttribute(
v.name,
self._enforce_inline_braces_spacing(v.value),
v.key_space,
v.val_space,
v.indent,
)
for k, v in en_entry.attributes.items()
}
return self._enforce_inline_braces_spacing(en_entry.value), attrs
def _sync_directories(
self, src_dir: Path, target_dir: Path, global_memory: Dict[str, FtlEntry]
) -> Set[Path]:
valid_targets: Set[Path] = set()
if not src_dir.exists():
logging.warning(f"Исходная директория {src_dir} не найдена.")
return valid_targets
for src_file in src_dir.rglob("*.ftl"):
rel_path = src_file.relative_to(src_dir)
target_file = target_dir / rel_path
if self.context.is_ignored(target_file):
continue
valid_targets.add(target_file.resolve())
en_entries, en_trailing, en_ends = self._get_parsed_ftl(src_file)
ru_entries, ru_trailing, ru_ends = (
self._get_parsed_ftl(target_file)
if target_file.exists()
else ({}, [], True)
)
is_new_file = not target_file.exists()
new_ru_entries: Dict[str, FtlEntry] = {}
updated = False
fmt = self.format_level
if fmt == "high" or (fmt == "low" and is_new_file):
for key, en_entry in en_entries.items():
ru_val, ru_attrs = self._get_best_translation(
key, en_entry, ru_entries, global_memory, keep_ru_format=False
)
if (
key in ru_entries
and ru_entries[key].variables != en_entry.variables
):
logging.warning(
f"Несовпадение переменных в {rel_path}: {key}. "
f"Ожидается: {en_entry.variables}, найдено: {ru_entries[key].variables}"
)
new_ru_entries[key] = FtlEntry(
key=key,
value=ru_val,
attributes=ru_attrs,
ignore_untranslated=en_entry.ignore_untranslated,
variables=set(en_entry.variables),
preceding_text=en_entry.preceding_text.copy(),
key_space=en_entry.key_space,
val_space=en_entry.val_space,
)
trailing_text = en_trailing
ends_with_newline = en_ends
if (
list(ru_entries.keys()) != list(new_ru_entries.keys())
or not is_new_file
):
updated = True
elif fmt == "medium" or (fmt == "low" and not is_new_file):
for key, en_entry in en_entries.items():
ru_val, ru_attrs = self._get_best_translation(
key,
en_entry,
ru_entries,
global_memory,
keep_ru_format=(key in ru_entries),
)
if key in ru_entries:
prec_text = ru_entries[key].preceding_text
key_sp = ru_entries[key].key_space
val_sp = ru_entries[key].val_space
if ru_entries[key].variables != en_entry.variables:
logging.warning(
f"Несовпадение переменных в {rel_path}: {key}. "
f"Ожидается: {en_entry.variables}, найдено: {ru_entries[key].variables}"
)
else:
prec_text = en_entry.preceding_text.copy()
key_sp = en_entry.key_space
val_sp = en_entry.val_space
new_ru_entries[key] = FtlEntry(
key=key,
value=ru_val,
attributes=ru_attrs,
ignore_untranslated=en_entry.ignore_untranslated,
variables=set(en_entry.variables),
preceding_text=prec_text,
key_space=key_sp,
val_space=val_sp,
)
trailing_text = ru_trailing if not is_new_file else en_trailing
ends_with_newline = ru_ends if not is_new_file else en_ends
if list(ru_entries.keys()) != list(new_ru_entries.keys()):
updated = True
if updated or is_new_file:
FtlParser.write_file(
target_file, new_ru_entries, trailing_text, ends_with_newline
)
return valid_targets
def _cleanup_orphans(
self,
target_dir: Path,
valid_targets: Set[Path],
ignore_dirs: Optional[List[Path]] = None,
) -> None:
if not target_dir.exists():
return
ignore_resolved = [p.resolve() for p in (ignore_dirs or [])]
for target_file in target_dir.rglob("*.ftl"):
resolved_file = target_file.resolve()
if self.context.is_ignored(target_file):
continue
if any(resolved_file.is_relative_to(ignore) for ignore in ignore_resolved):
continue
if resolved_file not in valid_targets:
logging.info(
f"Удаление устаревшего файла: {target_file.relative_to(self.root_dir)}"
)
try:
target_file.unlink()
except OSError as e:
logging.error(f"Ошибка удаления файла {target_file}: {e}")
def process_prototypes(self) -> None:
logging.info("Обработка прототипов...")
if not self.prototypes_src_dir.exists():
logging.error(
f"Директория прототипов не найдена: {self.prototypes_src_dir}"
)
return
yml_files = list(self.prototypes_src_dir.rglob("*.yml"))
prototypes: Dict[str, PrototypeEntry] = {}
with ProcessPoolExecutor() as executor:
futures = {
executor.submit(self._parse_yaml, yml_file): yml_file
for yml_file in yml_files
}
for future in as_completed(futures):
try:
for proto in future.result():
prototypes[proto.id] = proto
except Exception as e:
logging.error(f"Ошибка парсинга YAML в {futures[future]}: {e}")
resolved_prototypes = self._resolve_inheritance(prototypes)
valid_ftls = self._generate_prototype_ftls(resolved_prototypes)
self._cleanup_orphans(self.prototypes_ru_dir, valid_ftls)
logging.info("Обработка прототипов завершена.")
@staticmethod
def _parse_yaml(filepath: Path) -> List[PrototypeEntry]:
entries: List[PrototypeEntry] = []
try:
with open(filepath, "r", encoding="utf-8") as f:
documents = list(yaml.load_all(f, Loader=SS14YamlLoader))
idx = 0
for doc in documents:
if isinstance(doc, list):
items_to_check = doc
elif isinstance(doc, dict):
items_to_check = [doc]
else:
continue
for item in items_to_check:
if (
not isinstance(item, dict)
or item.get("type") != "entity"
or "id" not in item
):
continue
raw_id = item["id"]
raw_name = item.get("name")
raw_desc = item.get("description") or item.get("desc")
raw_suffix = item.get("suffix")
parent_raw = item.get("parent") or item.get("parents", [])
if isinstance(parent_raw, list):
parents = [str(p) for p in parent_raw]
elif parent_raw is not None:
parents = [str(parent_raw)]
else:
parents = []
entries.append(
PrototypeEntry(
id=str(raw_id),
parents=parents,
name=str(raw_name) if raw_name is not None else None,
desc=str(raw_desc) if raw_desc is not None else None,
suffix=str(raw_suffix)
if raw_suffix is not None
else None,
resolved_name=str(raw_name)
if raw_name is not None
else None,
resolved_desc=str(raw_desc)
if raw_desc is not None
else None,
resolved_suffix=str(raw_suffix)
if raw_suffix is not None
else None,
filepath=str(filepath),
file_index=idx,
)
)
idx += 1
except Exception:
pass
return entries
def _resolve_inheritance(
self, prototypes: Dict[str, PrototypeEntry]
) -> Dict[str, PrototypeEntry]:
resolved: Dict[str, PrototypeEntry] = {}
def resolve(proto_id: str, visited: Set[str]) -> PrototypeEntry:
if proto_id in resolved:
return resolved[proto_id]
proto = prototypes.get(proto_id, PrototypeEntry(id=proto_id, parents=[]))
if proto_id in visited:
return proto
visited.add(proto_id)
final_name, final_desc, final_suffix = proto.name, proto.desc, proto.suffix
name_parent = None
desc_parent = None
suffix_parent = None
for parent_id in proto.parents:
parent_proto = resolve(parent_id, visited)
if final_name is None and parent_proto.resolved_name is not None:
final_name = parent_proto.resolved_name
name_parent = parent_id
if final_desc is None and parent_proto.resolved_desc is not None:
final_desc = parent_proto.resolved_desc
desc_parent = parent_id
if final_suffix is None and parent_proto.resolved_suffix is not None:
final_suffix = parent_proto.resolved_suffix
suffix_parent = parent_id
resolved_proto = PrototypeEntry(
id=proto.id,
parents=proto.parents,
name=proto.name,
desc=proto.desc,
suffix=proto.suffix,
name_parent=name_parent,
desc_parent=desc_parent,
suffix_parent=suffix_parent,
resolved_name=final_name,
resolved_desc=final_desc,
resolved_suffix=final_suffix,
filepath=proto.filepath,
file_index=proto.file_index,
)
resolved[proto_id] = resolved_proto
visited.remove(proto_id)
return resolved_proto
for p_id in prototypes:
resolve(p_id, set())
return resolved
def _generate_prototype_ftls(
self, prototypes: Dict[str, PrototypeEntry]
) -> Set[Path]:
external_keys: Set[str] = set()
if self.ru_ru_dir.exists():
for ftl_file in self.ru_ru_dir.rglob("*.ftl"):
if ftl_file.is_relative_to(self.prototypes_ru_dir):
continue
if self.context.is_ignored(ftl_file):
continue
entries, _, _ = self._get_parsed_ftl(ftl_file)
external_keys.update(entries.keys())
existing_files: Dict[Path, Tuple[Dict[str, FtlEntry], List[str], bool]] = {}
global_entries: Dict[str, Tuple[FtlEntry, Path]] = {}
if self.prototypes_ru_dir.exists():
for ftl_file in self.prototypes_ru_dir.rglob("*.ftl"):
if self.context.is_ignored(ftl_file):
continue
entries, trailing, ends = self._get_parsed_ftl(ftl_file)
existing_files[ftl_file] = (entries, trailing, ends)
for k, v in entries.items():
global_entries[k] = (v, ftl_file)
desired_layout: Dict[Path, List[Tuple[PrototypeEntry, FtlEntry]]] = {}
used_keys = set()
for proto in prototypes.values():
ftl_key = f"ent-{proto.id}"
if ftl_key in external_keys:
continue
try:
rel_path = Path(proto.filepath).relative_to(self.prototypes_src_dir)
lower_parts = [p.lower() for p in rel_path.parts]
ftl_path = self.prototypes_ru_dir.joinpath(*lower_parts).with_suffix(
".ftl"
)
except ValueError:
continue
if self.context.is_ignored(ftl_path):
continue
used_keys.add(ftl_key)
mem_entry = (
global_entries.get(ftl_key)[0] if ftl_key in global_entries else None
)
entry = FtlEntry(key=ftl_key, value="")
if proto.name is not None:
entry.value = self._sanitize_yaml_string(str(proto.name), " ")
elif proto.name_parent:
entry.value = f"{{ ent-{proto.name_parent} }}"
elif proto.parents:
entry.value = f"{{ ent-{proto.parents[0]} }}"
else:
entry.value = '{ "" }'
if proto.desc is not None:
safe_desc = self._sanitize_yaml_string(str(proto.desc), " ")
entry.attributes["desc"] = FtlAttribute("desc", safe_desc)
elif proto.desc_parent:
entry.attributes["desc"] = FtlAttribute(
"desc", f"{{ ent-{proto.desc_parent}.desc }}"
)
elif proto.parents:
entry.attributes["desc"] = FtlAttribute(
"desc", f"{{ ent-{proto.parents[0]}.desc }}"
)
else:
entry.attributes["desc"] = FtlAttribute("desc", '{ "" }')
if proto.suffix is not None:
safe_suffix = self._sanitize_yaml_string(str(proto.suffix), " ")
entry.attributes["suffix"] = FtlAttribute("suffix", safe_suffix)
elif proto.suffix_parent:
entry.attributes["suffix"] = FtlAttribute(
"suffix", f"{{ ent-{proto.suffix_parent}.suffix }}"
)
if mem_entry:
if proto.name is not None:
if mem_entry.value.strip() and not mem_entry.value.startswith("{"):
entry.value = mem_entry.value
entry.key_space = mem_entry.key_space
entry.val_space = mem_entry.val_space
for attr_name, mem_attr in mem_entry.attributes.items():
if not mem_attr.value.strip():
continue
if attr_name == "desc" and proto.desc is None:
continue
if attr_name == "suffix" and proto.suffix is None:
continue
if attr_name in entry.attributes:
if not mem_attr.value.startswith("{"):
entry.attributes[attr_name].value = mem_attr.value
entry.attributes[attr_name].key_space = mem_attr.key_space
entry.attributes[attr_name].val_space = mem_attr.val_space
entry.attributes[attr_name].indent = mem_attr.indent
else:
if not mem_attr.value.startswith("{"):
entry.attributes[attr_name] = FtlAttribute(
attr_name,
mem_attr.value,
mem_attr.key_space,
mem_attr.val_space,
mem_attr.indent,
)
if ftl_path not in desired_layout:
desired_layout[ftl_path] = []
desired_layout[ftl_path].append((proto, entry))
files_to_write: Dict[Path, Dict[str, FtlEntry]] = {}
valid_ftls = set()
for ftl_path, items in desired_layout.items():
valid_ftls.add(ftl_path)
items.sort(key=lambda x: (x[0].filepath, x[0].file_index))
file_entries = {}
for proto, entry in items:
file_entries[entry.key] = entry
files_to_write[ftl_path] = file_entries
for key, (entry, old_path) in global_entries.items():
if key not in used_keys and key not in external_keys:
valid_ftls.add(old_path)
if old_path not in files_to_write:
files_to_write[old_path] = {}
files_to_write[old_path][key] = entry
for ftl_path, entries_dict in files_to_write.items():
if not entries_dict:
if ftl_path.exists():
try:
ftl_path.unlink()
except OSError:
pass
valid_ftls.discard(ftl_path)
continue
if ftl_path in existing_files:
trailing = existing_files[ftl_path][1]
ends = existing_files[ftl_path][2]
else:
trailing = []
ends = True
FtlParser.write_file(ftl_path, entries_dict, trailing, ends)
return valid_ftls
def fix_dashes(self) -> None:
logging.info("Расстановка правильных тире вместо дефисов в локализации...")
changed_files = 0
search_dirs = [self.ru_ru_dir, self.robust_ru_dir]
for directory in search_dirs:
if not directory.exists():
continue
for ftl_file in directory.rglob("*.ftl"):
if self.context.is_ignored(ftl_file):
continue
entries, trailing, ends = FtlParser.parse_file(ftl_file)
file_changed = False
for entry in entries.values():
new_val = self._replace_dashes_in_text(entry.value)
if new_val != entry.value:
entry.value = new_val
file_changed = True
for attr in entry.attributes.values():
new_attr_val = self._replace_dashes_in_text(attr.value)
if new_attr_val != attr.value:
attr.value = new_attr_val
file_changed = True
if file_changed:
FtlParser.write_file(ftl_file, entries, trailing, ends)
changed_files += 1
logging.info(
f"Обновлены тире в файле: {ftl_file.relative_to(self.root_dir)}"
)
logging.info(f"Завершена расстановка тире. Изменено файлов: {changed_files}")
def check_untranslated(self) -> None:
logging.info("Поиск непереведенных строк...")
untranslated_count = 0
search_dirs = [self.ru_ru_dir, self.robust_ru_dir]
for directory in search_dirs:
if not directory.exists():
continue
for ftl_file in directory.rglob("*.ftl"):
if self.context.is_ignored(ftl_file):
continue
if self.context.is_untranslated_ignored(ftl_file):
continue
entries, _, _ = self._get_parsed_ftl(ftl_file)
for key, entry in entries.items():
if entry.ignore_untranslated:
continue
def is_untranslated(text: str) -> bool:
if not text:
return False
clean_text = FtlParser.TAG_REGEX.sub(
"", FtlParser.REF_REGEX.sub("", text)
)
if not clean_text.strip():
return False
return bool(
self.LATIN_PATTERN.search(clean_text)
and not self.CYRILLIC_PATTERN.search(clean_text)
)
if is_untranslated(entry.value) or any(
is_untranslated(attr.value)
for attr in entry.attributes.values()
):
logging.warning(
f"Возможно не переведено: {ftl_file.relative_to(self.root_dir)} -> {key}"
)
untranslated_count += 1
logging.info(f"Найдено потенциально непереведенных строк: {untranslated_count}")
def setup_logging() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s[%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
def main() -> None:
setup_logging()
script_dir = Path(__file__).resolve().parent
default_root = script_dir.parent.parent
parser = argparse.ArgumentParser(
description="Инструмент управления локализацией SS14."
)
parser.add_argument(
"--root",
type=str,
default=str(default_root),
help="Путь к корню проекта (по умолчанию автоматически определяется корень репозитория).",
)
parser.add_argument(
"--format",
type=str,
choices=["low", "medium", "high"],
default="high",
help="Уровень сохранения форматирования (low, medium, high). По умолчанию: high.",
)
parser.add_argument(
"--config",
type=str,
default="localize_config.yml",
help="Имя конфигурационного файла (по умолчанию localize_config.yml).",
)
parser.add_argument(
"--sync", action="store_true", help="Синхронизировать системные FTL файлы."
)
parser.add_argument(
"--prototypes",
action="store_true",
help="Сгенерировать локализацию из YAML прототипов.",
)
parser.add_argument(
"--dashes",
action="store_true",
help="Расставить тире вместо дефисов в локализации.",
)
parser.add_argument(
"--check", action="store_true", help="Проверить непереведенные строки."
)
parser.add_argument(
"--all",
action="store_true",
help="Выполнить все операции (используется по умолчанию, если ничего не передано).",
)
args = parser.parse_args()
if not any([args.sync, args.prototypes, args.check, args.dashes, args.all]):
args.all = True
root_path = Path(args.root).resolve()
manager = LocalizationManager(root_path, args.format, args.config)
try:
if args.sync or args.all:
manager.sync_systems()
if args.prototypes or args.all:
manager.process_prototypes()
if args.dashes or args.all:
manager.fix_dashes()
if args.check or args.all:
manager.check_untranslated()
except KeyboardInterrupt:
logging.info("Операция прервана пользователем.")
if __name__ == "__main__":
multiprocessing.freeze_support()
main()