localingos 0.1.40 → 0.1.41

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "localingos",
3
- "version": "0.1.40",
3
+ "version": "0.1.41",
4
4
  "description": "CLI tool to sync translations with Localingos",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/api.js CHANGED
@@ -6,36 +6,25 @@ export class LocalingosApi {
6
6
  this.apiKey = apiKey;
7
7
  }
8
8
 
9
- async sync(projectId, translatables, { batchSize = 100, onProgress } = {}) {
10
- // Push in batches to avoid API timeout
11
- const batches = [];
12
- for (let i = 0; i < translatables.length; i += batchSize) {
13
- batches.push(translatables.slice(i, i + batchSize));
14
- }
9
+ async sync(projectId, translatables, { knownHashes = {}, onProgress } = {}) {
10
+ const result = await this._post('/sync', {
11
+ projectId,
12
+ translatables,
13
+ knownHashes
14
+ });
15
15
 
16
- for (let i = 0; i < batches.length; i++) {
17
- await this._post('/translatable', {
16
+ // Paginate pull if server returned nextPullToken
17
+ while (result.nextPullToken) {
18
+ if (onProgress) onProgress('pull');
19
+ const page = await this._post('/sync/pull', {
18
20
  projectId,
19
- translatableList: batches[i]
21
+ nextPullToken: result.nextPullToken
20
22
  });
21
- if (onProgress) onProgress(Math.min((i + 1) * batchSize, translatables.length), translatables.length, 'push');
23
+ if (page.translations) result.translations.push(...page.translations);
24
+ result.nextPullToken = page.nextPullToken;
22
25
  }
23
26
 
24
- // Pull translations in batches by foreignId
25
- const allTranslations = [];
26
- const ids = translatables.map(t => t.id);
27
- const pullBatchSize = 50;
28
- for (let i = 0; i < ids.length; i += pullBatchSize) {
29
- const batch = ids.slice(i, i + pullBatchSize);
30
- const batchTranslations = await this._post(`/translation/${projectId}/batch`, { foreignIds: batch });
31
- if (Array.isArray(batchTranslations)) allTranslations.push(...batchTranslations);
32
- if (onProgress) onProgress(Math.min(i + pullBatchSize, ids.length), ids.length, 'pull');
33
- }
34
-
35
- const availableIds = new Set(allTranslations.map(t => t.foreignId || t.translatableId));
36
- const pending = translatables.filter(t => !availableIds.has(t.id)).map(t => t.id);
37
-
38
- return { translations: allTranslations, pending };
27
+ return result;
39
28
  }
40
29
 
41
30
  async getTranslatables(projectId) {
@@ -402,8 +402,9 @@ How it works:
402
402
  4. If a key is never referenced anywhere (neither by its original export name nor any alias), it's an orphan
403
403
 
404
404
  Behavior:
405
+ - **Always**: orphan ids are excluded from the JSON output files — they are never written to the source or descriptions JSON, regardless of flags. This prevents them from being synced/translated.
405
406
  - **Default run** (\`npm run i18n:extract\`): report orphans with file, export name, record key, and id — grouped by file for readability. Print a message like: \`🧹 N orphan message(s) found — defined but never referenced in code\`
406
- - **\`--prune\`**: also exclude orphan ids from the JSON output files (so they don't get synced/translated)
407
+ - **\`--prune\`**: also remove stale keys (keys in JSON but no longer in any \`*${msgExt}\` file) from the JSON output files
407
408
  - **\`--check\`**: exit with error code if orphans exist (prevents wasting translation budget in CI)
408
409
 
409
410
  This prevents paying to translate strings that aren't even used in the app.
@@ -21,6 +21,21 @@ function loadConfig(cwd) {
21
21
  return config;
22
22
  }
23
23
 
24
+ function loadKnownHashes(cwd) {
25
+ try {
26
+ const data = JSON.parse(fs.readFileSync(path.join(cwd, '.localingos.json'), 'utf-8'));
27
+ return data.knownHashes || {};
28
+ } catch { return {}; }
29
+ }
30
+
31
+ function saveKnownHashes(cwd, hashes) {
32
+ const localPath = path.join(cwd, '.localingos.json');
33
+ let data = {};
34
+ try { data = JSON.parse(fs.readFileSync(localPath, 'utf-8')); } catch {}
35
+ data.knownHashes = hashes;
36
+ fs.writeFileSync(localPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
37
+ }
38
+
24
39
  async function api(method, urlPath, apiUrl, apiKey, body) {
25
40
  const url = `${apiUrl.replace(/\/+$/, '')}${urlPath}`;
26
41
  const opts = { method, headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json', 'Accept': 'application/json' } };
@@ -89,11 +104,36 @@ export async function mcpServeCommand() {
89
104
  }
90
105
  const entries = readSourceEntries(config, cwd);
91
106
  if (!entries.length) return { content: [{ type: 'text', text: 'No source strings found.' }] };
92
- const result = await api('POST', '/sync', apiUrl, config.apiKey, { projectId: config.projectId, translatables: entries });
93
- const written = writeTranslations(result.translations || [], config, cwd);
107
+ const knownHashes = loadKnownHashes(cwd);
108
+ const result = await api('POST', '/sync', apiUrl, config.apiKey, { projectId: config.projectId, translatables: entries, knownHashes });
109
+ const translations = result.translations || [];
110
+ // Paginate pull
111
+ while (result.nextPullToken) {
112
+ const page = await api('POST', '/sync/pull', apiUrl, config.apiKey, { projectId: config.projectId, nextPullToken: result.nextPullToken });
113
+ if (page.translations) translations.push(...page.translations);
114
+ result.nextPullToken = page.nextPullToken;
115
+ }
116
+ if (translations.length > 0) {
117
+ const changedLocales = new Set(translations.filter(t => t.locale !== config.sourceLocale).map(t => t.locale));
118
+ const outputDir = path.resolve(cwd, config.outputDir);
119
+ for (const locale of changedLocales) {
120
+ const existing = {};
121
+ const fileName = (config.outputPattern || '{locale}.json').replace('{locale}', locale);
122
+ const filePath = path.join(outputDir, fileName);
123
+ if (fs.existsSync(filePath)) {
124
+ try { for (const e of flatten(JSON.parse(fs.readFileSync(filePath, 'utf-8')))) existing[e.id] = { foreignId: e.id, text: e.text, locale }; } catch {}
125
+ }
126
+ for (const t of translations.filter(t => t.locale === locale)) existing[t.foreignId] = t;
127
+ writeTranslations(Object.values(existing), config, cwd);
128
+ }
129
+ }
130
+ if (result.hashes) saveKnownHashes(cwd, result.hashes);
131
+ const push = result.push || {};
94
132
  const pending = result.pending || [];
95
- const lines = [`Pushed ${entries.length} source strings`, `${(result.translations || []).length} translations available`,
96
- pending.length ? `${pending.length} keys pending` : null, written.length ? `Written: ${written.join(', ')}` : null,
133
+ const lines = [
134
+ `Push: ${push.created || 0} created, ${push.updated || 0} updated, ${push.deleted || 0} deleted, ${push.unchanged || 0} unchanged`,
135
+ `${translations.length} new translations received`,
136
+ pending.length ? `${pending.length} keys pending` : null,
97
137
  extractOut ? `\n${extractOut}` : null].filter(Boolean);
98
138
  return { content: [{ type: 'text', text: lines.join('\n') }] };
99
139
  }
@@ -6,6 +6,23 @@ import { loadConfig, resolveApiUrl } from '../config.js';
6
6
  import { LocalingosApi } from '../api.js';
7
7
  import { getFormatter } from '../formats/index.js';
8
8
 
9
+ const LOCAL_CONFIG_FILE = '.localingos.json';
10
+
11
+ function loadKnownHashes() {
12
+ try {
13
+ const data = JSON.parse(fs.readFileSync(path.resolve(LOCAL_CONFIG_FILE), 'utf-8'));
14
+ return data.knownHashes || {};
15
+ } catch { return {}; }
16
+ }
17
+
18
+ function saveKnownHashes(hashes) {
19
+ const localPath = path.resolve(LOCAL_CONFIG_FILE);
20
+ let data = {};
21
+ try { data = JSON.parse(fs.readFileSync(localPath, 'utf-8')); } catch {}
22
+ data.knownHashes = hashes;
23
+ fs.writeFileSync(localPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
24
+ }
25
+
9
26
  export async function syncCommand(options) {
10
27
  const config = loadConfig(options.config);
11
28
  const apiUrl = resolveApiUrl(options.env);
@@ -72,68 +89,64 @@ export async function syncCommand(options) {
72
89
  // 2. Call /sync endpoint (push + pull in one call)
73
90
  console.log(chalk.blue(`\n🔄 Syncing with Localingos (project: ${config.projectId})...`));
74
91
  const api = new LocalingosApi(apiUrl, config.apiKey);
92
+ const knownHashes = loadKnownHashes();
75
93
 
76
94
  let result;
77
95
  try {
78
- result = await api.sync(config.projectId, translatables, {
79
- onProgress: (done, total, phase) => {
80
- const label = phase === 'push' ? 'Pushed' : 'Pulled';
81
- process.stdout.write(`\r ${label} ${done}/${total} strings...`);
82
- }
83
- });
84
- process.stdout.write('\n');
96
+ result = await api.sync(config.projectId, translatables, { knownHashes });
85
97
  } catch (e) {
86
98
  console.error(chalk.red(`\nSync failed: ${e.message}`));
87
99
  process.exit(1);
88
100
  }
89
101
 
90
- const translations = result.translations || [];
91
- const pending = result.pending || [];
92
- console.log(chalk.green(` ${translations.length} translations available`));
102
+ const { push, translations = [], hashes = {}, pending = [] } = result;
103
+ if (push) {
104
+ console.log(chalk.gray(` Push: ${push.created} created, ${push.updated} updated, ${push.deleted} deleted, ${push.unchanged} unchanged`));
105
+ }
106
+ console.log(chalk.green(` ✅ ${translations.length} translations received`));
93
107
  if (pending.length > 0) {
94
108
  console.log(chalk.yellow(` ⏳ ${pending.length} keys pending translation: ${pending.slice(0, 5).join(', ')}${pending.length > 5 ? '...' : ''}`));
95
109
  }
96
110
 
97
- // 2b. Prune stale translatables from the remote project
98
- if (options.prune) {
99
- console.log(chalk.blue('\n🔍 Checking for stale translatables in the remote project...'));
100
- try {
101
- const remoteTranslatables = await api.getTranslatables(config.projectId);
102
- const localIds = new Set(translatables.map(t => t.id));
103
- const staleIds = remoteTranslatables
104
- .filter(t => !localIds.has(t.foreignId || t.id))
105
- .map(t => t.foreignId || t.id);
106
-
107
- if (staleIds.length > 0) {
108
- console.log(chalk.yellow(` Found ${staleIds.length} stale translatable(s) not in source file:`));
109
- for (const id of staleIds.slice(0, 10)) {
110
- console.log(chalk.gray(` - ${id}`));
111
- }
112
- if (staleIds.length > 10) console.log(chalk.gray(` ... and ${staleIds.length - 10} more`));
113
-
114
- const deleteResult = await api.deleteTranslatables(config.projectId, staleIds);
115
- console.log(chalk.green(` 🗑️ Deleted ${deleteResult.deleted} stale translatable(s) from remote`));
116
- } else {
117
- console.log(chalk.green(' ✅ No stale translatables found'));
118
- }
119
- } catch (e) {
120
- console.error(chalk.yellow(` ⚠️ Could not prune remote translatables: ${e.message}`));
121
- }
122
- }
111
+ // Save updated hashes
112
+ saveKnownHashes(hashes);
123
113
 
124
- // 3. Write translation files (skip source locale to avoid overwriting the source file)
114
+ // 3. Write translation files merge new translations into existing files
125
115
  const targetTranslations = translations.filter(t => t.locale !== config.sourceLocale);
126
116
 
127
117
  if (targetTranslations.length > 0) {
128
118
  const outputDir = path.resolve(config.outputDir);
129
119
  console.log(chalk.blue(`\n📝 Writing translation files to ${outputDir}`));
130
120
 
131
- const writtenFiles = formatter.write(targetTranslations, outputDir, config.outputPattern);
132
- for (const file of writtenFiles) {
133
- console.log(chalk.green(` ✅ ${file}`));
121
+ // Only rewrite locale files that have new/updated translations
122
+ const changedLocales = new Set(targetTranslations.map(t => t.locale));
123
+
124
+ for (const locale of changedLocales) {
125
+ const pattern = (config.outputPattern || '{locale}.json');
126
+ const fileName = pattern.replace('{locale}', locale);
127
+ const filePath = path.join(outputDir, fileName);
128
+
129
+ // Read existing translations for this locale from disk
130
+ const existing = {};
131
+ if (fs.existsSync(filePath)) {
132
+ try {
133
+ for (const e of formatter.extract(filePath)) {
134
+ existing[e.id] = { foreignId: e.id, text: e.text, locale };
135
+ }
136
+ } catch {}
137
+ }
138
+
139
+ // Merge: new translations override existing
140
+ for (const t of targetTranslations.filter(t => t.locale === locale)) {
141
+ existing[t.foreignId] = t;
142
+ }
143
+
144
+ const merged = Object.values(existing);
145
+ formatter.write(merged, outputDir, config.outputPattern);
146
+ console.log(chalk.green(` ✅ ${filePath}`));
134
147
  }
135
148
  } else {
136
- console.log(chalk.yellow('\n No translations available yet. Run "localingos sync" again later.'));
149
+ console.log(chalk.yellow('\n No new translations. Run "localingos sync" again later.'));
137
150
  }
138
151
 
139
152
  console.log(chalk.blue('\n✨ Sync complete!\n'));