l10n: docs: add translation instructions in AGENTS.md

Add a new "Translating po/XX.po" section to po/AGENTS.md with detailed workflow and procedures for AI agents to translate language-specific PO files. Users can invoke AI-assisted translation in coding tools with a prompt such as: "Translate the po/XX.po file by referring to @po/AGENTS.md" Translation results serve as drafts; human contributors must review and approve before submission. To address the low translation efficiency of some LLMs, batch translation replaces entry-by-entry translation. git-po-helper implements a gettext JSON format for translation files, replacing PO format during translation to enable batch processing. Evaluation with the Qwen model: git-po-helper agent-run --agent=qwen translate po/zh_CN.po Test translation (127 entries, 50 per batch): Initial state: 5998 translated, 91 fuzzy, 36 untranslated Final state: 6125 translated, 0 fuzzy, 0 untranslated Successfully translated: 127 entries (91 fuzzy + 36 untranslated) Success rate: 100% Benchmark results (3-run average): AI agent using gettext tools: | Metric | Value | |------------------|--------------------------------| | Avg. Num turns | 86 (176, 44, 40) | | Avg. Exec. Time | 20m44s (39m56s, 14m38s, 7m38s) | | Successful runs | 3/3 | AI agent using git-po-helper (JSON batch flow): | Metric | Value | |------------------|--------------------------------| | Avg. Num turns | 56 (68, 39, 63) | | Avg. Exec. Time | 19m8s (28m55s, 9m1s, 19m28s) | | Successful runs | 3/3 | The git-po-helper flow reduces the number of turns (86 → 56) with similar execution time; the bottleneck appears to be LLM processing rather than network interaction. Signed-off-by: Jiang Xin <worldhello.net@gmail.com>

Jiang Xin committed Feb 15, 2026 at 14:06 UTC fc59ba0dfe134fbb51230d50f396f5efc9ba1721
1 file changed +600 -1
po/AGENTS.md
+600 -1
@@ -5,7 +5,11 @@ housekeeping tasks for Git l10n. Use of AI is optional; many successful
5 l10n teams work well without it.
6
7 The section "Housekeeping tasks for localization workflows" documents the
8 -most commonly used housekeeping tasks.
8 +most commonly used housekeeping tasks:
9 +
10 +1. Generating or updating po/git.pot
11 +2. Updating po/XX.po
12 +3. Translating po/XX.po
13
14
15 ## Background knowledge for localization workflows
@@ -42,6 +46,356 @@ msgstr ""
46 metadata only and must be left unchanged.
47
48
49 +### Glossary Section
50 +
51 +PO files may have a glossary in comments before the header entry (first
52 +`msgid ""`), giving terminology guidelines (e.g.):
53 +
54 +```po
55 +# Git glossary for Chinese translators
56 +#
57 +# English | Chinese
58 +# ---------------------------------+--------------------------------------
59 +# 3-way merge | 三路合并
60 +# ...
61 +```
62 +
63 +**IMPORTANT**: Read and use the glossary when translating or reviewing. It is
64 +in `#` comments only. Leave that comment block unchanged.
65 +
66 +
67 +### PO entry structure (single-line and multi-line)
68 +
69 +PO entries are `msgid` / `msgstr` pairs. Plural messages add `msgid_plural` and
70 +`msgstr[n]`. The `msgid` is the immutable source; `msgstr` is the target
71 +translation. Each side may be a single quoted string or a multi-line block.
72 +In the multi-line form the header line is often `msgid ""` / `msgstr ""`, with
73 +the real text split across following quoted lines (concatenated by Gettext).
74 +
75 +**Single-line entries**:
76 +
77 +```po
78 +msgid "commit message"
79 +msgstr "提交说明"
80 +```
81 +
82 +**Multi-line entries**:
83 +
84 +```po
85 +msgid ""
86 +"Line 1\n"
87 +"Line 2"
88 +msgstr ""
89 +"行 1\n"
90 +"行 2"
91 +```
92 +
93 +**CRITICAL**: Do **not** use `grep '^msgstr ""'` to find untranslated entries;
94 +multi-line `msgstr` blocks use the same opening line, so grep gives false
95 +positives. Use `msgattrib` (next section).
96 +
97 +
98 +### Locating untranslated, fuzzy, and obsolete entries
99 +
100 +Use `msgattrib` to list untranslated, fuzzy, and obsolete entries. Task 3
101 +(translating `po/XX.po`) uses these commands.
102 +
103 +- **Untranslated**: `msgattrib --untranslated --no-obsolete po/XX.po`
104 +- **Fuzzy**: `msgattrib --only-fuzzy --no-obsolete po/XX.po`
105 +- **Obsolete** (`#~`): `msgattrib --obsolete --no-wrap po/XX.po`
106 +
107 +
108 +### Translating fuzzy entries
109 +
110 +Fuzzy entries need re-translation because the source text changed. The format
111 +differs by file type:
112 +
113 +- **PO file**: A `#, fuzzy` tag in the entry comments marks the entry as fuzzy.
114 +- **JSON file**: The entry has `"fuzzy": true`.
115 +
116 +**Translation principles**: Re-translate the `msgstr` (and, for plural entries,
117 +`msgstr[n]`) into the target language. Do **not** modify `msgid` or
118 +`msgid_plural`. After translation, **clear the fuzzy mark**: in PO, remove the
119 +`#, fuzzy` tag from comments; in JSON, omit or set `fuzzy` to `false`.
120 +
121 +
122 +### Preserving Special Characters
123 +
124 +Preserve escape sequences (`\n`, `\"`, `\\`, `\t`), placeholders (`%s`, `%d`,
125 +etc.), and quotes exactly as in `msgid`. Only reorder placeholders with
126 +positional syntax when needed (see Placeholder Reordering below).
127 +
128 +
129 +### Placeholder Reordering
130 +
131 +When reordering placeholders relative to `msgid`, use positional syntax (`%n$`)
132 +where *n* is the 1-based argument index, so each argument still binds to the
133 +right value. Preserve width and precision modifiers, and place `%n$` before
134 +them (see examples below).
135 +
136 +**Example 1** (placeholder reordering with precision):
137 +
138 +```po
139 +msgid "missing environment variable '%s' for configuration '%.*s'"
140 +msgstr "配置 '%3$.*2$s' 缺少环境变量 '%1$s'"
141 +```
142 +
143 +`%s` → argument 1 → `%1$s`. `%.*s` needs precision (arg 2) and string (arg 3) →
144 +`%3$.*2$s`.
145 +
146 +**Example 2** (multi-line, four `%s` reordered):
147 +
148 +```po
149 +msgid ""
150 +"Path updated: %s renamed to %s in %s, inside a directory that was renamed in "
151 +"%s; moving it to %s."
152 +msgstr ""
153 +"路径已更新:%1$s 在 %3$s 中被重命名为 %2$s,而其所在目录又在 %4$s 中被重命"
154 +"名,因此将其移动到 %5$s。"
155 +```
156 +
157 +Original order 1,2,3,4,5; in translation 1,3,2,4,5. Each line must be a
158 +complete quoted string.
159 +
160 +**Example 3** (no placeholder reordering):
161 +
162 +```po
163 +msgid "MIDX %s must be an ancestor of %s"
164 +msgstr "MIDX %s 必须是 %s 的祖先"
165 +```
166 +
167 +Argument order is still 1,2 in translation, so `%n$` is not needed.
168 +If no placeholder reordering occurs, you **must not** introduce `%n$`
169 +syntax; keep the original non-positional placeholders (`%s`, `%d`, etc.).
170 +
171 +
172 +### Validating PO File Format
173 +
174 +Check the PO file using the command below:
175 +
176 +```shell
177 +msgfmt --check -o /dev/null po/XX.po
178 +```
179 +
180 +Common validation errors include:
181 +- Unclosed quotes
182 +- Missing escape sequences
183 +- Invalid placeholder syntax
184 +- Malformed multi-line entries
185 +- Incorrect line breaks in multi-line strings
186 +
187 +On failure, `msgfmt` prints the line number; fix the PO at that line.
188 +
189 +
190 +### Using git-po-helper
191 +
192 +[git-po-helper](https://github.com/git-l10n/git-po-helper) supports Git l10n with
193 +**quality checking** (git-l10n PR conventions) and **AI-assisted translation**
194 +(subcommands for automated workflows). Housekeeping tasks in this document use
195 +it when available; otherwise rely on gettext tools.
196 +
197 +
198 +#### Splitting large PO files
199 +
200 +When a PO file is too large for translation or review, use `git-po-helper
201 +msg-select` to split it by entry index.
202 +
203 +- **Entry 0** is the header (included by default; use `--no-header` to omit).
204 +- **Entries 1, 2, 3, …** are content entries.
205 +- **Range format**: `--range "1-50"` (entries 1 through 50), `--range "-50"`
206 + (first 50 entries), `--range "51-"` (from entry 51 to end). Shortcuts:
207 + `--head N` (first N), `--tail N` (last N), `--since N` (from N to end).
208 +- **Output format**: PO by default; use `--json` for GETTEXT JSON. See the
209 + "GETTEXT JSON format" section (under git-po-helper) for details.
210 +- **State filter**: Use `--translated`, `--untranslated`, `--fuzzy` to filter
211 + by state (OR relationship). Use `--no-obsolete` to exclude obsolete entries;
212 + `--with-obsolete` to include (default). Use `--only-same` or `--only-obsolete`
213 + for a single state. Range applies to the filtered list.
214 +
215 +```shell
216 +# First 50 entries (header + entries 1–50)
217 +git-po-helper msg-select --range "-50" po/in.po -o po/out.po
218 +
219 +# Entries 51–100
220 +git-po-helper msg-select --range "51-100" po/in.po -o po/out.po
221 +
222 +# Entries 101 to end
223 +git-po-helper msg-select --range "101-" po/in.po -o po/out.po
224 +
225 +# Entries 1–50 without header (content only)
226 +git-po-helper msg-select --range "1-50" --no-header po/in.po -o po/frag.po
227 +
228 +# Output as JSON; select untranslated and fuzzy entries, exclude obsolete
229 +git-po-helper msg-select --json --untranslated --fuzzy --no-obsolete po/in.po >po/filtered.json
230 +```
231 +
232 +
233 +#### Comparing PO files for translation and review
234 +
235 +`git-po-helper compare` shows PO changes with full entry context (unlike
236 +`git diff`). Redirect output to a file: it is empty when there are no new or
237 +changed entries; otherwise it contains a valid PO header.
238 +
239 +```shell
240 +# Get full context of local changes (HEAD vs working tree)
241 +git-po-helper compare po/XX.po -o po/out.po
242 +
243 +# Get full context of changes in a specific commit (parent vs commit)
244 +git-po-helper compare --commit <commit> po/XX.po -o po/out.po
245 +
246 +# Get full context of changes since a commit (commit vs working tree)
247 +git-po-helper compare --since <commit> po/XX.po -o po/out.po
248 +
249 +# Get full context between two commits
250 +git-po-helper compare -r <commit1>..<commit2> po/XX.po -o po/out.po
251 +
252 +# Get full context of two worktree files
253 +git-po-helper compare po/old.po po/new.po -o po/out.po
254 +
255 +# Check msgid consistency (detect tampering); no output means target matches source
256 +git-po-helper compare --msgid po/old.po po/new.po >po/out.po
257 +```
258 +
259 +**Options summary**
260 +
261 +| Option | Meaning |
262 +|---------------------|------------------------------------------------|
263 +| (none) | Compare HEAD with working tree (local changes) |
264 +| `--commit <commit>` | Compare parent of commit with the commit |
265 +| `--since <commit>` | Compare commit with working tree |
266 +| `-r x..y` | Compare revision x with revision y |
267 +| `-r x..` | Compare revision x with working tree |
268 +| `-r x` | Compare parent of x with x |
269 +
270 +
271 +#### Concatenating multiple PO/JSON files
272 +
273 +`git-po-helper msg-cat` merges PO, POT, or gettext JSON inputs into one stream.
274 +Duplicate `msgid` values keep the first occurrence in file order. Write with
275 +`-o <file>` or stdout (`-o -` or omit); `--json` selects JSON output, else PO.
276 +
277 +```shell
278 +# Convert JSON to PO (e.g. after translation)
279 +git-po-helper msg-cat --unset-fuzzy -o po/out.po po/in.json
280 +
281 +# Merge multiple PO files
282 +git-po-helper msg-cat -o po/out.po po/in-1.po po/in-2.json
283 +```
284 +
285 +
286 +#### GETTEXT JSON format
287 +
288 +The **GETTEXT JSON** format is an internal format defined by `git-po-helper`
289 +for convenient batch processing of translation and related tasks by AI models.
290 +`git-po-helper msg-select`, `git-po-helper msg-cat`, and `git-po-helper compare`
291 +read and write this format.
292 +
293 +**Top-level structure**:
294 +
295 +```json
296 +{
297 + "header_comment": "string",
298 + "header_meta": "string",
299 + "entries": [ /* array of entry objects */ ]
300 +}
301 +```
302 +
303 +| Field | Description |
304 +|------------------|--------------------------------------------------------------------------------|
305 +| `header_comment` | Lines above the first `msgid ""` (comments, glossary), directly concatenated. |
306 +| `header_meta` | Encoded `msgstr` of the header entry (Project-Id-Version, Plural-Forms, etc.). |
307 +| `entries` | List of PO entries. Order matches source. |
308 +
309 +**Entry object** (each element of `entries`):
310 +
311 +| Field | Type | Description |
312 +|-----------------|----------|--------------------------------------------------------------|
313 +| `msgid` | string | Singular message ID. PO escapes encoded (e.g. `\n` → `\\n`). |
314 +| `msgstr` | []string | Translation forms as a **JSON array only**. Details below. |
315 +| `msgid_plural` | string | Plural form of msgid. Omit for non-plural. |
316 +| `comments` | []string | Comment lines (`#`, `#.`, `#:`, `#,`, etc.). |
317 +| `fuzzy` | bool | True if entry has fuzzy flag. |
318 +| `obsolete` | bool | True for `#~` obsolete entries. Omit if false. |
319 +
320 +**`msgstr` array (required shape)**:
321 +
322 +- **Always** a JSON array of strings, never a single string. One element = singular
323 + (PO `msgstr` / `msgstr[0]`); multiple elements = plural forms in order
324 + (`msgstr[0]`, `msgstr[1]`, …).
325 +- Omit the key or use an empty array when the entry is untranslated.
326 +
327 +**Example (single-line entry)**:
328 +
329 +```json
330 +{
331 + "header_comment": "# Glossary:\\n# term1\\tTranslation 1\\n#\\n",
332 + "header_meta": "Project-Id-Version: git\\nContent-Type: text/plain; charset=UTF-8\\n",
333 + "entries": [
334 + {
335 + "msgid": "Hello",
336 + "msgstr": ["你好"],
337 + "comments": ["#. Comment for translator\\n", "#: src/file.c:10\\n"],
338 + "fuzzy": false
339 + }
340 + ]
341 +}
342 +```
343 +
344 +**Example (plural entry)**:
345 +
346 +```json
347 +{
348 + "msgid": "One file",
349 + "msgid_plural": "%d files",
350 + "msgstr": ["一个文件", "%d 个文件"],
351 + "comments": ["#, c-format\\n"]
352 +}
353 +```
354 +
355 +**Example (fuzzy entry before translation)**:
356 +
357 +```json
358 +{
359 + "msgid": "Old message",
360 + "msgstr": ["旧翻译。"],
361 + "comments": ["#, fuzzy\\n"],
362 + "fuzzy": true
363 +}
364 +```
365 +
366 +**Translation notes for GETTEXT JSON files**:
367 +
368 +- **Preserve structure**: Keep `header_comment`, `header_meta`, `msgid`,
369 + `msgid_plural` unchanged.
370 +- **Fuzzy entries**: Entries extracted from fuzzy PO entries have `"fuzzy": true`.
371 + After translating, **remove the `fuzzy` field** or set it to `false` in the
372 + output JSON. The merge step uses `--unset-fuzzy`, which can also remove the
373 + `fuzzy` field.
374 +- **Placeholders**: Preserve `%s`, `%d`, etc. exactly; use `%n$` when
375 + reordering (see "Placeholder Reordering" above).
376 +
377 +
378 +### Quality checklist
379 +
380 +- **Accuracy**: Faithful to original meaning; no omissions or distortions.
381 +- **Fuzzy entries**: Re-translate fully and clear the fuzzy flag (see
382 + "Translating fuzzy entries" above).
383 +- **Terminology**: Consistent with glossary (see "Glossary Section" above) or
384 + domain standards.
385 +- **Grammar and fluency**: Correct and natural in the target language.
386 +- **Placeholders**: Preserve variables (`%s`, `{name}`, `$1`) exactly; use
387 + positional parameters when reordering (see "Placeholder Reordering" above).
388 +- **Special characters**: Preserve escape sequences (`\n`, `\"`, `\\`, `\t`),
389 + placeholders exactly as in `msgid`. See "Preserving Special Characters" above.
390 +- **Plurals and gender**: Correct forms and agreement.
391 +- **Context fit**: Suitable for UI space, tone, and use (e.g. error vs. tooltip).
392 +- **Cultural appropriateness**: No offensive or ambiguous content.
393 +- **Consistency**: Match prior translations of the same source.
394 +- **Technical integrity**: Do not translate code, paths, commands, brands, or
395 + proper nouns.
396 +- **Readability**: Clear, concise, and user-friendly.
397 +
398 +
399 ## Housekeeping tasks for localization workflows
400
401 For common housekeeping tasks, follow the steps in the matching subsection
@@ -70,6 +424,251 @@ When asked to update `po/XX.po` (or the like):
424 Simply run the command and consider the task complete.
425
426
427 +### Task 3: Translating po/XX.po
428 +
429 +To translate `po/XX.po`, use the steps below. The script uses gettext or
430 +`git-po-helper` depending on what is installed; JSON export (when available)
431 +supports batch translation rather than per-entry work.
432 +
433 +**Workflow loop**: Steps 1→2→3→4→5→6→7 form a loop. After step 6 succeeds,
434 +**always** go to step 7, which returns to step 1. The **only** exit to step 8
435 +is when step 2 finds `po/l10n-pending.po` empty. Do not skip step 7 or jump to
436 +step 8 after step 6.
437 +
438 +1. **Extract entries to translate**: **Directly execute** the script below—it is
439 + authoritative; do not reimplement. It generates `po/l10n-pending.po` with
440 + messages that need translation.
441 +
442 + ```shell
443 + l10n_extract_pending () {
444 + test $# -ge 1 || { echo "Usage: l10n_extract_pending <po-file>" >&2; return 1; }
445 + PO_FILE="$1"
446 + PENDING="po/l10n-pending.po"
447 + PENDING_FUZZY="${PENDING}.fuzzy"
448 + PENDING_REFER="${PENDING}.fuzzy.reference"
449 + PENDING_UNTRANS="${PENDING}.untranslated"
450 + rm -f "$PENDING"
451 +
452 + if command -v git-po-helper >/dev/null 2>&1
453 + then
454 + git-po-helper msg-select --untranslated --fuzzy --no-obsolete -o "$PENDING" "$PO_FILE"
455 + else
456 + msgattrib --untranslated --no-obsolete "$PO_FILE" >"${PENDING_UNTRANS}"
457 + msgattrib --only-fuzzy --no-obsolete --clear-fuzzy --empty "$PO_FILE" >"${PENDING_FUZZY}"
458 + msgattrib --only-fuzzy --no-obsolete "$PO_FILE" >"${PENDING_REFER}"
459 + msgcat --use-first "${PENDING_UNTRANS}" "${PENDING_FUZZY}" >"$PENDING"
460 + rm -f "${PENDING_UNTRANS}" "${PENDING_FUZZY}"
461 + fi
462 + if test -s "$PENDING"
463 + then
464 + msgfmt --stat -o /dev/null "$PENDING" || true
465 + echo "Pending file is not empty; there are still entries to translate."
466 + else
467 + echo "No entries need translation."
468 + return 1
469 + fi
470 + }
471 + # Run the extraction. Example: l10n_extract_pending po/zh_CN.po
472 + l10n_extract_pending po/XX.po
473 + ```
474 +
475 +2. **Check generated file**: If `po/l10n-pending.po` is empty or does not exist,
476 + translation is complete; go to step 8. Otherwise proceed to step 3.
477 +
478 +3. **Prepare one batch for translation**: Batching keeps each run small so the
479 + model can complete translation within limited context. **BEFORE translating**,
480 + **directly execute** the script below—it is authoritative; do not reimplement.
481 + Based on which file the script produces: if `po/l10n-todo.json` exists, go to
482 + step 4a; if `po/l10n-todo.po` exists, go to step 4b.
483 +
484 + ```shell
485 + l10n_one_batch () {
486 + test $# -ge 1 || { echo "Usage: l10n_one_batch <po-file> [min_batch_size]" >&2; return 1; }
487 + PO_FILE="$1"
488 + min_batch_size=${2:-100}
489 + PENDING="po/l10n-pending.po"
490 + TODO_JSON="po/l10n-todo.json"
491 + TODO_PO="po/l10n-todo.po"
492 + DONE_JSON="po/l10n-done.json"
493 + DONE_PO="po/l10n-done.po"
494 + rm -f "$TODO_JSON" "$TODO_PO" "$DONE_JSON" "$DONE_PO"
495 +
496 + ENTRY_COUNT=$(grep -c '^msgid ' "$PENDING" 2>/dev/null || echo 0)
497 + ENTRY_COUNT=$((ENTRY_COUNT > 0 ? ENTRY_COUNT - 1 : 0))
498 +
499 + if test "$ENTRY_COUNT" -gt $min_batch_size
500 + then
501 + if test "$ENTRY_COUNT" -gt $((min_batch_size * 8))
502 + then
503 + NUM=$((min_batch_size * 2))
504 + elif test "$ENTRY_COUNT" -gt $((min_batch_size * 4))
505 + then
506 + NUM=$((min_batch_size + min_batch_size / 2))
507 + else
508 + NUM=$min_batch_size
509 + fi
510 + BATCHING=1
511 + else
512 + NUM=$ENTRY_COUNT
513 + BATCHING=
514 + fi
515 +
516 + if command -v git-po-helper >/dev/null 2>&1
517 + then
518 + if test -n "$BATCHING"
519 + then
520 + git-po-helper msg-select --json --head "$NUM" -o "$TODO_JSON" "$PENDING"
521 + echo "Processing batch of $NUM entries (out of $ENTRY_COUNT remaining)"
522 + else
523 + git-po-helper msg-select --json -o "$TODO_JSON" "$PENDING"
524 + echo "Processing all $ENTRY_COUNT entries at once"
525 + fi
526 + else
527 + if test -n "$BATCHING"
528 + then
529 + awk -v num="$NUM" '/^msgid / && count++ > num {exit} 1' "$PENDING" |
530 + tac | awk '/^$/ {found=1} found' | tac >"$TODO_PO"
531 + echo "Processing batch of $NUM entries (out of $ENTRY_COUNT remaining)"
532 + else
533 + cp "$PENDING" "$TODO_PO"
534 + echo "Processing all $ENTRY_COUNT entries at once"
535 + fi
536 + fi
537 + }
538 + # Prepare one batch; shrink 2nd arg when batches exceed agent capacity.
539 + l10n_one_batch po/XX.po 100
540 + ```
541 +
542 +4a. **Translate JSON batch** (`po/l10n-todo.json` → `po/l10n-done.json`):
543 +
544 + - **Task**: Translate `po/l10n-todo.json` (input, GETTEXT JSON) into
545 + `po/l10n-done.json` (output, GETTEXT JSON). See the "GETTEXT JSON format"
546 + section above for format details and translation rules.
547 + - **Reference glossary**: Read the glossary from the batch file's
548 + `header_comment` (see "Glossary Section" above) and use it for
549 + consistent terminology.
550 + - **When translating**: Follow the "Quality checklist" above for correctness
551 + and quality. Handle escape sequences (`\n`, `\"`, `\\`, `\t`), placeholders,
552 + and quotes correctly as in `msgid`. For JSON, correctly escape and unescape
553 + these sequences when reading and writing. Modify `msgstr` and `msgstr[n]`
554 + (for plural entries); clear the fuzzy flag (omit or set `fuzzy` to `false`).
555 + Do **not** modify `msgid` or `msgid_plural`.
556 +
557 +4b. **Translate PO batch** (`po/l10n-todo.po` → `po/l10n-done.po`):
558 +
559 + - **Task**: Translate `po/l10n-todo.po` (input, GETTEXT PO) into
560 + `po/l10n-done.po` (output, GETTEXT PO).
561 + - **Reference glossary**: Read the glossary from the pending file header
562 + (see "Glossary Section" above) and use it for consistent terminology.
563 + - **When translating**: Follow the "Quality checklist" above for correctness
564 + and quality. Preserve escape sequences (`\n`, `\"`, `\\`, `\t`), placeholders,
565 + and quotes as in `msgid`. Modify `msgstr` and `msgstr[n]` (for plural
566 + entries); remove the `#, fuzzy` tag from comments when done. Do **not**
567 + modify `msgid` or `msgid_plural`.
568 +
569 +5. **Validate `po/l10n-done.po`**:
570 +
571 + Run the validation script below. If it fails, fix per the errors and notes,
572 + re-run until it succeeds.
573 +
574 + ```shell
575 + l10n_validate_done () {
576 + DONE_PO="po/l10n-done.po"
577 + DONE_JSON="po/l10n-done.json"
578 + PENDING="po/l10n-pending.po"
579 +
580 + if test -f "$DONE_JSON" && { ! test -f "$DONE_PO" || test "$DONE_JSON" -nt "$DONE_PO"; }
581 + then
582 + git-po-helper msg-cat --unset-fuzzy -o "$DONE_PO" "$DONE_JSON" || {
583 + echo "ERROR [JSON to PO conversion]: Fix $DONE_JSON and re-run." >&2
584 + return 1
585 + }
586 + fi
587 +
588 + # Check 1: msgid should not be modified
589 + MSGID_OUT=$(git-po-helper compare -q --msgid --assert-no-changes \
590 + "$PENDING" "$DONE_PO" 2>&1)
591 + MSGID_RC=$?
592 + if test $MSGID_RC -ne 0 || test -n "$MSGID_OUT"
593 + then
594 + echo "ERROR [msgid modified]: The following entries appeared after" >&2
595 + echo "translation because msgid was altered. Fix in $DONE_PO." >&2
596 + echo "$MSGID_OUT" >&2
597 + return 1
598 + fi
599 +
600 + # Check 2: PO format (see "Validating PO File Format" for error handling)
601 + MSGFMT_OUT=$(msgfmt --check -o /dev/null "$DONE_PO" 2>&1)
602 + MSGFMT_RC=$?
603 + if test $MSGFMT_RC -ne 0
604 + then
605 + echo "ERROR [PO format]: Fix errors in $DONE_PO." >&2
606 + echo "$MSGFMT_OUT" >&2
607 + return 1
608 + fi
609 +
610 + echo "Validation passed."
611 + }
612 + l10n_validate_done
613 + ```
614 +
615 + If the script fails, fix **directly in `po/l10n-done.po`**. Re-run
616 + `l10n_validate_done` until it succeeds. Editing `po/l10n-done.json` is not
617 + recommended because it adds an extra JSON-to-PO conversion step. Use the
618 + error message to decide:
619 +
620 + - **`[msgid modified]`**: The listed entries have altered `msgid`; restore
621 + them to match `po/l10n-pending.po`.
622 + - **`[PO format]`**: `msgfmt` reports line numbers; fix the errors in place.
623 + See "Validating PO File Format" for common issues.
624 +
625 +
626 +6. **Merge translation results into `po/XX.po`**: Run the script below. If it
627 + fails, fix the file the error names: **`[JSON to PO conversion]`** →
628 + `po/l10n-done.json`; **`[msgcat merge]`** → `po/l10n-done.po`. Re-run until
629 + it succeeds.
630 +
631 + ```shell
632 + l10n_merge_batch () {
633 + test $# -ge 1 || { echo "Usage: l10n_merge_batch <po-file>" >&2; return 1; }
634 + PO_FILE="$1"
635 + DONE_PO="po/l10n-done.po"
636 + DONE_JSON="po/l10n-done.json"
637 + MERGED="po/l10n-done.merged"
638 + PENDING="po/l10n-pending.po"
639 + PENDING_REFER="${PENDING}.fuzzy.reference"
640 + TODO_JSON="po/l10n-todo.json"
641 + TODO_PO="po/l10n-todo.po"
642 + if test -f "$DONE_JSON" && { ! test -f "$DONE_PO" || test "$DONE_JSON" -nt "$DONE_PO"; }
643 + then
644 + git-po-helper msg-cat --unset-fuzzy -o "$DONE_PO" "$DONE_JSON" || {
645 + echo "ERROR [JSON to PO conversion]: Fix $DONE_JSON and re-run." >&2
646 + return 1
647 + }
648 + fi
649 + msgcat --use-first "$DONE_PO" "$PO_FILE" >"$MERGED" || {
650 + echo "ERROR [msgcat merge]: Fix errors in $DONE_PO and re-run." >&2
651 + return 1
652 + }
653 + mv "$MERGED" "$PO_FILE"
654 + rm -f "$TODO_JSON" "$TODO_PO" "$DONE_JSON" "$DONE_PO" "$PENDING_REFER"
655 + }
656 + # Run the merge. Example: l10n_merge_batch po/zh_CN.po
657 + l10n_merge_batch po/XX.po
658 + ```
659 +
660 +7. **Loop**: **MUST** return to step 1 (Extract entries) and repeat the cycle.
661 + Do **not** skip this step or go to step 8. Step 8 (below) runs **only**
662 + when step 2 finds no more entries and redirects there.
663 +
664 +8. **Only after loop exits**: Run the command below to validate the PO file and
665 + display the report. The process ends here.
666 +
667 + ```shell
668 + msgfmt --check --stat -o /dev/null po/XX.po
669 + ```
670 +
671 +
672 ## Human translators remain in control
673
674 Git translation is human-driven; language team leaders and contributors are