@localheroai/cli 0.0.11 → 0.0.13
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/dist/api/client.js +16 -2
- package/dist/api/client.js.map +1 -1
- package/dist/api/translation-jobs.js +28 -0
- package/dist/api/translation-jobs.js.map +1 -0
- package/dist/api/translations.js +4 -2
- package/dist/api/translations.js.map +1 -1
- package/dist/commands/clone.js +13 -0
- package/dist/commands/clone.js.map +1 -1
- package/dist/commands/init.js +41 -21
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/login.js +3 -1
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/translate.js +37 -7
- package/dist/commands/translate.js.map +1 -1
- package/dist/types/index.js.map +1 -1
- package/dist/utils/config.js +10 -4
- package/dist/utils/config.js.map +1 -1
- package/dist/utils/files.js +12 -2
- package/dist/utils/files.js.map +1 -1
- package/dist/utils/github.js +20 -4
- package/dist/utils/github.js.map +1 -1
- package/dist/utils/import-service.js +42 -5
- package/dist/utils/import-service.js.map +1 -1
- package/dist/utils/po-surgical.js +707 -0
- package/dist/utils/po-surgical.js.map +1 -0
- package/dist/utils/po-utils-fallback.js +254 -0
- package/dist/utils/po-utils-fallback.js.map +1 -0
- package/dist/utils/po-utils.js +232 -0
- package/dist/utils/po-utils.js.map +1 -0
- package/dist/utils/sync-service.js +13 -5
- package/dist/utils/sync-service.js.map +1 -1
- package/dist/utils/translation-processor.js +57 -13
- package/dist/utils/translation-processor.js.map +1 -1
- package/dist/utils/translation-updater/index.js +39 -10
- package/dist/utils/translation-updater/index.js.map +1 -1
- package/dist/utils/translation-updater/po-handler.js +91 -0
- package/dist/utils/translation-updater/po-handler.js.map +1 -0
- package/dist/utils/translation-utils.js +35 -3
- package/dist/utils/translation-utils.js.map +1 -1
- package/package.json +3 -1
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
import { po } from 'gettext-parser';
|
|
2
|
+
import { createUniqueKey, normalizeStringValue, parseUniqueKey, parsePoFile, PLURAL_SUFFIX } from './po-utils.js';
|
|
3
|
+
/**
|
|
4
|
+
* Surgical update of .po file - only modify lines that actually changed
|
|
5
|
+
* We create our own to able to better preserve the original formatting
|
|
6
|
+
*/
|
|
7
|
+
export function surgicalUpdatePoFile(originalContent, translations, options) {
|
|
8
|
+
// If no translations to apply, return original content unchanged
|
|
9
|
+
if (Object.keys(translations).length === 0) {
|
|
10
|
+
return originalContent;
|
|
11
|
+
}
|
|
12
|
+
// Quick check, if all translations match normalized content exactly,
|
|
13
|
+
// return original unchanged to preserve formatting
|
|
14
|
+
const parsed = po.parse(originalContent);
|
|
15
|
+
let allIdentical = true;
|
|
16
|
+
Object.entries(parsed.translations).forEach(([context, entries]) => {
|
|
17
|
+
if (typeof entries === 'object' && entries !== null) {
|
|
18
|
+
Object.entries(entries).forEach(([msgid, entry]) => {
|
|
19
|
+
if (msgid === '')
|
|
20
|
+
return; // Skip header
|
|
21
|
+
const contextValue = context !== '' ? context : undefined;
|
|
22
|
+
const uniqueKey = createUniqueKey(msgid, contextValue);
|
|
23
|
+
if (translations[uniqueKey]) {
|
|
24
|
+
const newValue = translations[uniqueKey];
|
|
25
|
+
const currentValue = entry.msgid_plural
|
|
26
|
+
? normalizeStringValue(entry.msgstr[0] || '')
|
|
27
|
+
: normalizeStringValue(entry.msgstr);
|
|
28
|
+
const normalizedNewValue = normalizeStringValue(newValue);
|
|
29
|
+
if (currentValue !== normalizedNewValue) {
|
|
30
|
+
allIdentical = false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (entry.msgid_plural) {
|
|
34
|
+
const pluralKey = createUniqueKey(entry.msgid_plural, contextValue);
|
|
35
|
+
const pluralSuffixKey = uniqueKey + PLURAL_SUFFIX;
|
|
36
|
+
// Check for __plural_1 suffix key first, then fallback to msgid_plural key
|
|
37
|
+
let newPluralValue = '';
|
|
38
|
+
if (translations[pluralSuffixKey]) {
|
|
39
|
+
newPluralValue = translations[pluralSuffixKey];
|
|
40
|
+
}
|
|
41
|
+
else if (translations[pluralKey] && entry.msgid_plural !== msgid) {
|
|
42
|
+
newPluralValue = translations[pluralKey];
|
|
43
|
+
}
|
|
44
|
+
if (newPluralValue) {
|
|
45
|
+
const currentPluralValue = normalizeStringValue(entry.msgstr[1] || '');
|
|
46
|
+
const normalizedNewPluralValue = normalizeStringValue(newPluralValue);
|
|
47
|
+
if (currentPluralValue !== normalizedNewPluralValue) {
|
|
48
|
+
allIdentical = false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
// Check if we have new entries to add, even if existing entries are identical
|
|
56
|
+
const parsedForNewEntries = po.parse(originalContent);
|
|
57
|
+
const hasNewEntries = hasNewEntriesToAdd(translations, parsedForNewEntries, options);
|
|
58
|
+
if (allIdentical && !hasNewEntries) {
|
|
59
|
+
return originalContent;
|
|
60
|
+
}
|
|
61
|
+
return processLineByLine(originalContent, translations, options);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Group plural translations together
|
|
65
|
+
*/
|
|
66
|
+
function groupPluralTranslations(translations) {
|
|
67
|
+
const regular = {};
|
|
68
|
+
const pluralGroups = new Map();
|
|
69
|
+
// First pass: identify plural pairs
|
|
70
|
+
Object.keys(translations).forEach(key => {
|
|
71
|
+
if (key.endsWith(PLURAL_SUFFIX)) {
|
|
72
|
+
const baseKey = key.replace(new RegExp(PLURAL_SUFFIX + '$'), '');
|
|
73
|
+
if (translations[baseKey]) {
|
|
74
|
+
// This is a plural pair
|
|
75
|
+
pluralGroups.set(baseKey, {
|
|
76
|
+
singular: translations[baseKey],
|
|
77
|
+
plural: translations[key]
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
// Second pass: add regular keys (excluding those in plural pairs)
|
|
83
|
+
Object.entries(translations).forEach(([key, value]) => {
|
|
84
|
+
if (!key.endsWith(PLURAL_SUFFIX) && !pluralGroups.has(key)) {
|
|
85
|
+
regular[key] = value;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
return { regular, pluralGroups };
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Check if a key exists in the parsed .po file
|
|
92
|
+
*/
|
|
93
|
+
function keyExistsInParsed(uniqueKey, parsed) {
|
|
94
|
+
const { msgid, context } = parseUniqueKey(uniqueKey);
|
|
95
|
+
const contextKey = context || '';
|
|
96
|
+
return parsed.translations[contextKey] && parsed.translations[contextKey][msgid];
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Check if a key is already used as msgid_plural in an existing entry
|
|
100
|
+
*/
|
|
101
|
+
function isUsedAsPluralForm(uniqueKey, parsed) {
|
|
102
|
+
const { msgid } = parseUniqueKey(uniqueKey);
|
|
103
|
+
// Check all contexts for entries that use this msgid as msgid_plural
|
|
104
|
+
for (const [, entries] of Object.entries(parsed.translations)) {
|
|
105
|
+
if (typeof entries === 'object' && entries !== null) {
|
|
106
|
+
for (const [entryMsgid, entry] of Object.entries(entries)) {
|
|
107
|
+
if (entryMsgid !== '' && entry.msgid_plural === msgid) {
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Check if there are new entries to add that don't exist in the original file
|
|
117
|
+
*/
|
|
118
|
+
function hasNewEntriesToAdd(translations, parsed, options) {
|
|
119
|
+
const { regular, pluralGroups } = groupPluralTranslations(translations);
|
|
120
|
+
// Check for new regular entries
|
|
121
|
+
for (const [uniqueKey, value] of Object.entries(regular)) {
|
|
122
|
+
if (!keyExistsInParsed(uniqueKey, parsed)) {
|
|
123
|
+
const { msgid } = parseUniqueKey(uniqueKey);
|
|
124
|
+
// Skip malformed keys
|
|
125
|
+
if (!msgid || msgid.trim() === '') {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
// Skip adding new entries where msgid === msgstr (source language files only)
|
|
129
|
+
if (value === msgid && options?.sourceLanguage === options?.targetLanguage) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
// Skip if this msgid is already used as msgid_plural in an existing entry
|
|
133
|
+
if (isUsedAsPluralForm(uniqueKey, parsed)) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
return true; // Found a new entry to add
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// Check for new plural entries
|
|
140
|
+
for (const [uniqueKey] of pluralGroups) {
|
|
141
|
+
if (!keyExistsInParsed(uniqueKey, parsed)) {
|
|
142
|
+
const { msgid } = parseUniqueKey(uniqueKey);
|
|
143
|
+
// Skip malformed keys
|
|
144
|
+
if (!msgid || msgid.trim() === '') {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
return true; // Found a new plural entry to add
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return false; // No new entries to add
|
|
151
|
+
}
|
|
152
|
+
var State;
|
|
153
|
+
(function (State) {
|
|
154
|
+
State[State["IDLE"] = 0] = "IDLE";
|
|
155
|
+
State[State["IN_MSGCTXT"] = 1] = "IN_MSGCTXT";
|
|
156
|
+
State[State["IN_MSGID"] = 2] = "IN_MSGID";
|
|
157
|
+
State[State["IN_MSGID_PLURAL"] = 3] = "IN_MSGID_PLURAL";
|
|
158
|
+
State[State["IN_MSGSTR"] = 4] = "IN_MSGSTR";
|
|
159
|
+
State[State["IN_MSGSTR_PLURAL"] = 5] = "IN_MSGSTR_PLURAL";
|
|
160
|
+
})(State || (State = {}));
|
|
161
|
+
/**
|
|
162
|
+
* Process .po file line by line to make surgical updates
|
|
163
|
+
*/
|
|
164
|
+
function processLineByLine(content, translations, options) {
|
|
165
|
+
const lines = content.split('\n');
|
|
166
|
+
const result = [];
|
|
167
|
+
const parsed = po.parse(content);
|
|
168
|
+
const changesToMake = new Map();
|
|
169
|
+
Object.entries(parsed.translations).forEach(([context, entries]) => {
|
|
170
|
+
if (typeof entries === 'object' && entries !== null) {
|
|
171
|
+
Object.entries(entries).forEach(([msgid, entry]) => {
|
|
172
|
+
if (msgid === '')
|
|
173
|
+
return; // Skip header
|
|
174
|
+
const contextValue = context !== '' ? context : undefined;
|
|
175
|
+
const uniqueKey = createUniqueKey(msgid, contextValue);
|
|
176
|
+
if (translations[uniqueKey]) {
|
|
177
|
+
const newValue = translations[uniqueKey];
|
|
178
|
+
// Skip updating if this is a source language entry (msgid === msgstr)
|
|
179
|
+
if (newValue === msgid && options?.sourceLanguage === options?.targetLanguage) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
// For plural forms, only compare the singular form (msgstr[0])
|
|
183
|
+
const currentValue = entry.msgid_plural
|
|
184
|
+
? normalizeStringValue(entry.msgstr[0] || '')
|
|
185
|
+
: normalizeStringValue(entry.msgstr);
|
|
186
|
+
const normalizedNewValue = normalizeStringValue(newValue);
|
|
187
|
+
if (currentValue !== normalizedNewValue) {
|
|
188
|
+
changesToMake.set(uniqueKey, newValue);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// Handle plural forms
|
|
192
|
+
if (entry.msgid_plural) {
|
|
193
|
+
const pluralKey = createUniqueKey(entry.msgid_plural, contextValue);
|
|
194
|
+
const pluralSuffixKey = uniqueKey + PLURAL_SUFFIX;
|
|
195
|
+
let pluralTranslationKey = '';
|
|
196
|
+
let newPluralValue = '';
|
|
197
|
+
if (translations[pluralSuffixKey]) {
|
|
198
|
+
pluralTranslationKey = pluralSuffixKey;
|
|
199
|
+
newPluralValue = translations[pluralSuffixKey];
|
|
200
|
+
}
|
|
201
|
+
else if (translations[pluralKey] && entry.msgid_plural !== msgid) {
|
|
202
|
+
pluralTranslationKey = pluralKey;
|
|
203
|
+
newPluralValue = translations[pluralKey];
|
|
204
|
+
}
|
|
205
|
+
if (pluralTranslationKey && newPluralValue) {
|
|
206
|
+
const currentPluralValue = normalizeStringValue(entry.msgstr[1] || '');
|
|
207
|
+
const normalizedNewPluralValue = normalizeStringValue(newPluralValue);
|
|
208
|
+
if (currentPluralValue !== normalizedNewPluralValue) {
|
|
209
|
+
changesToMake.set(pluralTranslationKey, newPluralValue);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
if (changesToMake.size === 0) {
|
|
217
|
+
// Still need to add new entries even if no existing entries need changes
|
|
218
|
+
const newEntries = addNewEntries(translations, parsed, changesToMake, options);
|
|
219
|
+
if (newEntries.length > 0) {
|
|
220
|
+
const lines = content.split('\n');
|
|
221
|
+
lines.push('');
|
|
222
|
+
lines.push(...newEntries);
|
|
223
|
+
return lines.join('\n');
|
|
224
|
+
}
|
|
225
|
+
return content;
|
|
226
|
+
}
|
|
227
|
+
let currentEntry = {
|
|
228
|
+
currentState: State.IDLE,
|
|
229
|
+
multilineBuffer: [],
|
|
230
|
+
entryStartLine: 0
|
|
231
|
+
};
|
|
232
|
+
let i = 0;
|
|
233
|
+
while (i < lines.length) {
|
|
234
|
+
const line = lines[i];
|
|
235
|
+
const trimmedLine = line.trim();
|
|
236
|
+
// Handle comments - they can appear within entries, so don't reset state
|
|
237
|
+
if (trimmedLine.startsWith('#')) {
|
|
238
|
+
result.push(line);
|
|
239
|
+
i++;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
// Handle empty lines - they separate entries, so reset state
|
|
243
|
+
if (trimmedLine === '') {
|
|
244
|
+
currentEntry = { currentState: State.IDLE, multilineBuffer: [], entryStartLine: i + 1 };
|
|
245
|
+
result.push(line);
|
|
246
|
+
i++;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
// Handle multiline context
|
|
250
|
+
if (trimmedLine.startsWith('msgctxt ')) {
|
|
251
|
+
// Start new context entry
|
|
252
|
+
currentEntry = {
|
|
253
|
+
msgctxt: extractMultilineValue(lines, i)[0],
|
|
254
|
+
currentState: State.IN_MSGCTXT,
|
|
255
|
+
multilineBuffer: [],
|
|
256
|
+
entryStartLine: i
|
|
257
|
+
};
|
|
258
|
+
const { value, nextIndex } = extractMultilineValue(lines, i);
|
|
259
|
+
currentEntry.msgctxt = unescapePoString(value);
|
|
260
|
+
// Add all lines for this msgctxt
|
|
261
|
+
for (let j = i; j < nextIndex; j++) {
|
|
262
|
+
result.push(lines[j]);
|
|
263
|
+
}
|
|
264
|
+
i = nextIndex;
|
|
265
|
+
currentEntry.currentState = State.IDLE;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
// Handle msgid
|
|
269
|
+
if (trimmedLine.startsWith('msgid ')) {
|
|
270
|
+
// Reset msgctxt if this msgid is not preceded by a msgctxt
|
|
271
|
+
let hasContext = false;
|
|
272
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
273
|
+
const prevLine = lines[j].trim();
|
|
274
|
+
if (prevLine === '')
|
|
275
|
+
continue;
|
|
276
|
+
if (prevLine.startsWith('#'))
|
|
277
|
+
continue;
|
|
278
|
+
if (prevLine.startsWith('msgctxt ') || prevLine.startsWith('"')) {
|
|
279
|
+
hasContext = true;
|
|
280
|
+
}
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
if (!hasContext) {
|
|
284
|
+
currentEntry.msgctxt = undefined;
|
|
285
|
+
}
|
|
286
|
+
const { value, nextIndex } = extractMultilineValue(lines, i);
|
|
287
|
+
currentEntry.msgid = unescapePoString(value);
|
|
288
|
+
currentEntry.currentState = State.IN_MSGID;
|
|
289
|
+
// Add all lines for this msgid
|
|
290
|
+
for (let j = i; j < nextIndex; j++) {
|
|
291
|
+
result.push(lines[j]);
|
|
292
|
+
}
|
|
293
|
+
i = nextIndex;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
// Handle msgid_plural
|
|
297
|
+
if (trimmedLine.startsWith('msgid_plural ')) {
|
|
298
|
+
const { value, nextIndex } = extractMultilineValue(lines, i);
|
|
299
|
+
currentEntry.msgid_plural = unescapePoString(value);
|
|
300
|
+
currentEntry.currentState = State.IN_MSGID_PLURAL;
|
|
301
|
+
// Add all lines for this msgid_plural
|
|
302
|
+
for (let j = i; j < nextIndex; j++) {
|
|
303
|
+
result.push(lines[j]);
|
|
304
|
+
}
|
|
305
|
+
i = nextIndex;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
// Handle msgstr
|
|
309
|
+
if (trimmedLine.startsWith('msgstr ')) {
|
|
310
|
+
currentEntry.currentState = State.IN_MSGSTR;
|
|
311
|
+
const uniqueKey = createUniqueKey(currentEntry.msgid, currentEntry.msgctxt);
|
|
312
|
+
if (changesToMake.has(uniqueKey)) {
|
|
313
|
+
const newValue = changesToMake.get(uniqueKey);
|
|
314
|
+
const originalFormat = detectMsgstrFormat(lines, i);
|
|
315
|
+
const formattedLines = formatMsgstrValue(newValue, originalFormat, 'msgstr');
|
|
316
|
+
formattedLines.forEach(line => result.push(line));
|
|
317
|
+
// Skip original msgstr lines
|
|
318
|
+
const { nextIndex } = extractMultilineValue(lines, i);
|
|
319
|
+
i = nextIndex;
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
// Keep original - add all lines for this msgstr
|
|
323
|
+
const { nextIndex } = extractMultilineValue(lines, i);
|
|
324
|
+
for (let j = i; j < nextIndex; j++) {
|
|
325
|
+
result.push(lines[j]);
|
|
326
|
+
}
|
|
327
|
+
i = nextIndex;
|
|
328
|
+
}
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
// Handle msgstr[n]
|
|
332
|
+
const pluralMatch = trimmedLine.match(/^msgstr\[(\d+)\]\s/);
|
|
333
|
+
if (pluralMatch) {
|
|
334
|
+
const pluralIndex = parseInt(pluralMatch[1]);
|
|
335
|
+
currentEntry.pluralIndex = pluralIndex;
|
|
336
|
+
currentEntry.currentState = State.IN_MSGSTR_PLURAL;
|
|
337
|
+
let keyToCheck;
|
|
338
|
+
if (pluralIndex === 0) {
|
|
339
|
+
keyToCheck = createUniqueKey(currentEntry.msgid, currentEntry.msgctxt);
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
// For plural forms, check for the __plural_1 suffix key first
|
|
343
|
+
const baseKey = createUniqueKey(currentEntry.msgid, currentEntry.msgctxt);
|
|
344
|
+
const pluralSuffixKey = baseKey + PLURAL_SUFFIX;
|
|
345
|
+
// Try the __plural_1 key first, fall back to msgid_plural key
|
|
346
|
+
if (changesToMake.has(pluralSuffixKey)) {
|
|
347
|
+
keyToCheck = pluralSuffixKey;
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
keyToCheck = createUniqueKey(currentEntry.msgid_plural, currentEntry.msgctxt);
|
|
351
|
+
// For msgstr[1], only update if the msgid_plural is different from msgid
|
|
352
|
+
// Otherwise msgstr[1] would get the same translation as msgstr[0]
|
|
353
|
+
if (currentEntry.msgid_plural === currentEntry.msgid) {
|
|
354
|
+
keyToCheck = ''; // Use empty key to prevent match
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (keyToCheck && changesToMake.has(keyToCheck)) {
|
|
359
|
+
const newValue = changesToMake.get(keyToCheck);
|
|
360
|
+
const originalFormat = detectMsgstrFormat(lines, i);
|
|
361
|
+
const formattedLines = formatMsgstrValue(newValue, originalFormat, `msgstr[${pluralIndex}]`);
|
|
362
|
+
formattedLines.forEach(line => result.push(line));
|
|
363
|
+
// Skip original msgstr[n] lines
|
|
364
|
+
const { nextIndex } = extractMultilineValue(lines, i);
|
|
365
|
+
i = nextIndex;
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
// Keep original - add all lines for this msgstr[n]
|
|
369
|
+
const { nextIndex } = extractMultilineValue(lines, i);
|
|
370
|
+
for (let j = i; j < nextIndex; j++) {
|
|
371
|
+
result.push(lines[j]);
|
|
372
|
+
}
|
|
373
|
+
i = nextIndex;
|
|
374
|
+
}
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
// Handle continuation lines (lines starting with ")
|
|
378
|
+
if (trimmedLine.startsWith('"') && currentEntry.currentState !== State.IDLE) {
|
|
379
|
+
result.push(line);
|
|
380
|
+
i++;
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
// Default: pass through any other lines
|
|
384
|
+
result.push(line);
|
|
385
|
+
i++;
|
|
386
|
+
}
|
|
387
|
+
// Add new entries that don't exist in the original file
|
|
388
|
+
const newEntries = addNewEntries(translations, parsed, changesToMake, options);
|
|
389
|
+
if (newEntries.length > 0) {
|
|
390
|
+
// Add new entries at the end
|
|
391
|
+
result.push('');
|
|
392
|
+
result.push(...newEntries);
|
|
393
|
+
}
|
|
394
|
+
return result.join('\n');
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Add new translation entries that don't exist in the original file
|
|
398
|
+
*/
|
|
399
|
+
function addNewEntries(translations, parsed, existingChanges, options) {
|
|
400
|
+
const result = [];
|
|
401
|
+
const updatedTranslations = new Set();
|
|
402
|
+
// Track which translations were already handled as updates
|
|
403
|
+
existingChanges.forEach((_, key) => {
|
|
404
|
+
updatedTranslations.add(key);
|
|
405
|
+
});
|
|
406
|
+
// Group translations to handle plural forms
|
|
407
|
+
const { regular, pluralGroups } = groupPluralTranslations(translations);
|
|
408
|
+
// Add regular (non-plural) entries
|
|
409
|
+
Object.entries(regular).forEach(([uniqueKey, value]) => {
|
|
410
|
+
if (!updatedTranslations.has(uniqueKey) && !keyExistsInParsed(uniqueKey, parsed)) {
|
|
411
|
+
const { msgid, context } = parseUniqueKey(uniqueKey);
|
|
412
|
+
// Skip malformed keys
|
|
413
|
+
if (!msgid || msgid.trim() === '') {
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
// Skip adding new entries where msgid === msgstr (source language files only)
|
|
417
|
+
if (value === msgid && options?.sourceLanguage === options?.targetLanguage) {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
// Skip if this msgid is already used as msgid_plural in an existing entry
|
|
421
|
+
if (isUsedAsPluralForm(uniqueKey, parsed)) {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
result.push(...createNewEntry(msgid, value, context));
|
|
425
|
+
updatedTranslations.add(uniqueKey);
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
// Add plural entries
|
|
429
|
+
pluralGroups.forEach((pluralData, uniqueKey) => {
|
|
430
|
+
if (!updatedTranslations.has(uniqueKey) && !keyExistsInParsed(uniqueKey, parsed)) {
|
|
431
|
+
const { msgid, context } = parseUniqueKey(uniqueKey);
|
|
432
|
+
// Skip malformed keys
|
|
433
|
+
if (!msgid || msgid.trim() === '') {
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
const pluralKey = uniqueKey + PLURAL_SUFFIX;
|
|
437
|
+
// Try to find the proper msgid_plural from the source file if available
|
|
438
|
+
let msgid_plural = generateEnglishPlural(msgid); // fallback to simple pluralization
|
|
439
|
+
if (options?.sourceContent) {
|
|
440
|
+
try {
|
|
441
|
+
const sourceParsed = parsePoFile(options.sourceContent);
|
|
442
|
+
const sourceEntries = sourceParsed.entries;
|
|
443
|
+
// Find the matching source entry with msgid_plural
|
|
444
|
+
const sourceEntry = sourceEntries.find(entry => {
|
|
445
|
+
const sourceKey = createUniqueKey(entry.msgid, entry.msgctxt);
|
|
446
|
+
return sourceKey === uniqueKey && entry.msgid_plural;
|
|
447
|
+
});
|
|
448
|
+
if (sourceEntry && sourceEntry.msgid_plural) {
|
|
449
|
+
msgid_plural = sourceEntry.msgid_plural;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
catch (error) {
|
|
453
|
+
// If source parsing fails, continue with fallback
|
|
454
|
+
console.warn('Failed to parse source content for msgid_plural lookup', error);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
result.push(...createNewPluralEntry(msgid, msgid_plural, pluralData.singular, pluralData.plural, context));
|
|
458
|
+
// Mark both keys as updated
|
|
459
|
+
updatedTranslations.add(uniqueKey);
|
|
460
|
+
updatedTranslations.add(pluralKey);
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
return result;
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Generate simple English plural form
|
|
467
|
+
* This is a basic implementation for common cases
|
|
468
|
+
*/
|
|
469
|
+
function generateEnglishPlural(singular) {
|
|
470
|
+
if (!singular || singular.trim() === '') {
|
|
471
|
+
return singular;
|
|
472
|
+
}
|
|
473
|
+
const text = singular.trim();
|
|
474
|
+
// Handle strings with placeholders - find the last word that's likely a noun
|
|
475
|
+
const words = text.split(/\s+/);
|
|
476
|
+
let targetWordIndex = -1;
|
|
477
|
+
let targetWord = '';
|
|
478
|
+
// Look for the last word that could be a noun (not a placeholder, not a number)
|
|
479
|
+
for (let i = words.length - 1; i >= 0; i--) {
|
|
480
|
+
const word = words[i];
|
|
481
|
+
// Skip placeholders like %(count)s, %d, {count}, etc.
|
|
482
|
+
if (!word.match(/^[%{].*[}%sd]$/) && !word.match(/^\d+$/)) {
|
|
483
|
+
targetWordIndex = i;
|
|
484
|
+
targetWord = word;
|
|
485
|
+
break;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
if (targetWordIndex === -1 || !targetWord) {
|
|
489
|
+
// No suitable word found, return original
|
|
490
|
+
return text;
|
|
491
|
+
}
|
|
492
|
+
// Pluralize the target word
|
|
493
|
+
const pluralWord = pluralizeWord(targetWord);
|
|
494
|
+
// Reconstruct the full string with the pluralized word
|
|
495
|
+
const newWords = [...words];
|
|
496
|
+
newWords[targetWordIndex] = pluralWord;
|
|
497
|
+
return newWords.join(' ');
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Pluralize a single English word
|
|
501
|
+
*/
|
|
502
|
+
function pluralizeWord(word) {
|
|
503
|
+
if (!word)
|
|
504
|
+
return word;
|
|
505
|
+
// Handle common irregular plurals
|
|
506
|
+
const irregulars = {
|
|
507
|
+
'child': 'children',
|
|
508
|
+
'person': 'people',
|
|
509
|
+
'man': 'men',
|
|
510
|
+
'woman': 'women',
|
|
511
|
+
'tooth': 'teeth',
|
|
512
|
+
'foot': 'feet',
|
|
513
|
+
'mouse': 'mice',
|
|
514
|
+
'goose': 'geese'
|
|
515
|
+
};
|
|
516
|
+
// Check for irregular plurals (case insensitive, but preserve original case)
|
|
517
|
+
const lowerWord = word.toLowerCase();
|
|
518
|
+
if (irregulars[lowerWord]) {
|
|
519
|
+
// Preserve the case pattern of the original word
|
|
520
|
+
const irregular = irregulars[lowerWord];
|
|
521
|
+
if (word === word.toUpperCase()) {
|
|
522
|
+
return irregular.toUpperCase();
|
|
523
|
+
}
|
|
524
|
+
else if (word[0] === word[0].toUpperCase()) {
|
|
525
|
+
return irregular.charAt(0).toUpperCase() + irregular.slice(1);
|
|
526
|
+
}
|
|
527
|
+
return irregular;
|
|
528
|
+
}
|
|
529
|
+
// Handle words ending in consonant + y -> ies
|
|
530
|
+
if (word.length > 1 && word.endsWith('y') && !'aeiou'.includes(word[word.length - 2].toLowerCase())) {
|
|
531
|
+
return word.slice(0, -1) + 'ies';
|
|
532
|
+
}
|
|
533
|
+
// Handle words ending in s, ss, sh, ch, x, z -> es
|
|
534
|
+
if (word.match(/[sxz]$/) || word.match(/(sh|ch)$/)) {
|
|
535
|
+
return word + 'es';
|
|
536
|
+
}
|
|
537
|
+
// Handle words ending in f or fe -> ves
|
|
538
|
+
if (word.endsWith('f')) {
|
|
539
|
+
return word.slice(0, -1) + 'ves';
|
|
540
|
+
}
|
|
541
|
+
if (word.endsWith('fe')) {
|
|
542
|
+
return word.slice(0, -2) + 'ves';
|
|
543
|
+
}
|
|
544
|
+
// Default: add 's'
|
|
545
|
+
return word + 's';
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Create a new regular translation entry
|
|
549
|
+
*/
|
|
550
|
+
function createNewEntry(msgid, msgstr, context) {
|
|
551
|
+
const result = [];
|
|
552
|
+
if (context) {
|
|
553
|
+
result.push(`msgctxt "${escapePoString(context)}"`);
|
|
554
|
+
}
|
|
555
|
+
result.push(`msgid "${escapePoString(msgid)}"`);
|
|
556
|
+
result.push(`msgstr "${escapePoString(msgstr)}"`);
|
|
557
|
+
return result;
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Create a new plural translation entry
|
|
561
|
+
*/
|
|
562
|
+
function createNewPluralEntry(msgid, msgid_plural, singular, plural, context) {
|
|
563
|
+
const result = [];
|
|
564
|
+
if (context) {
|
|
565
|
+
result.push(`msgctxt "${escapePoString(context)}"`);
|
|
566
|
+
}
|
|
567
|
+
result.push(`msgid "${escapePoString(msgid)}"`);
|
|
568
|
+
result.push(`msgid_plural "${escapePoString(msgid_plural)}"`);
|
|
569
|
+
result.push(`msgstr[0] "${escapePoString(singular)}"`);
|
|
570
|
+
result.push(`msgstr[1] "${escapePoString(plural)}"`);
|
|
571
|
+
return result;
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Extract multiline value starting from a msgctxt, msgid, or msgstr line
|
|
575
|
+
*/
|
|
576
|
+
function extractMultilineValue(lines, startIndex) {
|
|
577
|
+
let value = '';
|
|
578
|
+
let i = startIndex;
|
|
579
|
+
const firstLine = lines[i];
|
|
580
|
+
// Extract initial quoted value
|
|
581
|
+
const match = firstLine.match(/^\s*msg\w+(?:\[\d+\])?\s+"(.*)"\s*$/) ||
|
|
582
|
+
firstLine.match(/^\s*msgctxt\s+"(.*)"\s*$/);
|
|
583
|
+
if (match) {
|
|
584
|
+
value = match[1];
|
|
585
|
+
}
|
|
586
|
+
i++;
|
|
587
|
+
// Look for continuation lines
|
|
588
|
+
while (i < lines.length && lines[i].trim().startsWith('"')) {
|
|
589
|
+
const continuationMatch = lines[i].match(/^\s*"(.*)"\s*$/);
|
|
590
|
+
if (continuationMatch) {
|
|
591
|
+
value += continuationMatch[1];
|
|
592
|
+
}
|
|
593
|
+
i++;
|
|
594
|
+
}
|
|
595
|
+
return { value, nextIndex: i };
|
|
596
|
+
}
|
|
597
|
+
function escapePoString(str) {
|
|
598
|
+
return str
|
|
599
|
+
.replace(/\\/g, '\\\\')
|
|
600
|
+
.replace(/"/g, '\\"')
|
|
601
|
+
.replace(/\n/g, '\\n')
|
|
602
|
+
.replace(/\r/g, '\\r')
|
|
603
|
+
.replace(/\t/g, '\\t');
|
|
604
|
+
}
|
|
605
|
+
function unescapePoString(str) {
|
|
606
|
+
return str
|
|
607
|
+
.replace(/\\"/g, '"')
|
|
608
|
+
.replace(/\\n/g, '\n')
|
|
609
|
+
.replace(/\\r/g, '\r')
|
|
610
|
+
.replace(/\\t/g, '\t')
|
|
611
|
+
.replace(/\\\\/g, '\\');
|
|
612
|
+
}
|
|
613
|
+
function detectMsgstrFormat(lines, startIndex) {
|
|
614
|
+
const format = {
|
|
615
|
+
isMultiline: false,
|
|
616
|
+
hasEmptyFirstLine: false,
|
|
617
|
+
maxLineLength: 80,
|
|
618
|
+
indentation: ''
|
|
619
|
+
};
|
|
620
|
+
let i = startIndex;
|
|
621
|
+
const msgstrLines = [];
|
|
622
|
+
while (i < lines.length) {
|
|
623
|
+
const line = lines[i];
|
|
624
|
+
const trimmed = line.trim();
|
|
625
|
+
// Stop if we hit the next entry or empty line
|
|
626
|
+
if (trimmed === '' || (!trimmed.startsWith('"') && !trimmed.startsWith('msgstr'))) {
|
|
627
|
+
break;
|
|
628
|
+
}
|
|
629
|
+
msgstrLines.push(line);
|
|
630
|
+
i++;
|
|
631
|
+
}
|
|
632
|
+
if (msgstrLines.length === 0) {
|
|
633
|
+
return format;
|
|
634
|
+
}
|
|
635
|
+
// Analyze the first line to get indentation
|
|
636
|
+
const firstLine = msgstrLines[0];
|
|
637
|
+
const match = firstLine.match(/^(\s*)msgstr(?:\[\d+\])?\s+"(.*)"\s*$/);
|
|
638
|
+
if (match) {
|
|
639
|
+
format.indentation = match[1];
|
|
640
|
+
const firstContent = match[2];
|
|
641
|
+
// Check if it's multiline (has continuation lines)
|
|
642
|
+
format.isMultiline = msgstrLines.length > 1;
|
|
643
|
+
// If multiline, check if first line is empty
|
|
644
|
+
if (format.isMultiline) {
|
|
645
|
+
format.hasEmptyFirstLine = firstContent === '';
|
|
646
|
+
// Calculate max line length from continuation lines
|
|
647
|
+
const continuationLines = msgstrLines.slice(1);
|
|
648
|
+
format.maxLineLength = Math.max(...continuationLines.map(line => line.length), 80 // minimum default
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
else {
|
|
652
|
+
format.hasEmptyFirstLine = false;
|
|
653
|
+
format.maxLineLength = firstLine.length;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return format;
|
|
657
|
+
}
|
|
658
|
+
function formatMsgstrValue(value, format, msgstrPrefix) {
|
|
659
|
+
const lines = [];
|
|
660
|
+
// Preserve single-line format if original was single-line and new value is reasonable length
|
|
661
|
+
if (!format.isMultiline && value.length <= 120 && !value.includes('\n')) {
|
|
662
|
+
lines.push(`${format.indentation}${msgstrPrefix} "${escapePoString(value)}"`);
|
|
663
|
+
return lines;
|
|
664
|
+
}
|
|
665
|
+
// Use multiline format (original was multiline)
|
|
666
|
+
if (format.hasEmptyFirstLine) {
|
|
667
|
+
lines.push(`${format.indentation}${msgstrPrefix} ""`);
|
|
668
|
+
}
|
|
669
|
+
else {
|
|
670
|
+
lines.push(`${format.indentation}${msgstrPrefix} ""`);
|
|
671
|
+
}
|
|
672
|
+
// For multiline, try to preserve original line breaking patterns when possible
|
|
673
|
+
// Split by actual newlines first
|
|
674
|
+
const contentLines = value.split('\n');
|
|
675
|
+
contentLines.forEach((contentLine, index) => {
|
|
676
|
+
const isLastLine = index === contentLines.length - 1;
|
|
677
|
+
const lineContent = escapePoString(contentLine);
|
|
678
|
+
const suffix = isLastLine ? '' : '\\n';
|
|
679
|
+
// If line is reasonably short, add as single continuation line
|
|
680
|
+
if ((lineContent + suffix).length <= format.maxLineLength) {
|
|
681
|
+
lines.push(`${format.indentation}"${lineContent}${suffix}"`);
|
|
682
|
+
}
|
|
683
|
+
else {
|
|
684
|
+
// Break long line into chunks at word boundaries
|
|
685
|
+
const maxContentLength = Math.max(60, format.maxLineLength - 10); // Leave room for quotes and suffix
|
|
686
|
+
const words = lineContent.split(' ');
|
|
687
|
+
let currentChunk = '';
|
|
688
|
+
for (let i = 0; i < words.length; i++) {
|
|
689
|
+
const word = words[i];
|
|
690
|
+
const spaceIfNeeded = currentChunk ? ' ' : '';
|
|
691
|
+
const wouldBeLength = currentChunk.length + spaceIfNeeded.length + word.length;
|
|
692
|
+
if (wouldBeLength <= maxContentLength || !currentChunk) {
|
|
693
|
+
currentChunk += spaceIfNeeded + word;
|
|
694
|
+
}
|
|
695
|
+
else {
|
|
696
|
+
lines.push(`${format.indentation}"${currentChunk}"`);
|
|
697
|
+
currentChunk = word;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
if (currentChunk) {
|
|
701
|
+
lines.push(`${format.indentation}"${currentChunk}${suffix}"`);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
return lines;
|
|
706
|
+
}
|
|
707
|
+
//# sourceMappingURL=po-surgical.js.map
|