champollion 0.3.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -26
- package/bin/cli.js +53 -5
- package/index.js +63 -2
- package/lib/api-key.js +17 -4
- package/lib/autofix.js +83 -36
- package/lib/bridge/method_bridge.py +15 -3
- package/lib/cards/reader.js +34 -0
- package/lib/cards/remote.js +15 -0
- package/lib/cards/search-names.js +178 -0
- package/lib/command-help.js +286 -85
- package/lib/commands/audit.js +10 -3
- package/lib/commands/card.js +583 -226
- package/lib/commands/doctor.js +54 -18
- package/lib/commands/help.js +37 -32
- package/lib/commands/init.js +1689 -87
- package/lib/commands/integrity.js +127 -40
- package/lib/commands/leaderboard.js +187 -67
- package/lib/commands/models.js +9 -2
- package/lib/commands/provenance.js +7 -2
- package/lib/commands/recommend.js +43 -14
- package/lib/commands/register-corpus.js +632 -125
- package/lib/commands/seal-corpus.js +1 -1
- package/lib/commands/status.js +564 -27
- package/lib/commands/submit.js +17 -12
- package/lib/commands/sync.js +31 -7
- package/lib/commands/tm.js +15 -9
- package/lib/commands/verify.js +27 -3
- package/lib/commands/wrap.js +63 -5
- package/lib/commands/xliff.js +135 -64
- package/lib/commercial-eligibility.js +1 -1
- package/lib/config.js +196 -14
- package/lib/content-estimate.js +96 -0
- package/lib/content-refusals.js +270 -0
- package/lib/content-review.js +372 -0
- package/lib/content-sync.js +1127 -344
- package/lib/content.js +94 -7
- package/lib/corpus-registration.mjs +194 -35
- package/lib/cost-label.js +29 -0
- package/lib/cost-report.js +726 -78
- package/lib/diff.js +38 -4
- package/lib/docusaurus-sync.js +965 -253
- package/lib/edit-distance.js +31 -0
- package/lib/fallback.js +964 -0
- package/lib/file-scope.js +106 -0
- package/lib/flatten.js +80 -3
- package/lib/flutter-locales.js +124 -0
- package/lib/format.js +266 -12
- package/lib/hash.js +146 -21
- package/lib/icu-structure.js +929 -0
- package/lib/integrity.js +223 -75
- package/lib/language-pair.js +157 -0
- package/lib/lint.js +78 -16
- package/lib/local-only-marks.js +106 -0
- package/lib/locale-layout.js +1103 -0
- package/lib/locale-state.js +571 -0
- package/lib/methods/anthropic.js +5 -0
- package/lib/methods/apertium.js +6 -3
- package/lib/methods/api.js +138 -25
- package/lib/methods/base.js +17 -0
- package/lib/methods/coaching-data.js +153 -0
- package/lib/methods/content-separator.js +43 -0
- package/lib/methods/deepl.js +1 -1
- package/lib/methods/direct-llm.js +252 -103
- package/lib/methods/external.js +146 -63
- package/lib/methods/gemini.js +1 -0
- package/lib/methods/google-translate.js +1 -0
- package/lib/methods/http-utils.js +41 -0
- package/lib/methods/libretranslate.js +7 -2
- package/lib/methods/llm-coached.js +68 -128
- package/lib/methods/llm.js +80 -31
- package/lib/methods/local.js +93 -10
- package/lib/methods/microsoft-translator.js +1 -2
- package/lib/methods/openai.js +4 -2
- package/lib/methods/openrouter-client.js +20 -19
- package/lib/methods/openrouter-pricing.js +150 -13
- package/lib/methods/prompt-methods.js +20 -0
- package/lib/methods/provider-pricing.js +42 -1
- package/lib/methods/request-capture.js +104 -0
- package/lib/methods/tilde.js +1 -1
- package/lib/methods/translated.js +1 -2
- package/lib/missing-key.js +93 -0
- package/lib/models.js +11 -0
- package/lib/name-rules.js +32 -0
- package/lib/named-keys.js +172 -0
- package/lib/no-translate.js +4 -3
- package/lib/output.js +160 -19
- package/lib/pairs.js +586 -30
- package/lib/placeholders.js +394 -0
- package/lib/plugins.js +8 -0
- package/lib/plural-gap-redo.js +109 -0
- package/lib/plurals.js +323 -0
- package/lib/po.js +1187 -0
- package/lib/public-catalogue.js +74 -0
- package/lib/recommend.js +527 -32
- package/lib/redo.js +95 -0
- package/lib/refusal-category.js +44 -0
- package/lib/registers.js +255 -11
- package/lib/repair-script.js +20 -13
- package/lib/scripts.js +6 -1
- package/lib/seal.mjs +4 -3
- package/lib/sealed-qualifier.mjs +1 -1
- package/lib/segment.js +2 -1
- package/lib/seo.js +19 -9
- package/lib/serve.js +43 -6
- package/lib/shared-output-seed.js +164 -0
- package/lib/source-contexts.js +39 -0
- package/lib/submit.mjs +57 -5
- package/lib/sync.js +2923 -474
- package/lib/terminology.js +13 -4
- package/lib/tm-evict.js +179 -0
- package/lib/tm-seed.js +5 -2
- package/lib/tm.js +818 -36
- package/lib/translate-pair.js +639 -34
- package/lib/translate.js +78 -5
- package/lib/types.js +22 -3
- package/lib/validate.js +880 -17
- package/lib/verify.js +1296 -104
- package/lib/watch.js +32 -13
- package/lib/xliff.js +44 -3
- package/package.json +1 -1
- package/shared/CORPORA-CARDS.md +2 -0
- package/shared/cards-fallback.json +1 -1
- package/shared/curated-orthography-conventions.json +26 -8
- package/shared/gettext-plural-forms.json +45 -0
- package/shared/method-registry.json +2 -0
- package/shared/metric-registry.json +96 -18
- package/shared/schemas/champollion-plugin.schema.json +4 -0
- package/shared/schemas/corpora-card.schema.json +8 -2
- package/shared/schemas/method-index-record.schema.json +67 -0
- package/shared/schemas/method-registry.schema.json +4 -0
- package/shared/schemas/metric-registry.schema.json +55 -1
- package/shared/docent/corpus.json +0 -11739
package/lib/po.js
ADDED
|
@@ -0,0 +1,1187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gettext catalogs (.po / .pot) — read into the flat key → value map the
|
|
3
|
+
* sync engine diffs and translates, and write back without disturbing
|
|
4
|
+
* anything sync did not translate.
|
|
5
|
+
*
|
|
6
|
+
* KEYS. An entry's key is its msgid. An entry with a context is keyed
|
|
7
|
+
* `msgctxt + "\u0004" + msgid` — gettext's own encoding (the string a .mo
|
|
8
|
+
* file stores and pgettext() looks up), so it cannot collide with any
|
|
9
|
+
* msgid and does not clash with the `<ns>::<key>` namespace separator.
|
|
10
|
+
*
|
|
11
|
+
* VALUES.
|
|
12
|
+
* source (.pot, or the source-language .po)
|
|
13
|
+
* msgstr when filled, else msgid — a .pot and `makemessages -l en`
|
|
14
|
+
* both leave msgstr empty, so the msgid IS the English text.
|
|
15
|
+
* target
|
|
16
|
+
* msgstr. An empty msgstr, or an entry flagged `fuzzy`, is
|
|
17
|
+
* UNTRANSLATED: it is absent from the map, so sync translates it.
|
|
18
|
+
*
|
|
19
|
+
* PLURALS. An entry with msgid_plural is ONE key whose value is an ICU
|
|
20
|
+
* plural message, e.g.
|
|
21
|
+
*
|
|
22
|
+
* msgid "One file" msgid_plural "%d files"
|
|
23
|
+
* → "{n, plural, one {One file} other {%d files}}"
|
|
24
|
+
*
|
|
25
|
+
* The model translates the whole message at once (each CLDR category of
|
|
26
|
+
* the target language — Polish one/few/many/other), the ICU structure gate
|
|
27
|
+
* protects it, and the writer maps categories onto the target's msgstr[i]
|
|
28
|
+
* through its Plural-Forms header: each index takes the CLDR category of
|
|
29
|
+
* the numbers that select it (Russian "n%10==1 && n%100!=11 ? 0 : …" →
|
|
30
|
+
* [one, few, many]). Branch text is copied verbatim — nothing inside it is
|
|
31
|
+
* interpreted — so %d, {name} and # survive byte for byte.
|
|
32
|
+
*
|
|
33
|
+
* PLURAL-FORMS. The target's own header is used when it has a valid one
|
|
34
|
+
* (Django `makemessages`, `msginit` and `pybabel init` all write it). A
|
|
35
|
+
* target without one (a catalog champollion creates) gets the header GNU
|
|
36
|
+
* msginit writes for its language (shared/gettext-plural-forms.json —
|
|
37
|
+
* French "nplurals=2; plural=(n > 1);"), so its msgstr[] slots are the ones
|
|
38
|
+
* gettext and Django select. A CLDR-derived French header (nplurals=3, for
|
|
39
|
+
* the rare "many" of 1 000 000) left every French plural carrying a
|
|
40
|
+
* permanent "write them, then delete this line" comment (Round 6, Django
|
|
41
|
+
* persona). A language msginit has no entry for gets a header derived from
|
|
42
|
+
* CLDR: the categories Intl.PluralRules gives integers, in CLDR order, and
|
|
43
|
+
* an expression synthesized from them and VERIFIED against
|
|
44
|
+
* Intl.PluralRules over 0…3000 and large probes. A language whose integer
|
|
45
|
+
* rules cannot be expressed that way fails loud with the msginit command to
|
|
46
|
+
* run instead — a guessed expression would pick the wrong form at runtime.
|
|
47
|
+
*
|
|
48
|
+
* LOSSLESS. Every entry keeps its raw text. Writing re-emits an entry
|
|
49
|
+
* byte-for-byte unless sync changed its value; a changed entry keeps its
|
|
50
|
+
* translator comments, takes the source's references, extracted comments
|
|
51
|
+
* and flags, loses `fuzzy` (and the `#|` previous-msgid lines that only
|
|
52
|
+
* mean something on a fuzzy entry). Entries the source no longer has and
|
|
53
|
+
* obsolete `#~` entries are kept, at the end, as they were.
|
|
54
|
+
*
|
|
55
|
+
* LIMITS (documented): UTF-8 catalogs only (others fail loud with the
|
|
56
|
+
* msgconv command); a plural msgid whose braces do not balance cannot be
|
|
57
|
+
* written as an ICU message and is reported, not translated.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
import fs from 'node:fs';
|
|
61
|
+
import { parseMessage } from './icu-structure.js';
|
|
62
|
+
import { pluralCategoriesFor } from './plurals.js';
|
|
63
|
+
|
|
64
|
+
/** msginit's Plural-Forms per language (shared/gettext-plural-forms.json), read once. */
|
|
65
|
+
let gettextForms = null;
|
|
66
|
+
function gettextPluralTable() {
|
|
67
|
+
if (gettextForms === null) {
|
|
68
|
+
const file = new URL('../shared/gettext-plural-forms.json', import.meta.url);
|
|
69
|
+
// Fail loud: a missing table would silently give every new catalog the
|
|
70
|
+
// CLDR header instead of the one gettext writes.
|
|
71
|
+
gettextForms = JSON.parse(fs.readFileSync(file, 'utf-8')).forms;
|
|
72
|
+
}
|
|
73
|
+
return gettextForms;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The Plural-Forms header GNU msginit writes for `locale` (its built-in
|
|
78
|
+
* table, looked up as msginit does: the full code, then the language), or
|
|
79
|
+
* null when msginit has none.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} locale - "fr", "pt-BR", "pt_BR"
|
|
82
|
+
* @returns {{ nplurals: number, expression: string, evaluate: Function }|null}
|
|
83
|
+
*/
|
|
84
|
+
function gettextPluralForms(locale) {
|
|
85
|
+
if (!locale) return null;
|
|
86
|
+
const parts = String(locale).replace(/-/g, '_').split('_');
|
|
87
|
+
const lang = parts[0].toLowerCase();
|
|
88
|
+
const region = parts.slice(1).find(p => /^[A-Za-z]{2}$|^\d{3}$/.test(p));
|
|
89
|
+
const table = gettextPluralTable();
|
|
90
|
+
const value = (region && table[`${lang}_${region.toUpperCase()}`]) || table[lang] || null;
|
|
91
|
+
return value ? parsePluralForms(value) : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** gettext's msgctxt/msgid separator (EOT). */
|
|
95
|
+
const PO_CONTEXT_SEPARATOR = '\u0004';
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A key as a report shows it: quoted, with the invisible context separator
|
|
99
|
+
* as "␄" (what `--redo keys:` accepts back) — JSON.stringify printed it as
|
|
100
|
+
* the six characters `\u0004`, which nothing accepts.
|
|
101
|
+
*/
|
|
102
|
+
function visibleKey(key) {
|
|
103
|
+
return `"${String(key).replace(/\u0004/g, '\u2404').replace(/\n/g, '\\n')}"`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The ICU variable name a plural entry is written with. */
|
|
107
|
+
const PO_PLURAL_ARG = 'n';
|
|
108
|
+
|
|
109
|
+
const CLDR_ORDER = ['zero', 'one', 'two', 'few', 'many', 'other'];
|
|
110
|
+
|
|
111
|
+
// -----------------------------------------------------------------
|
|
112
|
+
// Strings
|
|
113
|
+
// -----------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
function unescapeC(body) {
|
|
116
|
+
let out = '';
|
|
117
|
+
for (let i = 0; i < body.length; i++) {
|
|
118
|
+
const ch = body[i];
|
|
119
|
+
if (ch !== '\\') { out += ch; continue; }
|
|
120
|
+
const next = body[++i];
|
|
121
|
+
switch (next) {
|
|
122
|
+
case 'n': out += '\n'; break;
|
|
123
|
+
case 't': out += '\t'; break;
|
|
124
|
+
case 'r': out += '\r'; break;
|
|
125
|
+
case 'a': out += '\x07'; break;
|
|
126
|
+
case 'b': out += '\b'; break;
|
|
127
|
+
case 'f': out += '\f'; break;
|
|
128
|
+
case 'v': out += '\v'; break;
|
|
129
|
+
case '\\': out += '\\'; break;
|
|
130
|
+
case '"': out += '"'; break;
|
|
131
|
+
case "'": out += "'"; break;
|
|
132
|
+
case '?': out += '?'; break;
|
|
133
|
+
case 'x': {
|
|
134
|
+
const m = /^[0-9a-fA-F]+/.exec(body.slice(i + 1));
|
|
135
|
+
if (m) { out += String.fromCharCode(parseInt(m[0], 16)); i += m[0].length; } else out += 'x';
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
default:
|
|
139
|
+
if (next >= '0' && next <= '7') {
|
|
140
|
+
const m = /^[0-7]{1,3}/.exec(body.slice(i));
|
|
141
|
+
out += String.fromCharCode(parseInt(m[0], 8));
|
|
142
|
+
i += m[0].length - 1;
|
|
143
|
+
} else if (next !== undefined) {
|
|
144
|
+
out += next;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function escapeC(str) {
|
|
152
|
+
return str
|
|
153
|
+
.replace(/\\/g, '\\\\')
|
|
154
|
+
.replace(/"/g, '\\"')
|
|
155
|
+
.replace(/\n/g, '\\n')
|
|
156
|
+
.replace(/\t/g, '\\t')
|
|
157
|
+
.replace(/\r/g, '\\r');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Body of a `"..."` token (the quotes stripped), or null. */
|
|
161
|
+
function quoted(text) {
|
|
162
|
+
const t = text.trim();
|
|
163
|
+
if (t.length < 2 || t[0] !== '"' || t[t.length - 1] !== '"') return null;
|
|
164
|
+
return t.slice(1, -1);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Lines for `keyword "value"` in gettext's own style: one line, or — when
|
|
169
|
+
* the value has embedded newlines — an empty first string and one line per
|
|
170
|
+
* newline-terminated chunk.
|
|
171
|
+
*/
|
|
172
|
+
function formatField(keyword, value) {
|
|
173
|
+
const inner = value.slice(0, -1);
|
|
174
|
+
if (!inner.includes('\n')) return [`${keyword} "${escapeC(value)}"`];
|
|
175
|
+
const lines = [`${keyword} ""`];
|
|
176
|
+
const chunks = value.split(/(?<=\n)/);
|
|
177
|
+
for (const chunk of chunks) lines.push(`"${escapeC(chunk)}"`);
|
|
178
|
+
return lines;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// -----------------------------------------------------------------
|
|
182
|
+
// Parser
|
|
183
|
+
// -----------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Parse a PO/POT file.
|
|
187
|
+
*
|
|
188
|
+
* @param {string} text - File content (BOM already stripped)
|
|
189
|
+
* @param {string} [filePath] - For error messages
|
|
190
|
+
* @returns {{ lines: string[], eol: string, entries: PoEntry[], header: PoEntry|null }}
|
|
191
|
+
*
|
|
192
|
+
* @typedef {object} PoEntry
|
|
193
|
+
* @property {number} start - First line index (inclusive)
|
|
194
|
+
* @property {number} end - Last line index (inclusive)
|
|
195
|
+
* @property {'header'|'entry'|'obsolete'|'comment'} kind
|
|
196
|
+
* @property {string[]} translatorComments - Raw `# …` lines
|
|
197
|
+
* @property {string[]} extractedComments - Text of `#. …` lines
|
|
198
|
+
* @property {string[]} references - Raw `#: …` lines
|
|
199
|
+
* @property {string[]} flags - From `#, …` lines
|
|
200
|
+
* @property {string|null} msgctxt
|
|
201
|
+
* @property {string|null} msgid
|
|
202
|
+
* @property {string|null} msgidPlural
|
|
203
|
+
* @property {string[]} msgstr - msgstr, or msgstr[0..n]
|
|
204
|
+
* @property {object} fieldLines - keyword → [start, end] raw line range
|
|
205
|
+
*/
|
|
206
|
+
function parsePO(text, filePath = '') {
|
|
207
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
208
|
+
const lines = text.replace(/\r\n/g, '\n').split('\n');
|
|
209
|
+
const entries = [];
|
|
210
|
+
let cur = null;
|
|
211
|
+
let field = null; // { name, index } — the field continuation strings extend
|
|
212
|
+
|
|
213
|
+
const begin = (i) => {
|
|
214
|
+
cur = {
|
|
215
|
+
start: i, end: i, kind: 'entry',
|
|
216
|
+
translatorComments: [], extractedComments: [], references: [], flags: [], previous: [],
|
|
217
|
+
msgctxt: null, msgid: null, msgidPlural: null, msgstr: [],
|
|
218
|
+
fieldLines: {}, obsolete: false, seenKeyword: false, seenMsgid: false,
|
|
219
|
+
};
|
|
220
|
+
entries.push(cur);
|
|
221
|
+
field = null;
|
|
222
|
+
};
|
|
223
|
+
const where = (i) => `${filePath ? `${filePath}:` : 'line '}${i + 1}`;
|
|
224
|
+
|
|
225
|
+
const setField = (name, index, value, i) => {
|
|
226
|
+
if (name === 'msgstr') {
|
|
227
|
+
cur.msgstr[index] = (cur.msgstr[index] || '') + value;
|
|
228
|
+
} else if (name === 'msgctxt') {
|
|
229
|
+
cur.msgctxt = (cur.msgctxt || '') + value;
|
|
230
|
+
} else if (name === 'msgid') {
|
|
231
|
+
cur.msgid = (cur.msgid || '') + value;
|
|
232
|
+
} else if (name === 'msgid_plural') {
|
|
233
|
+
cur.msgidPlural = (cur.msgidPlural || '') + value;
|
|
234
|
+
}
|
|
235
|
+
const fl = name === 'msgstr' ? `msgstr[${index}]` : name;
|
|
236
|
+
if (!cur.fieldLines[fl]) cur.fieldLines[fl] = [i, i];
|
|
237
|
+
else cur.fieldLines[fl][1] = i;
|
|
238
|
+
cur.end = i;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const keyword = (content, i, obsolete) => {
|
|
242
|
+
const m = /^(msgctxt|msgid_plural|msgid|msgstr)(?:\[(\d+)\])?\s+(".*")\s*$/.exec(content);
|
|
243
|
+
if (!m) throw new Error(`${where(i)}: cannot parse "${content.slice(0, 60)}"`);
|
|
244
|
+
const [, name, idx, str] = m;
|
|
245
|
+
// A new msgctxt/msgid after the previous entry's msgid starts a new entry.
|
|
246
|
+
if (!cur || ((name === 'msgctxt' || name === 'msgid') && cur.seenMsgid)
|
|
247
|
+
|| (name === 'msgctxt' && cur.msgctxt !== null)) {
|
|
248
|
+
begin(i);
|
|
249
|
+
}
|
|
250
|
+
if (obsolete) cur.obsolete = true;
|
|
251
|
+
cur.seenKeyword = true;
|
|
252
|
+
if (name === 'msgid') cur.seenMsgid = true;
|
|
253
|
+
const body = quoted(str);
|
|
254
|
+
if (body === null) throw new Error(`${where(i)}: malformed string ${str.slice(0, 60)}`);
|
|
255
|
+
field = { name, index: idx === undefined ? 0 : Number(idx) };
|
|
256
|
+
setField(name, field.index, unescapeC(body), i);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
for (let i = 0; i < lines.length; i++) {
|
|
260
|
+
const line = lines[i];
|
|
261
|
+
const t = line.trim();
|
|
262
|
+
if (t === '') {
|
|
263
|
+
if (cur && cur.seenKeyword) { cur = null; field = null; }
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (t.startsWith('#~')) {
|
|
267
|
+
const content = t.slice(2).trim();
|
|
268
|
+
if (content.startsWith('|') || content === '') {
|
|
269
|
+
if (!cur || cur.seenKeyword) begin(i);
|
|
270
|
+
cur.obsolete = true;
|
|
271
|
+
cur.end = i;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (content.startsWith('"')) {
|
|
275
|
+
if (!cur || !field) throw new Error(`${where(i)}: string continuation without a keyword`);
|
|
276
|
+
const body = quoted(content);
|
|
277
|
+
if (body === null) throw new Error(`${where(i)}: malformed string`);
|
|
278
|
+
setField(field.name, field.index, unescapeC(body), i);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
keyword(content, i, true);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (t.startsWith('#')) {
|
|
285
|
+
if (!cur || cur.seenKeyword) begin(i);
|
|
286
|
+
cur.end = i;
|
|
287
|
+
field = null;
|
|
288
|
+
if (t.startsWith('#.')) cur.extractedComments.push(t.slice(2).trim());
|
|
289
|
+
else if (t.startsWith('#:')) cur.references.push(line);
|
|
290
|
+
else if (t.startsWith('#,')) {
|
|
291
|
+
for (const f of t.slice(2).split(',')) if (f.trim()) cur.flags.push(f.trim());
|
|
292
|
+
} else if (t.startsWith('#|')) cur.previous.push(line);
|
|
293
|
+
else cur.translatorComments.push(line);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (t.startsWith('"')) {
|
|
297
|
+
if (!cur || !field) throw new Error(`${where(i)}: string continuation without a keyword`);
|
|
298
|
+
const body = quoted(t);
|
|
299
|
+
if (body === null) throw new Error(`${where(i)}: malformed string ${t.slice(0, 60)}`);
|
|
300
|
+
setField(field.name, field.index, unescapeC(body), i);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
keyword(t, i, false);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
let header = null;
|
|
307
|
+
for (const e of entries) {
|
|
308
|
+
if (!e.seenKeyword) e.kind = 'comment';
|
|
309
|
+
else if (e.obsolete) e.kind = 'obsolete';
|
|
310
|
+
else if (e.msgid === '' && e.msgctxt === null) {
|
|
311
|
+
if (!header) { e.kind = 'header'; header = e; }
|
|
312
|
+
}
|
|
313
|
+
if (e.kind === 'entry' && e.msgid === null) {
|
|
314
|
+
throw new Error(`${where(e.start)}: entry without msgid`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return { lines, eol, entries, header };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** The flat-map key of an entry. */
|
|
321
|
+
function entryKey(e) {
|
|
322
|
+
return e.msgctxt !== null ? `${e.msgctxt}${PO_CONTEXT_SEPARATOR}${e.msgid}` : e.msgid;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** An entry's exact original text. */
|
|
326
|
+
function rawText(parsed, e) {
|
|
327
|
+
return parsed.lines.slice(e.start, e.end + 1).join('\n');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Header fields in order: [[name, value], …]. */
|
|
331
|
+
function headerFields(header) {
|
|
332
|
+
if (!header) return [];
|
|
333
|
+
const out = [];
|
|
334
|
+
for (const line of (header.msgstr[0] || '').split('\n')) {
|
|
335
|
+
if (!line.trim()) continue;
|
|
336
|
+
const i = line.indexOf(':');
|
|
337
|
+
if (i < 0) continue;
|
|
338
|
+
out.push([line.slice(0, i).trim(), line.slice(i + 1).trim()]);
|
|
339
|
+
}
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function headerField(header, name) {
|
|
344
|
+
const f = headerFields(header).find(([k]) => k.toLowerCase() === name.toLowerCase());
|
|
345
|
+
return f ? f[1] : null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Refuse a non-UTF-8 catalog: this module reads and writes UTF-8. */
|
|
349
|
+
function assertUtf8(header, filePath) {
|
|
350
|
+
const ct = headerField(header, 'Content-Type');
|
|
351
|
+
const m = ct && /charset\s*=\s*([^;\s]+)/i.exec(ct);
|
|
352
|
+
if (!m) return;
|
|
353
|
+
const cs = m[1].toUpperCase();
|
|
354
|
+
if (cs === 'UTF-8' || cs === 'UTF8' || cs === 'CHARSET') return;
|
|
355
|
+
throw new Error(
|
|
356
|
+
`${filePath || 'PO file'}: charset ${m[1]} — champollion reads and writes UTF-8 catalogs. `
|
|
357
|
+
+ `Convert it first: msgconv --to-code=UTF-8 -o "${filePath || 'file.po'}" "${filePath || 'file.po'}"`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// -----------------------------------------------------------------
|
|
361
|
+
// Plural-Forms
|
|
362
|
+
// -----------------------------------------------------------------
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Compile a gettext plural expression ("n%10==1 && n%100!=11 ? 0 : 1") into
|
|
366
|
+
* a function. The grammar is gettext's (plural-exp.y): ?:, ||, &&, == !=,
|
|
367
|
+
* < > <= >=, + -, * / %, unary !, parentheses, the variable n and
|
|
368
|
+
* non-negative integers. No eval.
|
|
369
|
+
*
|
|
370
|
+
* @param {string} expression
|
|
371
|
+
* @returns {(n: number) => number}
|
|
372
|
+
*/
|
|
373
|
+
function compilePluralExpression(expression) {
|
|
374
|
+
const tokens = [];
|
|
375
|
+
const re = /\s*(n|\d+|\?|:|\|\||&&|==|!=|<=|>=|<|>|\+|-|\*|\/|%|!|\(|\))/y;
|
|
376
|
+
let pos = 0;
|
|
377
|
+
const src = String(expression).trim().replace(/;$/, '');
|
|
378
|
+
while (pos < src.length) {
|
|
379
|
+
re.lastIndex = pos;
|
|
380
|
+
const m = re.exec(src);
|
|
381
|
+
if (!m) throw new Error(`unexpected "${src.slice(pos, pos + 10)}" in plural expression`);
|
|
382
|
+
tokens.push(m[1]);
|
|
383
|
+
pos = re.lastIndex;
|
|
384
|
+
while (pos < src.length && /\s/.test(src[pos])) pos++;
|
|
385
|
+
}
|
|
386
|
+
let i = 0;
|
|
387
|
+
const peek = () => tokens[i];
|
|
388
|
+
const take = (t) => {
|
|
389
|
+
if (tokens[i] !== t) throw new Error(`expected "${t}" in plural expression, got "${tokens[i] ?? 'end'}"`);
|
|
390
|
+
i++;
|
|
391
|
+
};
|
|
392
|
+
const bool = (v) => (v ? 1 : 0);
|
|
393
|
+
|
|
394
|
+
function ternary() {
|
|
395
|
+
const cond = or();
|
|
396
|
+
if (peek() === '?') {
|
|
397
|
+
i++;
|
|
398
|
+
const a = ternary();
|
|
399
|
+
take(':');
|
|
400
|
+
const b = ternary();
|
|
401
|
+
return (n) => (cond(n) ? a(n) : b(n));
|
|
402
|
+
}
|
|
403
|
+
return cond;
|
|
404
|
+
}
|
|
405
|
+
function binary(next, ops) {
|
|
406
|
+
return function level() {
|
|
407
|
+
let left = next();
|
|
408
|
+
while (ops[peek()]) {
|
|
409
|
+
const op = ops[tokens[i++]];
|
|
410
|
+
const right = next();
|
|
411
|
+
const l = left;
|
|
412
|
+
left = (n) => op(l(n), right(n));
|
|
413
|
+
}
|
|
414
|
+
return left;
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
function unary() {
|
|
418
|
+
if (peek() === '!') { i++; const v = unary(); return (n) => bool(!v(n)); }
|
|
419
|
+
return primary();
|
|
420
|
+
}
|
|
421
|
+
function primary() {
|
|
422
|
+
const t = tokens[i++];
|
|
423
|
+
if (t === undefined) throw new Error('plural expression ends early');
|
|
424
|
+
if (t === '(') { const v = ternary(); take(')'); return v; }
|
|
425
|
+
if (t === 'n') return (n) => n;
|
|
426
|
+
if (/^\d+$/.test(t)) { const k = Number(t); return () => k; }
|
|
427
|
+
throw new Error(`unexpected "${t}" in plural expression`);
|
|
428
|
+
}
|
|
429
|
+
const mul = binary(unary, {
|
|
430
|
+
'*': (a, b) => a * b,
|
|
431
|
+
'/': (a, b) => (b === 0 ? 0 : Math.trunc(a / b)),
|
|
432
|
+
'%': (a, b) => (b === 0 ? 0 : a % b),
|
|
433
|
+
});
|
|
434
|
+
const add = binary(mul, { '+': (a, b) => a + b, '-': (a, b) => a - b });
|
|
435
|
+
const rel = binary(add, {
|
|
436
|
+
'<': (a, b) => bool(a < b), '>': (a, b) => bool(a > b),
|
|
437
|
+
'<=': (a, b) => bool(a <= b), '>=': (a, b) => bool(a >= b),
|
|
438
|
+
});
|
|
439
|
+
const eq = binary(rel, { '==': (a, b) => bool(a === b), '!=': (a, b) => bool(a !== b) });
|
|
440
|
+
const and = binary(eq, { '&&': (a, b) => bool(a && b) });
|
|
441
|
+
const or = binary(and, { '||': (a, b) => bool(a || b) });
|
|
442
|
+
|
|
443
|
+
const fn = ternary();
|
|
444
|
+
if (i !== tokens.length) throw new Error(`unexpected "${tokens[i]}" in plural expression`);
|
|
445
|
+
return (n) => Number(fn(n));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Parse a `Plural-Forms:` header value. Template placeholders
|
|
450
|
+
* ("nplurals=INTEGER; plural=EXPRESSION;") and invalid expressions are null.
|
|
451
|
+
*
|
|
452
|
+
* @param {string|null} value
|
|
453
|
+
* @returns {{ nplurals: number, expression: string, evaluate: Function }|null}
|
|
454
|
+
*/
|
|
455
|
+
function parsePluralForms(value) {
|
|
456
|
+
if (!value) return null;
|
|
457
|
+
const m = /nplurals\s*=\s*(\d+)\s*;\s*plural\s*=\s*(.+?)\s*;?\s*$/.exec(value);
|
|
458
|
+
if (!m) return null;
|
|
459
|
+
const nplurals = Number(m[1]);
|
|
460
|
+
if (!(nplurals >= 1)) return null;
|
|
461
|
+
try {
|
|
462
|
+
const evaluate = compilePluralExpression(m[2]);
|
|
463
|
+
for (const n of [0, 1, 2, 3, 5, 11, 21, 101, 1000000]) {
|
|
464
|
+
const k = evaluate(n);
|
|
465
|
+
if (!Number.isInteger(k) || k < 0 || k >= nplurals) return null;
|
|
466
|
+
}
|
|
467
|
+
return { nplurals, expression: m[2].trim(), evaluate };
|
|
468
|
+
} catch {
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Integers probed beyond the exhaustive range (CLDR rules on n%1000000, …). */
|
|
474
|
+
const LARGE_PROBES = [];
|
|
475
|
+
for (const k of [1, 2, 3, 5, 10, 21, 100]) {
|
|
476
|
+
for (const r of [0, 1, 2, 3, 5, 11, 12, 21, 100, 101]) LARGE_PROBES.push(k * 1000000 + r);
|
|
477
|
+
}
|
|
478
|
+
LARGE_PROBES.push(10000, 10001, 100000, 100001, 1000000000);
|
|
479
|
+
|
|
480
|
+
/** Render "v==a || (v>=b && v<=c)" for a sorted list of integers. */
|
|
481
|
+
function renderSet(v, values) {
|
|
482
|
+
const runs = [];
|
|
483
|
+
for (const x of values) {
|
|
484
|
+
const last = runs[runs.length - 1];
|
|
485
|
+
if (last && x === last[1] + 1) last[1] = x; else runs.push([x, x]);
|
|
486
|
+
}
|
|
487
|
+
const parts = runs.map(([a, b]) => {
|
|
488
|
+
if (a === b) return `${v}==${a}`;
|
|
489
|
+
return a === 0 ? `${v}<=${b}` : `(${v}>=${a} && ${v}<=${b})`;
|
|
490
|
+
});
|
|
491
|
+
return parts.length === 1 ? parts[0] : `(${parts.join(' || ')})`;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Shortest condition on n%100 (or n%10 with exceptions) for a residue set. */
|
|
495
|
+
function renderResidues(residues) {
|
|
496
|
+
const direct = renderSet('n%100', residues);
|
|
497
|
+
const set = new Set(residues);
|
|
498
|
+
const digits = [];
|
|
499
|
+
for (let a = 0; a < 10; a++) {
|
|
500
|
+
let c = 0;
|
|
501
|
+
for (let r = a; r < 100; r += 10) if (set.has(r)) c++;
|
|
502
|
+
if (c > 5) digits.push(a);
|
|
503
|
+
}
|
|
504
|
+
if (digits.length === 0) return direct;
|
|
505
|
+
const base = [];
|
|
506
|
+
for (let r = 0; r < 100; r++) if (digits.includes(r % 10)) base.push(r);
|
|
507
|
+
const except = base.filter(r => !set.has(r));
|
|
508
|
+
const plus = residues.filter(r => !digits.includes(r % 10));
|
|
509
|
+
let viaDigits = renderSet('n%10', digits);
|
|
510
|
+
if (except.length > 0) {
|
|
511
|
+
const set = renderSet('n%100', except);
|
|
512
|
+
const not = except.length === 1 ? `n%100!=${except[0]}` : (set.startsWith('(') ? `!${set}` : `!(${set})`);
|
|
513
|
+
viaDigits = `(${viaDigits} && ${not})`;
|
|
514
|
+
}
|
|
515
|
+
if (plus.length > 0) viaDigits = `(${viaDigits} || ${renderSet('n%100', plus)})`;
|
|
516
|
+
return viaDigits.length < direct.length ? viaDigits : direct;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* A Plural-Forms header for `locale`, derived from CLDR (Intl.PluralRules)
|
|
521
|
+
* and verified, or null when CLDR has no rules for the locale or its
|
|
522
|
+
* integer rules do not reduce to small-number exceptions + a period of 100
|
|
523
|
+
* (+ the n%1000000 rule some languages use for "millions of").
|
|
524
|
+
*
|
|
525
|
+
* @param {string} locale
|
|
526
|
+
* @returns {{ nplurals: number, expression: string, categories: string[], evaluate: Function }|null}
|
|
527
|
+
*/
|
|
528
|
+
function synthesizePluralForms(locale) {
|
|
529
|
+
if (!pluralCategoriesFor(locale)) return null;
|
|
530
|
+
let rules;
|
|
531
|
+
try { rules = new Intl.PluralRules(String(locale).replace(/_/g, '-')); } catch { return null; }
|
|
532
|
+
const LIMIT = 3100;
|
|
533
|
+
const cat = [];
|
|
534
|
+
for (let n = 0; n < LIMIT; n++) cat.push(rules.select(n));
|
|
535
|
+
const seen = new Set(cat);
|
|
536
|
+
for (const n of LARGE_PROBES) seen.add(rules.select(n));
|
|
537
|
+
const categories = CLDR_ORDER.filter(c => seen.has(c));
|
|
538
|
+
if (categories.length === 1) {
|
|
539
|
+
return { nplurals: 1, expression: '0', categories, evaluate: () => 0 };
|
|
540
|
+
}
|
|
541
|
+
const index = (c) => categories.indexOf(c);
|
|
542
|
+
const fallback = categories.includes('other') ? 'other' : categories[categories.length - 1];
|
|
543
|
+
|
|
544
|
+
// Periodic (mod 100) from T on.
|
|
545
|
+
let T = 0;
|
|
546
|
+
for (let n = 0; n + 100 < LIMIT; n++) if (cat[n] !== cat[n + 100]) T = n + 1;
|
|
547
|
+
if (T > 200) return null;
|
|
548
|
+
const P = [];
|
|
549
|
+
for (let r = 0; r < 100; r++) P.push(cat[200 + r]);
|
|
550
|
+
|
|
551
|
+
const million = rules.select(1000000);
|
|
552
|
+
const special = million !== P[0] ? million : null;
|
|
553
|
+
|
|
554
|
+
const predicate = (c, guard) => {
|
|
555
|
+
const parts = [];
|
|
556
|
+
const small = [];
|
|
557
|
+
for (let n = 0; n < T; n++) if (cat[n] === c) small.push(n);
|
|
558
|
+
if (small.length > 0) parts.push(renderSet('n', small));
|
|
559
|
+
const residues = [];
|
|
560
|
+
for (let r = 0; r < 100; r++) if (P[r] === c) residues.push(r);
|
|
561
|
+
if (residues.length > 0) {
|
|
562
|
+
const res = renderResidues(residues);
|
|
563
|
+
parts.push(guard && T > 0 ? `(n>=${T} && ${res})` : res);
|
|
564
|
+
}
|
|
565
|
+
if (parts.length === 0) return null;
|
|
566
|
+
return parts.length === 1 ? parts[0] : `(${parts.join(' || ')})`;
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
const build = (guard) => {
|
|
570
|
+
let expr = String(index(fallback));
|
|
571
|
+
for (const c of [...categories].reverse()) {
|
|
572
|
+
if (c === fallback) continue;
|
|
573
|
+
const p = predicate(c, guard);
|
|
574
|
+
if (p) expr = `${p} ? ${index(c)} : ${expr}`;
|
|
575
|
+
}
|
|
576
|
+
if (special) expr = `(n!=0 && n%1000000==0) ? ${index(special)} : ${expr}`;
|
|
577
|
+
return expr;
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
for (const guard of [false, true]) {
|
|
581
|
+
const expression = build(guard);
|
|
582
|
+
let evaluate;
|
|
583
|
+
try { evaluate = compilePluralExpression(expression); } catch { continue; }
|
|
584
|
+
let ok = true;
|
|
585
|
+
for (let n = 0; n < LIMIT && ok; n++) if (evaluate(n) !== index(cat[n])) ok = false;
|
|
586
|
+
for (const n of LARGE_PROBES) if (ok && evaluate(n) !== index(rules.select(n))) ok = false;
|
|
587
|
+
if (ok) return { nplurals: categories.length, expression, categories, evaluate };
|
|
588
|
+
}
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* The ICU selector each msgstr index stands for: the CLDR category most of
|
|
594
|
+
* the numbers that select the index carry in `locale`. Ties and repeats
|
|
595
|
+
* fall back to an exact `=N` selector (N = the smallest number selecting
|
|
596
|
+
* the index), so every index is addressable.
|
|
597
|
+
*
|
|
598
|
+
* @param {number} nplurals
|
|
599
|
+
* @param {(n: number) => number} evaluate
|
|
600
|
+
* @param {string} locale
|
|
601
|
+
* @returns {string[]}
|
|
602
|
+
*/
|
|
603
|
+
function pluralIndexSelectors(nplurals, evaluate, locale) {
|
|
604
|
+
let rules = null;
|
|
605
|
+
if (pluralCategoriesFor(locale)) {
|
|
606
|
+
try { rules = new Intl.PluralRules(String(locale).replace(/_/g, '-')); } catch { rules = null; }
|
|
607
|
+
}
|
|
608
|
+
const byIndex = Array.from({ length: nplurals }, () => ({ count: 0, min: null, cats: new Map() }));
|
|
609
|
+
const samples = [];
|
|
610
|
+
for (let n = 0; n <= 1000; n++) samples.push(n);
|
|
611
|
+
samples.push(...LARGE_PROBES);
|
|
612
|
+
for (const n of samples) {
|
|
613
|
+
const k = evaluate(n);
|
|
614
|
+
if (!(k >= 0 && k < nplurals)) continue;
|
|
615
|
+
const slot = byIndex[k];
|
|
616
|
+
slot.count++;
|
|
617
|
+
if (slot.min === null) slot.min = n;
|
|
618
|
+
const c = rules ? rules.select(n) : (n === 1 ? 'one' : 'other');
|
|
619
|
+
slot.cats.set(c, (slot.cats.get(c) || 0) + 1);
|
|
620
|
+
}
|
|
621
|
+
const best = byIndex.map((slot) => {
|
|
622
|
+
let top = null;
|
|
623
|
+
for (const [c, k] of slot.cats) if (!top || k > top[1]) top = [c, k];
|
|
624
|
+
return top ? top[0] : null;
|
|
625
|
+
});
|
|
626
|
+
const selectors = new Array(nplurals).fill(null);
|
|
627
|
+
const order = byIndex.map((s, i) => i).sort((a, b) => byIndex[b].count - byIndex[a].count);
|
|
628
|
+
const taken = new Set();
|
|
629
|
+
for (const i of order) {
|
|
630
|
+
const c = best[i];
|
|
631
|
+
if (c && !taken.has(c)) { selectors[i] = c; taken.add(c); continue; }
|
|
632
|
+
const min = byIndex[i].min;
|
|
633
|
+
selectors[i] = min === null ? `=${-1 - i}` : `=${min}`;
|
|
634
|
+
}
|
|
635
|
+
return selectors;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* How plural entries of a TARGET catalog map onto ICU categories: from its
|
|
640
|
+
* own valid Plural-Forms header, else from the header msginit writes for
|
|
641
|
+
* the language, else from CLDR.
|
|
642
|
+
*
|
|
643
|
+
* @returns {{ nplurals: number, expression: string, selectors: string[], fromHeader: boolean }|null}
|
|
644
|
+
*/
|
|
645
|
+
function targetPluralInfo(header, locale) {
|
|
646
|
+
const fromHeader = parsePluralForms(headerField(header, 'Plural-Forms'));
|
|
647
|
+
if (fromHeader) {
|
|
648
|
+
return {
|
|
649
|
+
nplurals: fromHeader.nplurals, expression: fromHeader.expression,
|
|
650
|
+
selectors: pluralIndexSelectors(fromHeader.nplurals, fromHeader.evaluate, locale),
|
|
651
|
+
fromHeader: true,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
const conventional = gettextPluralForms(locale);
|
|
655
|
+
if (conventional) {
|
|
656
|
+
return {
|
|
657
|
+
nplurals: conventional.nplurals, expression: conventional.expression,
|
|
658
|
+
selectors: pluralIndexSelectors(conventional.nplurals, conventional.evaluate, locale),
|
|
659
|
+
fromHeader: false,
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
const synth = locale ? synthesizePluralForms(locale) : null;
|
|
663
|
+
if (!synth) return null;
|
|
664
|
+
return { nplurals: synth.nplurals, expression: synth.expression, selectors: synth.categories, fromHeader: false };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Keys of a TARGET catalog's entries flagged `fuzzy` — read as untranslated
|
|
669
|
+
* (absent from the flat map), so sync re-translates them; the log names them
|
|
670
|
+
* as fuzzy, not missing (Round 6, Django persona: an entry `makemessages`
|
|
671
|
+
* marked fuzzy after its msgid changed was reported "missing").
|
|
672
|
+
*
|
|
673
|
+
* @param {string} text - Catalog content
|
|
674
|
+
* @returns {Set<string>}
|
|
675
|
+
*/
|
|
676
|
+
function poFuzzyKeys(text) {
|
|
677
|
+
const out = new Set();
|
|
678
|
+
if (typeof text !== 'string' || !text.trim()) return out;
|
|
679
|
+
let parsed;
|
|
680
|
+
try { parsed = parsePO(text); } catch { return out; }
|
|
681
|
+
for (const e of parsed.entries) {
|
|
682
|
+
if (e.kind === 'entry' && e.flags.includes('fuzzy') && e.msgid) out.add(entryKey(e));
|
|
683
|
+
}
|
|
684
|
+
return out;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* The CLDR plural categories a catalog has a msgstr[] slot for — its own
|
|
689
|
+
* header's, else the header a new catalog gets — or null when no plural
|
|
690
|
+
* rules are known. A form without a slot cannot be written, so it is never
|
|
691
|
+
* asked again for, marked or reported as missing (French "many" in a
|
|
692
|
+
* two-form catalog).
|
|
693
|
+
*
|
|
694
|
+
* @param {string|null} text - Catalog content (null: a catalog not yet created)
|
|
695
|
+
* @param {string} locale
|
|
696
|
+
* @returns {string[]|null}
|
|
697
|
+
*/
|
|
698
|
+
function poPluralSlots(text, locale) {
|
|
699
|
+
let header = null;
|
|
700
|
+
if (typeof text === 'string' && text.trim()) {
|
|
701
|
+
try { header = parsePO(text).header; } catch { header = null; }
|
|
702
|
+
}
|
|
703
|
+
const info = targetPluralInfo(header, locale || headerField(header, 'Language') || null);
|
|
704
|
+
if (!info) return null;
|
|
705
|
+
const slots = info.selectors.filter(c => c && !c.startsWith('='));
|
|
706
|
+
// ICU's `other` always has a home: the last form when no index takes it.
|
|
707
|
+
if (!slots.includes('other')) slots.push('other');
|
|
708
|
+
return slots;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function pluralFormsUnavailable(locale, filePath) {
|
|
712
|
+
return new Error(
|
|
713
|
+
`${filePath || 'PO file'}: no usable Plural-Forms header, and CLDR's plural rules for "${locale}" cannot be `
|
|
714
|
+
+ 'turned into a gettext expression automatically. Add the header yourself — e.g. '
|
|
715
|
+
+ `\`msginit --locale=${locale} --input=<template>.pot --output=${filePath || '<file>.po'}\` writes it — and sync again.`);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// -----------------------------------------------------------------
|
|
719
|
+
// ICU plural values
|
|
720
|
+
// -----------------------------------------------------------------
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* "{n, plural, one {…} other {…}}" from selectors and form texts.
|
|
724
|
+
*
|
|
725
|
+
* ICU requires `other`; a language whose integer forms do not include it
|
|
726
|
+
* (Russian: one/few/many — CLDR's `other` is for fractions) gets the LAST
|
|
727
|
+
* form as `other`, gettext's general plural.
|
|
728
|
+
*/
|
|
729
|
+
function formsToICU(selectors, forms) {
|
|
730
|
+
const branches = [];
|
|
731
|
+
forms.forEach((text, i) => {
|
|
732
|
+
if (selectors[i] && !selectors[i].startsWith('=-')) branches.push(`${selectors[i]} {${text}}`);
|
|
733
|
+
});
|
|
734
|
+
if (!selectors.includes('other') && forms.length > 0) branches.push(`other {${forms[forms.length - 1]}}`);
|
|
735
|
+
return `{${PO_PLURAL_ARG}, plural, ${branches.join(' ')}}`;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** Does this ICU text parse back into exactly one plural argument? */
|
|
739
|
+
function icuPluralBranches(value) {
|
|
740
|
+
const parsed = parseMessage(value, { apostrophes: 'literal' });
|
|
741
|
+
if (!parsed.ok) return null;
|
|
742
|
+
const meaningful = parsed.nodes.filter(n => !(n.type === 'text' && n.value.trim() === ''));
|
|
743
|
+
if (meaningful.length !== 1 || meaningful[0].type !== 'branching') return null;
|
|
744
|
+
return new Map(meaningful[0].options.map(o => [o.selector, o.raw]));
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* msgstr[0..nplurals-1] from a translated ICU plural value. Branch text is
|
|
749
|
+
* copied verbatim. Returns null when the value is not a plural message.
|
|
750
|
+
*/
|
|
751
|
+
function icuToForms(value, info, locale) {
|
|
752
|
+
const mapped = icuToFormsWithCopies(value, info, locale);
|
|
753
|
+
return mapped ? mapped.forms : null;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* icuToForms, plus the forms that had to REPEAT the `other` branch because
|
|
758
|
+
* the translation has no branch for their CLDR category (a Russian message
|
|
759
|
+
* with only one/other fills few and many from other). msgfmt needs every
|
|
760
|
+
* msgstr[i], so the repeat is written — and marked (PO_COPIED_FORMS_MARK),
|
|
761
|
+
* never passed off as a translation of that form. An index with no integer
|
|
762
|
+
* counts (Django's Russian fraction form) takes `other` by right: CLDR's
|
|
763
|
+
* `other` IS that form, so it is not a copy.
|
|
764
|
+
*
|
|
765
|
+
* @returns {{ forms: string[], copied: Array<{ index: number, category: string }> }|null}
|
|
766
|
+
*/
|
|
767
|
+
function icuToFormsWithCopies(value, info, locale) {
|
|
768
|
+
const branches = icuPluralBranches(value);
|
|
769
|
+
if (!branches) return null;
|
|
770
|
+
let rules = null;
|
|
771
|
+
try { if (pluralCategoriesFor(locale)) rules = new Intl.PluralRules(String(locale).replace(/_/g, '-')); } catch { rules = null; }
|
|
772
|
+
const forms = [];
|
|
773
|
+
const copied = [];
|
|
774
|
+
for (let i = 0; i < info.nplurals; i++) {
|
|
775
|
+
const sel = info.selectors[i];
|
|
776
|
+
let text = branches.get(sel);
|
|
777
|
+
if (text === undefined && sel && sel.startsWith('=') && rules) {
|
|
778
|
+
const k = Number(sel.slice(1));
|
|
779
|
+
if (k >= 0) text = branches.get(rules.select(k));
|
|
780
|
+
}
|
|
781
|
+
if (text === undefined) {
|
|
782
|
+
text = branches.get('other');
|
|
783
|
+
if (text !== undefined && sel && sel !== 'other' && !sel.startsWith('=')) copied.push({ index: i, category: sel });
|
|
784
|
+
}
|
|
785
|
+
if (text === undefined) return null;
|
|
786
|
+
forms.push(text);
|
|
787
|
+
}
|
|
788
|
+
return { forms, copied };
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* The translator comment a catalog entry carries while some of its plural
|
|
793
|
+
* forms only repeat `other`: Poedit, Weblate and msgmerge keep `# ` comments
|
|
794
|
+
* and show them to the reviewer, and `champollion verify` reads it back
|
|
795
|
+
* (poPluralFindings) — in CI too, where there is no translation cache.
|
|
796
|
+
* A sync that re-translates the entry replaces the line.
|
|
797
|
+
*/
|
|
798
|
+
const PO_COPIED_FORMS_MARK = '# champollion:';
|
|
799
|
+
|
|
800
|
+
function copiedFormsComment(copied) {
|
|
801
|
+
const which = copied.map(c => `${c.category} (msgstr[${c.index}])`).join(', ');
|
|
802
|
+
return `${PO_COPIED_FORMS_MARK} plural form(s) ${which} were not supplied by the translation — `
|
|
803
|
+
+ 'they repeat the "other" form; write them, then delete this line';
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Plural findings in a TARGET catalog that the flat map cannot show:
|
|
808
|
+
* - copied: forms marked as repeating `other` (see PO_COPIED_FORMS_MARK)
|
|
809
|
+
* whose text still repeats it (all marked forms identical — and equal
|
|
810
|
+
* to the `other` form when the catalog has one). A reviewer who wrote
|
|
811
|
+
* real forms has fixed it, comment or not.
|
|
812
|
+
* - extraForms: an entry with more msgstr[n] than nplurals (msgfmt
|
|
813
|
+
* rejects it; the reader treats the entry as untranslated).
|
|
814
|
+
*
|
|
815
|
+
* @param {string} text - Catalog content
|
|
816
|
+
* @param {{ locale?: string|null, filePath?: string }} [options]
|
|
817
|
+
* @returns {{ copied: Array<{ key: string, categories: string[] }>,
|
|
818
|
+
* extraForms: Array<{ key: string, forms: number, nplurals: number, categories: string[] }> }}
|
|
819
|
+
*/
|
|
820
|
+
function poPluralFindings(text, { locale = null, filePath = '' } = {}) {
|
|
821
|
+
const parsed = parsePO(text, filePath);
|
|
822
|
+
const lang = locale || headerField(parsed.header, 'Language') || null;
|
|
823
|
+
const info = targetPluralInfo(parsed.header, lang);
|
|
824
|
+
const copied = [];
|
|
825
|
+
const extraForms = [];
|
|
826
|
+
if (!info) return { copied, extraForms };
|
|
827
|
+
for (const e of parsed.entries) {
|
|
828
|
+
if (e.kind !== 'entry' || e.msgidPlural === null || e.flags.includes('fuzzy')) continue;
|
|
829
|
+
const key = entryKey(e);
|
|
830
|
+
if (e.msgstr.length > info.nplurals) {
|
|
831
|
+
extraForms.push({ key, forms: e.msgstr.length, nplurals: info.nplurals, categories: info.selectors.filter(c => c && !c.startsWith('=')) });
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
const mark = e.translatorComments.find(l => l.startsWith(PO_COPIED_FORMS_MARK));
|
|
835
|
+
if (!mark || e.msgstr.length !== info.nplurals) continue;
|
|
836
|
+
const marked = [...mark.matchAll(/(\w+) \(msgstr\[(\d+)\]\)/g)].map(m => ({ category: m[1], index: Number(m[2]) }))
|
|
837
|
+
.filter(m => m.index < e.msgstr.length);
|
|
838
|
+
if (marked.length === 0) continue;
|
|
839
|
+
const texts = marked.map(m => e.msgstr[m.index]);
|
|
840
|
+
const otherIndex = info.selectors.indexOf('other');
|
|
841
|
+
const stillCopied = texts.every(t => t === texts[0])
|
|
842
|
+
&& (otherIndex < 0 || marked.some(m => m.index === otherIndex) || e.msgstr[otherIndex] === texts[0]);
|
|
843
|
+
if (stillCopied) copied.push({ key, categories: marked.map(m => m.category) });
|
|
844
|
+
}
|
|
845
|
+
return { copied, extraForms };
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// -----------------------------------------------------------------
|
|
849
|
+
// Read
|
|
850
|
+
// -----------------------------------------------------------------
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Read a catalog into the flat map (see the module header for the rules)
|
|
854
|
+
* plus per-key translator context for the prompt (msgctxt, `#.` comments).
|
|
855
|
+
*
|
|
856
|
+
* @param {string} text - File content
|
|
857
|
+
* @param {{ role?: 'source'|'target', locale?: string|null, filePath?: string }} [options]
|
|
858
|
+
* @returns {{ flat: object, context: object }}
|
|
859
|
+
*/
|
|
860
|
+
function readPO(text, { role = 'target', locale = null, filePath = '' } = {}) {
|
|
861
|
+
const parsed = parsePO(text, filePath);
|
|
862
|
+
assertUtf8(parsed.header, filePath);
|
|
863
|
+
const lang = locale || headerField(parsed.header, 'Language') || null;
|
|
864
|
+
const flat = {};
|
|
865
|
+
const context = {};
|
|
866
|
+
let info;
|
|
867
|
+
const plural = () => {
|
|
868
|
+
if (info === undefined) info = targetPluralInfo(parsed.header, lang);
|
|
869
|
+
return info;
|
|
870
|
+
};
|
|
871
|
+
|
|
872
|
+
for (const e of parsed.entries) {
|
|
873
|
+
if (e.kind !== 'entry') continue;
|
|
874
|
+
const key = entryKey(e);
|
|
875
|
+
if (Object.prototype.hasOwnProperty.call(flat, key)) {
|
|
876
|
+
console.warn(` [WARN] Duplicate gettext entry ${visibleKey(key)} in ${filePath} — later entry wins.`);
|
|
877
|
+
}
|
|
878
|
+
const fuzzy = e.flags.includes('fuzzy');
|
|
879
|
+
|
|
880
|
+
if (role === 'source') {
|
|
881
|
+
const notes = [];
|
|
882
|
+
if (e.msgctxt) notes.push(`Context (msgctxt): ${e.msgctxt}`);
|
|
883
|
+
if (e.extractedComments.length > 0) notes.push(e.extractedComments.join(' '));
|
|
884
|
+
if (e.msgidPlural === null) {
|
|
885
|
+
flat[key] = (!fuzzy && e.msgstr[0]) ? e.msgstr[0] : e.msgid;
|
|
886
|
+
} else {
|
|
887
|
+
let value = null;
|
|
888
|
+
const forms = e.msgstr;
|
|
889
|
+
const filled = !fuzzy && forms.length > 0 && forms.every(f => f);
|
|
890
|
+
const own = filled ? parsePluralForms(headerField(parsed.header, 'Plural-Forms')) : null;
|
|
891
|
+
if (own && own.nplurals === forms.length) {
|
|
892
|
+
value = formsToICU(pluralIndexSelectors(own.nplurals, own.evaluate, lang || 'en'), forms);
|
|
893
|
+
} else {
|
|
894
|
+
value = formsToICU(['one', 'other'], [e.msgid, e.msgidPlural]);
|
|
895
|
+
}
|
|
896
|
+
if (!icuPluralBranches(value)) {
|
|
897
|
+
console.warn(` [WARN] ${filePath}: plural entry ${visibleKey(key)} has unbalanced braces — it cannot be `
|
|
898
|
+
+ 'written as an ICU plural message and is NOT translated. Translate it by hand.');
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
flat[key] = value;
|
|
902
|
+
notes.push('gettext plural message, written as an ICU plural: give every plural category the target language needs.');
|
|
903
|
+
}
|
|
904
|
+
if (notes.length > 0) context[key] = notes.join(' — ');
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// Target: fuzzy and empty msgstr are untranslated.
|
|
909
|
+
if (fuzzy) continue;
|
|
910
|
+
if (e.msgidPlural === null) {
|
|
911
|
+
if (e.msgstr[0]) flat[key] = e.msgstr[0];
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
const forms = e.msgstr;
|
|
915
|
+
if (forms.length === 0 || forms.some(f => !f)) continue;
|
|
916
|
+
const pi = plural();
|
|
917
|
+
if (!pi) {
|
|
918
|
+
if (!lang) {
|
|
919
|
+
throw new Error(`${filePath}: plural entries need a "Language:" or "Plural-Forms:" header to be read.`);
|
|
920
|
+
}
|
|
921
|
+
throw pluralFormsUnavailable(lang, filePath);
|
|
922
|
+
}
|
|
923
|
+
// A form count that disagrees with Plural-Forms is not a usable
|
|
924
|
+
// translation (msgfmt rejects it) — re-translate it.
|
|
925
|
+
if (forms.length !== pi.nplurals) continue;
|
|
926
|
+
flat[key] = formsToICU(pi.selectors, forms);
|
|
927
|
+
}
|
|
928
|
+
return { flat, context };
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// -----------------------------------------------------------------
|
|
932
|
+
// Write
|
|
933
|
+
// -----------------------------------------------------------------
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Serialize a target catalog.
|
|
937
|
+
*
|
|
938
|
+
* @param {object} options
|
|
939
|
+
* @param {object} options.flat - Target key → value map (sync's edited data)
|
|
940
|
+
* @param {string|null} options.sourceText - The template (source .po/.pot);
|
|
941
|
+
* null = the target is its own template (xliff import, autofix)
|
|
942
|
+
* @param {string|null} options.targetText - Existing target content, or null
|
|
943
|
+
* @param {string} options.locale - Target locale code
|
|
944
|
+
* @param {string} [options.filePath]
|
|
945
|
+
* @param {string|null} [options.projectName] - A NEW catalog's
|
|
946
|
+
* Project-Id-Version when the template carries no real one (see
|
|
947
|
+
* newHeaderFields)
|
|
948
|
+
* @param {Date} [options.now] - A new catalog's PO-Revision-Date
|
|
949
|
+
* @returns {string}
|
|
950
|
+
*/
|
|
951
|
+
function writePO({ flat, sourceText, targetText, locale, filePath = '', projectName = null, now = new Date() }) {
|
|
952
|
+
const tgt = targetText ? parsePO(targetText, filePath) : null;
|
|
953
|
+
const src = sourceText ? parsePO(sourceText, filePath) : tgt;
|
|
954
|
+
if (tgt) assertUtf8(tgt.header, filePath);
|
|
955
|
+
// Callers without a locale (xliff import, autofix) rely on the header.
|
|
956
|
+
locale = locale || headerField(tgt?.header || null, 'Language') || null;
|
|
957
|
+
const eol = (tgt || src)?.eol || '\n';
|
|
958
|
+
const blocks = [];
|
|
959
|
+
|
|
960
|
+
const tgtByKey = new Map();
|
|
961
|
+
for (const e of tgt ? tgt.entries : []) if (e.kind === 'entry') tgtByKey.set(entryKey(e), e);
|
|
962
|
+
const srcEntries = src ? src.entries.filter(e => e.kind === 'entry') : [];
|
|
963
|
+
const hasPlural = srcEntries.some(e => e.msgidPlural !== null)
|
|
964
|
+
|| Object.values(flat).some(v => typeof v === 'string' && v.startsWith(`{${PO_PLURAL_ARG}, plural,`));
|
|
965
|
+
|
|
966
|
+
let info = targetPluralInfo(tgt?.header || null, locale);
|
|
967
|
+
if (!info && hasPlural) throw pluralFormsUnavailable(locale, filePath);
|
|
968
|
+
|
|
969
|
+
// ── Header ──
|
|
970
|
+
const header = tgt?.header || null;
|
|
971
|
+
if (header) {
|
|
972
|
+
const fields = headerFields(header);
|
|
973
|
+
let changed = false;
|
|
974
|
+
const pf = fields.find(([k]) => k.toLowerCase() === 'plural-forms');
|
|
975
|
+
if (info && !info.fromHeader) {
|
|
976
|
+
const value = `nplurals=${info.nplurals}; plural=${info.expression};`;
|
|
977
|
+
if (pf) pf[1] = value; else fields.push(['Plural-Forms', value]);
|
|
978
|
+
changed = true;
|
|
979
|
+
}
|
|
980
|
+
const ct = fields.find(([k]) => k.toLowerCase() === 'content-type');
|
|
981
|
+
if (ct && /charset\s*=\s*CHARSET/i.test(ct[1])) {
|
|
982
|
+
ct[1] = ct[1].replace(/charset\s*=\s*CHARSET/i, 'charset=UTF-8');
|
|
983
|
+
changed = true;
|
|
984
|
+
}
|
|
985
|
+
if (!changed) blocks.push(rawText(tgt, header));
|
|
986
|
+
else {
|
|
987
|
+
const out = [];
|
|
988
|
+
for (let i = header.start; i < (header.fieldLines.msgid?.[0] ?? header.start); i++) out.push(tgt.lines[i]);
|
|
989
|
+
out.push('msgid ""', 'msgstr ""');
|
|
990
|
+
for (const [k, v] of fields) out.push(`"${escapeC(`${k}: ${v}\n`)}"`);
|
|
991
|
+
blocks.push(out.join('\n'));
|
|
992
|
+
}
|
|
993
|
+
} else {
|
|
994
|
+
const fields = newHeaderFields({ srcHeader: src?.header || null, locale, info, projectName, now });
|
|
995
|
+
blocks.push(['msgid ""', 'msgstr ""', ...fields.map(([k, v]) => `"${escapeC(`${k}: ${v}\n`)}"`)].join('\n'));
|
|
996
|
+
}
|
|
997
|
+
if (!info) info = { nplurals: 2, expression: '(n != 1)', selectors: ['one', 'other'], fromHeader: false };
|
|
998
|
+
|
|
999
|
+
// What the reader returns for an existing target entry — "unchanged" is
|
|
1000
|
+
// judged against exactly this.
|
|
1001
|
+
const existingValue = (e) => {
|
|
1002
|
+
if (e.flags.includes('fuzzy')) return undefined;
|
|
1003
|
+
if (e.msgidPlural === null) return e.msgstr[0] || undefined;
|
|
1004
|
+
if (e.msgstr.length !== info.nplurals || e.msgstr.some(f => !f)) return undefined;
|
|
1005
|
+
return formsToICU(info.selectors, e.msgstr);
|
|
1006
|
+
};
|
|
1007
|
+
|
|
1008
|
+
const fieldRaw = (parsed, e, name) => {
|
|
1009
|
+
const range = e.fieldLines[name];
|
|
1010
|
+
return range ? parsed.lines.slice(range[0], range[1] + 1) : null;
|
|
1011
|
+
};
|
|
1012
|
+
|
|
1013
|
+
const render = (s, e, value) => {
|
|
1014
|
+
const out = [];
|
|
1015
|
+
// Our own "repeats other" line is re-decided for the new value below.
|
|
1016
|
+
if (e) out.push(...e.translatorComments.filter(l => !l.startsWith(PO_COPIED_FORMS_MARK)));
|
|
1017
|
+
const mapped = s.msgidPlural !== null && value !== null ? icuToFormsWithCopies(value, info, locale) : null;
|
|
1018
|
+
if (mapped && mapped.copied.length > 0) out.push(copiedFormsComment(mapped.copied));
|
|
1019
|
+
for (const c of s.extractedComments) out.push(`#. ${c}`);
|
|
1020
|
+
out.push(...s.references);
|
|
1021
|
+
const flags = [...new Set([...s.flags, ...(e ? e.flags : [])])]
|
|
1022
|
+
.filter(f => !(f === 'fuzzy' && value !== null));
|
|
1023
|
+
if (flags.length > 0) out.push(`#, ${flags.join(', ')}`);
|
|
1024
|
+
if (value === null && e) out.push(...e.previous);
|
|
1025
|
+
const srcParsed = s === e ? tgt : src;
|
|
1026
|
+
for (const name of ['msgctxt', 'msgid', 'msgid_plural']) {
|
|
1027
|
+
const raw = fieldRaw(srcParsed, s, name);
|
|
1028
|
+
if (raw) out.push(...raw);
|
|
1029
|
+
else if (name === 'msgctxt' && s.msgctxt !== null) out.push(...formatField('msgctxt', s.msgctxt));
|
|
1030
|
+
else if (name === 'msgid') out.push(...formatField('msgid', s.msgid));
|
|
1031
|
+
else if (name === 'msgid_plural' && s.msgidPlural !== null) out.push(...formatField('msgid_plural', s.msgidPlural));
|
|
1032
|
+
}
|
|
1033
|
+
if (s.msgidPlural === null) {
|
|
1034
|
+
out.push(...formatField('msgstr', value ?? ''));
|
|
1035
|
+
} else {
|
|
1036
|
+
const forms = value === null ? new Array(info.nplurals).fill('') : mapped.forms;
|
|
1037
|
+
for (let i = 0; i < info.nplurals; i++) out.push(...formatField(`msgstr[${i}]`, forms[i]));
|
|
1038
|
+
}
|
|
1039
|
+
return out.join('\n');
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
const srcKeys = new Set();
|
|
1043
|
+
for (const s of srcEntries) {
|
|
1044
|
+
const key = entryKey(s);
|
|
1045
|
+
srcKeys.add(key);
|
|
1046
|
+
const e = tgtByKey.get(key) || null;
|
|
1047
|
+
const wanted = flat[key];
|
|
1048
|
+
const have = e ? existingValue(e) : undefined;
|
|
1049
|
+
if (wanted === undefined || wanted === have) {
|
|
1050
|
+
blocks.push(e ? rawText(tgt, e) : render(s, null, null));
|
|
1051
|
+
continue;
|
|
1052
|
+
}
|
|
1053
|
+
if (s.msgidPlural !== null && !icuToForms(wanted, info, locale)) {
|
|
1054
|
+
console.warn(` [WARN] ${filePath}: ${visibleKey(key)} — the translation is not an ICU plural message; left untranslated.`);
|
|
1055
|
+
blocks.push(e ? rawText(tgt, e) : render(s, null, null));
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
blocks.push(render(s, e, wanted));
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// Keys sync added that no template entry carries (xliff import of a new
|
|
1062
|
+
// key): plain singular entries.
|
|
1063
|
+
for (const [key, value] of Object.entries(flat)) {
|
|
1064
|
+
if (srcKeys.has(key) || typeof value !== 'string') continue;
|
|
1065
|
+
if (tgtByKey.has(key) && src !== tgt) continue;
|
|
1066
|
+
const sep = key.indexOf(PO_CONTEXT_SEPARATOR);
|
|
1067
|
+
const out = [];
|
|
1068
|
+
if (sep >= 0) out.push(...formatField('msgctxt', key.slice(0, sep)));
|
|
1069
|
+
out.push(...formatField('msgid', sep >= 0 ? key.slice(sep + 1) : key));
|
|
1070
|
+
out.push(...formatField('msgstr', value));
|
|
1071
|
+
blocks.push(out.join('\n'));
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// Target entries the template no longer has, obsolete entries and loose
|
|
1075
|
+
// comment blocks: kept exactly as they were.
|
|
1076
|
+
if (tgt && src !== tgt) {
|
|
1077
|
+
for (const e of tgt.entries) {
|
|
1078
|
+
if (e.kind === 'header') continue;
|
|
1079
|
+
if (e.kind === 'entry' && srcKeys.has(entryKey(e))) continue;
|
|
1080
|
+
blocks.push(rawText(tgt, e));
|
|
1081
|
+
}
|
|
1082
|
+
} else if (tgt) {
|
|
1083
|
+
for (const e of tgt.entries) {
|
|
1084
|
+
if (e.kind === 'obsolete' || e.kind === 'comment') blocks.push(rawText(tgt, e));
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
return blocks.join('\n\n').split('\n').join(eol) + eol;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/** xgettext's template value for Project-Id-Version (msgfmt -c: "still has the initial default value"). */
|
|
1092
|
+
const PO_TEMPLATE_PROJECT_ID = 'PACKAGE VERSION';
|
|
1093
|
+
|
|
1094
|
+
/** "YYYY-MM-DD HH:MM+ZZZZ" in local time — the stamp gettext's tools write. */
|
|
1095
|
+
function poTimestamp(date) {
|
|
1096
|
+
const pad = (n) => String(Math.trunc(Math.abs(n))).padStart(2, '0');
|
|
1097
|
+
const offset = -date.getTimezoneOffset();
|
|
1098
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} `
|
|
1099
|
+
+ `${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
1100
|
+
+ `${offset < 0 ? '-' : '+'}${pad(offset / 60)}${pad(offset % 60)}`;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* The header of a catalog champollion CREATES (init --langs, or sync for a
|
|
1105
|
+
* locale with no catalog yet). An existing catalog's header is never
|
|
1106
|
+
* rewritten — only a placeholder Plural-Forms/charset in it is filled.
|
|
1107
|
+
*
|
|
1108
|
+
* It used to carry five fields, and `msgfmt -c` warned about four missing
|
|
1109
|
+
* ones (Project-Id-Version, PO-Revision-Date, Last-Translator,
|
|
1110
|
+
* Language-Team; synthetic Django persona, 2026-10). The fields and values
|
|
1111
|
+
* are what `msginit --no-translator` writes — gettext's own non-interactive
|
|
1112
|
+
* way to start a catalog, whose output `msgfmt -c` accepts:
|
|
1113
|
+
* - Project-Id-Version, Report-Msgid-Bugs-To, POT-Creation-Date: copied
|
|
1114
|
+
* from the template (msginit copies them from the .pot). xgettext's
|
|
1115
|
+
* "PACKAGE VERSION" placeholder is replaced, as msginit replaces it,
|
|
1116
|
+
* by the project's name (its folder). No template date → no
|
|
1117
|
+
* POT-Creation-Date: there was no .pot to date.
|
|
1118
|
+
* - PO-Revision-Date: when the file was made.
|
|
1119
|
+
* - Last-Translator "Automatically generated", Language-Team "none": no
|
|
1120
|
+
* person has worked on it (msginit's own values for this case — not a
|
|
1121
|
+
* made-up name, and not FULL NAME <EMAIL@ADDRESS>, which msgfmt -c
|
|
1122
|
+
* reports as an unfilled template).
|
|
1123
|
+
*
|
|
1124
|
+
* @returns {Array<[string, string]>}
|
|
1125
|
+
*/
|
|
1126
|
+
function newHeaderFields({ srcHeader, locale, info, projectName, now }) {
|
|
1127
|
+
const fromSource = (name) => headerField(srcHeader, name);
|
|
1128
|
+
const projectId = fromSource('Project-Id-Version');
|
|
1129
|
+
const fields = [
|
|
1130
|
+
['Project-Id-Version', projectId && projectId !== PO_TEMPLATE_PROJECT_ID
|
|
1131
|
+
? projectId : (projectName || PO_TEMPLATE_PROJECT_ID)],
|
|
1132
|
+
['Report-Msgid-Bugs-To', fromSource('Report-Msgid-Bugs-To') ?? ''],
|
|
1133
|
+
];
|
|
1134
|
+
const potDate = fromSource('POT-Creation-Date');
|
|
1135
|
+
if (potDate) fields.push(['POT-Creation-Date', potDate]);
|
|
1136
|
+
fields.push(
|
|
1137
|
+
['PO-Revision-Date', poTimestamp(now instanceof Date ? now : new Date())],
|
|
1138
|
+
['Last-Translator', 'Automatically generated'],
|
|
1139
|
+
['Language-Team', 'none'],
|
|
1140
|
+
);
|
|
1141
|
+
if (locale) fields.push(['Language', locale]);
|
|
1142
|
+
fields.push(
|
|
1143
|
+
['MIME-Version', '1.0'],
|
|
1144
|
+
['Content-Type', 'text/plain; charset=UTF-8'],
|
|
1145
|
+
['Content-Transfer-Encoding', '8bit'],
|
|
1146
|
+
);
|
|
1147
|
+
if (info) fields.push(['Plural-Forms', `nplurals=${info.nplurals}; plural=${info.expression};`]);
|
|
1148
|
+
return fields;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* The content of a new, empty target catalog: a header only.
|
|
1153
|
+
*
|
|
1154
|
+
* @param {string} locale
|
|
1155
|
+
* @param {{ sourceText?: string|null, projectName?: string|null, now?: Date }} [options] -
|
|
1156
|
+
* sourceText: the template the catalog will mirror (its header fields are
|
|
1157
|
+
* carried over — see newHeaderFields); no entries are copied, sync fills them
|
|
1158
|
+
* @returns {string}
|
|
1159
|
+
*/
|
|
1160
|
+
function emptyPO(locale, { sourceText = null, projectName = null, now = new Date() } = {}) {
|
|
1161
|
+
const header = sourceText ? parsePO(sourceText).header : null;
|
|
1162
|
+
const info = targetPluralInfo(null, locale);
|
|
1163
|
+
const fields = newHeaderFields({ srcHeader: header, locale, info, projectName, now });
|
|
1164
|
+
return ['msgid ""', 'msgstr ""', ...fields.map(([k, v]) => `"${escapeC(`${k}: ${v}\n`)}"`)].join('\n') + '\n';
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
export {
|
|
1168
|
+
PO_CONTEXT_SEPARATOR,
|
|
1169
|
+
PO_PLURAL_ARG,
|
|
1170
|
+
parsePO,
|
|
1171
|
+
readPO,
|
|
1172
|
+
writePO,
|
|
1173
|
+
emptyPO,
|
|
1174
|
+
entryKey,
|
|
1175
|
+
headerField,
|
|
1176
|
+
parsePluralForms,
|
|
1177
|
+
compilePluralExpression,
|
|
1178
|
+
synthesizePluralForms,
|
|
1179
|
+
pluralIndexSelectors,
|
|
1180
|
+
targetPluralInfo,
|
|
1181
|
+
gettextPluralForms,
|
|
1182
|
+
poPluralSlots,
|
|
1183
|
+
poFuzzyKeys,
|
|
1184
|
+
poPluralFindings,
|
|
1185
|
+
icuToFormsWithCopies,
|
|
1186
|
+
PO_COPIED_FORMS_MARK,
|
|
1187
|
+
};
|