Compare commits

...
Author SHA1 Message Date
Ludy 2c05f0cf70 Merge branch 'V2' into sync_translations_20251026 2025-10-29 22:44:22 +01:00
Ludy87 07579373f5 Rename --procent-translations to --report-percentages
Updated both sync_translations.py and counter_translation_v2.py to use the more descriptive --report-percentages flag instead of --procent-translations for reporting translation percentages.
https://github.com/Stirling-Tools/Stirling-PDF/pull/4739#discussion_r2468528425
2025-10-28 10:01:12 +01:00
Ludy87 d46cffb00f Update sync_translations.py 2025-10-28 08:27:10 +01:00
Ludy87 95a7106d28 Update translation.json 2025-10-28 08:18:30 +01:00
Ludy87 b8e88f5fbb Update translation.json 2025-10-28 08:18:12 +01:00
Ludy87 4daefcd243 Update sync_translations.py 2025-10-28 08:16:03 +01:00
Ludy87 ce0bbd8a50 Switch workflow trigger to pull_request event
Changed the GitHub Actions workflow to use the 'pull_request' event instead of 'pull_request_target'. Updated the event check and trigger to improve security and ensure the workflow runs only for pull requests.
2025-10-28 08:15:43 +01:00
Ludy 48fd05453d Merge branch 'V2' into sync_translations_20251026 2025-10-28 08:12:55 +01:00
LudyandJames Brunton ff32769d69 Update .github/scripts/sync_translations.py
Co-authored-by: James Brunton <jbrunton96@gmail.com>
2025-10-28 08:12:44 +01:00
Ludy87 427c52e0cc Replace check_language_json.py with sync_translations.py
Removed .github/scripts/check_language_json.py and updated the workflow to use .github/scripts/sync_translations.py for translation checks and syncing. Updated the usage documentation in sync_translations.py. Refactored scripts/counter_translation_v2.py to use sync_translations.py for translation progress calculation, simplifying logic and removing TOML-based ignore handling.
2025-10-27 08:50:51 +01:00
Ludy87 8fc3f3e8cb Update check_locales.yml 2025-10-27 00:31:21 +01:00
Ludy87 cbf36018c0 Update sync_translations.py 2025-10-27 00:23:05 +01:00
Ludy87 76bac0c256 Update sync_translations.py 2025-10-26 23:09:49 +01:00
Ludy87 927b283b41 Update sync_translations.py 2025-10-26 22:57:10 +01:00
Ludy87 84309c2222 Update sync_translations.py 2025-10-26 22:55:45 +01:00
Ludy87 6f8c3e92c2 Update check_locales.yml 2025-10-26 22:51:30 +01:00
Ludy87 c8d4a02299 Enhance translation sync and update translation files
Improved the translation sync script to better handle dotted reference keys and type mismatches, ensuring more robust merging and reporting of missing translations. Updated German and English translation files to use flattened keys for certain options, expanded tool descriptions, and added or reorganized many UI strings for new features and improved clarity.
2025-10-26 22:50:59 +01:00
Ludy87 e806844980 Remove report messages for invalid ignore entries
Eliminates the addition of report messages when ignore entries reference missing reference keys, streamlining the reporting output in the translation sync script.
2025-10-26 21:20:22 +01:00
Ludy87 6d0f8c2559 Prune obsolete ignore entries in translations sync
Updated sync_translations.py to detect and optionally remove ignore entries referencing non-existent keys in the reference translation. Cleaned up scripts/ignore_locales.toml by removing many obsolete ignore entries, reducing maintenance overhead and improving accuracy.
2025-10-26 20:32:24 +01:00
Ludy87 e559fd0c4b Update locale file patterns in labeler and workflow
Refines the regex for matching locale translation.json files in both the labeler config and the check_locales workflow by removing the underscore from the language code pattern. This ensures consistent and accurate detection of locale files.
2025-10-26 14:04:30 +01:00
Ludy87 a1bedaa051 Update labeler-config-srvaroa.yml 2025-10-26 14:01:42 +01:00
Ludy87 9fef66e80d Update check_locales.yml 2025-10-26 13:59:27 +01:00
Ludy87 c8d3f80a90 Update check_locales.yml 2025-10-26 13:54:59 +01:00
Ludy87 1dea9ae7ea Enhance translation sync script with ignore list management
Refactors and extends .github/scripts/sync_translations.py to support reading, updating, and cleaning up ignored translation keys via scripts/ignore_locales.toml. Now, when a previously ignored key is translated, it is automatically removed from the ignore list. Also updates the de-DE translation.json to move the 'mobile' section and adjusts formatting in ignore_locales.toml.
2025-10-26 13:52:12 +01:00
Ludy87 eea3bb2fc3 Update sync_translations.py 2025-10-26 13:26:44 +01:00
Ludy87 61b25ab787 Update sync_translations.py 2025-10-26 13:17:45 +01:00
Ludy87 f072c8d580 Update sync_translations.py 2025-10-26 13:07:17 +01:00
Ludy87 2076859490 Refactor translation sync script and add ignore list
Improves .github/scripts/sync_translations.py with clearer docstrings, better reporting, and more robust handling of missing/extra translation keys. Adds scripts/ignore_locales.toml to specify keys/paths to ignore during locale synchronization checks.
2025-10-26 13:02:04 +01:00
Ludy87 320ed0ab13 Update translation.json 2025-10-26 12:46:22 +01:00
Ludy87 6e1314799a Update translation.json 2025-10-26 12:44:34 +01:00
Ludy87 43e4143218 Update check_locales.yml 2025-10-26 12:43:02 +01:00
Ludy87 b2fc9b0d81 Update sync_translations.py 2025-10-26 12:42:55 +01:00
Ludy87 00d5b6b9a2 Update translation.json 2025-10-26 12:17:08 +01:00
Ludy87 b9aadb7a6e Update check_locales.yml 2025-10-26 12:14:56 +01:00
Ludy87 78028434e9 Update German translations and workflow check
Refactored and updated the de-DE translation.json file to remove unused keys, add missing tags, and improve consistency in tool descriptions and options. Also added the --check flag to the check_locales.yml workflow for enhanced locale validation.
2025-10-26 12:13:34 +01:00
Ludy87 5ee77eddc6 Update translation.json 2025-10-26 12:04:48 +01:00
Ludy87 2b552521b3 Improve translation sync script path handling
Refactors sync_translations.py to better handle reference and target file path resolution, including fallback logic and improved safety checks. Also adds support for splitting space-separated file lists and auto-discovery of locale files when --files is omitted. Removes unnecessary debug output from check_locales.yml.
2025-10-26 12:00:45 +01:00
Ludy87 8f3f5f3d1d Update check_locales.yml 2025-10-26 11:48:59 +01:00
Ludy87 54a11303b8 Update check_locales.yml 2025-10-26 11:44:41 +01:00
Ludy87 fc2c03b5b2 Update check_locales.yml 2025-10-26 11:40:48 +01:00
Ludy87 6c1cfeb7bb Update locale file path regex to use hyphens
Changed the regular expressions in the workflow to match locale directories using hyphens (e.g., en-US) instead of underscores. This ensures the workflow correctly identifies translation.json files in the updated directory structure.
2025-10-26 11:39:18 +01:00
Ludy87 3ec842f59d Update check_locales.yml 2025-10-26 11:37:21 +01:00
Ludy87 680555c5dd Update translation.json 2025-10-26 11:35:44 +01:00
Ludy87 4b20ed5a97 Add translation sync script and CI workflow
Introduces a Python script for checking and synchronizing JSON translation files against a reference, ensuring consistency across locales. Adds a GitHub Actions workflow to automatically verify and comment on translation file changes in pull requests.
2025-10-26 11:32:28 +01:00
10 changed files with 4521 additions and 1518 deletions
+2
View File
@@ -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:
-345
View File
@@ -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)
+810
View File
@@ -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()
+295
View File
@@ -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
+1 -1
View File
@@ -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
+88 -173
View File
@@ -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"![{value}%](https://geps.dev/progress/{value})",
)
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"![{value}%](https://geps.dev/progress/{value})",
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()
+276
View File
@@ -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',
]