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.
- package/CHANGELOG.md +53 -0
- package/README.md +158 -1188
- package/dist/commands/auth.js +85 -19
- package/dist/commands/config.js +36 -26
- package/dist/commands/export.js +11 -122
- package/dist/commands/import.js +32 -143
- package/dist/commands/init.js +61 -72
- package/dist/commands/keys.js +145 -279
- package/dist/commands/org.js +45 -111
- package/dist/commands/projects.js +124 -312
- package/dist/commands/pull.js +86 -129
- package/dist/commands/push.js +125 -0
- package/dist/commands/review.js +65 -0
- package/dist/commands/team.js +68 -257
- package/dist/commands/translate.js +99 -0
- package/dist/core/cache.js +48 -0
- package/dist/core/config.js +130 -0
- package/dist/core/errors.js +67 -0
- package/dist/core/files.js +38 -0
- package/dist/core/http.js +156 -0
- package/dist/core/output.js +96 -0
- package/dist/core/project.js +58 -0
- package/dist/core/prompts.js +56 -0
- package/dist/formats/index.js +285 -0
- package/dist/index.js +277 -508
- package/dist/version.js +4 -0
- package/package.json +22 -15
- package/.gitattributes +0 -2
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -26
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -19
- package/CONTRIBUTING.md +0 -33
- package/dist/api.d.ts +0 -20
- package/dist/api.js +0 -75
- package/dist/auth.d.ts +0 -38
- package/dist/auth.js +0 -106
- package/dist/commands/auth.d.ts +0 -3
- package/dist/commands/config.d.ts +0 -2
- package/dist/commands/debug.d.ts +0 -3
- package/dist/commands/debug.js +0 -61
- package/dist/commands/export.d.ts +0 -13
- package/dist/commands/import.d.ts +0 -12
- package/dist/commands/init.d.ts +0 -2
- package/dist/commands/keys.d.ts +0 -25
- package/dist/commands/org.d.ts +0 -13
- package/dist/commands/projects.d.ts +0 -33
- package/dist/commands/pull.d.ts +0 -9
- package/dist/commands/team.d.ts +0 -29
- package/dist/config.d.ts +0 -48
- package/dist/config.js +0 -69
- package/dist/exporters/index.d.ts +0 -36
- package/dist/exporters/index.js +0 -214
- package/dist/index.d.ts +0 -3
- package/dist/utils/banner.d.ts +0 -9
- package/dist/utils/banner.js +0 -24
- package/translations/en.json +0 -5
- package/translations/en.xml +0 -9
- package/translations/fr.json +0 -4
- package/translations/fr.xml +0 -7
- package/translations/hi.json +0 -4
- package/translations/hi.xml +0 -7
- package/translations/ios/en.strings +0 -9
- package/translations/ios/fr.strings +0 -6
- package/translations/ios/hi.strings +0 -6
- package/translations/ios/ja.strings +0 -6
- package/translations/ja.json +0 -4
- package/translations/ja.xml +0 -7
package/dist/commands/pull.js
CHANGED
|
@@ -1,139 +1,96 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
+
}
|