mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c05f0cf70 | ||
|
|
07579373f5 | ||
|
|
d46cffb00f | ||
|
|
95a7106d28 | ||
|
|
b8e88f5fbb | ||
|
|
4daefcd243 | ||
|
|
ce0bbd8a50 | ||
|
|
48fd05453d | ||
|
|
ff32769d69 | ||
|
|
427c52e0cc | ||
|
|
8fc3f3e8cb | ||
|
|
cbf36018c0 | ||
|
|
76bac0c256 | ||
|
|
927b283b41 | ||
|
|
84309c2222 | ||
|
|
6f8c3e92c2 | ||
|
|
c8d4a02299 | ||
|
|
e806844980 | ||
|
|
6d0f8c2559 | ||
|
|
e559fd0c4b | ||
|
|
a1bedaa051 | ||
|
|
9fef66e80d | ||
|
|
c8d3f80a90 | ||
|
|
1dea9ae7ea | ||
|
|
eea3bb2fc3 | ||
|
|
61b25ab787 | ||
|
|
f072c8d580 | ||
|
|
2076859490 | ||
|
|
320ed0ab13 | ||
|
|
6e1314799a | ||
|
|
43e4143218 | ||
|
|
b2fc9b0d81 | ||
|
|
00d5b6b9a2 | ||
|
|
b9aadb7a6e | ||
|
|
78028434e9 | ||
|
|
5ee77eddc6 | ||
|
|
2b552521b3 | ||
|
|
8f3f5f3d1d | ||
|
|
54a11303b8 | ||
|
|
fc2c03b5b2 | ||
|
|
6c1cfeb7bb | ||
|
|
3ec842f59d | ||
|
|
680555c5dd | ||
|
|
4b20ed5a97 |
@@ -55,6 +55,8 @@ labels:
|
||||
- 'scripts/ignore_translation.toml'
|
||||
- 'app/core/src/main/resources/templates/fragments/languages.html'
|
||||
- '.github/scripts/check_language_properties.py'
|
||||
- '.github/scripts/sync_translations.py'
|
||||
- 'frontend/public/locales/[a-zA-Z]{2}-[a-zA-Z-]{2,7}/translation.json'
|
||||
|
||||
- label: 'Front End'
|
||||
files:
|
||||
|
||||
@@ -1,345 +0,0 @@
|
||||
"""
|
||||
Author: Ludy87
|
||||
Description: This script processes JSON translation files for localization checks. It compares translation files in a branch with
|
||||
a reference file to ensure consistency. The script performs two main checks:
|
||||
1. Verifies that the number of translation keys in the translation files matches the reference file.
|
||||
2. Ensures that all keys in the translation files are present in the reference file and vice versa.
|
||||
|
||||
The script also provides functionality to update the translation files to match the reference file by adding missing keys and
|
||||
adjusting the format.
|
||||
|
||||
Usage:
|
||||
python check_language_json.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_files>]
|
||||
"""
|
||||
# Sample for Windows:
|
||||
# python .github/scripts/check_language_json.py --reference-file frontend/public/locales/en-GB/translation.json --branch "" --files frontend/public/locales/de-DE/translation.json frontend/public/locales/fr-FR/translation.json
|
||||
|
||||
import copy
|
||||
import glob
|
||||
import os
|
||||
import argparse
|
||||
import re
|
||||
import json
|
||||
|
||||
|
||||
def find_duplicate_keys(file_path, keys=None, prefix=""):
|
||||
"""
|
||||
Identifies duplicate keys in a JSON file (including nested keys).
|
||||
:param file_path: Path to the JSON file.
|
||||
:param keys: Dictionary to track keys (used for recursion).
|
||||
:param prefix: Prefix for nested keys.
|
||||
:return: List of tuples (key, first_occurrence_path, duplicate_path).
|
||||
"""
|
||||
if keys is None:
|
||||
keys = {}
|
||||
|
||||
duplicates = []
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as file:
|
||||
data = json.load(file)
|
||||
|
||||
def process_dict(obj, current_prefix=""):
|
||||
for key, value in obj.items():
|
||||
full_key = f"{current_prefix}.{key}" if current_prefix else key
|
||||
|
||||
if isinstance(value, dict):
|
||||
process_dict(value, full_key)
|
||||
else:
|
||||
if full_key in keys:
|
||||
duplicates.append((full_key, keys[full_key], full_key))
|
||||
else:
|
||||
keys[full_key] = full_key
|
||||
|
||||
process_dict(data, prefix)
|
||||
return duplicates
|
||||
|
||||
|
||||
# Maximum size for JSON files (e.g., 500 KB)
|
||||
MAX_FILE_SIZE = 500 * 1024
|
||||
|
||||
|
||||
def parse_json_file(file_path):
|
||||
"""
|
||||
Parses a JSON translation file and returns a flat dictionary of all keys.
|
||||
:param file_path: Path to the JSON file.
|
||||
:return: Dictionary with flattened keys.
|
||||
"""
|
||||
with open(file_path, "r", encoding="utf-8") as file:
|
||||
data = json.load(file)
|
||||
|
||||
def flatten_dict(d, parent_key="", sep="."):
|
||||
items = {}
|
||||
for k, v in d.items():
|
||||
new_key = f"{parent_key}{sep}{k}" if parent_key else k
|
||||
if isinstance(v, dict):
|
||||
items.update(flatten_dict(v, new_key, sep=sep))
|
||||
else:
|
||||
items[new_key] = v
|
||||
return items
|
||||
|
||||
return flatten_dict(data)
|
||||
|
||||
|
||||
def unflatten_dict(d, sep="."):
|
||||
"""
|
||||
Converts a flat dictionary with dot notation keys back to nested dict.
|
||||
:param d: Flattened dictionary.
|
||||
:param sep: Separator used in keys.
|
||||
:return: Nested dictionary.
|
||||
"""
|
||||
result = {}
|
||||
for key, value in d.items():
|
||||
parts = key.split(sep)
|
||||
current = result
|
||||
for part in parts[:-1]:
|
||||
if part not in current:
|
||||
current[part] = {}
|
||||
current = current[part]
|
||||
current[parts[-1]] = value
|
||||
return result
|
||||
|
||||
|
||||
def write_json_file(file_path, updated_properties):
|
||||
"""
|
||||
Writes updated properties back to the JSON file.
|
||||
:param file_path: Path to the JSON file.
|
||||
:param updated_properties: Dictionary of updated properties to write.
|
||||
"""
|
||||
nested_data = unflatten_dict(updated_properties)
|
||||
|
||||
with open(file_path, "w", encoding="utf-8", newline="\n") as file:
|
||||
json.dump(nested_data, file, ensure_ascii=False, indent=2)
|
||||
file.write("\n") # Add trailing newline
|
||||
|
||||
|
||||
def update_missing_keys(reference_file, file_list, branch=""):
|
||||
"""
|
||||
Updates missing keys in the translation files based on the reference file.
|
||||
:param reference_file: Path to the reference JSON file.
|
||||
:param file_list: List of translation files to update.
|
||||
:param branch: Branch where the files are located.
|
||||
"""
|
||||
reference_properties = parse_json_file(reference_file)
|
||||
|
||||
for file_path in file_list:
|
||||
basename_current_file = os.path.basename(os.path.join(branch, file_path))
|
||||
if (
|
||||
basename_current_file == os.path.basename(reference_file)
|
||||
or not file_path.endswith(".json")
|
||||
or not os.path.dirname(file_path).endswith("locales")
|
||||
):
|
||||
continue
|
||||
|
||||
current_properties = parse_json_file(os.path.join(branch, file_path))
|
||||
updated_properties = {}
|
||||
|
||||
for ref_key, ref_value in reference_properties.items():
|
||||
if ref_key in current_properties:
|
||||
# Keep the current translation
|
||||
updated_properties[ref_key] = current_properties[ref_key]
|
||||
else:
|
||||
# Add missing key with reference value
|
||||
updated_properties[ref_key] = ref_value
|
||||
|
||||
write_json_file(os.path.join(branch, file_path), updated_properties)
|
||||
|
||||
|
||||
def check_for_missing_keys(reference_file, file_list, branch):
|
||||
update_missing_keys(reference_file, file_list, branch)
|
||||
|
||||
|
||||
def read_json_keys(file_path):
|
||||
if os.path.isfile(file_path) and os.path.exists(file_path):
|
||||
return parse_json_file(file_path)
|
||||
return {}
|
||||
|
||||
|
||||
def check_for_differences(reference_file, file_list, branch, actor):
|
||||
reference_branch = branch
|
||||
basename_reference_file = os.path.basename(reference_file)
|
||||
|
||||
report = []
|
||||
report.append(f"#### 🔄 Reference Branch: `{reference_branch}`")
|
||||
reference_keys = read_json_keys(reference_file)
|
||||
has_differences = False
|
||||
|
||||
only_reference_file = True
|
||||
|
||||
file_arr = file_list
|
||||
|
||||
if len(file_list) == 1:
|
||||
file_arr = file_list[0].split()
|
||||
|
||||
base_dir = os.path.abspath(
|
||||
os.path.join(os.getcwd(), "frontend", "public", "locales")
|
||||
)
|
||||
|
||||
for file_path in file_arr:
|
||||
file_normpath = os.path.normpath(file_path)
|
||||
absolute_path = os.path.abspath(file_normpath)
|
||||
|
||||
# Verify that file is within the expected directory
|
||||
if not absolute_path.startswith(base_dir):
|
||||
raise ValueError(f"Unsafe file found: {file_normpath}")
|
||||
|
||||
# Verify file size before processing
|
||||
if os.path.getsize(os.path.join(branch, file_normpath)) > MAX_FILE_SIZE:
|
||||
raise ValueError(
|
||||
f"The file {file_normpath} is too large and could pose a security risk."
|
||||
)
|
||||
|
||||
basename_current_file = os.path.basename(os.path.join(branch, file_normpath))
|
||||
locale_dir = os.path.basename(os.path.dirname(file_normpath))
|
||||
|
||||
if (
|
||||
basename_current_file == basename_reference_file
|
||||
and locale_dir == "en-GB"
|
||||
):
|
||||
continue
|
||||
|
||||
if not file_normpath.endswith(".json") or basename_current_file != "translation.json":
|
||||
continue
|
||||
|
||||
only_reference_file = False
|
||||
report.append(f"#### 📃 **File Check:** `{locale_dir}/{basename_current_file}`")
|
||||
current_keys = read_json_keys(os.path.join(branch, file_path))
|
||||
reference_key_count = len(reference_keys)
|
||||
current_key_count = len(current_keys)
|
||||
|
||||
if reference_key_count != current_key_count:
|
||||
report.append("")
|
||||
report.append("1. **Test Status:** ❌ **_Failed_**")
|
||||
report.append(" - **Issue:**")
|
||||
has_differences = True
|
||||
if reference_key_count > current_key_count:
|
||||
report.append(
|
||||
f" - **_Mismatched key count_**: {reference_key_count} (reference) vs {current_key_count} (current). Translation keys are missing."
|
||||
)
|
||||
elif reference_key_count < current_key_count:
|
||||
report.append(
|
||||
f" - **_Too many keys_**: {reference_key_count} (reference) vs {current_key_count} (current). Please verify if there are additional keys that need to be removed."
|
||||
)
|
||||
else:
|
||||
report.append("1. **Test Status:** ✅ **_Passed_**")
|
||||
|
||||
# Check for missing or extra keys
|
||||
current_keys_set = set(current_keys.keys())
|
||||
reference_keys_set = set(reference_keys.keys())
|
||||
missing_keys = current_keys_set.difference(reference_keys_set)
|
||||
extra_keys = reference_keys_set.difference(current_keys_set)
|
||||
missing_keys_list = list(missing_keys)
|
||||
extra_keys_list = list(extra_keys)
|
||||
|
||||
if missing_keys_list or extra_keys_list:
|
||||
has_differences = True
|
||||
missing_keys_str = "`, `".join(missing_keys_list)
|
||||
extra_keys_str = "`, `".join(extra_keys_list)
|
||||
report.append("2. **Test Status:** ❌ **_Failed_**")
|
||||
report.append(" - **Issue:**")
|
||||
if missing_keys_list:
|
||||
report.append(
|
||||
f" - **_Extra keys in `{locale_dir}/{basename_current_file}`_**: `{missing_keys_str}` that are not present in **_`{basename_reference_file}`_**."
|
||||
)
|
||||
if extra_keys_list:
|
||||
report.append(
|
||||
f" - **_Missing keys in `{locale_dir}/{basename_current_file}`_**: `{extra_keys_str}` that are not present in **_`{basename_reference_file}`_**."
|
||||
)
|
||||
else:
|
||||
report.append("2. **Test Status:** ✅ **_Passed_**")
|
||||
|
||||
if find_duplicate_keys(os.path.join(branch, file_normpath)):
|
||||
has_differences = True
|
||||
output = "\n".join(
|
||||
[
|
||||
f" - `{key}`: first at {first}, duplicate at `{duplicate}`"
|
||||
for key, first, duplicate in find_duplicate_keys(
|
||||
os.path.join(branch, file_normpath)
|
||||
)
|
||||
]
|
||||
)
|
||||
report.append("3. **Test Status:** ❌ **_Failed_**")
|
||||
report.append(" - **Issue:**")
|
||||
report.append(" - duplicate entries were found:")
|
||||
report.append(output)
|
||||
else:
|
||||
report.append("3. **Test Status:** ✅ **_Passed_**")
|
||||
|
||||
report.append("")
|
||||
report.append("---")
|
||||
report.append("")
|
||||
|
||||
if has_differences:
|
||||
report.append("## ❌ Overall Check Status: **_Failed_**")
|
||||
report.append("")
|
||||
report.append(
|
||||
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-GB/translation.json](https://github.com/Stirling-Tools/Stirling-PDF/blob/V2/frontend/public/locales/en-GB/translation.json)"
|
||||
)
|
||||
else:
|
||||
report.append("## ✅ Overall Check Status: **_Success_**")
|
||||
report.append("")
|
||||
report.append(
|
||||
f"Thanks @{actor} for your help in keeping the translations up to date."
|
||||
)
|
||||
|
||||
if not only_reference_file:
|
||||
print("\n".join(report))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Find missing keys")
|
||||
parser.add_argument(
|
||||
"--actor",
|
||||
required=False,
|
||||
help="Actor from PR.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--reference-file",
|
||||
required=True,
|
||||
help="Path to the reference file.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--branch",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Branch name.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check-file",
|
||||
type=str,
|
||||
required=False,
|
||||
help="List of changed files, separated by spaces.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--files",
|
||||
nargs="+",
|
||||
required=False,
|
||||
help="List of changed files, separated by spaces.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Sanitize --actor input to avoid injection attacks
|
||||
if args.actor:
|
||||
args.actor = re.sub(r"[^a-zA-Z0-9_\\-]", "", args.actor)
|
||||
|
||||
# Sanitize --branch input to avoid injection attacks
|
||||
if args.branch:
|
||||
args.branch = re.sub(r"[^a-zA-Z0-9\\-]", "", args.branch)
|
||||
|
||||
file_list = args.files
|
||||
if file_list is None:
|
||||
if args.check_file:
|
||||
file_list = [args.check_file]
|
||||
else:
|
||||
file_list = glob.glob(
|
||||
os.path.join(
|
||||
os.getcwd(),
|
||||
"frontend",
|
||||
"public",
|
||||
"locales",
|
||||
"*",
|
||||
"translation.json",
|
||||
)
|
||||
)
|
||||
update_missing_keys(args.reference_file, file_list)
|
||||
else:
|
||||
check_for_differences(args.reference_file, file_list, args.branch, args.actor)
|
||||
@@ -0,0 +1,810 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Author: Ludy87
|
||||
Description: Checks and synchronizes JSON translation files against a reference file.
|
||||
It does two things:
|
||||
1) CI check: verifies that all keys from the reference exist in the target (recursively),
|
||||
flags extras, duplicate keys, and now also flags untranslated values (same as English).
|
||||
2) Sync/update: adds missing keys (and optionally prunes extras).
|
||||
|
||||
Also prints a CI-friendly report (intended for PR comments).
|
||||
|
||||
Usage:
|
||||
python sync_translations.py --reference-file <path_to_reference_json> [--branch <branch_root>] [--actor <actor_name>] [--files <list_of_target_jsons>] [--check] [--prune] [--report-percentages] [--dry-run]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
import sys
|
||||
from typing import Any, Dict, Tuple, List
|
||||
|
||||
try:
|
||||
import tomllib # Python 3.11+
|
||||
except ModuleNotFoundError: # pragma: no cover - fallback for older versions
|
||||
import tomli as tomllib # type: ignore
|
||||
|
||||
JsonDict = Dict[str, Any]
|
||||
|
||||
IGNORE_LOCALES_FILE = Path("scripts/ignore_locales.toml")
|
||||
|
||||
|
||||
@dataclass
|
||||
class MergeStats:
|
||||
added: int = 0
|
||||
pruned: int = 0
|
||||
missing_keys: list[str] = field(default_factory=list)
|
||||
extra_keys: list[str] = field(default_factory=list)
|
||||
# Missing translatable leaf nodes (non-dict values)
|
||||
missing_leafs: int = 0
|
||||
# Untranslated values (same as reference English)
|
||||
untranslated_leafs: int = 0
|
||||
untranslated_keys: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def is_mapping(v: Any) -> bool:
|
||||
return isinstance(v, dict)
|
||||
|
||||
|
||||
# Count all translatable entries (non-dict values) in any nested structure
|
||||
def count_leaves(obj: Any) -> int:
|
||||
if is_mapping(obj):
|
||||
return sum(count_leaves(v) for v in obj.values())
|
||||
return 1
|
||||
|
||||
|
||||
def collect_leaf_paths(obj: Any, base_path: str) -> list[str]:
|
||||
if is_mapping(obj):
|
||||
paths: list[str] = []
|
||||
for k, v in obj.items():
|
||||
new_path = f"{base_path}.{k}" if base_path else k
|
||||
paths.extend(collect_leaf_paths(v, new_path))
|
||||
return paths
|
||||
return [base_path]
|
||||
|
||||
|
||||
def _prune_empty_parent_stack(stack: list[tuple[JsonDict, str, Any]]) -> None:
|
||||
"""Remove empty dictionaries along a captured parent stack."""
|
||||
|
||||
child_empty = True
|
||||
for idx in range(len(stack) - 1, -1, -1):
|
||||
parent, key, child = stack[idx]
|
||||
if idx == len(stack) - 1:
|
||||
parent.pop(key, None)
|
||||
child_empty = len(parent) == 0
|
||||
else:
|
||||
if child_empty and isinstance(child, dict) and len(child) == 0:
|
||||
parent.pop(key, None)
|
||||
child_empty = len(parent) == 0
|
||||
else:
|
||||
child_empty = False
|
||||
|
||||
if not child_empty:
|
||||
break
|
||||
|
||||
|
||||
def relocate_dotted_reference_keys(ref: Any, target: Any) -> None:
|
||||
"""Align target structure with dotted keys defined in the reference."""
|
||||
|
||||
if not (is_mapping(ref) and is_mapping(target)):
|
||||
return
|
||||
|
||||
for key in ref:
|
||||
if "." not in key:
|
||||
continue
|
||||
if key in target:
|
||||
continue
|
||||
|
||||
segments = key.split(".")
|
||||
current = target
|
||||
stack: list[tuple[JsonDict, str, Any]] = []
|
||||
valid_path = True
|
||||
|
||||
for segment in segments:
|
||||
if not (is_mapping(current) and segment in current):
|
||||
valid_path = False
|
||||
break
|
||||
next_current = current[segment]
|
||||
stack.append((current, segment, next_current))
|
||||
current = next_current
|
||||
|
||||
if not valid_path:
|
||||
continue
|
||||
|
||||
target[key] = deepcopy(current)
|
||||
_prune_empty_parent_stack(stack)
|
||||
|
||||
for key, ref_val in ref.items():
|
||||
if "." in key:
|
||||
continue
|
||||
if key in target:
|
||||
relocate_dotted_reference_keys(ref_val, target[key])
|
||||
|
||||
|
||||
def record_missing_leaf(
|
||||
path: str, *, stats: MergeStats, ignored_paths: set[str]
|
||||
) -> None:
|
||||
if not path or path in ignored_paths:
|
||||
return
|
||||
stats.missing_leafs += 1
|
||||
if path not in stats.missing_keys:
|
||||
stats.missing_keys.append(path)
|
||||
|
||||
|
||||
def load_ignore_locales(path: Path) -> tuple[dict[str, set[str]], list[str], list[str]]:
|
||||
if not path.exists():
|
||||
return {}, [], []
|
||||
|
||||
text = path.read_text(encoding="utf-8")
|
||||
header_lines: list[str] = []
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("#") or (stripped == "" and header_lines):
|
||||
header_lines.append(line)
|
||||
continue
|
||||
break
|
||||
|
||||
parsed = tomllib.loads(text)
|
||||
locales: dict[str, set[str]] = {}
|
||||
order: list[str] = []
|
||||
for locale, table in parsed.items():
|
||||
order.append(locale)
|
||||
ignore_values = table.get("ignore", []) if isinstance(table, dict) else []
|
||||
locales[locale] = (
|
||||
set(ignore_values) if isinstance(ignore_values, list) else set()
|
||||
)
|
||||
return locales, header_lines, order
|
||||
|
||||
|
||||
def write_ignore_locales(
|
||||
path: Path,
|
||||
data: dict[str, set[str]],
|
||||
header_lines: list[str],
|
||||
order: list[str],
|
||||
) -> list[str]:
|
||||
ordered_locales = [locale for locale in order if locale in data]
|
||||
extras = sorted(locale for locale in data.keys() if locale not in ordered_locales)
|
||||
ordered_locales.extend(extras)
|
||||
|
||||
lines: list[str] = []
|
||||
if header_lines:
|
||||
lines.extend(header_lines)
|
||||
if header_lines[-1].strip() != "":
|
||||
lines.append("")
|
||||
|
||||
for locale in ordered_locales:
|
||||
if lines and lines[-1] != "":
|
||||
lines.append("")
|
||||
lines.append(f"[{locale}]")
|
||||
lines.append("ignore = [")
|
||||
for item in sorted(data[locale]):
|
||||
lines.append(f" '{item}',")
|
||||
lines.append("]")
|
||||
|
||||
content = "\n".join(lines)
|
||||
if not content.endswith("\n"):
|
||||
content += "\n"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return ordered_locales
|
||||
|
||||
|
||||
def normalize_text(s: str) -> str:
|
||||
"""Normalize strings for a strict-but-fair equality check."""
|
||||
# Trim, collapse whitespace, lower-case. Keep placeholders intact.
|
||||
s = s.strip()
|
||||
s = re.sub(r"\s+", " ", s)
|
||||
return s.lower()
|
||||
|
||||
|
||||
def collect_untranslated_values(
|
||||
ref: Any,
|
||||
tgt: Any,
|
||||
*,
|
||||
path: str = "",
|
||||
stats: MergeStats,
|
||||
ignored_paths: set[str],
|
||||
translated_ignored_paths: set[str],
|
||||
) -> None:
|
||||
"""
|
||||
Walk ref + target without mutating anything and find values that are present
|
||||
but not translated (target string equals reference string).
|
||||
"""
|
||||
if is_mapping(ref) and is_mapping(tgt):
|
||||
for k, ref_val in ref.items():
|
||||
new_path = f"{path}.{k}" if path else k
|
||||
if k in tgt:
|
||||
collect_untranslated_values(
|
||||
ref_val,
|
||||
tgt[k],
|
||||
path=new_path,
|
||||
stats=stats,
|
||||
ignored_paths=ignored_paths,
|
||||
translated_ignored_paths=translated_ignored_paths,
|
||||
)
|
||||
return
|
||||
|
||||
# Only compare leaf strings
|
||||
if isinstance(ref, str) and isinstance(tgt, str):
|
||||
if path in ignored_paths:
|
||||
if normalize_text(ref) != normalize_text(tgt):
|
||||
translated_ignored_paths.add(path)
|
||||
return
|
||||
if normalize_text(ref) == normalize_text(tgt):
|
||||
stats.untranslated_leafs += 1
|
||||
|
||||
if path not in stats.untranslated_keys:
|
||||
stats.untranslated_keys.append(path)
|
||||
|
||||
|
||||
def deep_merge_and_collect(
|
||||
ref: Any,
|
||||
target: Any,
|
||||
*,
|
||||
prune_extras: bool,
|
||||
path: str = "",
|
||||
stats: MergeStats,
|
||||
ignored_paths: set[str],
|
||||
) -> Any:
|
||||
"""
|
||||
Recursively ensure `target` contains at least the structure/keys of `ref`.
|
||||
- Adds any missing keys using the reference values.
|
||||
- Tracks missing keys and how many leaf nodes are missing (for %).
|
||||
- Optionally prunes extra keys that don't exist in the reference.
|
||||
"""
|
||||
ref_is_mapping = is_mapping(ref)
|
||||
target_is_mapping = is_mapping(target)
|
||||
|
||||
if ref_is_mapping and target_is_mapping:
|
||||
merged: JsonDict = {}
|
||||
|
||||
# Walk reference keys in order so we keep the same structure/order
|
||||
for k, ref_val in ref.items():
|
||||
new_path = f"{path}.{k}" if path else k
|
||||
if k in target:
|
||||
merged[k] = deep_merge_and_collect(
|
||||
ref_val,
|
||||
target[k],
|
||||
prune_extras=prune_extras,
|
||||
path=new_path,
|
||||
stats=stats,
|
||||
ignored_paths=ignored_paths,
|
||||
)
|
||||
else:
|
||||
# Entire key (possibly subtree) is missing → copy from ref
|
||||
merged[k] = deepcopy(ref_val)
|
||||
stats.added += 1
|
||||
stats.missing_leafs += count_leaves(ref_val)
|
||||
leaf_paths = collect_leaf_paths(ref_val, new_path)
|
||||
if leaf_paths:
|
||||
for leaf_path in leaf_paths:
|
||||
record_missing_leaf(
|
||||
leaf_path,
|
||||
stats=stats,
|
||||
ignored_paths=ignored_paths,
|
||||
)
|
||||
else:
|
||||
record_missing_leaf(
|
||||
new_path,
|
||||
stats=stats,
|
||||
ignored_paths=ignored_paths,
|
||||
)
|
||||
|
||||
# Handle keys that exist in target but not in ref
|
||||
if prune_extras:
|
||||
for k in target.keys():
|
||||
if k not in ref:
|
||||
stats.pruned += 1
|
||||
stats.extra_keys.append(f"{path}.{k}" if path else k)
|
||||
# Do not copy extras when pruning
|
||||
else:
|
||||
# Keep extras (but still list them for the report)
|
||||
for k, v in target.items():
|
||||
if k not in ref:
|
||||
merged[k] = deepcopy(v)
|
||||
stats.extra_keys.append(f"{path}.{k}" if path else k)
|
||||
|
||||
return merged
|
||||
|
||||
if ref_is_mapping != target_is_mapping:
|
||||
stats.added += 1
|
||||
stats.missing_leafs += count_leaves(ref)
|
||||
leaf_paths = collect_leaf_paths(ref, path)
|
||||
if leaf_paths:
|
||||
for leaf_path in leaf_paths:
|
||||
record_missing_leaf(
|
||||
leaf_path,
|
||||
stats=stats,
|
||||
ignored_paths=ignored_paths,
|
||||
)
|
||||
else:
|
||||
record_missing_leaf(
|
||||
path,
|
||||
stats=stats,
|
||||
ignored_paths=ignored_paths,
|
||||
)
|
||||
return deepcopy(ref)
|
||||
|
||||
# Non-dict values → keep existing translation; if it's None, count it as missing
|
||||
if target is None:
|
||||
record_missing_leaf(path, stats=stats, ignored_paths=ignored_paths)
|
||||
return deepcopy(ref)
|
||||
|
||||
if type(target) is not type(ref):
|
||||
stats.added += 1
|
||||
stats.missing_leafs += count_leaves(ref)
|
||||
record_missing_leaf(path, stats=stats, ignored_paths=ignored_paths)
|
||||
return deepcopy(ref)
|
||||
|
||||
return deepcopy(target)
|
||||
|
||||
|
||||
def order_like_reference(ref: Any, obj: Any) -> Any:
|
||||
"""
|
||||
Reorder dict keys in `obj` to match the order in `ref` (recursively).
|
||||
Extra keys are appended at the end.
|
||||
"""
|
||||
if not (is_mapping(ref) and is_mapping(obj)):
|
||||
return obj
|
||||
ordered = {}
|
||||
for k in ref:
|
||||
if k in obj:
|
||||
ordered[k] = order_like_reference(ref[k], obj[k])
|
||||
for k in obj:
|
||||
if k not in ref:
|
||||
ordered[k] = order_like_reference(None, obj[k])
|
||||
return ordered
|
||||
|
||||
|
||||
def read_json(path: Path) -> Any:
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
# Read JSON while detecting duplicate keys (json.load would normally overwrite silently)
|
||||
def read_json_with_duplicates(path: Path) -> Tuple[Any, list[str]]:
|
||||
"""
|
||||
Returns: (data, duplicate_keys)
|
||||
"""
|
||||
duplicates: list[str] = []
|
||||
|
||||
def object_pairs_hook(pairs):
|
||||
obj = {}
|
||||
seen = set()
|
||||
for k, v in pairs:
|
||||
if k in seen:
|
||||
duplicates.append(k)
|
||||
else:
|
||||
seen.add(k)
|
||||
obj[k] = v
|
||||
return obj
|
||||
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f, object_pairs_hook=object_pairs_hook)
|
||||
return data, duplicates
|
||||
|
||||
|
||||
def write_json(path: Path, data: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
def backup_file(path: Path) -> None:
|
||||
backup = path.with_suffix(path.suffix + ".bak")
|
||||
shutil.copy2(path, backup)
|
||||
|
||||
|
||||
def sanitize_actor(s: str | None) -> str | None:
|
||||
if s is None:
|
||||
return None
|
||||
return re.sub(r"[^a-zA-Z0-9_\-]", "", s)
|
||||
|
||||
|
||||
def sanitize_branch(s: str | None) -> str | None:
|
||||
if s is None:
|
||||
return None
|
||||
return re.sub(r"[^a-zA-Z0-9_\-\/\.]", "", s)
|
||||
|
||||
|
||||
def resolve_in_branch(branch: Path | None, p: Path) -> Path:
|
||||
# If no branch root or an absolute path is provided, use it as-is
|
||||
if p.is_absolute() or branch is None or str(branch) == "":
|
||||
return p
|
||||
return (branch / p).resolve()
|
||||
|
||||
|
||||
def is_within(base: Path | None, target: Path) -> bool:
|
||||
# Allow everything if no base is provided
|
||||
if base is None or str(base) == "":
|
||||
return True
|
||||
base_resolved = base.resolve()
|
||||
target_resolved = target.resolve()
|
||||
if os.name == "nt":
|
||||
return str(target_resolved).lower().startswith(str(base_resolved).lower())
|
||||
return str(target_resolved).startswith(str(base_resolved))
|
||||
|
||||
|
||||
def assert_within_branch(base: Path | None, target: Path) -> None:
|
||||
if not is_within(base, target):
|
||||
raise ValueError(f"Unsafe path outside branch: {target}")
|
||||
|
||||
|
||||
def process_file(
|
||||
ref_path: Path,
|
||||
target_path: Path,
|
||||
*,
|
||||
prune: bool,
|
||||
dry_run: bool,
|
||||
check_only: bool,
|
||||
backup: bool,
|
||||
ignored_paths: set[str] | None = None,
|
||||
) -> Tuple[MergeStats, bool, List[str], int, set[str]]:
|
||||
# Load both files, capturing duplicate keys in the target
|
||||
ref, _ref_dupes = read_json_with_duplicates(ref_path)
|
||||
target, target_dupes = read_json_with_duplicates(target_path)
|
||||
|
||||
# Total number of translatable leaves in the reference (for % calculation)
|
||||
total_ref_leaves = count_leaves(ref)
|
||||
|
||||
stats = MergeStats()
|
||||
translated_ignored_paths: set[str] = set()
|
||||
ignored = ignored_paths or set()
|
||||
|
||||
# Detect untranslated values before we mutate/merge anything
|
||||
collect_untranslated_values(
|
||||
ref,
|
||||
target,
|
||||
path="",
|
||||
stats=stats,
|
||||
ignored_paths=ignored,
|
||||
translated_ignored_paths=translated_ignored_paths,
|
||||
)
|
||||
|
||||
merged = deep_merge_and_collect(
|
||||
ref,
|
||||
target,
|
||||
prune_extras=prune,
|
||||
stats=stats,
|
||||
ignored_paths=ignored,
|
||||
)
|
||||
merged = order_like_reference(ref, merged)
|
||||
|
||||
# "Success" means: no missing keys, (if pruning) no extras, no duplicate keys, no untranslated values
|
||||
success = (
|
||||
not stats.missing_keys
|
||||
and (not prune or not stats.extra_keys)
|
||||
and not target_dupes
|
||||
)
|
||||
|
||||
if not check_only and not dry_run:
|
||||
if backup:
|
||||
backup_file(target_path)
|
||||
write_json(target_path, merged)
|
||||
|
||||
return stats, success, target_dupes, total_ref_leaves, translated_ignored_paths
|
||||
|
||||
|
||||
def find_all_locale_files(branch_root: Path, ref_path: Path) -> List[Path]:
|
||||
"""
|
||||
Find all `translation.json` files under `frontend/public/locales/**`,
|
||||
excluding the reference file itself.
|
||||
"""
|
||||
locales_dir = branch_root / "frontend" / "public" / "locales"
|
||||
if not locales_dir.exists():
|
||||
return []
|
||||
files = sorted(locales_dir.rglob("translation.json"))
|
||||
ref_resolved = ref_path.resolve()
|
||||
return [f for f in files if f.resolve() != ref_resolved]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Compare and sync translation JSON files against a reference (with branch support)."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--reference-file",
|
||||
"--ref",
|
||||
dest="ref",
|
||||
required=True,
|
||||
type=Path,
|
||||
help="Path to reference JSON file (e.g., frontend/public/locales/en-GB/translation.json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--files",
|
||||
nargs="+",
|
||||
required=False,
|
||||
type=Path,
|
||||
help="List of target JSON files (optional; if omitted, all locales/*/translation.json will be processed)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--branch",
|
||||
type=str,
|
||||
required=False,
|
||||
help="Branch/checkout root directory used as prefix for --reference-file and --files",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--actor",
|
||||
type=str,
|
||||
required=False,
|
||||
help="Actor from PR (used for CI comment mention).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="Check mode: do not write files, only print a CI-friendly report.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--prune",
|
||||
action="store_true",
|
||||
help="Remove keys that are not present in the reference.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Dry run: do not write changes (useful for local testing).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--report-percentages",
|
||||
action="store_true",
|
||||
help="Report percentage of translated values (not same as English).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-backup",
|
||||
dest="backup",
|
||||
action="store_false",
|
||||
help="Disable .bak backup when writing in-place.",
|
||||
)
|
||||
parser.set_defaults(backup=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Normalize inputs
|
||||
actor = sanitize_actor(args.actor) if args.actor else "translator"
|
||||
branch_str = sanitize_branch(args.branch) if args.branch else ""
|
||||
branch_base: Path | None = Path(branch_str).resolve() if branch_str else Path.cwd()
|
||||
|
||||
ignore_file_path = resolve_in_branch(branch_base, IGNORE_LOCALES_FILE)
|
||||
if not ignore_file_path.exists():
|
||||
alt_ignore = (Path.cwd() / IGNORE_LOCALES_FILE).resolve()
|
||||
if alt_ignore.exists():
|
||||
ignore_file_path = alt_ignore
|
||||
else:
|
||||
script_root = Path(__file__).resolve().parents[2]
|
||||
candidate_ignore = (script_root / IGNORE_LOCALES_FILE).resolve()
|
||||
if candidate_ignore.exists():
|
||||
ignore_file_path = candidate_ignore
|
||||
ignore_locales_map, ignore_header_lines, ignore_order = load_ignore_locales(
|
||||
ignore_file_path
|
||||
)
|
||||
ignore_locales_modified = False
|
||||
|
||||
# Resolve the reference path. First try under branch root, then fall back to raw path.
|
||||
ref_path = resolve_in_branch(branch_base, args.ref)
|
||||
if not ref_path.exists():
|
||||
alt = Path(args.ref)
|
||||
if not alt.is_absolute():
|
||||
alt = (Path.cwd() / alt).resolve()
|
||||
if alt.exists():
|
||||
ref_path = alt
|
||||
if not ref_path.exists():
|
||||
raise SystemExit(f"Reference file not found: {ref_path}")
|
||||
|
||||
# Pre-load the reference so we can identify valid translation paths (used for
|
||||
# trimming ignore entries that no longer exist in the reference).
|
||||
ref_data, _ = read_json_with_duplicates(ref_path)
|
||||
reference_leaf_paths = set(collect_leaf_paths(ref_data, ""))
|
||||
|
||||
# Track ignore entries that reference non-existent keys in the reference
|
||||
# translation so we can report (and optionally prune) them.
|
||||
invalid_ignore_entries: dict[str, list[str]] = {}
|
||||
|
||||
for locale_key, ignored_paths in list(ignore_locales_map.items()):
|
||||
current_ignored = set(ignored_paths)
|
||||
invalid_entries = sorted(
|
||||
path for path in current_ignored if path not in reference_leaf_paths
|
||||
)
|
||||
if not invalid_entries:
|
||||
continue
|
||||
invalid_ignore_entries[locale_key] = invalid_entries
|
||||
if args.check or args.dry_run:
|
||||
continue
|
||||
updated_ignore = current_ignored - set(invalid_entries)
|
||||
if updated_ignore:
|
||||
ignore_locales_map[locale_key] = updated_ignore
|
||||
else:
|
||||
ignore_locales_map.pop(locale_key, None)
|
||||
ignore_locales_modified = True
|
||||
|
||||
# Build the targets list. If CI passed a single space-separated string, split it.
|
||||
files_list: List[Path] = []
|
||||
if args.files:
|
||||
if len(args.files) == 1 and " " in str(args.files[0]):
|
||||
files_list = [Path(p) for p in str(args.files[0]).split()]
|
||||
else:
|
||||
files_list = list(args.files)
|
||||
else:
|
||||
base = branch_base if branch_base else Path.cwd()
|
||||
files_list = find_all_locale_files(base, ref_path)
|
||||
|
||||
if not files_list:
|
||||
raise SystemExit("No translation.json files found under locales/.")
|
||||
|
||||
# Build CI report
|
||||
report: list[str] = []
|
||||
total_added = total_pruned = 0
|
||||
any_failed = False
|
||||
|
||||
report.append(
|
||||
f"#### 🔄 Reference File: `{args.ref}` (branch root: `{branch_base if branch_base else '.'}`)"
|
||||
)
|
||||
report.append("")
|
||||
|
||||
for target_rel in files_list:
|
||||
target_rel_path = Path(target_rel)
|
||||
target_path = resolve_in_branch(branch_base, target_rel_path)
|
||||
|
||||
# Keep target access inside branch (when branch is set)
|
||||
try:
|
||||
assert_within_branch(branch_base, target_path)
|
||||
except ValueError as e:
|
||||
report.append(f"❌ {e}")
|
||||
any_failed = True
|
||||
continue
|
||||
|
||||
if not target_path.exists():
|
||||
report.append(
|
||||
f"❌ File not found: `{target_rel_path}` (resolved: `{target_path}`)"
|
||||
)
|
||||
any_failed = True
|
||||
continue
|
||||
|
||||
locale_segment: str | None = None
|
||||
parts = list(target_rel_path.parts)
|
||||
if "locales" in parts:
|
||||
try:
|
||||
idx = parts.index("locales")
|
||||
if idx + 1 < len(parts):
|
||||
locale_segment = parts[idx + 1]
|
||||
except ValueError:
|
||||
locale_segment = None
|
||||
if locale_segment is None:
|
||||
locale_segment = (
|
||||
target_rel_path.parent.name if target_rel_path.parent else None
|
||||
)
|
||||
locale_key = locale_segment.replace("-", "_") if locale_segment else ""
|
||||
existing_ignore = ignore_locales_map.get(locale_key, set())
|
||||
ignored_paths = set(existing_ignore) if existing_ignore else set()
|
||||
|
||||
invalid_for_locale = invalid_ignore_entries.get(locale_key, [])
|
||||
if invalid_for_locale:
|
||||
ignored_paths -= set(invalid_for_locale)
|
||||
|
||||
stats, success, dupes, total_ref_leaves, translated_ignored_paths = (
|
||||
process_file(
|
||||
ref_path,
|
||||
target_path,
|
||||
prune=args.prune,
|
||||
dry_run=args.dry_run,
|
||||
check_only=args.check,
|
||||
backup=args.backup,
|
||||
ignored_paths=ignored_paths,
|
||||
)
|
||||
)
|
||||
|
||||
total_added += stats.added
|
||||
total_pruned += stats.pruned
|
||||
|
||||
# Missing translations (absolute + % of total reference leaves)
|
||||
missing_abs = stats.missing_leafs
|
||||
total_abs = total_ref_leaves if total_ref_leaves > 0 else 0
|
||||
missing_pct = (missing_abs / total_abs * 100.0) if total_abs > 0 else 0.0
|
||||
|
||||
# Untranslated values (absolute + % of total reference leaves)
|
||||
untranslated_abs = stats.untranslated_leafs
|
||||
untranslated_pct = (
|
||||
(untranslated_abs / total_abs * 100.0) if total_abs > 0 else 0.0
|
||||
)
|
||||
|
||||
translated_pct = 100.0 - untranslated_pct
|
||||
if args.procent_translations:
|
||||
print(f"{translated_pct:.2f}")
|
||||
sys.exit(0)
|
||||
|
||||
_target_rel_path = str(target_rel_path).replace("\\", "/").replace("//", "/")
|
||||
|
||||
report.append(f"#### 📄 File: `{target_rel_path}`")
|
||||
if not _target_rel_path.endswith(
|
||||
"en-GB/translation.json"
|
||||
) and not _target_rel_path.endswith("en-US/translation.json"):
|
||||
report.append(f"💬 **Translated:** {translated_pct:.2f}%")
|
||||
if success:
|
||||
report.append("✅ **Passed:** All keys in sync.")
|
||||
else:
|
||||
report.append("❌ **Failed:** Differences detected.")
|
||||
if stats.missing_keys:
|
||||
report.append(
|
||||
f"- Missing keys ({len(stats.missing_keys)}): `{', '.join(stats.missing_keys)}`"
|
||||
)
|
||||
if stats.extra_keys:
|
||||
if args.prune:
|
||||
report.append(
|
||||
f"- Extra keys removed/flagged ({len(stats.extra_keys)}): `{', '.join(stats.extra_keys)}`"
|
||||
)
|
||||
else:
|
||||
report.append(
|
||||
f"- Extra keys present ({len(stats.extra_keys)}): `{', '.join(stats.extra_keys)}`"
|
||||
)
|
||||
if dupes:
|
||||
report.append(f"- Duplicate keys ({len(dupes)}): `{', '.join(dupes)}`")
|
||||
|
||||
if not _target_rel_path.endswith("en-GB/translation.json"):
|
||||
if missing_abs > 0:
|
||||
report.append(
|
||||
f"- Missing translations keys: {missing_abs} / {total_abs} ({missing_pct:.2f}%)"
|
||||
)
|
||||
if not _target_rel_path.endswith("en-US/translation.json"):
|
||||
if untranslated_abs > 0:
|
||||
report.append(
|
||||
f"- Untranslated values: {untranslated_abs} / {total_abs} ({untranslated_pct:.2f}%)"
|
||||
)
|
||||
if translated_pct == 100.0:
|
||||
report.append(f"- 🎉 All values translated! Thank you @{actor}!")
|
||||
|
||||
removed_entries = sorted(translated_ignored_paths & ignored_paths)
|
||||
if removed_entries:
|
||||
if args.check or args.dry_run:
|
||||
report.append(
|
||||
"- Translation provided for previously ignored keys: "
|
||||
+ f"`{', '.join(removed_entries)}` (update `scripts/ignore_locales.toml`)"
|
||||
)
|
||||
else:
|
||||
report.append(
|
||||
f"- Cleared ignore entries: `{', '.join(removed_entries)}`"
|
||||
)
|
||||
if existing_ignore is not None:
|
||||
updated_ignore = existing_ignore - set(removed_entries)
|
||||
if updated_ignore:
|
||||
ignore_locales_map[locale_key] = updated_ignore
|
||||
else:
|
||||
ignore_locales_map.pop(locale_key, None)
|
||||
ignore_locales_modified = True
|
||||
# report.append(f"- Added: {stats.added}, Pruned: {stats.pruned}")
|
||||
report.append("")
|
||||
report.append("---")
|
||||
report.append("")
|
||||
if not success:
|
||||
any_failed = True
|
||||
|
||||
if ignore_locales_modified and not args.check and not args.dry_run:
|
||||
ignore_order = write_ignore_locales(
|
||||
ignore_file_path, ignore_locales_map, ignore_header_lines, ignore_order
|
||||
)
|
||||
|
||||
# Final summary
|
||||
# report.append("## 🧾 Summary")
|
||||
# report.append(f"- Total added: {total_added}")
|
||||
# report.append(f"- Total pruned: {total_pruned}")
|
||||
report.append("")
|
||||
|
||||
if any_failed:
|
||||
report.append("## ❌ Overall Status: **Failed**")
|
||||
report.append(f"@{actor} please check and sync the missing translations.")
|
||||
else:
|
||||
report.append("## ✅ Overall Status: **Success**")
|
||||
report.append(f"Thanks @{actor} for keeping translations in sync! 🎉")
|
||||
|
||||
# CI comment output (for PR comment body)
|
||||
print("\n".join(report))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,295 @@
|
||||
name: Check Localization Files on PR
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- "frontend/public/locales/*-*/translation.json"
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
|
||||
# or a pull request is updated.
|
||||
# It helps to save resources and time by ensuring that only the latest commit is built and tested
|
||||
# This is particularly useful for long-running jobs that may take a while to complete.
|
||||
# The `group` is set to a combination of the workflow name, event name, and branch name.
|
||||
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
|
||||
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref_name || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read # Allow read access to repository content
|
||||
|
||||
jobs:
|
||||
check-files:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write # Allow posting comments on issues/PRs
|
||||
pull-requests: write # Allow writing to pull requests
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout V2 branch first
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Get PR data
|
||||
id: get-pr-data
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const repoOwner = context.payload.repository.owner.login;
|
||||
const repoName = context.payload.repository.name;
|
||||
const branch = context.payload.pull_request.head.ref;
|
||||
|
||||
console.log(`PR Number: ${prNumber}`);
|
||||
console.log(`Repo Owner: ${repoOwner}`);
|
||||
console.log(`Repo Name: ${repoName}`);
|
||||
console.log(`Branch: ${branch}`);
|
||||
|
||||
core.setOutput("pr_number", prNumber);
|
||||
core.setOutput("repo_owner", repoOwner);
|
||||
core.setOutput("repo_name", repoName);
|
||||
core.setOutput("branch", branch);
|
||||
continue-on-error: true
|
||||
|
||||
- name: Fetch PR changed files
|
||||
id: fetch-pr-changes
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
|
||||
run: |
|
||||
echo "Fetching PR changed files..."
|
||||
echo "Getting list of changed files from PR..."
|
||||
# Check if PR number exists
|
||||
if [ -z "${{ steps.get-pr-data.outputs.pr_number }}" ]; then
|
||||
echo "Error: PR number is empty"
|
||||
exit 1
|
||||
fi
|
||||
# Get changed files and filter for properties files, handle case where no matches are found
|
||||
gh pr view ${{ steps.get-pr-data.outputs.pr_number }} --json files -q ".files[].path" | grep -E '^frontend/public/locales/[a-zA-Z]{2}-[a-zA-Z-]{2,7}/translation.json$' > changed_files.txt || echo "No matching properties files found in PR"
|
||||
# Check if any files were found
|
||||
if [ ! -s changed_files.txt ]; then
|
||||
echo "No properties files changed in this PR"
|
||||
echo "Workflow will exit early as no relevant files to check"
|
||||
exit 0
|
||||
fi
|
||||
echo "Found $(wc -l < changed_files.txt) matching properties files"
|
||||
|
||||
- name: Determine reference file test
|
||||
id: determine-file
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const prNumber = ${{ steps.get-pr-data.outputs.pr_number }};
|
||||
const repoOwner = "${{ steps.get-pr-data.outputs.repo_owner }}";
|
||||
const repoName = "${{ steps.get-pr-data.outputs.repo_name }}";
|
||||
|
||||
const prRepoOwner = "${{ github.event.pull_request.head.repo.owner.login }}";
|
||||
const prRepoName = "${{ github.event.pull_request.head.repo.name }}";
|
||||
const branch = "${{ steps.get-pr-data.outputs.branch }}";
|
||||
|
||||
console.log(`Determining reference file for PR #${prNumber}`);
|
||||
|
||||
// Validate inputs
|
||||
const validateInput = (input, regex, name) => {
|
||||
if (!regex.test(input)) {
|
||||
throw new Error(`Invalid ${name}: ${input}`);
|
||||
}
|
||||
};
|
||||
|
||||
validateInput(repoOwner, /^[a-zA-Z0-9_-]+$/, "repository owner");
|
||||
validateInput(repoName, /^[a-zA-Z0-9._-]+$/, "repository name");
|
||||
validateInput(branch, /^[a-zA-Z0-9._/-]+$/, "branch name");
|
||||
|
||||
// Get the list of changed files in the PR
|
||||
const { data: files } = await github.rest.pulls.listFiles({
|
||||
owner: repoOwner,
|
||||
repo: repoName,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
|
||||
// Filter for relevant files based on the PR changes
|
||||
const changedFiles = files
|
||||
.filter(file =>
|
||||
file.status !== "removed" &&
|
||||
/^frontend\/public\/locales\/[a-zA-Z_]{2}-[a-zA-Z-]{2,7}\/translation\.json$/.test(file.filename)
|
||||
)
|
||||
.map(file => file.filename);
|
||||
|
||||
console.log("Changed files:", changedFiles);
|
||||
|
||||
// Create a temporary directory for PR files
|
||||
const tempDir = "pr-branch";
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Download and save each changed file
|
||||
for (const file of changedFiles) {
|
||||
const { data: fileContent } = await github.rest.repos.getContent({
|
||||
owner: prRepoOwner,
|
||||
repo: prRepoName,
|
||||
path: file,
|
||||
ref: branch,
|
||||
});
|
||||
|
||||
const content = Buffer.from(fileContent.content, "base64").toString("utf-8");
|
||||
const filePath = path.join(tempDir, file);
|
||||
const dirPath = path.dirname(filePath);
|
||||
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, content);
|
||||
console.log(`Saved file: ${filePath}`);
|
||||
}
|
||||
|
||||
// Output the list of changed files for further processing
|
||||
const fileList = changedFiles.join(" ");
|
||||
core.exportVariable("FILES_LIST", fileList);
|
||||
console.log("Files saved and listed in FILES_LIST.");
|
||||
|
||||
// Determine reference file
|
||||
let referenceFilePath;
|
||||
if (changedFiles.includes("frontend/public/locales/en-GB/translation.json")) {
|
||||
console.log("Using PR branch reference file.");
|
||||
const { data: fileContent } = await github.rest.repos.getContent({
|
||||
owner: prRepoOwner,
|
||||
repo: prRepoName,
|
||||
path: "frontend/public/locales/en-GB/translation.json",
|
||||
ref: branch,
|
||||
});
|
||||
|
||||
referenceFilePath = "pr-branch-translation.json";
|
||||
const content = Buffer.from(fileContent.content, "base64").toString("utf-8");
|
||||
fs.writeFileSync(referenceFilePath, content);
|
||||
} else {
|
||||
console.log("Using V2 branch reference file.");
|
||||
const { data: fileContent } = await github.rest.repos.getContent({
|
||||
owner: repoOwner,
|
||||
repo: repoName,
|
||||
path: "frontend/public/locales/en-GB/translation.json",
|
||||
ref: "V2",
|
||||
});
|
||||
|
||||
referenceFilePath = "main-branch-translation.json";
|
||||
const content = Buffer.from(fileContent.content, "base64").toString("utf-8");
|
||||
fs.writeFileSync(referenceFilePath, content);
|
||||
}
|
||||
|
||||
console.log(`Reference file path: ${referenceFilePath}`);
|
||||
core.exportVariable("REFERENCE_FILE", referenceFilePath);
|
||||
|
||||
- name: Run Python script to check files
|
||||
id: run-check
|
||||
run: |
|
||||
echo "Running Python script to check files..."
|
||||
python .github/scripts/sync_translations.py \
|
||||
--actor ${{ github.event.pull_request.user.login }} \
|
||||
--reference-file "${REFERENCE_FILE}" \
|
||||
--branch "pr-branch" \
|
||||
--prune \
|
||||
--no-backup \
|
||||
--check \
|
||||
--files "${FILES_LIST[@]}" > result.txt
|
||||
continue-on-error: true # Continue the job even if this step fails
|
||||
|
||||
- name: Capture output
|
||||
id: capture-output
|
||||
run: |
|
||||
if [ -f result.txt ] && [ -s result.txt ]; then
|
||||
echo "Test, capturing output..."
|
||||
SCRIPT_OUTPUT=$(cat result.txt)
|
||||
echo "SCRIPT_OUTPUT<<EOF" >> $GITHUB_ENV
|
||||
echo "$SCRIPT_OUTPUT" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
echo "${SCRIPT_OUTPUT}"
|
||||
|
||||
# Determine job failure based on script output
|
||||
if [[ "$SCRIPT_OUTPUT" == *"❌"* ]]; then
|
||||
echo "FAIL_JOB=true" >> $GITHUB_ENV
|
||||
else
|
||||
echo "FAIL_JOB=false" >> $GITHUB_ENV
|
||||
fi
|
||||
else
|
||||
echo "No update found."
|
||||
echo "SCRIPT_OUTPUT=" >> $GITHUB_ENV
|
||||
echo "FAIL_JOB=false" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Post comment on PR
|
||||
if: env.SCRIPT_OUTPUT != ''
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { GITHUB_REPOSITORY, SCRIPT_OUTPUT } = process.env;
|
||||
const [repoOwner, repoName] = GITHUB_REPOSITORY.split('/');
|
||||
const issueNumber = context.issue.number;
|
||||
|
||||
// Find existing comment
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: repoOwner,
|
||||
repo: repoName,
|
||||
issue_number: issueNumber
|
||||
});
|
||||
|
||||
const comment = comments.data.find(c => c.body.includes("## 🚀 Translation Verification Summary"));
|
||||
|
||||
// Only update or create comments by the action user
|
||||
const expectedActor = "${{ steps.setup-bot.outputs.app-slug }}[bot]";
|
||||
|
||||
if (comment && comment.user.login === expectedActor) {
|
||||
// Update existing comment
|
||||
await github.rest.issues.updateComment({
|
||||
owner: repoOwner,
|
||||
repo: repoName,
|
||||
comment_id: comment.id,
|
||||
body: `## 🚀 Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
|
||||
});
|
||||
console.log("Updated existing comment.");
|
||||
} else if (!comment) {
|
||||
// Create new comment if no existing comment is found
|
||||
await github.rest.issues.createComment({
|
||||
owner: repoOwner,
|
||||
repo: repoName,
|
||||
issue_number: issueNumber,
|
||||
body: `## 🚀 Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
|
||||
});
|
||||
console.log("Created new comment.");
|
||||
} else {
|
||||
console.log("Comment update attempt denied. Actor does not match.");
|
||||
}
|
||||
|
||||
- name: Fail job if errors found
|
||||
if: env.FAIL_JOB == 'true'
|
||||
run: |
|
||||
echo "Failing the job because errors were detected."
|
||||
exit 1
|
||||
|
||||
- name: Cleanup temporary files
|
||||
if: always()
|
||||
run: |
|
||||
echo "Cleaning up temporary files..."
|
||||
rm -rf pr-branch
|
||||
rm -f pr-branch-translation.json main-branch-translation.json changed_files.txt result.txt
|
||||
echo "Cleanup complete."
|
||||
continue-on-error: true # Ensure cleanup runs even if previous steps fail
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
|
||||
- name: Sync translation JSON files
|
||||
run: |
|
||||
python .github/scripts/check_language_json.py --reference-file "frontend/public/locales/en-GB/translation.json" --branch V2
|
||||
python .github/scripts/sync_translations.py --reference-file "frontend/public/locales/en-GB/translation.json" --branch V2
|
||||
|
||||
- name: Commit translation files
|
||||
run: |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -456,9 +456,6 @@
|
||||
"alphabetical": "Alphabetical",
|
||||
"globalPopularity": "Global Popularity",
|
||||
"sortBy": "Sort by:",
|
||||
"mobile": {
|
||||
"brandAlt": "Stirling PDF logo"
|
||||
},
|
||||
"multiTool": {
|
||||
"tags": "multiple,tools",
|
||||
"title": "PDF Multi Tool",
|
||||
@@ -3063,33 +3060,6 @@
|
||||
"failedToSignIn": "Failed to sign in with {{provider}}: {{message}}",
|
||||
"unexpectedError": "Unexpected error: {{message}}"
|
||||
},
|
||||
"signup": {
|
||||
"title": "Create an account",
|
||||
"subtitle": "Join Stirling PDF to get started",
|
||||
"name": "Name",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm password",
|
||||
"enterName": "Enter your name",
|
||||
"enterEmail": "Enter your email",
|
||||
"enterPassword": "Enter your password",
|
||||
"confirmPasswordPlaceholder": "Confirm password",
|
||||
"or": "or",
|
||||
"creatingAccount": "Creating Account...",
|
||||
"signUp": "Sign Up",
|
||||
"alreadyHaveAccount": "Already have an account? Sign in",
|
||||
"pleaseFillAllFields": "Please fill in all fields",
|
||||
"passwordsDoNotMatch": "Passwords do not match",
|
||||
"passwordTooShort": "Password must be at least 6 characters long",
|
||||
"invalidEmail": "Please enter a valid email address",
|
||||
"nameRequired": "Name is required",
|
||||
"emailRequired": "Email is required",
|
||||
"passwordRequired": "Password is required",
|
||||
"confirmPasswordRequired": "Confirm password is required",
|
||||
"checkEmailConfirmation": "Check your email for a confirmation link to complete your registration.",
|
||||
"accountCreatedSuccessfully": "Account created successfully! You can now sign in.",
|
||||
"unexpectedError": "Unexpected error: {{message}}"
|
||||
},
|
||||
"pdfToSinglePage": {
|
||||
"title": "PDF To Single Page",
|
||||
"header": "PDF To Single Page",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,204 +1,119 @@
|
||||
"""A script to update language progress status in README.md based on
|
||||
JSON translation file comparison.
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
This script compares the default translation JSON file with others in the locales directory to
|
||||
determine language progress.
|
||||
It then updates README.md based on provided progress list.
|
||||
"""
|
||||
A tiny helper that updates README.md translation progress by asking
|
||||
.sync_translations.py for the per-locale percentage (via --report-percentages).
|
||||
|
||||
Author: Ludy87
|
||||
"""
|
||||
|
||||
Example:
|
||||
To use this script, simply run it from command line:
|
||||
$ python counter_translation_v2.py
|
||||
""" # noqa: D205
|
||||
|
||||
from __future__ import annotations
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
|
||||
import tomlkit
|
||||
import tomlkit.toml_file
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
|
||||
|
||||
def convert_to_multiline(data: tomlkit.TOMLDocument) -> tomlkit.TOMLDocument:
|
||||
"""Converts 'ignore' and 'missing' arrays to multiline arrays and sorts the first-level keys of the TOML document.
|
||||
Enhances readability and consistency in the TOML file by ensuring arrays contain unique and sorted entries.
|
||||
|
||||
Parameters:
|
||||
data (tomlkit.TOMLDocument): The original TOML document containing the data.
|
||||
|
||||
Returns:
|
||||
tomlkit.TOMLDocument: A new TOML document with sorted keys and properly formatted arrays.
|
||||
""" # noqa: D205
|
||||
sorted_data = tomlkit.document()
|
||||
for key in sorted(data.keys()):
|
||||
value = data[key]
|
||||
if isinstance(value, dict):
|
||||
new_table = tomlkit.table()
|
||||
for subkey in ("ignore", "missing"):
|
||||
if subkey in value:
|
||||
# Convert the list to a set to remove duplicates, sort it, and convert to multiline for readability
|
||||
unique_sorted_array = sorted(set(value[subkey]))
|
||||
array = tomlkit.array()
|
||||
array.multiline(True)
|
||||
for item in unique_sorted_array:
|
||||
array.append(item)
|
||||
new_table[subkey] = array
|
||||
sorted_data[key] = new_table
|
||||
else:
|
||||
# Add other types of data unchanged
|
||||
sorted_data[key] = value
|
||||
return sorted_data
|
||||
REPO_ROOT = Path(os.getcwd())
|
||||
LOCALES_DIR = REPO_ROOT / "frontend" / "public" / "locales"
|
||||
REF_FILE = LOCALES_DIR / "en-GB" / "translation.json"
|
||||
SYNC_SCRIPT = REPO_ROOT / ".github" / "scripts" / "sync_translations.py"
|
||||
README = REPO_ROOT / "README.md"
|
||||
|
||||
|
||||
def write_readme(progress_list: list[tuple[str, int]]) -> None:
|
||||
"""Updates the progress status in the README.md file based
|
||||
on the provided progress list.
|
||||
|
||||
Parameters:
|
||||
progress_list (list[tuple[str, int]]): A list of tuples containing
|
||||
language and progress percentage.
|
||||
|
||||
Returns:
|
||||
None
|
||||
""" # noqa: D205
|
||||
with open("README.md", encoding="utf-8") as file:
|
||||
content = file.readlines()
|
||||
|
||||
for i, line in enumerate(content[2:], start=2):
|
||||
for progress in progress_list:
|
||||
language, value = progress
|
||||
if language in line:
|
||||
if match := re.search(r"\!\[(\d+(\.\d+)?)%\]\(.*\)", line):
|
||||
content[i] = line.replace(
|
||||
match.group(0),
|
||||
f"",
|
||||
)
|
||||
|
||||
with open("README.md", "w", encoding="utf-8", newline="\n") as file:
|
||||
file.writelines(content)
|
||||
def find_locale_files() -> List[Path]:
|
||||
return sorted(
|
||||
Path(p) for p in glob.glob(str(LOCALES_DIR / "*" / "translation.json"))
|
||||
)
|
||||
|
||||
|
||||
def parse_json_file(file_path):
|
||||
def percent_done_for_file(file_path: Path) -> int:
|
||||
"""
|
||||
Parses a JSON translation file and returns a flat dictionary of all keys.
|
||||
:param file_path: Path to the JSON file.
|
||||
:return: Dictionary with flattened keys and values.
|
||||
Calls sync_translations.py --report-percentages for a single locale file.
|
||||
Returns an int 0..100.
|
||||
"""
|
||||
with open(file_path, "r", encoding="utf-8") as file:
|
||||
data = json.load(file)
|
||||
# en-GB / en-US are always 100% by definition
|
||||
norm = str(file_path).replace("\\", "/")
|
||||
if norm.endswith("en-GB/translation.json") or norm.endswith(
|
||||
"en-US/translation.json"
|
||||
):
|
||||
return 100
|
||||
|
||||
def flatten_dict(d, parent_key="", sep="."):
|
||||
items = {}
|
||||
for k, v in d.items():
|
||||
new_key = f"{parent_key}{sep}{k}" if parent_key else k
|
||||
if isinstance(v, dict):
|
||||
items.update(flatten_dict(v, new_key, sep=sep))
|
||||
else:
|
||||
items[new_key] = v
|
||||
return items
|
||||
|
||||
return flatten_dict(data)
|
||||
cmd = [
|
||||
"python",
|
||||
str(SYNC_SCRIPT),
|
||||
"--reference-file",
|
||||
str(REF_FILE),
|
||||
"--files",
|
||||
str(file_path),
|
||||
"--check",
|
||||
"--report-percentages",
|
||||
]
|
||||
res = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
out = res.stdout.strip()
|
||||
return int(float(out))
|
||||
|
||||
|
||||
def compare_files(
|
||||
default_file_path, file_paths, ignore_translation_file
|
||||
) -> list[tuple[str, int]]:
|
||||
"""Compares the default JSON translation file with other
|
||||
translation files in the locales directory.
|
||||
def update_readme(progress_list: List[Tuple[str, int]]) -> None:
|
||||
"""
|
||||
Update README badges. Expects lines like:
|
||||
... [xx%](https://geps.dev/progress/xx)
|
||||
and replaces xx with the new percent.
|
||||
"""
|
||||
if not README.exists():
|
||||
print("README.md not found — skipping write.")
|
||||
return
|
||||
|
||||
Parameters:
|
||||
default_file_path (str): The path to the default translation JSON file.
|
||||
file_paths (list): List of paths to translation JSON files.
|
||||
ignore_translation_file (str): Path to the TOML file with ignore rules.
|
||||
content = README.read_text(encoding="utf-8").splitlines(keepends=True)
|
||||
|
||||
Returns:
|
||||
list[tuple[str, int]]: A list of tuples containing
|
||||
language and progress percentage.
|
||||
""" # noqa: D205
|
||||
default_keys = parse_json_file(default_file_path)
|
||||
num_keys = len(default_keys)
|
||||
# we start at line 2 like your original (skip title, etc.)
|
||||
for i in range(2, len(content)):
|
||||
line = content[i]
|
||||
for lang, value in progress_list:
|
||||
if lang in line:
|
||||
content[i] = re.sub(
|
||||
r"!\[(\d+(?:\.\d+)?)%\]\(https://geps\.dev/progress/\d+\)",
|
||||
f"",
|
||||
line,
|
||||
)
|
||||
break
|
||||
|
||||
result_list = []
|
||||
sort_ignore_translation: tomlkit.TOMLDocument
|
||||
README.write_text("".join(content), encoding="utf-8", newline="\n")
|
||||
|
||||
# read toml
|
||||
with open(ignore_translation_file, encoding="utf-8") as f:
|
||||
sort_ignore_translation = tomlkit.parse(f.read())
|
||||
|
||||
for file_path in file_paths:
|
||||
# Extract language code from directory name
|
||||
locale_dir = os.path.basename(os.path.dirname(file_path))
|
||||
def main() -> None:
|
||||
files = find_locale_files()
|
||||
if not files:
|
||||
print("No translation.json files found.")
|
||||
return
|
||||
|
||||
# Convert locale format from hyphen to underscore for TOML compatibility
|
||||
# e.g., en-GB -> en_GB, sr-LATN-RS -> sr_LATN_RS
|
||||
language = locale_dir.replace("-", "_")
|
||||
results: List[Tuple[str, int]] = []
|
||||
for f in files:
|
||||
# language label from folder, e.g. de-DE, sr-LATN-RS
|
||||
lang = f.parent.name.replace(
|
||||
"-", "_"
|
||||
) # keep hyphenated form to match README lines
|
||||
pct = percent_done_for_file(f)
|
||||
results.append((lang, pct))
|
||||
|
||||
fails = 0
|
||||
if language in ["en_GB", "en_US"]:
|
||||
result_list.append(("en_GB", 100))
|
||||
result_list.append(("en_US", 100))
|
||||
continue
|
||||
# ensure en-GB/en-US are included & set to 100
|
||||
have = {lang for lang, _ in results}
|
||||
for hard in ("en-GB", "en-US"):
|
||||
if hard not in have:
|
||||
results.append((hard, 100))
|
||||
|
||||
if language not in sort_ignore_translation:
|
||||
sort_ignore_translation[language] = tomlkit.table()
|
||||
# optional: sort by percent desc (nice to have)
|
||||
results.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
if (
|
||||
"ignore" not in sort_ignore_translation[language]
|
||||
or len(sort_ignore_translation[language].get("ignore", [])) < 1
|
||||
):
|
||||
sort_ignore_translation[language]["ignore"] = tomlkit.array(
|
||||
["language.direction"]
|
||||
)
|
||||
update_readme(results)
|
||||
|
||||
current_keys = parse_json_file(file_path)
|
||||
|
||||
# Compare keys
|
||||
for default_key, default_value in default_keys.items():
|
||||
if default_key not in current_keys:
|
||||
# Key is missing entirely
|
||||
if default_key not in sort_ignore_translation[language]["ignore"]:
|
||||
print(f"{language}: Key '{default_key}' is missing.")
|
||||
fails += 1
|
||||
elif (
|
||||
default_value == current_keys[default_key]
|
||||
and default_key not in sort_ignore_translation[language]["ignore"]
|
||||
):
|
||||
# Key exists but value is untranslated (same as reference)
|
||||
print(f"{language}: Key '{default_key}' is missing the translation.")
|
||||
fails += 1
|
||||
elif default_value != current_keys[default_key]:
|
||||
# Key is translated, remove from ignore list if present
|
||||
if default_key in sort_ignore_translation[language]["ignore"]:
|
||||
sort_ignore_translation[language]["ignore"].remove(default_key)
|
||||
|
||||
print(f"{language}: {fails} out of {num_keys} keys are not translated.")
|
||||
result_list.append(
|
||||
(
|
||||
language,
|
||||
int((num_keys - fails) * 100 / num_keys),
|
||||
)
|
||||
)
|
||||
|
||||
ignore_translation = convert_to_multiline(sort_ignore_translation)
|
||||
with open(ignore_translation_file, "w", encoding="utf-8", newline="\n") as file:
|
||||
file.write(tomlkit.dumps(ignore_translation))
|
||||
|
||||
unique_data = list(set(result_list))
|
||||
unique_data.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
return unique_data
|
||||
# also print a compact summary to stdout (useful in CI logs)
|
||||
# for lang, pct in results:
|
||||
# print(f"{lang}: {pct}%")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
directory = os.path.join(os.getcwd(), "frontend", "public", "locales")
|
||||
translation_file_paths = glob.glob(os.path.join(directory, "*", "translation.json"))
|
||||
reference_file = os.path.join(directory, "en-GB", "translation.json")
|
||||
|
||||
scripts_directory = os.path.join(os.getcwd(), "scripts")
|
||||
translation_state_file = os.path.join(scripts_directory, "ignore_translation.toml")
|
||||
|
||||
write_readme(
|
||||
compare_files(reference_file, translation_file_paths, translation_state_file)
|
||||
)
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
# Keys and paths to ignore for locale synchronization checks.
|
||||
# The structure mirrors scripts/ignore_translation.toml and is consumed by
|
||||
# .github/scripts/sync_translations.py.
|
||||
|
||||
[az_AZ]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
]
|
||||
|
||||
[bg_BG]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
]
|
||||
|
||||
[bo_CN]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
]
|
||||
|
||||
[ca_CA]
|
||||
ignore = [
|
||||
'adminUserSettings.admin',
|
||||
'language.direction',
|
||||
'watermark.type.1',
|
||||
]
|
||||
|
||||
[cs_CZ]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
'text',
|
||||
]
|
||||
|
||||
[da_DK]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
]
|
||||
|
||||
[de_DE]
|
||||
ignore = [
|
||||
'AddStampRequest.alphabet',
|
||||
'AddStampRequest.position',
|
||||
'alphabet',
|
||||
'certSign.name',
|
||||
'endpointStatistics.top10',
|
||||
'endpointStatistics.top20',
|
||||
'fileChooser.dragAndDrop',
|
||||
'language.direction',
|
||||
'legal.impressum',
|
||||
'licenses.version',
|
||||
'pipeline.title',
|
||||
'pipelineOptions.pipelineHeader',
|
||||
'pro',
|
||||
'sponsor',
|
||||
'text',
|
||||
'validateSignature.cert.version',
|
||||
'watermark.type.1',
|
||||
]
|
||||
|
||||
[el_GR]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
]
|
||||
|
||||
[es_ES]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
'no',
|
||||
'showJS.tags',
|
||||
]
|
||||
|
||||
[eu_ES]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
]
|
||||
|
||||
[fa_IR]
|
||||
ignore = [
|
||||
]
|
||||
|
||||
[fr_FR]
|
||||
ignore = [
|
||||
'AddStampRequest.alphabet',
|
||||
'AddStampRequest.position',
|
||||
'AddStampRequest.rotation',
|
||||
'adminUserSettings.actions',
|
||||
'alphabet',
|
||||
'compare.document.1',
|
||||
'compare.document.2',
|
||||
'language.direction',
|
||||
'licenses.license',
|
||||
'licenses.module',
|
||||
'licenses.nav',
|
||||
'licenses.version',
|
||||
'pipeline.title',
|
||||
'watermark.type.2',
|
||||
]
|
||||
|
||||
[ga_IE]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
]
|
||||
|
||||
[hi_IN]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
]
|
||||
|
||||
[hr_HR]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
'showJS.tags',
|
||||
]
|
||||
|
||||
[hu_HU]
|
||||
ignore = [
|
||||
'endpointStatistics.top10',
|
||||
'endpointStatistics.top20',
|
||||
'language.direction',
|
||||
'pipeline.title',
|
||||
'pipelineOptions.pipelineHeader',
|
||||
'pro',
|
||||
'showJS.tags',
|
||||
]
|
||||
|
||||
[id_ID]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
]
|
||||
|
||||
[it_IT]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
'no',
|
||||
'password',
|
||||
'pipeline.title',
|
||||
'pipelineOptions.pipelineHeader',
|
||||
'showJS.tags',
|
||||
'sponsor',
|
||||
]
|
||||
|
||||
[ja_JP]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
]
|
||||
|
||||
[ko_KR]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
]
|
||||
|
||||
[ml_IN]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
]
|
||||
|
||||
[ml_ML]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
]
|
||||
|
||||
[nl_NL]
|
||||
ignore = [
|
||||
'compare.document.1',
|
||||
'compare.document.2',
|
||||
'language.direction',
|
||||
'navbar.allTools',
|
||||
'sponsor',
|
||||
]
|
||||
|
||||
[no_NB]
|
||||
ignore = [
|
||||
'adminUserSettings.admin',
|
||||
'info',
|
||||
'language.direction',
|
||||
'oops',
|
||||
'sponsor',
|
||||
]
|
||||
|
||||
[pl_PL]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
]
|
||||
|
||||
[pt_BR]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
'pipelineOptions.pipelineHeader',
|
||||
]
|
||||
|
||||
[pt_PT]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
]
|
||||
|
||||
[ro_RO]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
]
|
||||
|
||||
[ru_RU]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
]
|
||||
|
||||
[sk_SK]
|
||||
ignore = [
|
||||
'adminUserSettings.admin',
|
||||
'info',
|
||||
'language.direction',
|
||||
'navbar.sections.security',
|
||||
'text',
|
||||
'watermark.type.1',
|
||||
]
|
||||
|
||||
[sl_SI]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
]
|
||||
|
||||
[sr_LATN_RS]
|
||||
ignore = [
|
||||
'endpointStatistics.top',
|
||||
'endpointStatistics.top10',
|
||||
'endpointStatistics.top20',
|
||||
'font',
|
||||
'info',
|
||||
'language.direction',
|
||||
'pro',
|
||||
'showJS.tags',
|
||||
]
|
||||
|
||||
[sv_SE]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
]
|
||||
|
||||
[th_TH]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
'pipelineOptions.pipelineHeader',
|
||||
'showJS.tags',
|
||||
]
|
||||
|
||||
[tr_TR]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
]
|
||||
|
||||
[uk_UA]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
]
|
||||
|
||||
[vi_VN]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
'pipelineOptions.pipelineHeader',
|
||||
'showJS.tags',
|
||||
]
|
||||
|
||||
[zh_BO]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
]
|
||||
|
||||
[zh_CN]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
]
|
||||
|
||||
[zh_TW]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
'poweredBy',
|
||||
'showJS.tags',
|
||||
]
|
||||
Reference in New Issue
Block a user