langctl 0.2.0 → 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.
Files changed (66) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +158 -1188
  3. package/dist/commands/auth.js +85 -19
  4. package/dist/commands/config.js +36 -26
  5. package/dist/commands/export.js +11 -122
  6. package/dist/commands/import.js +32 -143
  7. package/dist/commands/init.js +61 -72
  8. package/dist/commands/keys.js +145 -279
  9. package/dist/commands/org.js +45 -111
  10. package/dist/commands/projects.js +124 -312
  11. package/dist/commands/pull.js +86 -129
  12. package/dist/commands/push.js +125 -0
  13. package/dist/commands/review.js +65 -0
  14. package/dist/commands/team.js +68 -257
  15. package/dist/commands/translate.js +99 -0
  16. package/dist/core/cache.js +48 -0
  17. package/dist/core/config.js +130 -0
  18. package/dist/core/errors.js +67 -0
  19. package/dist/core/files.js +38 -0
  20. package/dist/core/http.js +156 -0
  21. package/dist/core/output.js +96 -0
  22. package/dist/core/project.js +58 -0
  23. package/dist/core/prompts.js +56 -0
  24. package/dist/formats/index.js +285 -0
  25. package/dist/index.js +277 -508
  26. package/dist/version.js +4 -0
  27. package/package.json +22 -15
  28. package/.gitattributes +0 -2
  29. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -26
  30. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -19
  31. package/CONTRIBUTING.md +0 -33
  32. package/dist/api.d.ts +0 -20
  33. package/dist/api.js +0 -75
  34. package/dist/auth.d.ts +0 -38
  35. package/dist/auth.js +0 -106
  36. package/dist/commands/auth.d.ts +0 -3
  37. package/dist/commands/config.d.ts +0 -2
  38. package/dist/commands/debug.d.ts +0 -3
  39. package/dist/commands/debug.js +0 -61
  40. package/dist/commands/export.d.ts +0 -13
  41. package/dist/commands/import.d.ts +0 -12
  42. package/dist/commands/init.d.ts +0 -2
  43. package/dist/commands/keys.d.ts +0 -25
  44. package/dist/commands/org.d.ts +0 -13
  45. package/dist/commands/projects.d.ts +0 -33
  46. package/dist/commands/pull.d.ts +0 -9
  47. package/dist/commands/team.d.ts +0 -29
  48. package/dist/config.d.ts +0 -48
  49. package/dist/config.js +0 -69
  50. package/dist/exporters/index.d.ts +0 -36
  51. package/dist/exporters/index.js +0 -214
  52. package/dist/index.d.ts +0 -3
  53. package/dist/utils/banner.d.ts +0 -9
  54. package/dist/utils/banner.js +0 -24
  55. package/translations/en.json +0 -5
  56. package/translations/en.xml +0 -9
  57. package/translations/fr.json +0 -4
  58. package/translations/fr.xml +0 -7
  59. package/translations/hi.json +0 -4
  60. package/translations/hi.xml +0 -7
  61. package/translations/ios/en.strings +0 -9
  62. package/translations/ios/fr.strings +0 -6
  63. package/translations/ios/hi.strings +0 -6
  64. package/translations/ios/ja.strings +0 -6
  65. package/translations/ja.json +0 -4
  66. package/translations/ja.xml +0 -7
@@ -1,139 +1,96 @@
1
1
  import chalk from 'chalk';
2
- import ora from 'ora';
3
- import { writeFileSync, mkdirSync } from 'fs';
4
- import { dirname, join } from 'path';
5
- import { isAuthenticated } from '../auth.js';
6
- import { getApiClient } from '../api.js';
7
- import { config } from '../config.js';
8
- import { exportTranslations } from '../exporters/index.js';
9
- /**
10
- * Get platform-specific output directory
11
- */
12
- function getPlatformDirectory(format) {
13
- const platformDirs = {
14
- 'json': 'i18n',
15
- 'json-nested': 'i18n',
16
- 'ios': 'ios',
17
- 'android': 'android',
18
- 'flutter': 'flutter'
19
- };
20
- return platformDirs[format] || 'i18n';
21
- }
22
- export async function pullCommand(projectIdentifier, options) {
23
- // Check authentication
24
- if (!isAuthenticated()) {
25
- console.log(chalk.red('✗ Not authenticated. Please run "langctl auth <api-key>" first.\n'));
26
- process.exitCode = 1;
27
- return;
2
+ import { relative, resolve } from 'path';
3
+ import { loadProjectConfig } from '../core/config.js';
4
+ import { CliError, ExitCode, usageError } from '../core/errors.js';
5
+ import { syncFile } from '../core/files.js';
6
+ import { getSession } from '../core/http.js';
7
+ import { log, printJson, runtime, spinner } from '../core/output.js';
8
+ import { assertLanguages, getProject, projectSlugFrom } from '../core/project.js';
9
+ import { expandTemplate, getFormat, templateHasLanguage } from '../formats/index.js';
10
+ export async function pullCommand(projectArg, opts) {
11
+ const loaded = loadProjectConfig();
12
+ const cfg = loaded?.config ?? {};
13
+ const slug = projectSlugFrom(projectArg);
14
+ const format = getFormat(opts.format ?? cfg.format);
15
+ // Paths from flags are relative to the cwd; paths from langctl.json are relative to that file
16
+ let template;
17
+ if (opts.output)
18
+ template = resolve(opts.output);
19
+ else if (opts.dir)
20
+ template = resolve(opts.dir, format.defaultOutput);
21
+ else if (cfg.output && loaded)
22
+ template = resolve(loaded.root, cfg.output);
23
+ else
24
+ template = resolve(format.defaultOutput);
25
+ const includeDrafts = Boolean(opts.includeDrafts || opts.publishedOnly === false || cfg.includeDrafts);
26
+ const module = opts.module ?? cfg.module;
27
+ const dryRun = Boolean(opts.check || opts.dryRun);
28
+ const session = await getSession();
29
+ const spin = spinner(`Fetching ${slug}…`);
30
+ let project, snapshot;
31
+ try {
32
+ project = await getProject(session, slug);
33
+ // ETag-cached: unchanged translations come back as a tiny 304 and the saved snapshot is reused
34
+ ({ data: snapshot } = await session.api.getCached(`/orgs/${session.orgId}/projects/${project.id}/export`, {
35
+ publishedOnly: includeDrafts ? 'false' : 'true',
36
+ includeUnreviewed: opts.includeUnreviewed ? 'true' : undefined,
37
+ module,
38
+ }));
28
39
  }
29
- const orgId = config.get('organizationId');
30
- if (!orgId) {
31
- console.log(chalk.red('✗ Organization ID not found. Please run "langctl auth <api-key>" again.\n'));
32
- process.exitCode = 1;
33
- return;
40
+ finally {
41
+ spin.stop();
34
42
  }
35
- // Set defaults
36
- const format = (options.format || 'json');
37
- const publishedOnly = options.publishedOnly !== false; // Default to true
38
- const specificLanguage = options.language;
39
- const baseDir = options.dir || './translations';
40
- // Validate format
41
- const validFormats = ['json', 'json-nested', 'ios', 'android', 'flutter'];
42
- if (!validFormats.includes(format)) {
43
- console.log(chalk.red(`✗ Invalid format: ${format}`));
44
- console.log(chalk.yellow(`Valid formats: ${validFormats.join(', ')}\n`));
45
- return;
43
+ const languages = opts.languages ? splitList(opts.languages) : (cfg.languages ?? project.languages);
44
+ assertLanguages(project, languages);
45
+ if (languages.length > 1 && !templateHasLanguage(template)) {
46
+ throw usageError(`Output "${relative(process.cwd(), template)}" has no {lang} placeholder, so ${languages.length} languages would overwrite each other.`, 'Use a template like "locales/{lang}.json", or pick one language with --languages.');
46
47
  }
47
- const spinner = ora('Resolving project...').start();
48
- try {
49
- const api = getApiClient();
50
- // Resolve project by slug
51
- const project = await api.get(`/orgs/${orgId}/projects/by-slug/${projectIdentifier}`);
52
- if (!project) {
53
- spinner.fail(chalk.red(`Project "${projectIdentifier}" not found`));
54
- console.log(chalk.yellow('\nRun "langctl projects list" to see available projects.\n'));
55
- return;
56
- }
57
- // Determine which languages to pull
58
- let languagesToPull;
59
- if (specificLanguage) {
60
- if (!project.languages.includes(specificLanguage)) {
61
- spinner.fail(chalk.red(`Language "${specificLanguage}" not found in project`));
62
- console.log(chalk.yellow(`Available languages: ${project.languages.join(', ')}\n`));
63
- return;
64
- }
65
- languagesToPull = [specificLanguage];
48
+ const files = [];
49
+ for (const lang of languages) {
50
+ const entries = snapshot.keys
51
+ .filter(k => typeof k.translations[lang] === 'string' && k.translations[lang] !== '')
52
+ .map(k => ({ key: k.key, value: k.translations[lang], description: k.description }));
53
+ const path = expandTemplate(template, lang, project.defaultLanguage);
54
+ const status = syncFile(path, format.serialize(entries, lang), dryRun, text => format.parse(text, lang));
55
+ files.push({ path, language: lang, status, keys: entries.length, translated: entries.length, total: snapshot.keys.length });
56
+ }
57
+ const changed = files.filter(f => f.status !== 'unchanged');
58
+ const incomplete = files.filter(f => f.translated < f.total);
59
+ if (runtime.json) {
60
+ printJson({
61
+ project: project.slug,
62
+ format: format.id,
63
+ publishedOnly: !includeDrafts,
64
+ keys: snapshot.keys.length,
65
+ dryRun,
66
+ files: files.map(f => ({ ...f, path: relative(process.cwd(), f.path) })),
67
+ changed: changed.length,
68
+ unreviewedSkipped: snapshot.metadata?.unreviewedSkipped ?? 0,
69
+ });
70
+ }
71
+ else {
72
+ for (const f of files) {
73
+ const icon = f.status === 'unchanged' ? chalk.dim('=') : f.status === 'created' ? chalk.green('+') : chalk.yellow('~');
74
+ const verb = dryRun && f.status !== 'unchanged' ? `would be ${f.status}` : f.status;
75
+ const coverage = f.translated < f.total ? chalk.yellow(` ${f.translated}/${f.total} translated`) : '';
76
+ log.out(`${icon} ${relative(process.cwd(), f.path)} ${chalk.dim(verb)}${coverage}`);
66
77
  }
67
- else {
68
- languagesToPull = project.languages;
78
+ log.info(chalk.dim(`${project.slug}: ${snapshot.keys.length} ${includeDrafts ? '' : 'published '}keys, ${languages.length} language(s), format ${format.id}`));
79
+ const unreviewed = snapshot.metadata?.unreviewedSkipped ?? 0;
80
+ if (unreviewed > 0) {
81
+ log.warn(`${unreviewed} AI translation(s) awaiting review were left out (your app falls back to ${project.defaultLanguage} for them). Review with "langctl review ${project.slug}", or pass --include-unreviewed.`);
69
82
  }
70
- spinner.text = `Fetching translations for "${project.name}"...`;
71
- // Get platform directory
72
- const platformDir = getPlatformDirectory(format);
73
- // Export translations for each language
74
- const exportedFiles = [];
75
- let totalKeyCount = 0;
76
- for (const language of languagesToPull) {
77
- spinner.text = `Exporting ${language}...`;
78
- // Fetch flat translations from the API
79
- const result = await api.get(`/orgs/${orgId}/projects/${project.id}/export`, {
80
- language,
81
- publishedOnly: publishedOnly ? 'true' : 'false'
82
- });
83
- // Convert flat translations to TranslationKey array for the exporter
84
- const flatTranslations = result.translations || {};
85
- const translationKeys = Object.entries(flatTranslations).map(([key, value]) => ({
86
- key,
87
- translations: { [language]: value }
88
- }));
89
- if (totalKeyCount === 0) {
90
- totalKeyCount = translationKeys.length;
91
- }
92
- const exportResult = exportTranslations(translationKeys, language, format);
93
- // Determine output path
94
- let outputPath;
95
- if (options.output) {
96
- // If custom output is specified and multiple languages, append language code
97
- if (languagesToPull.length > 1) {
98
- const ext = exportResult.filename.split('.').pop();
99
- const base = options.output.replace(/\.[^.]+$/, '');
100
- outputPath = `${base}-${language}.${ext}`;
101
- }
102
- else {
103
- outputPath = options.output;
104
- }
105
- }
106
- else {
107
- // Default output path: <baseDir>/<platform>/<language>.<ext>
108
- const ext = exportResult.filename.split('.').pop();
109
- outputPath = join(baseDir, platformDir, `${language}.${ext}`);
110
- }
111
- // Create directory if it doesn't exist
112
- const dir = dirname(outputPath);
113
- mkdirSync(dir, { recursive: true });
114
- writeFileSync(outputPath, exportResult.content, 'utf-8');
115
- exportedFiles.push(outputPath);
116
- spinner.succeed(chalk.green(`Exported ${language} → ${outputPath}`));
117
- // Restart spinner if more languages remain
118
- if (languagesToPull.indexOf(language) < languagesToPull.length - 1) {
119
- spinner.start();
120
- }
83
+ if (snapshot.keys.length === 0 && !includeDrafts) {
84
+ log.warn('No published keys — drafts are excluded by default. Publish keys, or pass --include-drafts.');
121
85
  }
122
- // Summary
123
- console.log(chalk.hex('#10b981').bold('\n📦 Export Summary:'));
124
- console.log(chalk.white(` Project: ${project.name}`));
125
- console.log(chalk.white(` Format: ${format}`));
126
- console.log(chalk.white(` Platform: ${platformDir}`));
127
- console.log(chalk.white(` Output: ${baseDir}`));
128
- console.log(chalk.white(` Keys: ${totalKeyCount}`));
129
- console.log(chalk.white(` Languages: ${languagesToPull.join(', ')}`));
130
- console.log(chalk.white(` Files: ${exportedFiles.length}`));
131
- console.log('');
132
86
  }
133
- catch (error) {
134
- spinner.fail(chalk.red('Failed to pull translations'));
135
- console.error(chalk.red(`Error: ${error.message}\n`));
136
- process.exitCode = 1;
87
+ if (opts.requireComplete && incomplete.length) {
88
+ throw new CliError(`Missing translations: ${incomplete.map(f => `${f.language} (${f.total - f.translated} missing)`).join(', ')}`, ExitCode.Error);
137
89
  }
90
+ if (opts.check && changed.length) {
91
+ throw new CliError(`${changed.length} translation file(s) are out of date.`, ExitCode.Drift, 'Run "langctl pull" and commit the result.');
92
+ }
93
+ }
94
+ export function splitList(value) {
95
+ return value.split(',').map(s => s.trim()).filter(Boolean);
138
96
  }
139
- //# sourceMappingURL=pull.js.map
@@ -0,0 +1,125 @@
1
+ import chalk from 'chalk';
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { relative, resolve } from 'path';
4
+ import { loadProjectConfig } from '../core/config.js';
5
+ import { notFoundError, usageError } from '../core/errors.js';
6
+ import { getSession } from '../core/http.js';
7
+ import { log, printJson, runtime, spinner } from '../core/output.js';
8
+ import { assertLanguages, getProject, projectSlugFrom } from '../core/project.js';
9
+ import { expandTemplate, formatFromPath, getFormat, templateHasLanguage } from '../formats/index.js';
10
+ import { splitList } from './pull.js';
11
+ const CHUNK = 1000; // stay well under the API's 1 MB request body limit
12
+ /** Upload one language's translations. Shared by `push` and the single-file `import`. */
13
+ export async function uploadTranslations(session, project, language, translations, opts) {
14
+ const entries = Object.entries(translations);
15
+ const result = { language, keys: entries.length, created: 0, updated: 0, unchanged: 0, published: undefined };
16
+ if (entries.length === 0)
17
+ return result;
18
+ if (opts.dryRun) {
19
+ // Compare against the server, including drafts, without writing anything
20
+ const snap = await session.api.get(`/orgs/${session.orgId}/projects/${project.id}/export`, { language, publishedOnly: 'false' });
21
+ const all = await session.api.get(`/orgs/${session.orgId}/projects/${project.id}/export`, { publishedOnly: 'false' });
22
+ const existingKeys = new Set(all.keys.map(k => k.key));
23
+ for (const [key, value] of entries) {
24
+ if (!existingKeys.has(key))
25
+ result.created++;
26
+ else if (snap.translations[key] === value)
27
+ result.unchanged++;
28
+ else if (opts.overwrite || snap.translations[key] === undefined)
29
+ result.updated++;
30
+ else
31
+ result.unchanged++;
32
+ }
33
+ return result;
34
+ }
35
+ for (let i = 0; i < entries.length; i += CHUNK) {
36
+ const chunk = Object.fromEntries(entries.slice(i, i + CHUNK));
37
+ // Import is an upsert, so retrying after a dropped connection is safe
38
+ const res = await session.api.post(`/orgs/${session.orgId}/projects/${project.id}/import`, { language, translations: chunk, overwriteExisting: Boolean(opts.overwrite), module: opts.module }, true);
39
+ result.created += res.created;
40
+ result.updated += res.updated;
41
+ result.unchanged += res.skipped;
42
+ }
43
+ if (opts.publish) {
44
+ let published = 0;
45
+ const names = entries.map(([k]) => k);
46
+ for (let i = 0; i < names.length; i += 5000) {
47
+ const res = await session.api.post(`/orgs/${session.orgId}/projects/${project.id}/keys/bulk-publish`, { keys: names.slice(i, i + 5000), published: true }, true);
48
+ published += res.count;
49
+ }
50
+ result.published = published;
51
+ }
52
+ return result;
53
+ }
54
+ export function readTranslationFile(path, format, lang) {
55
+ if (!existsSync(path))
56
+ throw notFoundError(`File not found: ${relative(process.cwd(), path) || path}`);
57
+ const fmt = format ?? formatFromPath(path);
58
+ const translations = fmt.parse(readFileSync(path, 'utf-8'), lang);
59
+ if ((fmt.id === 'android' || fmt.id === 'ios') && Object.values(translations).some(v => /\{\{\d+\}\}/.test(v))) {
60
+ log.warn(`${relative(process.cwd(), path)}: positional placeholders (%1$s / %1$@) were imported as {{1}}, {{2}}… — named placeholders can't be recovered from ${fmt.id} files.`);
61
+ }
62
+ return translations;
63
+ }
64
+ export async function pushCommand(projectArg, opts) {
65
+ const loaded = loadProjectConfig();
66
+ const cfg = loaded?.config ?? {};
67
+ const slug = projectSlugFrom(projectArg);
68
+ const format = opts.format || cfg.format ? getFormat(opts.format ?? cfg.format) : undefined;
69
+ let template;
70
+ if (opts.input)
71
+ template = resolve(opts.input);
72
+ else if (cfg.output && loaded)
73
+ template = resolve(loaded.root, cfg.output);
74
+ else
75
+ template = resolve((format ?? getFormat('json')).defaultOutput);
76
+ const session = await getSession();
77
+ const spin = spinner(`Fetching ${slug}…`);
78
+ let project;
79
+ try {
80
+ project = await getProject(session, slug);
81
+ }
82
+ finally {
83
+ spin.stop();
84
+ }
85
+ // Default: push only the source language, so translators' work in other languages isn't overwritten
86
+ const explicit = Boolean(opts.languages);
87
+ const languages = opts.languages === 'all' ? project.languages
88
+ : opts.languages ? splitList(opts.languages)
89
+ : [cfg.sourceLanguage ?? project.defaultLanguage];
90
+ assertLanguages(project, languages);
91
+ if (languages.length > 1 && !templateHasLanguage(template)) {
92
+ throw usageError(`Input "${relative(process.cwd(), template)}" has no {lang} placeholder but ${languages.length} languages were requested.`);
93
+ }
94
+ const results = [];
95
+ for (const lang of languages) {
96
+ const path = expandTemplate(template, lang, project.defaultLanguage);
97
+ if (!existsSync(path)) {
98
+ if (explicit)
99
+ throw notFoundError(`File not found for ${lang}: ${relative(process.cwd(), path)}`);
100
+ throw notFoundError(`Source file not found: ${relative(process.cwd(), path)}`, 'Run "langctl pull" first, or set "output" in langctl.json / pass --input.');
101
+ }
102
+ const translations = readTranslationFile(path, format, lang);
103
+ const s = spinner(`${opts.dryRun ? 'Checking' : 'Uploading'} ${lang} (${Object.keys(translations).length} keys)…`);
104
+ try {
105
+ const r = await uploadTranslations(session, project, lang, translations, { ...opts, module: opts.module ?? cfg.module });
106
+ results.push({ ...r, file: relative(process.cwd(), path) });
107
+ }
108
+ finally {
109
+ s.stop();
110
+ }
111
+ }
112
+ if (runtime.json) {
113
+ printJson({ project: project.slug, dryRun: Boolean(opts.dryRun), overwrite: Boolean(opts.overwrite), results });
114
+ return;
115
+ }
116
+ for (const r of results) {
117
+ const parts = [`${chalk.green(`${r.created} new`)}`, `${chalk.yellow(`${r.updated} updated`)}`, chalk.dim(`${r.unchanged} unchanged`)];
118
+ if (r.published !== undefined)
119
+ parts.push(chalk.cyan(`${r.published} published`));
120
+ log.out(`${r.language} ${r.file} ${parts.join(', ')}${opts.dryRun ? chalk.dim(' (dry run)') : ''}`);
121
+ }
122
+ if (!opts.overwrite && results.some(r => r.unchanged > 0)) {
123
+ log.info(chalk.dim('Existing translations are never overwritten unless you pass --overwrite.'));
124
+ }
125
+ }
@@ -0,0 +1,65 @@
1
+ import chalk from 'chalk';
2
+ import { usageError } from '../core/errors.js';
3
+ import { getSession } from '../core/http.js';
4
+ import { log, printJson, runtime, spinner, table } from '../core/output.js';
5
+ import { assertLanguages, getProject, projectSlugFrom } from '../core/project.js';
6
+ import { confirm } from '../core/prompts.js';
7
+ const split = (v) => (v ? v.split(',').map(s => s.trim()).filter(Boolean) : undefined);
8
+ /**
9
+ * List AI translations awaiting review, or approve them with --approve.
10
+ * Unreviewed AI translations are left out of `langctl pull` until approved (or edited) in langctl.
11
+ */
12
+ export async function reviewCommand(projectArg, opts) {
13
+ const session = await getSession();
14
+ const project = await getProject(session, projectSlugFrom(projectArg));
15
+ const keys = split(opts.keys);
16
+ const languages = split(opts.languages);
17
+ if (languages)
18
+ assertLanguages(project, languages);
19
+ const spin = spinner('Fetching AI translations awaiting review…');
20
+ let list;
21
+ try {
22
+ list = await session.api.get(`/orgs/${session.orgId}/projects/${project.id}/review`);
23
+ }
24
+ finally {
25
+ spin.stop();
26
+ }
27
+ const items = list.items.filter(i => (!keys || keys.includes(i.key)) && (!languages || languages.includes(i.language)));
28
+ if (keys) {
29
+ const unknown = keys.filter(k => !list.items.some(i => i.key === k));
30
+ if (unknown.length)
31
+ log.warn(`Nothing awaiting review for: ${unknown.join(', ')}`);
32
+ }
33
+ if (!opts.approve) {
34
+ if (runtime.json)
35
+ return printJson({ project: project.slug, total: items.length, items });
36
+ if (!items.length) {
37
+ log.success('No AI translations awaiting review.');
38
+ return;
39
+ }
40
+ table(items.map(i => [i.key, i.language, i.text.length > 60 ? `${i.text.slice(0, 57)}…` : i.text]), ['key', 'lang', 'AI translation']);
41
+ log.info(chalk.dim(`${items.length} awaiting review — left out of "langctl pull" until approved. Approve with "langctl review ${project.slug} --approve" (narrow with --keys / --languages), or edit them in the web app.`));
42
+ return;
43
+ }
44
+ if (!items.length) {
45
+ if (runtime.json)
46
+ return printJson({ project: project.slug, approved: 0 });
47
+ log.success('Nothing to approve.');
48
+ return;
49
+ }
50
+ if (!(await confirm(`Approve ${items.length} AI translation(s) in ${project.slug}? They will ship on the next pull.`))) {
51
+ throw usageError('Cancelled.');
52
+ }
53
+ const keyIds = [...new Set(items.map(i => i.keyId))];
54
+ let approved = 0;
55
+ for (let i = 0; i < keyIds.length; i += 1000) {
56
+ const res = await session.api.post(`/orgs/${session.orgId}/projects/${project.id}/keys/review`, {
57
+ keyIds: keyIds.slice(i, i + 1000),
58
+ languages: languages ?? [...new Set(items.map(it => it.language))],
59
+ });
60
+ approved += res.approved;
61
+ }
62
+ if (runtime.json)
63
+ return printJson({ project: project.slug, approved });
64
+ log.success(`Approved ${approved} AI translation(s). They'll be included in the next "langctl pull".`);
65
+ }