@localheroai/cli 0.0.12 → 0.0.14

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.
Files changed (36) hide show
  1. package/dist/api/client.js +16 -2
  2. package/dist/api/client.js.map +1 -1
  3. package/dist/api/translation-jobs.js +28 -0
  4. package/dist/api/translation-jobs.js.map +1 -0
  5. package/dist/commands/clone.js +13 -0
  6. package/dist/commands/clone.js.map +1 -1
  7. package/dist/commands/init.js +41 -21
  8. package/dist/commands/init.js.map +1 -1
  9. package/dist/commands/translate.js +20 -2
  10. package/dist/commands/translate.js.map +1 -1
  11. package/dist/types/index.js.map +1 -1
  12. package/dist/utils/config.js +10 -4
  13. package/dist/utils/config.js.map +1 -1
  14. package/dist/utils/files.js +11 -2
  15. package/dist/utils/files.js.map +1 -1
  16. package/dist/utils/import-service.js +42 -5
  17. package/dist/utils/import-service.js.map +1 -1
  18. package/dist/utils/po-surgical.js +707 -0
  19. package/dist/utils/po-surgical.js.map +1 -0
  20. package/dist/utils/po-utils-fallback.js +254 -0
  21. package/dist/utils/po-utils-fallback.js.map +1 -0
  22. package/dist/utils/po-utils.js +232 -0
  23. package/dist/utils/po-utils.js.map +1 -0
  24. package/dist/utils/spinner.js +32 -0
  25. package/dist/utils/spinner.js.map +1 -0
  26. package/dist/utils/sync-service.js +13 -5
  27. package/dist/utils/sync-service.js.map +1 -1
  28. package/dist/utils/translation-processor.js +47 -8
  29. package/dist/utils/translation-processor.js.map +1 -1
  30. package/dist/utils/translation-updater/index.js +39 -10
  31. package/dist/utils/translation-updater/index.js.map +1 -1
  32. package/dist/utils/translation-updater/po-handler.js +91 -0
  33. package/dist/utils/translation-updater/po-handler.js.map +1 -0
  34. package/dist/utils/translation-utils.js +35 -3
  35. package/dist/utils/translation-utils.js.map +1 -1
  36. package/package.json +3 -2
@@ -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