|
| 1 | +"""Render local PO translation changes as a Markdown review report.""" |
| 2 | + |
| 3 | +import argparse |
| 4 | +import hashlib |
| 5 | +import json |
| 6 | +import os |
| 7 | +from pathlib import Path |
| 8 | +import re |
| 9 | +import subprocess |
| 10 | +import sys |
| 11 | +from urllib.parse import quote |
| 12 | + |
| 13 | +import polib |
| 14 | + |
| 15 | + |
| 16 | +def git(root, *args): |
| 17 | + return subprocess.run( |
| 18 | + ["git", "-C", str(root), *args], |
| 19 | + check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, |
| 20 | + ).stdout |
| 21 | + |
| 22 | + |
| 23 | +def entries(data): |
| 24 | + if data is None: |
| 25 | + return {} |
| 26 | + encoding = polib.detect_encoding(data) |
| 27 | + catalog = polib.pofile(data.decode(encoding), encoding=encoding) |
| 28 | + result = {} |
| 29 | + for entry in catalog: |
| 30 | + if entry.obsolete or not entry.msgid: |
| 31 | + continue |
| 32 | + key = (entry.msgctxt, entry.msgid) |
| 33 | + if key in result: |
| 34 | + raise ValueError(f"Duplicate PO entry: {key!r}") |
| 35 | + result[key] = entry |
| 36 | + return result |
| 37 | + |
| 38 | + |
| 39 | +def signature(entry): |
| 40 | + if entry is None: |
| 41 | + return None |
| 42 | + return ( |
| 43 | + entry.msgid_plural, entry.msgstr, entry.msgstr_plural, |
| 44 | + "fuzzy" in entry.flags, |
| 45 | + ) |
| 46 | + |
| 47 | + |
| 48 | +def changes(before, after): |
| 49 | + # Keep document order; entries removed from a catalog follow surviving ones. |
| 50 | + for key in dict.fromkeys([*after, *before]): |
| 51 | + old, new = before.get(key), after.get(key) |
| 52 | + if signature(old) != signature(new): |
| 53 | + yield old, new |
| 54 | + |
| 55 | + |
| 56 | +def escape(text): |
| 57 | + # Ordinary punctuation such as dots, hyphens and parentheses needs no escape. |
| 58 | + return re.sub(r"([\\`*_\[\]<>#|~])", r"\\\1", text.replace("&", "&")) |
| 59 | + |
| 60 | + |
| 61 | +INLINE_RST = re.compile( |
| 62 | + r"(?<![\\`])(?::[\w.-]+(?::[\w.-]+)*:)?" |
| 63 | + r"(?P<ticks>`{1,2})(?!`)(?:\\.|[^`\\\n])+(?P=ticks)(?!`)" |
| 64 | +) |
| 65 | + |
| 66 | + |
| 67 | +def inline_text(text): |
| 68 | + # Preserve the whole reST token in a Markdown code span, including its |
| 69 | + # role and backticks. This avoids backslash noise in the Markdown source. |
| 70 | + parts = [] |
| 71 | + start = 0 |
| 72 | + for match in INLINE_RST.finditer(text): |
| 73 | + parts.append(escape(text[start:match.start()])) |
| 74 | + token = match.group() |
| 75 | + fence = "`" * (1 + max(len(run) for run in re.findall(r"`+", token))) |
| 76 | + parts.append(f"{fence} {token} {fence}") |
| 77 | + start = match.end() |
| 78 | + parts.append(escape(text[start:])) |
| 79 | + result = "".join(parts) |
| 80 | + # Prevent prose lines from becoming Markdown lists or thematic breaks. |
| 81 | + return re.sub(r"^(\s*)([-+]|\d+[.)])(?=\s|$)", |
| 82 | + lambda match: match[1] + match[2][:-1] + "\\" + match[2][-1], result) |
| 83 | + |
| 84 | + |
| 85 | +def paragraph(text): |
| 86 | + return "\n".join( |
| 87 | + "> " + inline_text(line) + (" " if line else "") |
| 88 | + for line in text.split("\n") |
| 89 | + ) |
| 90 | + |
| 91 | + |
| 92 | +def field(label, text): |
| 93 | + # Keep indentation and alignment in code examples, while allowing prose |
| 94 | + # to wrap naturally in the Markdown viewer. |
| 95 | + if text.startswith((">>> ", ".. code-block::", ".. code::")): |
| 96 | + fence = "`" * max(3, 1 + max((len(run) for run in re.findall(r"`+", text)), default=0)) |
| 97 | + return f"**{label}**\n\n{fence}text\n{text}\n{fence}\n" |
| 98 | + return f"**{label}**\n\n{paragraph(text)}\n" |
| 99 | + |
| 100 | + |
| 101 | +def translation(entry): |
| 102 | + if entry is None: |
| 103 | + return "(エントリーなし)" |
| 104 | + if entry.msgid_plural: |
| 105 | + return "\n\n".join( |
| 106 | + f"msgstr[{index}]: {text or '(未翻訳)'}" |
| 107 | + for index, text in sorted(entry.msgstr_plural.items()) |
| 108 | + ) or "(未翻訳)" |
| 109 | + return entry.msgstr or "(未翻訳)" |
| 110 | + |
| 111 | + |
| 112 | +def status(entry): |
| 113 | + if entry is None: |
| 114 | + return "なし" |
| 115 | + if "fuzzy" in entry.flags: |
| 116 | + return "fuzzy(要確認)" |
| 117 | + return "翻訳済み" if entry.translated() else "未翻訳" |
| 118 | + |
| 119 | + |
| 120 | +def render_entry(path, old, new, number, link): |
| 121 | + entry = new if new is not None else old |
| 122 | + identity = json.dumps([path, entry.msgctxt, entry.msgid], ensure_ascii=False) |
| 123 | + identifier = hashlib.sha256(identity.encode()).hexdigest()[:12] |
| 124 | + kind = "追加" if old is None else "削除" if new is None else "変更" |
| 125 | + lines = [f"### {number}. {kind} · {identifier}", ""] |
| 126 | + if new is not None: |
| 127 | + lines.extend([f"[PO の該当箇所]({link}#L{new.linenum})", ""]) |
| 128 | + if entry.occurrences: |
| 129 | + locations = ", ".join( |
| 130 | + f"{name}:{line}" if line else name for name, line in entry.occurrences |
| 131 | + ) |
| 132 | + lines.extend(["原文の位置: " + escape(locations), ""]) |
| 133 | + if entry.msgctxt is not None: |
| 134 | + lines.append(field("文脈(msgctxt)", entry.msgctxt)) |
| 135 | + lines.append(field("原文", entry.msgid)) |
| 136 | + if entry.msgid_plural: |
| 137 | + lines.append(field("原文(複数形)", entry.msgid_plural)) |
| 138 | + if old is not None and old.msgid_plural != entry.msgid_plural: |
| 139 | + lines.append(field("変更前の原文(複数形)", old.msgid_plural or "(なし)")) |
| 140 | + lines.append(field("変更前", translation(old))) |
| 141 | + lines.append(field("変更後", translation(new))) |
| 142 | + lines.extend([f"状態: {status(old)} → {status(new)}", ""]) |
| 143 | + if entry.tcomment: |
| 144 | + lines.append(field("翻訳者コメント", entry.tcomment)) |
| 145 | + if entry.comment: |
| 146 | + lines.append(field("抽出コメント", entry.comment)) |
| 147 | + return "\n".join(lines) |
| 148 | + |
| 149 | + |
| 150 | +def report(root, *, cached=False, base=None, paths=(), output_dir=None): |
| 151 | + diff_args = ["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z"] |
| 152 | + revision = None |
| 153 | + if cached: |
| 154 | + diff_args.append("--cached") |
| 155 | + comparison = "HEAD → ステージ済み(git diff --cached)" |
| 156 | + elif base: |
| 157 | + revision = git(root, "rev-parse", "--verify", "--end-of-options", base + "^{commit}").decode().strip() |
| 158 | + diff_args.append(revision) |
| 159 | + comparison = f"{base} ({revision[:12]}) → 作業ツリー" |
| 160 | + else: |
| 161 | + comparison = "インデックス → 作業ツリー(git diff)" |
| 162 | + diff_args.extend(["--", *(paths or ("*.po",))]) |
| 163 | + records = git(root, *diff_args).split(b"\0") |
| 164 | + sections, deleted = [], [] |
| 165 | + count = 0 |
| 166 | + for offset in range(0, len(records) - 1, 2): |
| 167 | + change = records[offset].decode() |
| 168 | + path = os.fsdecode(records[offset + 1]) |
| 169 | + if not path.endswith(".po"): |
| 170 | + continue |
| 171 | + if change == "D": |
| 172 | + deleted.append(path) |
| 173 | + continue |
| 174 | + if change not in ("A", "M"): |
| 175 | + raise ValueError(f"Unsupported file change {change}: {path}") |
| 176 | + old_ref = "HEAD" if cached else revision or "" |
| 177 | + before = entries(None if change == "A" else git(root, "show", f"{old_ref}:{path}")) |
| 178 | + after = entries(git(root, "show", f":{path}") if cached else (root / path).read_bytes()) |
| 179 | + modified = list(changes(before, after)) |
| 180 | + if not modified: |
| 181 | + continue |
| 182 | + link = quote(Path(os.path.relpath(root / path, output_dir or root)).as_posix(), safe="/") |
| 183 | + section = ["## " + escape(path), ""] |
| 184 | + for old, new in modified: |
| 185 | + count += 1 |
| 186 | + section.append(render_entry(path, old, new, count, link)) |
| 187 | + sections.append("\n".join(section)) |
| 188 | + header = [ |
| 189 | + "# 翻訳レビュー", "", "比較: " + escape(comparison), "", |
| 190 | + f"翻訳・状態の変更: {count} エントリー / {len(sections)} ファイル。" |
| 191 | + f"ファイル全体の削除: {len(deleted)} 件。", "", |
| 192 | + "PO ヘッダー、折り返し、参照行番号だけの変更は省略しています。" |
| 193 | + "原文が変わったエントリーは追加・削除として表示します。", "", |
| 194 | + ] |
| 195 | + if not count: |
| 196 | + header.extend(["翻訳・状態の変更はありません。", ""]) |
| 197 | + if deleted: |
| 198 | + sections.append("## 削除された PO ファイル\n\n" + "\n".join( |
| 199 | + "- " + escape(path) for path in deleted |
| 200 | + ) + "\n") |
| 201 | + return "\n".join([*header, *sections]).rstrip() + "\n" |
| 202 | + |
| 203 | + |
| 204 | +def main(): |
| 205 | + parser = argparse.ArgumentParser(description=__doc__) |
| 206 | + selection = parser.add_mutually_exclusive_group() |
| 207 | + selection.add_argument("--cached", action="store_true", help="compare HEAD to the index") |
| 208 | + selection.add_argument("--base", metavar="REV", help="compare REV to the working tree, including staged changes") |
| 209 | + parser.add_argument("-o", "--output", type=Path, help="write UTF-8 Markdown to this file (default: stdout)") |
| 210 | + parser.add_argument("paths", nargs="*", help="Git pathspecs relative to the repository root (default: all PO files)") |
| 211 | + args = parser.parse_args() |
| 212 | + try: |
| 213 | + root = Path(os.fsdecode(git(Path.cwd(), "rev-parse", "--show-toplevel")).rstrip("\n")) |
| 214 | + output = args.output.resolve() if args.output else None |
| 215 | + # A report must never overwrite a translation or another tracked file. |
| 216 | + if output: |
| 217 | + if output.suffix.lower() != ".md": |
| 218 | + raise ValueError("Output must have a .md extension") |
| 219 | + tracked = git(root, "ls-files", "-z").split(b"\0") |
| 220 | + if any((root / os.fsdecode(path)).resolve() == output for path in tracked if path): |
| 221 | + raise ValueError(f"Output is a tracked file: {output}") |
| 222 | + markdown = report(root, cached=args.cached, base=args.base, paths=args.paths, |
| 223 | + output_dir=output.parent if output else root) |
| 224 | + if output: |
| 225 | + output.write_text(markdown, encoding="utf-8") |
| 226 | + else: |
| 227 | + sys.stdout.write(markdown) |
| 228 | + except subprocess.CalledProcessError as error: |
| 229 | + parser.exit(1, error.stderr.decode(errors="replace")) |
| 230 | + except (OSError, ValueError) as error: |
| 231 | + parser.exit(1, f"{error}\n") |
| 232 | + |
| 233 | + |
| 234 | +if __name__ == "__main__": |
| 235 | + main() |
0 commit comments