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
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
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