mirror of
https://github.com/emanuele-f/PCAPdroid.git
synced 2026-05-08 21:12:26 +00:00
Add weblate.py script to automate translation updates
Cherry-picks translation commits from the weblate remote, squashing consecutive commits by the same author. Uses 3-way merge to preserve master-only changes. Tracks last processed commit per locale in tools/weblate_status.
This commit is contained in:
+30
-2
@@ -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 <locale>` — 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 <locale>` to cherry-pick the translation commits
|
||||
|
||||
Executable
+289
@@ -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 <locale> 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 <status|update> [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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user