This is an automated email from the ASF dual-hosted git repository.
jimjag pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/openoffice-devtools.git
The following commit(s) were added to refs/heads/main by this push:
new 924c90c translation-scripts: add sdf_integrate.py for merging PO
translations
924c90c is described below
commit 924c90cff85b2706336b7f2d88ae2742c159e300
Author: Jim Jagielski <[email protected]>
AuthorDate: Wed Nov 11 16:23:44 2026 -0500
translation-scripts: add sdf_integrate.py for merging PO translations
Replacing localize.sdf with po2oo output loses tree translations that are
fuzzy or empty in Pootle, so this merges changed entries and gates on
gsicheck.
---
translation-scripts/integrate/README.md | 54 ++++++
translation-scripts/integrate/sdf_integrate.py | 257 +++++++++++++++++++++++++
2 files changed, 311 insertions(+)
diff --git a/translation-scripts/integrate/README.md
b/translation-scripts/integrate/README.md
new file mode 100644
index 0000000..ed85cf8
--- /dev/null
+++ b/translation-scripts/integrate/README.md
@@ -0,0 +1,54 @@
+# Integrating Pootle translations into the source tree
+
+`sdf_integrate.py` brings translations from the PO files in
+[apache/openoffice-translation](https://github.com/apache/openoffice-translation)
+into `extras/l10n/source/<lang>/localize.sdf`.
+
+It merges instead of replacing the file. `po2oo` writes English for every entry
+that is fuzzy or empty in Pootle, and many of those are translated in the tree,
+so a whole-file replacement loses them (15,441 entries for `am` in 2026).
+
+Needs Python 3.7+, translate-toolkit (`po2oo`) and `gsicheck` from a built
+OpenOffice tree. No other dependencies.
+
+## Steps, for one language
+
+1. Convert the PO files with the English SDF they were made from:
+
+ po2oo --template=en-US.sdf --skipsource --keeptimestamp \
+ --language=de PO-Files/de de.po2oo.sdf
+
+ `po2oo` writes the `--language` value into every line, so use the tree's
+ code (`pt-BR`) here and for `--lang` below. Only the PO directory keeps
+ Pootle's form (`PO-Files/pt_BR`).
+
+2. Optional: if a newer snapshot may have rolled translations back, convert
+ the trusted snapshot, the newer one and some older ones, then combine.
+ A newer value is taken only if it is not blank and does not match text
+ from any of the `--history` files.
+
+ sdf_integrate.py combine --lang de --template en-US.sdf \
+ --base de.2022.sdf --newer de.head.sdf \
+ --history de.2019.sdf de.2021.sdf --out de.po2oo.sdf
+
+3. Merge and gate:
+
+ sdf_integrate.py gate --lang de \
+ --tree extras/l10n/source/de/localize.sdf \
+ --new de.po2oo.sdf --template en-US.sdf --out de.merged.sdf \
+ --gsicheck main/solver/<ver>/<platform>/bin/gsicheck \
+ --libdir main/solver/<ver>/<platform>/lib \
+ --skip-out de.left-out.json
+
+ Only entries whose translation changed are updated. Every other line, its
+ line ending and its timestamp stay as they are. Keys that are new and
+ translated are added after the last line of the same source file.
+
+ Entries whose new text adds a `gsicheck` error that the tree file does not
+ already have keep their tree text, and are listed as "left out". Report
+ those to the language team. The command exits 1 if any new error is left
+ that it cannot tie to an entry.
+
+4. Copy `de.merged.sdf` over the tree file and commit it, one commit per
+ language. `merge --skip de.left-out.json` gives the same result without
+ running `gsicheck` again.
diff --git a/translation-scripts/integrate/sdf_integrate.py
b/translation-scripts/integrate/sdf_integrate.py
new file mode 100755
index 0000000..fbd2a96
--- /dev/null
+++ b/translation-scripts/integrate/sdf_integrate.py
@@ -0,0 +1,257 @@
+#!/usr/bin/env python3
+################################################################
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+################################################################
+"""Merge po2oo output into an existing localize.sdf.
+
+ combine build one po2oo SDF from a base and a newer snapshot, ignoring
+ newer values that are blank or revert to text from older snapshots
+ merge update only the entries whose translation changed
+ gate merge, then run gsicheck and keep the tree text for every entry
+ that adds a gsicheck error the tree file does not already have
+
+See README.md for the full workflow.
+"""
+
+import argparse
+import collections
+import json
+import os
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+
+TEXT_COLUMNS = range(10, 14) # text, helptext, quickhelptext, title
+
+
+def key(cols):
+ # project, file, type, gid, lid, helpid, platform
+ return (cols[0], cols[1], cols[3], cols[4], cols[5], cols[6], cols[7])
+
+
+def load(path, lang):
+ entries = {}
+ with open(path, encoding="utf-8") as f:
+ for line in f:
+ cols = line.rstrip("\r\n").split("\t")
+ if len(cols) == 15 and cols[9] == lang:
+ entries[key(cols)] = cols
+ return entries
+
+
+def is_translation(value, english):
+ return bool(value) and value != english
+
+
+def combine(lang, template, base_path, newer_path, history_paths):
+ en = load(template, "en-US")
+ base, newer = load(base_path, lang), load(newer_path, lang)
+ history = collections.defaultdict(set)
+ for path in history_paths:
+ for k, cols in load(path, lang).items():
+ for i in TEXT_COLUMNS:
+ history[(k, i)].add(cols[i])
+
+ stats = collections.Counter()
+ out = []
+ for k, a in base.items():
+ row = list(a)
+ b = newer.get(k)
+ if b:
+ for i in TEXT_COLUMNS:
+ if b[i] == a[i]:
+ continue
+ english = en[k][i] if k in en else None
+ if not is_translation(b[i], english):
+ stats["newer blank or English, kept base"] += 1
+ elif b[i] in history[(k, i)]:
+ stats["newer reverts to older text, kept base"] += 1
+ else:
+ row[i] = b[i]
+ stats["newer value taken"] += 1
+ out.append("\t".join(row) + "\n")
+ for k, b in newer.items():
+ if k not in base:
+ out.append("\t".join(b) + "\n")
+ stats["key only in newer"] += 1
+ return "".join(out), stats
+
+
+def merge(lang, tree_path, new_path, template, skip=()):
+ """Return (text, changed keys, added keys)."""
+ new = load(new_path, lang)
+ if not new:
+ sys.exit("no %s lines in %s; po2oo --language must use the tree's code
(pt-BR, not pt_BR)"
+ % (lang, new_path))
+ en = load(template, "en-US")
+ skip = set(skip)
+ with open(tree_path, encoding="utf-8", newline="") as f:
+ lines = f.read().splitlines(keepends=True)
+
+ seen, changed, last_of_file = set(), [], {}
+ timestamp, eol = None, "\n"
+ out = []
+ for line in lines:
+ body = line.rstrip("\r\n")
+ cols = body.split("\t")
+ if len(cols) == 15 and cols[9] == lang:
+ k = key(cols)
+ seen.add(k)
+ timestamp = timestamp or cols[14]
+ eol = line[len(body):] or eol
+ n, e = new.get(k), en.get(k)
+ if n and k not in skip:
+ updated = list(cols)
+ for i in TEXT_COLUMNS:
+ if n[i] != cols[i] and is_translation(n[i], e[i] if e else
None):
+ updated[i] = n[i]
+ if updated != cols:
+ changed.append(k)
+ line = "\t".join(updated) + line[len(body):]
+ last_of_file[(cols[0], cols[1])] = len(out)
+ out.append(line)
+
+ # New keys go after the last line of the same source file, so the file
stays grouped.
+ added, inserts = [], collections.defaultdict(list)
+ for k, n in new.items():
+ if k in seen or k in skip:
+ continue
+ e = en.get(k)
+ if not any(is_translation(n[i], e[i] if e else None) for i in
TEXT_COLUMNS):
+ continue
+ row = list(n)
+ row[14] = timestamp or row[14]
+ inserts[last_of_file.get((n[0], n[1]), len(out) -
1)].append("\t".join(row) + eol)
+ added.append(k)
+ for pos in sorted(inserts, reverse=True):
+ out[pos + 1:pos + 1] = inserts[pos]
+ return "".join(out), changed, added
+
+
+def run_gsicheck(gsicheck, libdir, path, lang):
+ """Return a Counter of (message, UniqueID) for every error except header
lines."""
+ if not os.access(gsicheck, os.X_OK):
+ sys.exit("gsicheck not found or not executable: %s" % gsicheck)
+ env = dict(os.environ)
+ if libdir:
+ for var in ("DYLD_LIBRARY_PATH", "LD_LIBRARY_PATH"):
+ env[var] = libdir + (os.pathsep + env[var] if env.get(var) else "")
+ with tempfile.TemporaryDirectory() as tmp:
+ shutil.copy(path, os.path.join(tmp, "localize.sdf"))
+ p = subprocess.run([gsicheck, "-c", "-t", "-l", lang, "localize.sdf"],
cwd=tmp, env=env,
+ capture_output=True, text=True, errors="replace")
+ if p.returncode not in (0, 55) and "Error" not in p.stdout + p.stderr:
+ sys.exit("gsicheck failed (%d): %s" % (p.returncode, (p.stdout +
p.stderr).strip()[:500]))
+ errors = collections.Counter()
+ for line in (p.stdout + p.stderr).splitlines():
+ if not line.startswith("Error") or "Format of line is unknown" in line:
+ continue
+ m = re.search(r"UniqueID (.*?): ", line)
+ message = re.sub(r"Line \d+[,:] ?", "", line.split(' "')[0]).strip()
+ errors[(message, m.group(1) if m else None)] += 1
+ return errors
+
+
+def uid_to_key(uid):
+ parts = uid.split("/")
+ gid, lid, helpid, platform = "/".join(parts[3:]).rsplit("/", 3)
+ return (parts[0], parts[1], parts[2], gid, lid, helpid, platform)
+
+
+def write(path, text):
+ with open(path, "w", encoding="utf-8", newline="") as f:
+ f.write(text)
+
+
+def cmd_combine(a):
+ text, stats = combine(a.lang, a.template, a.base, a.newer, a.history)
+ write(a.out, text)
+ print(a.lang, dict(stats))
+
+
+def cmd_merge(a):
+ skip = [tuple(k) for k in json.load(open(a.skip))] if a.skip else []
+ text, changed, added = merge(a.lang, a.tree, a.new, a.template, skip)
+ write(a.out, text)
+ print("%s: %d entries updated, %d keys added" % (a.lang, len(changed),
len(added)))
+
+
+def cmd_gate(a):
+ baseline = run_gsicheck(a.gsicheck, a.libdir, a.tree, a.lang)
+ skip = []
+ for _ in range(a.rounds):
+ text, changed, added = merge(a.lang, a.tree, a.new, a.template, skip)
+ write(a.out, text)
+ new_errors = run_gsicheck(a.gsicheck, a.libdir, a.out, a.lang) -
baseline
+ keyed = {uid_to_key(uid) for _, uid in new_errors if uid} - set(skip)
+ if not keyed:
+ break
+ skip += sorted(keyed)
+ unkeyed = [msg for msg, uid in new_errors.elements() if not uid]
+ if a.skip_out:
+ with open(a.skip_out, "w", encoding="utf-8") as f:
+ json.dump(skip, f, ensure_ascii=False, indent=1)
+ print("%s: %d entries updated, %d keys added, %d left out" % (a.lang,
len(changed), len(added), len(skip)))
+ for k in skip:
+ print(" left out: %s %s %s" % (k[1], k[3], k[4]))
+ if new_errors:
+ for msg, uid in sorted(new_errors):
+ print(" new gsicheck error: %s %s" % (msg, uid or ""))
+ sys.exit(1)
+
+
+def main():
+ p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
+ sub = p.add_subparsers(dest="command", required=True)
+
+ c = sub.add_parser("combine", help="combine a base and a newer po2oo SDF")
+ c.add_argument("--lang", required=True)
+ c.add_argument("--template", required=True, help="en-US.sdf used for
po2oo")
+ c.add_argument("--base", required=True, help="po2oo output from the
trusted snapshot")
+ c.add_argument("--newer", required=True, help="po2oo output from the newer
snapshot")
+ c.add_argument("--history", nargs="*", default=[], help="po2oo output from
older snapshots")
+ c.add_argument("--out", required=True)
+ c.set_defaults(func=cmd_combine)
+
+ for name, func, helptext in (("merge", cmd_merge, "merge without
checking"),
+ ("gate", cmd_gate, "merge and gate on
gsicheck")):
+ m = sub.add_parser(name, help=helptext)
+ m.add_argument("--lang", required=True)
+ m.add_argument("--tree", required=True,
help="extras/l10n/source/<lang>/localize.sdf")
+ m.add_argument("--new", required=True, help="po2oo (or combine)
output")
+ m.add_argument("--template", required=True, help="en-US.sdf used for
po2oo")
+ m.add_argument("--out", required=True)
+ if name == "merge":
+ m.add_argument("--skip", help="JSON list of keys to leave as they
are")
+ else:
+ m.add_argument("--gsicheck", required=True, help="path to the
gsicheck binary")
+ m.add_argument("--libdir", help="directory with libtl and friends,
e.g. solver/<ver>/<platform>/lib")
+ m.add_argument("--rounds", type=int, default=4)
+ m.add_argument("--skip-out", help="write the left-out keys here as
JSON")
+ m.set_defaults(func=func)
+
+ a = p.parse_args()
+ a.func(a)
+
+
+if __name__ == "__main__":
+ main()