diff --git a/docs/languages.md b/docs/languages.md index e48b7a2e..66d3aee2 100644 --- a/docs/languages.md +++ b/docs/languages.md @@ -9,10 +9,38 @@ The languages currently included into the app need to be specified in 2 places: Note: the locale name in `locales_config.xml` can differ from the language specified in `resourceConfigurations`, see https://developer.android.com/guide/topics/resources/app-languages#sample-config for some examples +## Updating translations + +The `tools/weblate.py` script automates cherry-picking translation commits from the `weblate` remote. +It assumes a `weblate` git remote is configured: + +``` +git remote add weblate https://hosted.weblate.org/git/pcapdroid/app +``` + +The correct way to update translations is: + +1. Merge origin into weblate +2. Git fetch weblate +3. run the locale update script and push the commits +4. Merge origin into weblate again + +The script tracks the last cherry-picked commit for each locale in `tools/weblate_status`. +Only locales listed in `resourceConfigurations` in `app/build.gradle` are considered. + +Available commands: + +- `tools/weblate.py status` — show which locales have pending translation commits +- `tools/weblate.py update` — cherry-pick pending commits for all supported locales +- `tools/weblate.py update ` — cherry-pick pending commits for a single locale (e.g. `ru`) + +Consecutive commits by the same author are automatically squashed into one. +After processing a locale, the script verifies that the local file matches `weblate/master`; if not, it exits with an error for manual resolution. + ## Adding a new language Here is a summary of the steps needed to add a new language: 1. The language translation first needs to be completed on [Weblate](https://hosted.weblate.org/projects/pcapdroid) -2. The language related commits are cherry-picked to `master`, and possibly squashed -3. `build.gradle` and `locales_config.xml` is updated as explained about +2. `build.gradle` and `locales_config.xml` are updated as explained above +3. Run `tools/weblate.py update ` to cherry-pick the translation commits diff --git a/tools/weblate.py b/tools/weblate.py new file mode 100755 index 00000000..7d06906d --- /dev/null +++ b/tools/weblate.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +# Copyright 2026 - Emanuele Faranda +# +# Cherry-pick translation updates from Weblate and squash consecutive +# commits by the same author. +# +# Usage: +# tools/weblate.py status Show pending translation commits +# tools/weblate.py update Update all tracked locales +# tools/weblate.py update Update a single locale (e.g. "ru") + +import subprocess +import sys +import os +import re + +REMOTE = "weblate" +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +REPO_ROOT = os.path.dirname(SCRIPT_DIR) +STATUS_FILE = os.path.join(SCRIPT_DIR, "weblate_status") +BUILD_GRADLE = os.path.join(REPO_ROOT, "app", "build.gradle") +STRINGS_BASE = "app/src/main/res" + +def die(msg): + print(f"Error: {msg}", file=sys.stderr) + sys.exit(1) + +def git(*args, check=True, stdin=None): + result = subprocess.run(["git"] + list(args), + input=stdin, capture_output=True, text=True) + if check and (result.returncode != 0): + die(f"git {' '.join(args)}\n{result.stderr.strip()}") + return result + +def locale_path(locale): + return f"{STRINGS_BASE}/values-{locale}/strings.xml" + +# --------------------------------------------------------------------------- +# Status file: one "locale=commit_hash" per line +# --------------------------------------------------------------------------- + +def load_status(): + status = {} + if os.path.exists(STATUS_FILE): + with open(STATUS_FILE) as f: + for line in f: + line = line.strip() + if (not line) or line.startswith("#"): + continue + locale, commit = line.split("=", 1) + status[locale] = commit + return status + +def save_status(status): + with open(STATUS_FILE, "w") as f: + for locale in sorted(status): + f.write(f"{locale}={status[locale]}\n") + +# --------------------------------------------------------------------------- +# Git helpers +# --------------------------------------------------------------------------- + +def is_on_master(commit): + """True if *commit* is already reachable from HEAD.""" + return git("merge-base", "--is-ancestor", commit, "HEAD", + check=False).returncode == 0 + +def get_pending_commits(locale, last_commit): + """Commits on weblate/master after *last_commit* that touch *locale*.""" + path = locale_path(locale) + args = ["log", "--format=%H", "--reverse", "--no-merges"] + if last_commit: + args += ["--ancestry-path", f"{last_commit}..{REMOTE}/master"] + else: + args.append(f"{REMOTE}/master") + args += ["--", path] + out = git(*args).stdout.strip() + return [h for h in out.split("\n") if h] + +def get_commit_info(commit): + """Return (name, email, date, message) for *commit*.""" + out = git("log", "-1", "--format=%an\n%ae\n%aI\n%s", commit).stdout.strip() + name, email, date, message = out.split("\n", 3) + return name, email, date, message + +def has_staged_changes(): + return git("diff", "--cached", "--quiet", check=False).returncode != 0 + +def locale_exists_on_disk(locale): + return os.path.isdir(os.path.join(STRINGS_BASE, f"values-{locale}")) + +def working_tree_clean(): + return git("diff", "--quiet", "--", STRINGS_BASE, check=False).returncode == 0 and \ + git("diff", "--cached", "--quiet", "--", STRINGS_BASE, check=False).returncode == 0 + +def get_supported_locales(): + """Non-English locales from resourceConfigurations in build.gradle.""" + with open(BUILD_GRADLE) as f: + content = f.read() + locales = re.findall(r'"([a-z]{2}(?:-r[A-Z]{2})?)"', + content.split("resourceConfigurations")[1].split("]")[0]) + locales = [l for l in locales if l != "en"] + return sorted(locales) + +# --------------------------------------------------------------------------- +# Grouping consecutive commits by author +# --------------------------------------------------------------------------- + +def group_by_author(commits): + """Return list of groups: {name, email, date, commits, message}.""" + groups = [] + for commit in commits: + name, email, date, message = get_commit_info(commit) + if groups and (groups[-1]["email"] == email): + groups[-1]["commits"].append(commit) + groups[-1]["date"] = date + groups[-1]["message"] = message + else: + groups.append({ + "name": name, + "email": email, + "date": date, + "commits": [commit], + "message": message, + }) + return groups + +# --------------------------------------------------------------------------- +# Applying translation changes +# --------------------------------------------------------------------------- + +def apply_group(group, path): + """Apply a group's translation changes via 3-way merge. + + Computes a diff spanning from the parent of the first commit to the + last commit in the group, then applies it with --3way so that + master-only changes (e.g. removed strings) are preserved. + """ + first = group["commits"][0] + last = group["commits"][-1] + first_parent = git("rev-parse", f"{first}^").stdout.strip() + + patch = git("diff", "--full-index", first_parent, last, "--", path).stdout + if not patch.strip(): + return + + result = git("apply", "--3way", check=False, stdin=patch) + if result.returncode != 0: + die(f"Failed to apply changes for {path} " + f"({first[:12]}..{last[:12]}):\n{result.stderr.strip()}") + +def verify_locale(locale): + """Verify that the locale file matches the one on weblate/master.""" + path = locale_path(locale) + weblate_content = git("show", f"{REMOTE}/master:{path}").stdout + + with open(path) as f: + our_content = f.read() + + if our_content != weblate_content: + die(f"locale '{locale}' differs from {REMOTE}/master after update — " + "manual fix required") + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- + +def cmd_status(): + git("fetch", REMOTE) + status = load_status() + supported = get_supported_locales() + + for locale in supported: + last = status.get(locale) + if last: + pending = get_pending_commits(locale, last) + translation = [c for c in pending if not is_on_master(c)] + if translation: + print(f" {locale}: {len(translation)} pending translation commit(s)") + else: + print(f" {locale}: up-to-date") + else: + pending = get_pending_commits(locale, None) + if not pending: + continue + translation = [c for c in pending if not is_on_master(c)] + if not translation: + continue + if locale_exists_on_disk(locale): + print(f" {locale}: NOT TRACKED (exists on disk, " + f"{len(translation)} commit(s) — add to status file)") + else: + print(f" {locale}: NEW ({len(translation)} commit(s))") + +def update_locale(locale, status): + last = status.get(locale) + path = locale_path(locale) + + if last is None: + if locale_exists_on_disk(locale): + die(f"locale '{locale}' exists on disk but is not in the " + "status file — add it manually first") + + all_pending = get_pending_commits(locale, last) + if not all_pending: + print(f" {locale}: up-to-date") + return + + pending = [c for c in all_pending if not is_on_master(c)] + skipped = len(all_pending) - len(pending) + + if not pending: + print(f" {locale}: {skipped} commit(s) already in master, nothing to do") + return + + extra = f" ({skipped} already in master)" if skipped else "" + print(f" {locale}: {len(pending)} pending commit(s){extra}") + + locale_dir = os.path.join(STRINGS_BASE, f"values-{locale}") + os.makedirs(locale_dir, exist_ok=True) + + groups = group_by_author(pending) + for group in groups: + apply_group(group, path) + git("add", path) + + if not has_staged_changes(): + n = len(group["commits"]) + print(f" Skipping {n} commit(s) by {group['name']} (no diff)") + continue + + author = f"{group['name']} <{group['email']}>" + message = group["message"] + git("commit", f"--author={author}", f"--date={group['date']}", "-m", message) + + n = len(group["commits"]) + squash_note = f" (squashed {n} commits)" if (n > 1) else "" + print(f" Committed: {message} by {group['name']}{squash_note}") + + verify_locale(locale) + + # track the last weblate-side commit (not a master commit) so the + # range for the next run stays on the weblate lineage + status[locale] = pending[-1] + save_status(status) + print(f" {locale}: done") + +def cmd_update(target_locale=None): + if not working_tree_clean(): + die("working tree has uncommitted changes — commit or stash first") + + git("fetch", REMOTE) + status = load_status() + supported = get_supported_locales() + + if target_locale: + if target_locale not in supported: + die(f"locale '{target_locale}' is not in resourceConfigurations") + locales = [target_locale] + else: + locales = supported + + for locale in locales: + update_locale(locale, status) + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def usage(): + print("Usage: weblate.py [locale]") + sys.exit(1) + +def main(): + if len(sys.argv) < 2: + usage() + + cmd = sys.argv[1] + + if cmd == "status": + cmd_status() + elif cmd == "update": + target = sys.argv[2] if (len(sys.argv) > 2) else None + cmd_update(target) + else: + usage() + +if __name__ == "__main__": + main() diff --git a/tools/weblate_status b/tools/weblate_status new file mode 100644 index 00000000..6117928e --- /dev/null +++ b/tools/weblate_status @@ -0,0 +1,12 @@ +ar=c88b846da7548f95c361f19f1d6baa304899dd1c +az=5ee3868efbbb6d6b390f6c10f3b16d1e7ebd8422 +de=5fc8414f6732b58fd40a4f5cda46992d0d025e7f +es=e9e35f8bb54d1b044de74d37010090b809144e2a +in=6d135bdda6c868b30f7a477aa66f9b181220ed06 +it=fbf7a9b8c50cd0d789b5bdfd93b0330755e7179b +pl=e9e35f8bb54d1b044de74d37010090b809144e2a +ru=4604a361e0d797e84985ab93daa5f39898cdf58f +ta=2cfd5d7a6e380a8468e694dab69d0fbeb07a0f0e +tr=2908b0a26800b56e4378b60f2e0c57a3322db1b0 +uk=e9e35f8bb54d1b044de74d37010090b809144e2a +zh-rCN=f507279aa45b7681da1a216633011427a5cd4dcc