locize-cli 12.1.1 → 12.3.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/.github/workflows/node.yml +2 -2
- package/CHANGELOG.md +21 -0
- package/README.md +1 -0
- package/dist/cjs/addLanguage.js +42 -0
- package/dist/cjs/cli.js +4 -1
- package/dist/cjs/getRemoteLanguages.js +18 -5
- package/dist/cjs/migrate.js +9 -19
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/request.js +1 -1
- package/dist/cjs/sync.js +97 -1
- package/dist/esm/addLanguage.js +40 -0
- package/dist/esm/cli.js +4 -1
- package/dist/esm/getRemoteLanguages.js +18 -5
- package/dist/esm/migrate.js +9 -19
- package/dist/esm/request.js +1 -1
- package/dist/esm/sync.js +97 -1
- package/package.json +1 -1
|
@@ -15,9 +15,9 @@ jobs:
|
|
|
15
15
|
# os: [ubuntu-latest, windows-latest, macOS-latest]
|
|
16
16
|
os: [ubuntu-latest, windows-latest, macOS-latest]
|
|
17
17
|
steps:
|
|
18
|
-
- uses: actions/checkout@
|
|
18
|
+
- uses: actions/checkout@v6
|
|
19
19
|
- name: Setup Node.js
|
|
20
|
-
uses: actions/setup-node@
|
|
20
|
+
uses: actions/setup-node@v6
|
|
21
21
|
with:
|
|
22
22
|
node-version: ${{ matrix.node }}
|
|
23
23
|
- run: npm install
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
Project versioning adheres to [Semantic Versioning](http://semver.org/).
|
|
6
6
|
Change log format is based on [Keep a Changelog](http://keepachangelog.com/).
|
|
7
7
|
|
|
8
|
+
## [12.3.0](https://github.com/locize/locize-cli/compare/v12.2.0...v12.3.0) - 2026-06-10
|
|
9
|
+
|
|
10
|
+
- sync: creates missing remote languages on the fly. A project without any
|
|
11
|
+
languages yet is bootstrapped automatically from the local language folders
|
|
12
|
+
(or `--language`/`--languages`), and a language that exists locally but not
|
|
13
|
+
in the project is created too instead of being silently ignored — the server
|
|
14
|
+
decides whether the api-key may (admin keys always; any write-capable key
|
|
15
|
+
while the project still has no content). On rejection sync warns and
|
|
16
|
+
continues; `--dry` never creates languages.
|
|
17
|
+
- migrate: also works on a project without any languages yet (previously both
|
|
18
|
+
sync and migrate failed with a misleading "Project not found" error when the
|
|
19
|
+
project's languages file was empty).
|
|
20
|
+
- fix: the "wrong cdnType" hint no longer crashes (or fires) when the other
|
|
21
|
+
endpoint answers with a non-JSON response; the empty-project error now reads
|
|
22
|
+
"Project … not found — or it has no languages yet!" and carries an
|
|
23
|
+
`EMPTY_LANGUAGES` / `WRONG_CDN_TYPE` error code for programmatic consumers.
|
|
24
|
+
|
|
25
|
+
## [12.2.0](https://github.com/locize/locize-cli/compare/v12.1.1...v12.2.0) - 2026-06-01
|
|
26
|
+
|
|
27
|
+
- sync: introduce `--auto-translate-languages <lng1,lng2>` option to restrict auto-translation to specific target languages
|
|
28
|
+
|
|
8
29
|
## [12.1.1](https://github.com/locize/locize-cli/compare/v12.1.0...v12.1.1) - 2026-04-21
|
|
9
30
|
|
|
10
31
|
- sync: fix default for `--auto-translate` option
|
package/README.md
CHANGED
|
@@ -178,6 +178,7 @@ locize sync --api-key my-api-key-d9de-4f55-9855-a9ef0ed44672 --project-id my-pro
|
|
|
178
178
|
- all non reference languages will always be just locally replaced by what is published on locize, unless the *--skip-delete true* argument is used
|
|
179
179
|
- if you change the values of existing keys in the reference language, it will not change them in locize (to change the existing values you have to change it directly in locize or use the *--update-values true* argument)
|
|
180
180
|
- if you update existing keys (with *--update-values true*) the automatic translation is not triggered, unless the *--auto-translate true* argument us used
|
|
181
|
+
- if you want to restrict the automatic translation to specific target languages only, you can use the *--auto-translate-languages de,fr* argument (together with *--auto-translate true*); if omitted, all languages are auto-translated
|
|
181
182
|
- **if you want to take into account all languages instead of the reference language only while comparing the namespace content between local and remote, you can use the command argument *--reference-language-only false***
|
|
182
183
|
- **if you want to take into account the modification time while comparing the namespace content between local and remote, you can use the command argument *--compare-modification-time true***
|
|
183
184
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var colors = require('colors');
|
|
4
|
+
var request = require('./request.js');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Adds a language to the project via POST /language/{projectId}/{language}.
|
|
8
|
+
*
|
|
9
|
+
* Idempotent for bootstrap flows: an "already exists" answer counts as
|
|
10
|
+
* success — the API responds 400 (ValidationError "Language already exists!"),
|
|
11
|
+
* and 412 is kept for backwards compatibility with older deployments.
|
|
12
|
+
*
|
|
13
|
+
* Other failures throw an Error carrying `status` (e.g. 401 when the key
|
|
14
|
+
* may not create languages) so callers can give role-specific guidance.
|
|
15
|
+
*/
|
|
16
|
+
const addLanguage = async (opt, l) => {
|
|
17
|
+
const url = opt.apiEndpoint + '/language/' + opt.projectId + '/' + l;
|
|
18
|
+
try {
|
|
19
|
+
const { res, obj } = await request(url, {
|
|
20
|
+
method: 'post',
|
|
21
|
+
headers: {
|
|
22
|
+
Authorization: opt.apiKey
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
if (res.status === 400 && ((obj && (obj.errorMessage || obj.message)) || '').indexOf('already exists') > -1) {
|
|
26
|
+
console.log(colors.yellow(`language ${l} already exists...`));
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
if (res.status >= 300 && res.status !== 412) {
|
|
30
|
+
const serverMessage = obj && (obj.errorMessage || obj.message);
|
|
31
|
+
const err = new Error(serverMessage ? `${res.statusText} (${res.status}) | ${serverMessage}` : `${res.statusText} (${res.status})`);
|
|
32
|
+
err.status = res.status;
|
|
33
|
+
throw err
|
|
34
|
+
}
|
|
35
|
+
console.log(colors.green(`added language ${l}...`));
|
|
36
|
+
} catch (err) {
|
|
37
|
+
console.log(colors.red(`failed to add language ${l}...`));
|
|
38
|
+
throw err
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
module.exports = addLanguage;
|
package/dist/cjs/cli.js
CHANGED
|
@@ -50,7 +50,7 @@ const program = new commander.Command();
|
|
|
50
50
|
|
|
51
51
|
program
|
|
52
52
|
.description('The official locize CLI.')
|
|
53
|
-
.version('12.
|
|
53
|
+
.version('12.3.0'); // This string is replaced with the actual version at build time by rollup
|
|
54
54
|
// .option('-a, --api-endpoint <url>', `Specify the api-endpoint url that should be used (default: ${defaultApiEndpoint})`)
|
|
55
55
|
// .option('-C, --config-path <configPath>', `Specify the path to the optional locize config file (default: ${configInWorkingDirectory} or ${configInHome})`);
|
|
56
56
|
|
|
@@ -419,6 +419,7 @@ program
|
|
|
419
419
|
.option('-u, --update-values <true|false>', 'This will update values of existing translations. (default: false)', 'false')
|
|
420
420
|
.option('--auto-translate <true|false>', 'This will trigger auto-translation of updated translations.')
|
|
421
421
|
.option('--auto-translate-review <true|false>', 'When auto-translating, route results through the review workflow for languages that have review enabled. (default: false)', 'false')
|
|
422
|
+
.option('--auto-translate-languages <lng1,lng2>', 'Restrict auto-translation to these target languages only (comma separated). Only effective together with --auto-translate and when updating the reference language. If omitted, all languages are auto-translated.')
|
|
422
423
|
.option('-S, --skip-delete <true|false>', 'This will skip the removal of keys on locize. (default: false)', 'false')
|
|
423
424
|
.option('-D, --delete-remote-namespace <true|false>', 'This will delete a complete namespace on locize, if a local file in reference language was deleted. (default: false)', 'false')
|
|
424
425
|
.option('-m, --path-mask <mask>', 'This will define the folder and file structure; do not add a file extension (default: {{language}}/{{namespace}})', `{{language}}${path.sep}{{namespace}}`)
|
|
@@ -476,6 +477,7 @@ program
|
|
|
476
477
|
const updateValues = options.updateValues === 'true';
|
|
477
478
|
const autoTranslate = options.autoTranslate === 'true' ? true : options.autoTranslate === 'false' ? false : undefined;
|
|
478
479
|
const autoTranslateReview = options.autoTranslateReview === 'true';
|
|
480
|
+
const autoTranslateLanguages = options.autoTranslateLanguages;
|
|
479
481
|
const skipDelete = options.skipDelete === 'true';
|
|
480
482
|
const deleteRemoteNamespace = options.deleteRemoteNamespace === 'true';
|
|
481
483
|
const languageFolderPrefix = options.languageFolderPrefix || '';
|
|
@@ -500,6 +502,7 @@ program
|
|
|
500
502
|
updateValues,
|
|
501
503
|
autoTranslate,
|
|
502
504
|
autoTranslateReview,
|
|
505
|
+
autoTranslateLanguages: autoTranslateLanguages && autoTranslateLanguages.split(',').map((l) => l.trim()).filter(Boolean),
|
|
503
506
|
skipDelete,
|
|
504
507
|
deleteRemoteNamespace,
|
|
505
508
|
languageFolderPrefix,
|
|
@@ -21,13 +21,26 @@ const getRemoteLanguages = async (opt) => {
|
|
|
21
21
|
if (res.status >= 300) throw new Error(res.statusText + ' (' + res.status + ')')
|
|
22
22
|
|
|
23
23
|
if (Object.keys(obj).length === 0) {
|
|
24
|
-
|
|
24
|
+
// An empty object is ambiguous: unknown project, a project without
|
|
25
|
+
// languages, or a freshly created project whose languages file has not
|
|
26
|
+
// been generated yet.
|
|
27
|
+
let errMsg = 'Project with id "' + opt.projectId + '" not found — or it has no languages yet!';
|
|
25
28
|
const otherEndpoint = getOtherApiEndpoint(opt.apiEndpoint);
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
if (otherEndpoint) {
|
|
30
|
+
const { res: res2, obj: obj2 } = await request(otherEndpoint + '/languages/' + opt.projectId + '?ts=' + Date.now() + (opt.cdnType === 'standard' ? '' : '&cache=no'), { method: 'get' });
|
|
31
|
+
// obj2 is undefined for non-JSON responses (e.g. an HTML 404 page) —
|
|
32
|
+
// only a real, non-empty JSON object indicates the other cdnType.
|
|
33
|
+
if (res2.status === 200 && obj2 && typeof obj2 === 'object' && Object.keys(obj2).length > 0) {
|
|
34
|
+
errMsg += ` It seems you're using the wrong cdnType. Your Locize project is configured to use "${opt.cdnType === 'standard' ? 'pro' : 'standard'}" but here you've configured "${opt.cdnType}".`;
|
|
35
|
+
const cdnErr = new Error(errMsg);
|
|
36
|
+
cdnErr.code = 'WRONG_CDN_TYPE';
|
|
37
|
+
throw cdnErr
|
|
38
|
+
}
|
|
29
39
|
}
|
|
30
|
-
|
|
40
|
+
const err = new Error(errMsg);
|
|
41
|
+
// lets callers (sync/migrate) bootstrap the languages instead of failing
|
|
42
|
+
err.code = 'EMPTY_LANGUAGES';
|
|
43
|
+
throw err
|
|
31
44
|
}
|
|
32
45
|
|
|
33
46
|
const lngs = Object.keys(obj);
|
package/dist/cjs/migrate.js
CHANGED
|
@@ -6,6 +6,7 @@ var flatten = require('flat');
|
|
|
6
6
|
var colors = require('colors');
|
|
7
7
|
var request = require('./request.js');
|
|
8
8
|
var getRemoteLanguages = require('./getRemoteLanguages.js');
|
|
9
|
+
var addLanguage = require('./addLanguage.js');
|
|
9
10
|
var os = require('node:os');
|
|
10
11
|
var mapLimit = require('./mapLimit.js');
|
|
11
12
|
var download = require('./download.js');
|
|
@@ -149,23 +150,6 @@ const upload = async (opt, nss) => {
|
|
|
149
150
|
await mapLimit(nssNonRefLng, concurrency, async (ns) => transfer(opt, ns));
|
|
150
151
|
};
|
|
151
152
|
|
|
152
|
-
const addLanguage = async (opt, l) => {
|
|
153
|
-
const url = opt.apiEndpoint + '/language/' + opt.projectId + '/' + l;
|
|
154
|
-
try {
|
|
155
|
-
const { res } = await request(url, {
|
|
156
|
-
method: 'post',
|
|
157
|
-
headers: {
|
|
158
|
-
Authorization: opt.apiKey
|
|
159
|
-
}
|
|
160
|
-
});
|
|
161
|
-
if (res.status >= 300 && res.status !== 412) throw new Error(res.statusText + ' (' + res.status + ')')
|
|
162
|
-
console.log(colors.green(`added language ${l}...`));
|
|
163
|
-
} catch (err) {
|
|
164
|
-
console.log(colors.red(`failed to add language ${l}...`));
|
|
165
|
-
throw err
|
|
166
|
-
}
|
|
167
|
-
};
|
|
168
|
-
|
|
169
153
|
const downloadAfterMigrate = async (opt) => {
|
|
170
154
|
console.log(colors.yellow('downloading translations after migration...'));
|
|
171
155
|
await new Promise((resolve) => setTimeout(resolve, 15000));
|
|
@@ -224,8 +208,14 @@ const migrate = async (opt) => {
|
|
|
224
208
|
try {
|
|
225
209
|
remoteLanguages = await getRemoteLanguages(opt);
|
|
226
210
|
} catch (err) {
|
|
227
|
-
|
|
228
|
-
|
|
211
|
+
// A project without any languages yet is fine for migrate — every local
|
|
212
|
+
// language is created below via addLanguage before the upload.
|
|
213
|
+
if (err.code === 'EMPTY_LANGUAGES' && opt.apiKey) {
|
|
214
|
+
remoteLanguages = [];
|
|
215
|
+
} else {
|
|
216
|
+
console.error(colors.red(err.stack));
|
|
217
|
+
process.exit(1);
|
|
218
|
+
}
|
|
229
219
|
}
|
|
230
220
|
const localLanguages = [];
|
|
231
221
|
nss.forEach((n) => {
|
package/dist/cjs/package.json
CHANGED
package/dist/cjs/request.js
CHANGED
|
@@ -39,7 +39,7 @@ async function request (url, options) {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
options.headers = options.headers || {};
|
|
42
|
-
options.headers['User-Agent'] = `locize-cli/v12.
|
|
42
|
+
options.headers['User-Agent'] = `locize-cli/v12.3.0 (node/${process.version}; ${process.platform} ${process.arch})`; // This string is replaced with the actual version at build time by rollup
|
|
43
43
|
options.headers['X-User-Agent'] = options.headers['User-Agent'];
|
|
44
44
|
if (options.body || options.method !== 'get') options.headers['Content-Type'] = 'application/json';
|
|
45
45
|
if (options.body) {
|
package/dist/cjs/sync.js
CHANGED
|
@@ -19,6 +19,7 @@ var getProjectStats = require('./getProjectStats.js');
|
|
|
19
19
|
var xcstrings = require('locize-xcstrings');
|
|
20
20
|
var getBranches = require('./getBranches.js');
|
|
21
21
|
var isValidUuid = require('./isValidUuid.js');
|
|
22
|
+
var addLanguage = require('./addLanguage.js');
|
|
22
23
|
var os = require('node:os');
|
|
23
24
|
var lngs = require('./lngs.js');
|
|
24
25
|
|
|
@@ -435,6 +436,9 @@ async function update (opt, lng, ns, shouldOmit = false) {
|
|
|
435
436
|
if (opt.autoTranslateReview && lng === opt.referenceLanguage) {
|
|
436
437
|
queryParams.append('autotranslatereview', 'true');
|
|
437
438
|
}
|
|
439
|
+
if (opt.autoTranslateLanguages && opt.autoTranslateLanguages.length > 0 && lng === opt.referenceLanguage) {
|
|
440
|
+
queryParams.append('autotranslatelanguages', opt.autoTranslateLanguages.join(','));
|
|
441
|
+
}
|
|
438
442
|
if (so) {
|
|
439
443
|
queryParams.append('omitstatsgeneration', 'true');
|
|
440
444
|
}
|
|
@@ -667,9 +671,101 @@ const checkForMissingLocalNamespaces = (downloads, localNamespaces, opt) => {
|
|
|
667
671
|
return localMissingNamespaces
|
|
668
672
|
};
|
|
669
673
|
|
|
674
|
+
/**
|
|
675
|
+
* Discovers which languages the local files intend, independent of the remote
|
|
676
|
+
* state: explicit --language/--languages win; otherwise the language folders
|
|
677
|
+
* under opt.path (only when the path mask starts with the language segment —
|
|
678
|
+
* the default `{{language}}/{{namespace}}` layout).
|
|
679
|
+
*/
|
|
680
|
+
const discoverLocalLanguages = (opt) => {
|
|
681
|
+
if (opt.languages && opt.languages.length > 0) return [...opt.languages]
|
|
682
|
+
if (opt.language) return [opt.language]
|
|
683
|
+
const languageToken = `${opt.pathMaskInterpolationPrefix || '{{'}language${opt.pathMaskInterpolationSuffix || '}}'}`;
|
|
684
|
+
const pathMask = opt.pathMask || `${languageToken}${path.sep}{{namespace}}`;
|
|
685
|
+
if (!pathMask.startsWith(`${opt.languageFolderPrefix || ''}${languageToken}`)) return []
|
|
686
|
+
try {
|
|
687
|
+
return fs.readdirSync(opt.path)
|
|
688
|
+
.filter((file) => fs.statSync(path.join(opt.path, file)).isDirectory())
|
|
689
|
+
.map((dir) => (opt.languageFolderPrefix ? dir.replace(opt.languageFolderPrefix, '') : dir))
|
|
690
|
+
} catch (err) {
|
|
691
|
+
return []
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Bootstraps the remote languages of a project that has none yet by creating
|
|
697
|
+
* the locally intended languages (the server authorizes: admin keys always;
|
|
698
|
+
* any write-capable key while the project is still empty), then waits for the
|
|
699
|
+
* asynchronously regenerated languages file.
|
|
700
|
+
*/
|
|
701
|
+
const bootstrapRemoteLanguages = async (opt) => {
|
|
702
|
+
const noLanguagesHelp = `Project with id "${opt.projectId}" has no languages yet`;
|
|
703
|
+
const localLanguages = discoverLocalLanguages(opt);
|
|
704
|
+
if (localLanguages.length === 0) {
|
|
705
|
+
throw new Error(`${noLanguagesHelp} and no local languages were found to create — add languages in the project settings (or push initial content via "locize migrate"), then retry.`)
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
console.log(colors.yellow(`project has no languages yet — creating ${localLanguages.join(', ')}...`));
|
|
709
|
+
try {
|
|
710
|
+
for (const l of localLanguages) {
|
|
711
|
+
await addLanguage(opt, l);
|
|
712
|
+
}
|
|
713
|
+
} catch (err) {
|
|
714
|
+
if (err.status === 401) {
|
|
715
|
+
throw new Error(`${noLanguagesHelp} and this api-key may not create them — use an admin-scoped api-key (or any write-capable key while the project is still empty), or add the languages in the project settings, then retry.`)
|
|
716
|
+
}
|
|
717
|
+
throw err
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// the languages file is regenerated asynchronously — retry a few times
|
|
721
|
+
let lastError;
|
|
722
|
+
for (let i = 0; i < 3; i++) {
|
|
723
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
724
|
+
try {
|
|
725
|
+
return await getRemoteLanguages(opt)
|
|
726
|
+
} catch (err) {
|
|
727
|
+
lastError = err;
|
|
728
|
+
if (err.code !== 'EMPTY_LANGUAGES') throw err
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
throw lastError
|
|
732
|
+
};
|
|
733
|
+
|
|
670
734
|
async function continueToSync (opt) {
|
|
671
735
|
console.log(colors.grey('checking remote (locize)...'));
|
|
672
|
-
|
|
736
|
+
let remoteLanguages;
|
|
737
|
+
try {
|
|
738
|
+
remoteLanguages = await getRemoteLanguages(opt);
|
|
739
|
+
} catch (err) {
|
|
740
|
+
// A project without languages is bootstrapped on the fly (unless this is
|
|
741
|
+
// a dry run or we have no key to create them with).
|
|
742
|
+
if (err.code !== 'EMPTY_LANGUAGES' || !opt.apiKey || opt.dry) throw err
|
|
743
|
+
remoteLanguages = await bootstrapRemoteLanguages(opt);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Languages that exist locally but not remotely are created too — the
|
|
747
|
+
// server decides whether the key may (admin always; write-capable keys
|
|
748
|
+
// while the project is empty). On rejection we warn instead of silently
|
|
749
|
+
// ignoring the local files (the pre-existing behavior).
|
|
750
|
+
const missingRemotely = discoverLocalLanguages(opt).filter((l) => remoteLanguages.indexOf(l) < 0);
|
|
751
|
+
if (missingRemotely.length > 0) {
|
|
752
|
+
if (opt.apiKey && !opt.dry) {
|
|
753
|
+
const created = [];
|
|
754
|
+
for (const l of missingRemotely) {
|
|
755
|
+
try {
|
|
756
|
+
await addLanguage(opt, l);
|
|
757
|
+
created.push(l);
|
|
758
|
+
} catch (err) {
|
|
759
|
+
console.log(colors.yellow(`language "${l}" exists locally but not in your locize project and this api-key may not create it (${err.message}) — add it in the project settings or use an admin api-key.`));
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
// the regenerated languages file lags behind — continue with the
|
|
763
|
+
// created languages included so this run already syncs them
|
|
764
|
+
created.forEach((l) => remoteLanguages.push(l));
|
|
765
|
+
} else {
|
|
766
|
+
missingRemotely.forEach((l) => console.log(colors.yellow(`language "${l}" exists locally but not in your locize project${opt.dry ? ' (dry run: not creating it)' : ' (no api-key: cannot create it)'}.`)));
|
|
767
|
+
}
|
|
768
|
+
}
|
|
673
769
|
|
|
674
770
|
if (opt.referenceLanguageOnly && opt.language && opt.referenceLanguage !== opt.language) {
|
|
675
771
|
opt.referenceLanguage = opt.language;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import colors from 'colors';
|
|
2
|
+
import request from './request.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Adds a language to the project via POST /language/{projectId}/{language}.
|
|
6
|
+
*
|
|
7
|
+
* Idempotent for bootstrap flows: an "already exists" answer counts as
|
|
8
|
+
* success — the API responds 400 (ValidationError "Language already exists!"),
|
|
9
|
+
* and 412 is kept for backwards compatibility with older deployments.
|
|
10
|
+
*
|
|
11
|
+
* Other failures throw an Error carrying `status` (e.g. 401 when the key
|
|
12
|
+
* may not create languages) so callers can give role-specific guidance.
|
|
13
|
+
*/
|
|
14
|
+
const addLanguage = async (opt, l) => {
|
|
15
|
+
const url = opt.apiEndpoint + '/language/' + opt.projectId + '/' + l;
|
|
16
|
+
try {
|
|
17
|
+
const { res, obj } = await request(url, {
|
|
18
|
+
method: 'post',
|
|
19
|
+
headers: {
|
|
20
|
+
Authorization: opt.apiKey
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
if (res.status === 400 && ((obj && (obj.errorMessage || obj.message)) || '').indexOf('already exists') > -1) {
|
|
24
|
+
console.log(colors.yellow(`language ${l} already exists...`));
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
if (res.status >= 300 && res.status !== 412) {
|
|
28
|
+
const serverMessage = obj && (obj.errorMessage || obj.message);
|
|
29
|
+
const err = new Error(serverMessage ? `${res.statusText} (${res.status}) | ${serverMessage}` : `${res.statusText} (${res.status})`);
|
|
30
|
+
err.status = res.status;
|
|
31
|
+
throw err
|
|
32
|
+
}
|
|
33
|
+
console.log(colors.green(`added language ${l}...`));
|
|
34
|
+
} catch (err) {
|
|
35
|
+
console.log(colors.red(`failed to add language ${l}...`));
|
|
36
|
+
throw err
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export { addLanguage as default };
|
package/dist/esm/cli.js
CHANGED
|
@@ -48,7 +48,7 @@ const program = new Command();
|
|
|
48
48
|
|
|
49
49
|
program
|
|
50
50
|
.description('The official locize CLI.')
|
|
51
|
-
.version('12.
|
|
51
|
+
.version('12.3.0'); // This string is replaced with the actual version at build time by rollup
|
|
52
52
|
// .option('-a, --api-endpoint <url>', `Specify the api-endpoint url that should be used (default: ${defaultApiEndpoint})`)
|
|
53
53
|
// .option('-C, --config-path <configPath>', `Specify the path to the optional locize config file (default: ${configInWorkingDirectory} or ${configInHome})`);
|
|
54
54
|
|
|
@@ -417,6 +417,7 @@ program
|
|
|
417
417
|
.option('-u, --update-values <true|false>', 'This will update values of existing translations. (default: false)', 'false')
|
|
418
418
|
.option('--auto-translate <true|false>', 'This will trigger auto-translation of updated translations.')
|
|
419
419
|
.option('--auto-translate-review <true|false>', 'When auto-translating, route results through the review workflow for languages that have review enabled. (default: false)', 'false')
|
|
420
|
+
.option('--auto-translate-languages <lng1,lng2>', 'Restrict auto-translation to these target languages only (comma separated). Only effective together with --auto-translate and when updating the reference language. If omitted, all languages are auto-translated.')
|
|
420
421
|
.option('-S, --skip-delete <true|false>', 'This will skip the removal of keys on locize. (default: false)', 'false')
|
|
421
422
|
.option('-D, --delete-remote-namespace <true|false>', 'This will delete a complete namespace on locize, if a local file in reference language was deleted. (default: false)', 'false')
|
|
422
423
|
.option('-m, --path-mask <mask>', 'This will define the folder and file structure; do not add a file extension (default: {{language}}/{{namespace}})', `{{language}}${path.sep}{{namespace}}`)
|
|
@@ -474,6 +475,7 @@ program
|
|
|
474
475
|
const updateValues = options.updateValues === 'true';
|
|
475
476
|
const autoTranslate = options.autoTranslate === 'true' ? true : options.autoTranslate === 'false' ? false : undefined;
|
|
476
477
|
const autoTranslateReview = options.autoTranslateReview === 'true';
|
|
478
|
+
const autoTranslateLanguages = options.autoTranslateLanguages;
|
|
477
479
|
const skipDelete = options.skipDelete === 'true';
|
|
478
480
|
const deleteRemoteNamespace = options.deleteRemoteNamespace === 'true';
|
|
479
481
|
const languageFolderPrefix = options.languageFolderPrefix || '';
|
|
@@ -498,6 +500,7 @@ program
|
|
|
498
500
|
updateValues,
|
|
499
501
|
autoTranslate,
|
|
500
502
|
autoTranslateReview,
|
|
503
|
+
autoTranslateLanguages: autoTranslateLanguages && autoTranslateLanguages.split(',').map((l) => l.trim()).filter(Boolean),
|
|
501
504
|
skipDelete,
|
|
502
505
|
deleteRemoteNamespace,
|
|
503
506
|
languageFolderPrefix,
|
|
@@ -19,13 +19,26 @@ const getRemoteLanguages = async (opt) => {
|
|
|
19
19
|
if (res.status >= 300) throw new Error(res.statusText + ' (' + res.status + ')')
|
|
20
20
|
|
|
21
21
|
if (Object.keys(obj).length === 0) {
|
|
22
|
-
|
|
22
|
+
// An empty object is ambiguous: unknown project, a project without
|
|
23
|
+
// languages, or a freshly created project whose languages file has not
|
|
24
|
+
// been generated yet.
|
|
25
|
+
let errMsg = 'Project with id "' + opt.projectId + '" not found — or it has no languages yet!';
|
|
23
26
|
const otherEndpoint = getOtherApiEndpoint(opt.apiEndpoint);
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
if (otherEndpoint) {
|
|
28
|
+
const { res: res2, obj: obj2 } = await request(otherEndpoint + '/languages/' + opt.projectId + '?ts=' + Date.now() + (opt.cdnType === 'standard' ? '' : '&cache=no'), { method: 'get' });
|
|
29
|
+
// obj2 is undefined for non-JSON responses (e.g. an HTML 404 page) —
|
|
30
|
+
// only a real, non-empty JSON object indicates the other cdnType.
|
|
31
|
+
if (res2.status === 200 && obj2 && typeof obj2 === 'object' && Object.keys(obj2).length > 0) {
|
|
32
|
+
errMsg += ` It seems you're using the wrong cdnType. Your Locize project is configured to use "${opt.cdnType === 'standard' ? 'pro' : 'standard'}" but here you've configured "${opt.cdnType}".`;
|
|
33
|
+
const cdnErr = new Error(errMsg);
|
|
34
|
+
cdnErr.code = 'WRONG_CDN_TYPE';
|
|
35
|
+
throw cdnErr
|
|
36
|
+
}
|
|
27
37
|
}
|
|
28
|
-
|
|
38
|
+
const err = new Error(errMsg);
|
|
39
|
+
// lets callers (sync/migrate) bootstrap the languages instead of failing
|
|
40
|
+
err.code = 'EMPTY_LANGUAGES';
|
|
41
|
+
throw err
|
|
29
42
|
}
|
|
30
43
|
|
|
31
44
|
const lngs = Object.keys(obj);
|
package/dist/esm/migrate.js
CHANGED
|
@@ -4,6 +4,7 @@ import flatten from 'flat';
|
|
|
4
4
|
import colors from 'colors';
|
|
5
5
|
import request from './request.js';
|
|
6
6
|
import getRemoteLanguages from './getRemoteLanguages.js';
|
|
7
|
+
import addLanguage from './addLanguage.js';
|
|
7
8
|
import os from 'node:os';
|
|
8
9
|
import mapLimit from './mapLimit.js';
|
|
9
10
|
import download from './download.js';
|
|
@@ -147,23 +148,6 @@ const upload = async (opt, nss) => {
|
|
|
147
148
|
await mapLimit(nssNonRefLng, concurrency, async (ns) => transfer(opt, ns));
|
|
148
149
|
};
|
|
149
150
|
|
|
150
|
-
const addLanguage = async (opt, l) => {
|
|
151
|
-
const url = opt.apiEndpoint + '/language/' + opt.projectId + '/' + l;
|
|
152
|
-
try {
|
|
153
|
-
const { res } = await request(url, {
|
|
154
|
-
method: 'post',
|
|
155
|
-
headers: {
|
|
156
|
-
Authorization: opt.apiKey
|
|
157
|
-
}
|
|
158
|
-
});
|
|
159
|
-
if (res.status >= 300 && res.status !== 412) throw new Error(res.statusText + ' (' + res.status + ')')
|
|
160
|
-
console.log(colors.green(`added language ${l}...`));
|
|
161
|
-
} catch (err) {
|
|
162
|
-
console.log(colors.red(`failed to add language ${l}...`));
|
|
163
|
-
throw err
|
|
164
|
-
}
|
|
165
|
-
};
|
|
166
|
-
|
|
167
151
|
const downloadAfterMigrate = async (opt) => {
|
|
168
152
|
console.log(colors.yellow('downloading translations after migration...'));
|
|
169
153
|
await new Promise((resolve) => setTimeout(resolve, 15000));
|
|
@@ -222,8 +206,14 @@ const migrate = async (opt) => {
|
|
|
222
206
|
try {
|
|
223
207
|
remoteLanguages = await getRemoteLanguages(opt);
|
|
224
208
|
} catch (err) {
|
|
225
|
-
|
|
226
|
-
|
|
209
|
+
// A project without any languages yet is fine for migrate — every local
|
|
210
|
+
// language is created below via addLanguage before the upload.
|
|
211
|
+
if (err.code === 'EMPTY_LANGUAGES' && opt.apiKey) {
|
|
212
|
+
remoteLanguages = [];
|
|
213
|
+
} else {
|
|
214
|
+
console.error(colors.red(err.stack));
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
227
217
|
}
|
|
228
218
|
const localLanguages = [];
|
|
229
219
|
nss.forEach((n) => {
|
package/dist/esm/request.js
CHANGED
|
@@ -37,7 +37,7 @@ async function request (url, options) {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
options.headers = options.headers || {};
|
|
40
|
-
options.headers['User-Agent'] = `locize-cli/v12.
|
|
40
|
+
options.headers['User-Agent'] = `locize-cli/v12.3.0 (node/${process.version}; ${process.platform} ${process.arch})`; // This string is replaced with the actual version at build time by rollup
|
|
41
41
|
options.headers['X-User-Agent'] = options.headers['User-Agent'];
|
|
42
42
|
if (options.body || options.method !== 'get') options.headers['Content-Type'] = 'application/json';
|
|
43
43
|
if (options.body) {
|
package/dist/esm/sync.js
CHANGED
|
@@ -17,6 +17,7 @@ import getProjectStats from './getProjectStats.js';
|
|
|
17
17
|
import xcstrings from 'locize-xcstrings';
|
|
18
18
|
import getBranches from './getBranches.js';
|
|
19
19
|
import isValidUuid from './isValidUuid.js';
|
|
20
|
+
import addLanguage from './addLanguage.js';
|
|
20
21
|
import os from 'node:os';
|
|
21
22
|
import lngCodes from './lngs.js';
|
|
22
23
|
|
|
@@ -433,6 +434,9 @@ async function update (opt, lng, ns, shouldOmit = false) {
|
|
|
433
434
|
if (opt.autoTranslateReview && lng === opt.referenceLanguage) {
|
|
434
435
|
queryParams.append('autotranslatereview', 'true');
|
|
435
436
|
}
|
|
437
|
+
if (opt.autoTranslateLanguages && opt.autoTranslateLanguages.length > 0 && lng === opt.referenceLanguage) {
|
|
438
|
+
queryParams.append('autotranslatelanguages', opt.autoTranslateLanguages.join(','));
|
|
439
|
+
}
|
|
436
440
|
if (so) {
|
|
437
441
|
queryParams.append('omitstatsgeneration', 'true');
|
|
438
442
|
}
|
|
@@ -665,9 +669,101 @@ const checkForMissingLocalNamespaces = (downloads, localNamespaces, opt) => {
|
|
|
665
669
|
return localMissingNamespaces
|
|
666
670
|
};
|
|
667
671
|
|
|
672
|
+
/**
|
|
673
|
+
* Discovers which languages the local files intend, independent of the remote
|
|
674
|
+
* state: explicit --language/--languages win; otherwise the language folders
|
|
675
|
+
* under opt.path (only when the path mask starts with the language segment —
|
|
676
|
+
* the default `{{language}}/{{namespace}}` layout).
|
|
677
|
+
*/
|
|
678
|
+
const discoverLocalLanguages = (opt) => {
|
|
679
|
+
if (opt.languages && opt.languages.length > 0) return [...opt.languages]
|
|
680
|
+
if (opt.language) return [opt.language]
|
|
681
|
+
const languageToken = `${opt.pathMaskInterpolationPrefix || '{{'}language${opt.pathMaskInterpolationSuffix || '}}'}`;
|
|
682
|
+
const pathMask = opt.pathMask || `${languageToken}${path.sep}{{namespace}}`;
|
|
683
|
+
if (!pathMask.startsWith(`${opt.languageFolderPrefix || ''}${languageToken}`)) return []
|
|
684
|
+
try {
|
|
685
|
+
return fs.readdirSync(opt.path)
|
|
686
|
+
.filter((file) => fs.statSync(path.join(opt.path, file)).isDirectory())
|
|
687
|
+
.map((dir) => (opt.languageFolderPrefix ? dir.replace(opt.languageFolderPrefix, '') : dir))
|
|
688
|
+
} catch (err) {
|
|
689
|
+
return []
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Bootstraps the remote languages of a project that has none yet by creating
|
|
695
|
+
* the locally intended languages (the server authorizes: admin keys always;
|
|
696
|
+
* any write-capable key while the project is still empty), then waits for the
|
|
697
|
+
* asynchronously regenerated languages file.
|
|
698
|
+
*/
|
|
699
|
+
const bootstrapRemoteLanguages = async (opt) => {
|
|
700
|
+
const noLanguagesHelp = `Project with id "${opt.projectId}" has no languages yet`;
|
|
701
|
+
const localLanguages = discoverLocalLanguages(opt);
|
|
702
|
+
if (localLanguages.length === 0) {
|
|
703
|
+
throw new Error(`${noLanguagesHelp} and no local languages were found to create — add languages in the project settings (or push initial content via "locize migrate"), then retry.`)
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
console.log(colors.yellow(`project has no languages yet — creating ${localLanguages.join(', ')}...`));
|
|
707
|
+
try {
|
|
708
|
+
for (const l of localLanguages) {
|
|
709
|
+
await addLanguage(opt, l);
|
|
710
|
+
}
|
|
711
|
+
} catch (err) {
|
|
712
|
+
if (err.status === 401) {
|
|
713
|
+
throw new Error(`${noLanguagesHelp} and this api-key may not create them — use an admin-scoped api-key (or any write-capable key while the project is still empty), or add the languages in the project settings, then retry.`)
|
|
714
|
+
}
|
|
715
|
+
throw err
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// the languages file is regenerated asynchronously — retry a few times
|
|
719
|
+
let lastError;
|
|
720
|
+
for (let i = 0; i < 3; i++) {
|
|
721
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
722
|
+
try {
|
|
723
|
+
return await getRemoteLanguages(opt)
|
|
724
|
+
} catch (err) {
|
|
725
|
+
lastError = err;
|
|
726
|
+
if (err.code !== 'EMPTY_LANGUAGES') throw err
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
throw lastError
|
|
730
|
+
};
|
|
731
|
+
|
|
668
732
|
async function continueToSync (opt) {
|
|
669
733
|
console.log(colors.grey('checking remote (locize)...'));
|
|
670
|
-
|
|
734
|
+
let remoteLanguages;
|
|
735
|
+
try {
|
|
736
|
+
remoteLanguages = await getRemoteLanguages(opt);
|
|
737
|
+
} catch (err) {
|
|
738
|
+
// A project without languages is bootstrapped on the fly (unless this is
|
|
739
|
+
// a dry run or we have no key to create them with).
|
|
740
|
+
if (err.code !== 'EMPTY_LANGUAGES' || !opt.apiKey || opt.dry) throw err
|
|
741
|
+
remoteLanguages = await bootstrapRemoteLanguages(opt);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Languages that exist locally but not remotely are created too — the
|
|
745
|
+
// server decides whether the key may (admin always; write-capable keys
|
|
746
|
+
// while the project is empty). On rejection we warn instead of silently
|
|
747
|
+
// ignoring the local files (the pre-existing behavior).
|
|
748
|
+
const missingRemotely = discoverLocalLanguages(opt).filter((l) => remoteLanguages.indexOf(l) < 0);
|
|
749
|
+
if (missingRemotely.length > 0) {
|
|
750
|
+
if (opt.apiKey && !opt.dry) {
|
|
751
|
+
const created = [];
|
|
752
|
+
for (const l of missingRemotely) {
|
|
753
|
+
try {
|
|
754
|
+
await addLanguage(opt, l);
|
|
755
|
+
created.push(l);
|
|
756
|
+
} catch (err) {
|
|
757
|
+
console.log(colors.yellow(`language "${l}" exists locally but not in your locize project and this api-key may not create it (${err.message}) — add it in the project settings or use an admin api-key.`));
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
// the regenerated languages file lags behind — continue with the
|
|
761
|
+
// created languages included so this run already syncs them
|
|
762
|
+
created.forEach((l) => remoteLanguages.push(l));
|
|
763
|
+
} else {
|
|
764
|
+
missingRemotely.forEach((l) => console.log(colors.yellow(`language "${l}" exists locally but not in your locize project${opt.dry ? ' (dry run: not creating it)' : ' (no api-key: cannot create it)'}.`)));
|
|
765
|
+
}
|
|
766
|
+
}
|
|
671
767
|
|
|
672
768
|
if (opt.referenceLanguageOnly && opt.language && opt.referenceLanguage !== opt.language) {
|
|
673
769
|
opt.referenceLanguage = opt.language;
|