|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +scripts/review_report.py |
| 4 | +
|
| 5 | +Builds the markdown body of the "translation review needed" issue from the |
| 6 | +changes currently STAGED in git (run it after `git add --all` and |
| 7 | +`unstage_cosmetic.py`). Writes an empty file if there is nothing to report. |
| 8 | +
|
| 9 | +Sections: |
| 10 | + - Summary |
| 11 | + - New PO files (counts only; every string in them is untranslated) |
| 12 | + - Newly fuzzy strings (old msgid -> new msgid, plus the current msgstr) |
| 13 | + - New untranslated strings (new msgids that matched nothing, not fuzzy) |
| 14 | +
|
| 15 | +"New" always means "not present in HEAD", so re-running never re-reports |
| 16 | +strings from earlier syncs. |
| 17 | +
|
| 18 | +Requires: polib (pip install polib) |
| 19 | +""" |
| 20 | +from __future__ import annotations |
| 21 | + |
| 22 | +import argparse |
| 23 | +import subprocess |
| 24 | +from pathlib import Path |
| 25 | + |
| 26 | +import polib |
| 27 | + |
| 28 | +# GitHub rejects issue bodies over 65,536 characters. |
| 29 | +MAX_BODY = 60_000 |
| 30 | +FUZZY_PER_FILE = 10 |
| 31 | +UNTRANSLATED_PER_FILE = 25 |
| 32 | +MAX_LINE = 160 |
| 33 | + |
| 34 | + |
| 35 | +def git(*args: str) -> str: |
| 36 | + return subprocess.run( |
| 37 | + ["git", *args], capture_output=True, text=True, check=True |
| 38 | + ).stdout |
| 39 | + |
| 40 | + |
| 41 | +def staged(diff_filter: str) -> list: |
| 42 | + out = git( |
| 43 | + "diff", |
| 44 | + "--staged", |
| 45 | + "--no-renames", |
| 46 | + "-z", |
| 47 | + "--name-only", |
| 48 | + f"--diff-filter={diff_filter}", |
| 49 | + "--", |
| 50 | + "*.po", |
| 51 | + ) |
| 52 | + return [p for p in out.split("\0") if p] |
| 53 | + |
| 54 | + |
| 55 | +def key(entry: polib.POEntry) -> tuple: |
| 56 | + return (entry.msgctxt, entry.msgid) |
| 57 | + |
| 58 | + |
| 59 | +def live(po: polib.POFile) -> list: |
| 60 | + return [e for e in po if not e.obsolete] |
| 61 | + |
| 62 | + |
| 63 | +def is_fuzzy(entry: polib.POEntry) -> bool: |
| 64 | + return "fuzzy" in entry.flags |
| 65 | + |
| 66 | + |
| 67 | +def is_untranslated(entry: polib.POEntry) -> bool: |
| 68 | + if entry.msgid_plural: |
| 69 | + return not any(entry.msgstr_plural.values()) |
| 70 | + return not entry.msgstr |
| 71 | + |
| 72 | + |
| 73 | +def clip(text: str, limit: int = MAX_LINE) -> str: |
| 74 | + # Keep everything on one line and make sure we can't close a code fence. |
| 75 | + text = text.replace("\n", "\\n").replace("```", "'''") |
| 76 | + return text if len(text) <= limit else text[: limit - 1] + "…" |
| 77 | + |
| 78 | + |
| 79 | +# --------------------------------------------------------------------------- |
| 80 | +# Collect |
| 81 | +# --------------------------------------------------------------------------- |
| 82 | + |
| 83 | + |
| 84 | +def collect(): |
| 85 | + new_files = [] # (path, number of strings) |
| 86 | + changes = [] # (path, newly_fuzzy, new_untranslated) |
| 87 | + |
| 88 | + for path in staged("A"): |
| 89 | + po = polib.pofile(path) |
| 90 | + new_files.append((path, len(live(po)))) |
| 91 | + |
| 92 | + for path in staged("M"): |
| 93 | + old = polib.pofile(git("show", f"HEAD:{path}")) |
| 94 | + new = polib.pofile(path) |
| 95 | + old_live = live(old) |
| 96 | + old_keys = {key(e) for e in old_live} |
| 97 | + old_fuzzy = {key(e) for e in old_live if is_fuzzy(e)} |
| 98 | + |
| 99 | + newly_fuzzy = [] |
| 100 | + new_untranslated = [] |
| 101 | + for e in live(new): |
| 102 | + if is_fuzzy(e): |
| 103 | + if key(e) not in old_fuzzy: |
| 104 | + newly_fuzzy.append(e) |
| 105 | + elif is_untranslated(e) and key(e) not in old_keys: |
| 106 | + new_untranslated.append(e) |
| 107 | + |
| 108 | + if newly_fuzzy or new_untranslated: |
| 109 | + changes.append((path, newly_fuzzy, new_untranslated)) |
| 110 | + |
| 111 | + return new_files, changes |
| 112 | + |
| 113 | + |
| 114 | +# --------------------------------------------------------------------------- |
| 115 | +# Render |
| 116 | +# --------------------------------------------------------------------------- |
| 117 | + |
| 118 | + |
| 119 | +def fuzzy_block(path: str, entries: list) -> str: |
| 120 | + lines = [f"### `{path}` ({len(entries)} new fuzzy)", "", "```diff"] |
| 121 | + for e in entries[:FUZZY_PER_FILE]: |
| 122 | + if e.previous_msgid: |
| 123 | + lines.append("- " + clip(e.previous_msgid)) |
| 124 | + else: |
| 125 | + lines.append(" (previous msgid unavailable)") |
| 126 | + lines.append("+ " + clip(e.msgid)) |
| 127 | + if e.msgstr: |
| 128 | + lines.append(" msgstr: " + clip(e.msgstr)) |
| 129 | + lines.append("") |
| 130 | + if len(entries) > FUZZY_PER_FILE: |
| 131 | + lines.append(f" … and {len(entries) - FUZZY_PER_FILE} more") |
| 132 | + lines.append("```") |
| 133 | + return "\n".join(lines) + "\n" |
| 134 | + |
| 135 | + |
| 136 | +def untranslated_block(path: str, entries: list) -> str: |
| 137 | + lines = [f"### `{path}` ({len(entries)} new untranslated)", "", "```text"] |
| 138 | + lines += [clip(e.msgid) for e in entries[:UNTRANSLATED_PER_FILE]] |
| 139 | + if len(entries) > UNTRANSLATED_PER_FILE: |
| 140 | + lines.append(f"… and {len(entries) - UNTRANSLATED_PER_FILE} more") |
| 141 | + lines.append("```") |
| 142 | + return "\n".join(lines) + "\n" |
| 143 | + |
| 144 | + |
| 145 | +class Body: |
| 146 | + """Accumulates blocks until the size budget runs out.""" |
| 147 | + |
| 148 | + def __init__(self, limit: int): |
| 149 | + self.parts: list = [] |
| 150 | + self.size = 0 |
| 151 | + self.limit = limit |
| 152 | + self.omitted = 0 |
| 153 | + |
| 154 | + def add(self, text: str) -> None: |
| 155 | + if self.size + len(text) > self.limit: |
| 156 | + self.omitted += 1 |
| 157 | + return |
| 158 | + self.parts.append(text) |
| 159 | + self.size += len(text) + 1 |
| 160 | + |
| 161 | + |
| 162 | +def render(new_files: list, changes: list) -> str: |
| 163 | + fuzzy_total = sum(len(f) for _, f, _ in changes) |
| 164 | + untr_total = sum(len(u) for _, _, u in changes) |
| 165 | + if not (new_files or fuzzy_total or untr_total): |
| 166 | + return "" |
| 167 | + |
| 168 | + body = Body(MAX_BODY) |
| 169 | + |
| 170 | + summary = ["## Summary", ""] |
| 171 | + if new_files: |
| 172 | + strings = sum(n for _, n in new_files) |
| 173 | + summary.append( |
| 174 | + f"- 🆕 **{len(new_files)}** new PO files ({strings} strings to translate)" |
| 175 | + ) |
| 176 | + if fuzzy_total: |
| 177 | + n = sum(1 for _, f, _ in changes if f) |
| 178 | + summary.append(f"- 🔍 **{fuzzy_total}** newly fuzzy strings in {n} files") |
| 179 | + if untr_total: |
| 180 | + n = sum(1 for _, _, u in changes if u) |
| 181 | + summary.append(f"- ✏️ **{untr_total}** new untranslated strings in {n} files") |
| 182 | + body.add("\n".join(summary) + "\n") |
| 183 | + |
| 184 | + if new_files: |
| 185 | + lines = [f"## 🆕 New PO files ({len(new_files)})"] |
| 186 | + lines += [f"- `{p}` ({n} strings)" for p, n in new_files] |
| 187 | + body.add("\n".join(lines) + "\n") |
| 188 | + |
| 189 | + if fuzzy_total: |
| 190 | + body.add( |
| 191 | + "## 🔍 Newly fuzzy strings\n\n" |
| 192 | + "The source text changed slightly, so the existing translation " |
| 193 | + "(`msgstr`) may need updating. `-` is the old source, `+` the new.\n" |
| 194 | + ) |
| 195 | + for path, fuzzy, _ in changes: |
| 196 | + if fuzzy: |
| 197 | + body.add(fuzzy_block(path, fuzzy)) |
| 198 | + |
| 199 | + if untr_total: |
| 200 | + body.add( |
| 201 | + "## ✏️ New untranslated strings\n\n" |
| 202 | + "Brand-new source strings with no close match to an existing " |
| 203 | + "translation.\n" |
| 204 | + ) |
| 205 | + for path, _, untr in changes: |
| 206 | + if untr: |
| 207 | + body.add(untranslated_block(path, untr)) |
| 208 | + |
| 209 | + if body.omitted: |
| 210 | + body.parts.append( |
| 211 | + f"_{body.omitted} section(s) omitted to fit GitHub's issue size " |
| 212 | + f"limit — see the sync commit's diff for the full list._\n" |
| 213 | + ) |
| 214 | + return "\n".join(body.parts) |
| 215 | + |
| 216 | + |
| 217 | +def main() -> None: |
| 218 | + parser = argparse.ArgumentParser(description=__doc__) |
| 219 | + parser.add_argument("--output", required=True, type=Path) |
| 220 | + args = parser.parse_args() |
| 221 | + |
| 222 | + new_files, changes = collect() |
| 223 | + report = render(new_files, changes) |
| 224 | + args.output.write_text(report, encoding="utf-8") |
| 225 | + print( |
| 226 | + f"Report: {len(report)} chars, {len(new_files)} new files, " |
| 227 | + f"{len(changes)} changed files with new strings" |
| 228 | + ) |
| 229 | + |
| 230 | + |
| 231 | +if __name__ == "__main__": |
| 232 | + main() |
0 commit comments