locize-cli 8.6.2 → 8.7.1

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 CHANGED
@@ -5,6 +5,14 @@ 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
+ ## [8.7.1](https://github.com/locize/locize-cli/compare/v8.7.0...v8.7.1) - 2025-04-02
9
+
10
+ - make generated files POSIX compliant, addresses [107](https://github.com/locize/locize-cli/issues/107)
11
+
12
+ ## [8.7.0](https://github.com/locize/locize-cli/compare/v8.6.2...v8.7.0) - 2025-03-20
13
+
14
+ - intruduce xcstrings format [106](https://github.com/locize/locize-cli/issues/106)
15
+
8
16
  ## [8.6.2](https://github.com/locize/locize-cli/compare/v8.6.1...v8.6.2) - 2025-03-18
9
17
 
10
18
  - fix migrate command to handle paged update
package/README.md CHANGED
@@ -84,7 +84,7 @@ or
84
84
  locize download
85
85
  ```
86
86
 
87
- or add a format like (json, nested, flat, xliff2, xliff12, xlf2, xlf12, android, yaml, yaml-rails, yaml-nested, yml, yml-rails, yml-nested, csv, xlsx, po, strings, resx, fluent, tmx, laravel, properties)
87
+ or add a format like (json, nested, flat, xliff2, xliff12, xlf2, xlf12, android, yaml, yaml-rails, yaml-nested, yml, yml-rails, yml-nested, csv, xlsx, po, strings, resx, fluent, tmx, laravel, properties, xcstrings)
88
88
 
89
89
  ```sh
90
90
  locize download --project-id my-project-id-93e1-442a-ab35-24331fa294ba --ver latest --language en --namespace namespace1 --path ./backup --format android
@@ -133,7 +133,7 @@ Add your api-key and your project-id and let's go...
133
133
  locize sync --api-key my-api-key-d9de-4f55-9855-a9ef0ed44672 --project-id my-project-id-93e1-442a-ab35-24331fa294ba
134
134
  ```
135
135
 
136
- or add a format like (json, nested, flat, xliff2, xliff12, xlf2, xlf12, android, yaml, yaml-rails, yaml-nested, yml, yml-rails, yml-nested, csv, xlsx, po, strings, resx, fluent, tmx, laravel, properties)
136
+ or add a format like (json, nested, flat, xliff2, xliff12, xlf2, xlf12, android, yaml, yaml-rails, yaml-nested, yml, yml-rails, yml-nested, csv, xlsx, po, strings, resx, fluent, tmx, laravel, properties, xcstrings)
137
137
 
138
138
  ```sh
139
139
  locize sync --api-key my-api-key-d9de-4f55-9855-a9ef0ed44672 --project-id my-project-id-93e1-442a-ab35-24331fa294ba --format android
@@ -158,9 +158,10 @@ Navigate to your locize project and check the results => [www.locize.app](https:
158
158
 
159
159
  ## Push missing keys to locize from your repository (or any other local directory)
160
160
  This is useful, when i.e. using [i18next-scanner](https://github.com/i18next/i18next-scanner), like described [here](https://github.com/locize/i18next-locize-backend/issues/315#issuecomment-586967039).
161
- The save-missing command uses the [missing API](https://www.locize.com/docs/api#missing-translations) and the sync command uses the [update API](https://www.locize.com/docs/api#update-remove-translations)
161
+ The save-missing command uses the [missing API](https://www.locize.com/docs/api#missing-translations) and the sync command uses the [update API](https://www.locize.com/docs/api#update-remove-translations).
162
162
  So, if you want to save new keys (that does not exist in locize), the save-missing command is the better choice.
163
163
  Doing so, you can then for example make use of the “created by missing API" filter in the locize UI.
164
+ Also, using this save-missing command does not generate extra modification costs.
164
165
 
165
166
  But if you need to update existing keys, the sync command is the correct choice.
166
167
 
@@ -192,7 +193,7 @@ Add your api-key and your project-id and let's go...
192
193
  locize save-missing --api-key my-api-key-d9de-4f55-9855-a9ef0ed44672 --project-id my-project-id-93e1-442a-ab35-24331fa294ba
193
194
  ```
194
195
 
195
- or add a format like (json, nested, flat, xliff2, xliff12, xlf2, xlf12, android, yaml, yaml-rails, yaml-nested, yml, yml-rails, yml-nested, csv, xlsx, po, strings, resx, fluent, tmx, laravel, properties)
196
+ or add a format like (json, nested, flat, xliff2, xliff12, xlf2, xlf12, android, yaml, yaml-rails, yaml-nested, yml, yml-rails, yml-nested, csv, xlsx, po, strings, resx, fluent, tmx, laravel, properties, xcstrings)
196
197
 
197
198
  ```sh
198
199
  locize save-missing --api-key my-api-key-d9de-4f55-9855-a9ef0ed44672 --project-id my-project-id-93e1-442a-ab35-24331fa294ba --format android
package/bin/locize CHANGED
@@ -232,7 +232,7 @@ program
232
232
  .option('-p, --path <path>', `Specify the path that should be used (default: ${process.cwd()})`, process.cwd())
233
233
  .option('-g, --get-path <url>', `Specify the get-path url that should be used (default: ${getPathUrl})`)
234
234
  .option('-k, --api-key <apiKey>', 'The api-key that should be used')
235
- .option('-f, --format <json>', 'File format of namespaces (default: json; [nested, flat, xliff2, xliff12, xlf2, xlf12, android, yaml, yaml-rails, yaml-nested, yml, yml-rails, yml-nested, csv, xlsx, po, strings, resx, fluent, tmx, laravel, properties])', 'json')
235
+ .option('-f, --format <json>', 'File format of namespaces (default: json; [nested, flat, xliff2, xliff12, xlf2, xlf12, android, yaml, yaml-rails, yaml-nested, yml, yml-rails, yml-nested, csv, xlsx, po, strings, resx, fluent, tmx, laravel, properties, xcstrings])', 'json')
236
236
  .option('-s, --skip-empty <true|false>', 'Skips to download empty files (default: true)', 'true')
237
237
  .option('-P, --language-folder-prefix <prefix>', 'This will be added as a local folder name prefix in front of the language.', '')
238
238
  .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}}`)
@@ -361,7 +361,7 @@ program
361
361
  .option('-p, --path <path>', `Specify the path that should be used (default: ${process.cwd()})`, process.cwd())
362
362
  .option('-B, --backup-deleted-path <path>', 'Saves the segments that will be deleted in this path')
363
363
  .option('-A, --auto-create-path <true|false>', 'This will automatically make sure the --path is created. (default: true)', 'true')
364
- .option('-f, --format <json>', 'File format of namespaces (default: json; [nested, flat, xliff2, xliff12, xlf2, xlf12, android, yaml, yaml-rails, yaml-nested, yml, yml-rails, yml-nested, csv, xlsx, po, strings, resx, fluent, tmx, laravel, properties])', 'json')
364
+ .option('-f, --format <json>', 'File format of namespaces (default: json; [nested, flat, xliff2, xliff12, xlf2, xlf12, android, yaml, yaml-rails, yaml-nested, yml, yml-rails, yml-nested, csv, xlsx, po, strings, resx, fluent, tmx, laravel, properties, xcstrings])', 'json')
365
365
  .option('-s, --skip-empty <true|false>', 'Skips to download empty files (default: false)', 'false')
366
366
  .option('-c, --clean <true|false>', 'Removes all local files by removing the whole folder (default: false)', 'false')
367
367
  .option('-cf, --clean-local-files <true|false>', 'Removes all local files without removing any folder (default: false)', 'false')
@@ -472,7 +472,7 @@ program
472
472
  .option('-i, --project-id <projectId>', 'The project-id that should be used')
473
473
  .option('-v, --ver <version>', 'Found namespaces will be matched to this version (default: latest)')
474
474
  .option('-p, --path <path>', `Specify the path that should be used (default: ${process.cwd()})`, process.cwd())
475
- .option('-f, --format <json>', 'File format of namespaces (default: json; [nested, flat, xliff2, xliff12, xlf2, xlf12, android, yaml, yaml-rails, yaml-nested, yml, yml-rails, yml-nested, csv, xlsx, po, strings, resx, fluent, tmx, laravel, properties])', 'json')
475
+ .option('-f, --format <json>', 'File format of namespaces (default: json; [nested, flat, xliff2, xliff12, xlf2, xlf12, android, yaml, yaml-rails, yaml-nested, yml, yml-rails, yml-nested, csv, xlsx, po, strings, resx, fluent, tmx, laravel, properties, xcstrings])', 'json')
476
476
  .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}}`)
477
477
  .option('-P, --language-folder-prefix <prefix>', 'This will be added as a local folder name prefix in front of the language.', '')
478
478
  .option('-d, --dry <true|false>', 'Dry run (default: false)', 'false')
@@ -695,7 +695,7 @@ program
695
695
  .command('format [fileOrDirectory]')
696
696
  .alias('ft')
697
697
  .description('format local files')
698
- .option('-f, --format <json>', 'File format of namespaces (default: json; [nested, flat, xliff2, xliff12, xlf2, xlf12, android, yaml, yaml-rails, yaml-nested, yml, yml-rails, yml-nested, csv, xlsx, po, strings, resx, fluent, tmx, laravel, properties])', 'json')
698
+ .option('-f, --format <json>', 'File format of namespaces (default: json; [nested, flat, xliff2, xliff12, xlf2, xlf12, android, yaml, yaml-rails, yaml-nested, yml, yml-rails, yml-nested, csv, xlsx, po, strings, resx, fluent, tmx, laravel, properties, xcstrings])', 'json')
699
699
  .option('-l, --reference-language <lng>', 'Some format conversions need to know the reference language.', 'en')
700
700
  .option('-d, --dry <true|false>', 'Dry run (default: false)', 'false')
701
701
  .option('-C, --config-path <configPath>', `Specify the path to the optional locize config file (default: ${configInWorkingDirectory} or ${configInHome})`)
@@ -13,6 +13,7 @@ const ftl2js = require('fluent_conv/cjs/ftl2js');
13
13
  const tmx2js = require('tmexchange/cjs/tmx2js');
14
14
  const laravel2js = require('laravelphp/cjs/laravel2js');
15
15
  const javaProperties = require('@js.properties/properties');
16
+ const xcstrings2locize = require('locize-xcstrings/cjs/xcstrings2locize');
16
17
  const flatten = require('flat');
17
18
  const prepareCombinedImport = require('./combineSubkeyPreprocessor').prepareImport;
18
19
 
@@ -293,6 +294,10 @@ const convertToFlatFormat = (opt, data, lng, cb) => {
293
294
  cb(null, javaProperties.parseToProperties(data.toString()));
294
295
  return;
295
296
  }
297
+ if (opt.format === 'xcstrings') {
298
+ cb(null, xcstrings2locize(data.toString()));
299
+ return;
300
+ }
296
301
  cb(new Error(`${opt.format} is not a valid format!`));
297
302
  } catch (err) {
298
303
  cb(err);
package/download.js CHANGED
@@ -12,6 +12,15 @@ const convertToDesiredFormat = require('./convertToDesiredFormat');
12
12
  const formats = require('./formats');
13
13
  const getProjectStats = require('./getProjectStats');
14
14
  const reversedFileExtensionsMap = formats.reversedFileExtensionsMap;
15
+ const locize2xcstrings = require('locize-xcstrings/cjs/locize2xcstrings');
16
+
17
+ function getInfosInUrl(download) {
18
+ const splitted = download.key.split('/');
19
+ const version = splitted[download.isPrivate ? 2 : 1];
20
+ const language = splitted[download.isPrivate ? 3 : 2];
21
+ const namespace = splitted[download.isPrivate ? 4 : 3];
22
+ return { version, language, namespace };
23
+ }
15
24
 
16
25
  function handleDownload(opt, url, err, res, downloads, cb) {
17
26
  if (err || (downloads && (downloads.errorMessage || downloads.message))) {
@@ -34,113 +43,267 @@ function handleDownload(opt, url, err, res, downloads, cb) {
34
43
  return;
35
44
  }
36
45
 
37
- async.eachLimit(downloads, 20, (download, clb) => {
38
- const splitted = download.key.split('/');
39
- const version = splitted[download.isPrivate ? 2 : 1];
40
- const lng = splitted[download.isPrivate ? 3 : 2];
41
- const namespace = splitted[download.isPrivate ? 4 : 3];
42
- opt.isPrivate = download.isPrivate;
43
-
44
- if (opt.namespace && opt.namespace !== namespace) return clb(null);
45
- if (opt.namespaces && opt.namespaces.length > 0 && opt.namespaces.indexOf(namespace) < 0) return clb(null);
46
+ if (opt.format === 'xcstrings') { // 1 file per namespace including all languages
47
+ const downloadsByNamespace = {};
48
+ downloads.forEach((download) => {
49
+ const { version, namespace } = getInfosInUrl(download);
50
+ opt.isPrivate = download.isPrivate;
46
51
 
47
- getRemoteNamespace(opt, lng, namespace, (err, ns, lastModified) => {
48
- if (err) return clb(err);
52
+ downloadsByNamespace[version] = downloadsByNamespace[version] || {};
53
+ downloadsByNamespace[version][namespace] = downloadsByNamespace[version][namespace] || [];
54
+ downloadsByNamespace[version][namespace].push(download);
55
+ });
49
56
 
50
- if (opt.skipEmpty && Object.keys(flatten(ns)).length === 0) {
51
- return clb(null);
57
+ async.eachSeries(Object.keys(downloadsByNamespace), (version, clb) => {
58
+ async.eachLimit(Object.keys(downloadsByNamespace[version]), 20, (ns, clb) => {
59
+ if (opt.namespace && opt.namespace !== ns) return clb(null);
60
+ if (opt.namespaces && opt.namespaces.length > 0 && opt.namespaces.indexOf(ns) < 0) return clb(null);
61
+
62
+ const locizeData = {
63
+ sourceLng: opt.referenceLanguage,
64
+ resources: {}
65
+ };
66
+ async.eachLimit(downloadsByNamespace[version][ns], 20, (download, clb2) => {
67
+ const { language } = getInfosInUrl(download);
68
+ getRemoteNamespace(opt, language, ns, (err, ns, lastModified) => {
69
+ if (err) return clb2(err);
70
+
71
+ if (opt.skipEmpty && Object.keys(flatten(ns)).length === 0) {
72
+ return clb2(null);
73
+ }
74
+
75
+ locizeData.resources[language] = ns;
76
+ clb2();
77
+ });
78
+ }, (err) => {
79
+ if (err) return clb(err);
80
+
81
+ try {
82
+ const result = locize2xcstrings(locizeData);
83
+ const converted = JSON.stringify(result, null, 2);
84
+
85
+ var filledMask = opt.pathMask.replace(`${opt.pathMaskInterpolationPrefix}language${opt.pathMaskInterpolationSuffix}`, '').replace(`${opt.pathMaskInterpolationPrefix}namespace${opt.pathMaskInterpolationSuffix}`, ns) + reversedFileExtensionsMap[opt.format];
86
+ var mkdirPath;
87
+ if (filledMask.lastIndexOf(path.sep) > 0) {
88
+ mkdirPath = filledMask.substring(0, filledMask.lastIndexOf(path.sep));
89
+ }
90
+
91
+ function logAndClb(err) {
92
+ if (err) return clb(err);
93
+ if (!cb) console.log(colors.green(`downloaded ${version}/${ns} to ${opt.path}...`));
94
+ if (clb) clb(null);
95
+ }
96
+
97
+ const fileContent = opt.format !== 'xlsx' ? (converted + '\n') : converted;
98
+ if (!opt.version) {
99
+ if (mkdirPath) mkdirp.sync(path.join(opt.path, version, mkdirPath));
100
+ fs.writeFile(path.join(opt.path, version, filledMask), fileContent, logAndClb);
101
+ return;
102
+ }
103
+
104
+ if (mkdirPath) mkdirp.sync(path.join(opt.path, mkdirPath));
105
+ fs.writeFile(path.join(opt.path, filledMask), converted, logAndClb);
106
+ } catch (e) {
107
+ err.message = 'Invalid content for "' + opt.format + '" format!\n' + (err.message || '');
108
+ return clb(err);
109
+ }
110
+ });
111
+ }, clb);
112
+ }, (err) => {
113
+ if (err) {
114
+ if (!cb) {
115
+ console.error(colors.red(err.message));
116
+ process.exit(1);
117
+ }
118
+ if (cb) cb(err);
119
+ return;
52
120
  }
53
121
 
54
- convertToDesiredFormat(opt, namespace, lng, ns, lastModified, (err, converted) => {
55
- if (err) {
56
- err.message = 'Invalid content for "' + opt.format + '" format!\n' + (err.message || '');
57
- return clb(err);
58
- }
59
- var filledMask = opt.pathMask.replace(`${opt.pathMaskInterpolationPrefix}language${opt.pathMaskInterpolationSuffix}`, lng).replace(`${opt.pathMaskInterpolationPrefix}namespace${opt.pathMaskInterpolationSuffix}`, namespace) + reversedFileExtensionsMap[opt.format];
60
- var mkdirPath;
61
- if (filledMask.lastIndexOf(path.sep) > 0) {
62
- mkdirPath = filledMask.substring(0, filledMask.lastIndexOf(path.sep));
63
- }
64
- if (!opt.version) {
65
- if (mkdirPath) mkdirp.sync(path.join(opt.path, version, mkdirPath));
66
- fs.writeFile(path.join(opt.path, version, filledMask), converted, clb);
67
- return;
68
- }
69
- if (!opt.language) {
70
- if (mkdirPath) mkdirp.sync(path.join(opt.path, mkdirPath));
71
- fs.writeFile(path.join(opt.path, filledMask), converted, clb);
72
- return;
122
+ if (cb) cb(null);
123
+ });
124
+ } else { // 1 file per namespace/lng
125
+ async.eachLimit(downloads, 20, (download, clb) => {
126
+ const { version, language, namespace } = getInfosInUrl(download);
127
+ opt.isPrivate = download.isPrivate;
128
+
129
+ if (opt.namespace && opt.namespace !== namespace) return clb(null);
130
+ if (opt.namespaces && opt.namespaces.length > 0 && opt.namespaces.indexOf(namespace) < 0) return clb(null);
131
+
132
+ getRemoteNamespace(opt, language, namespace, (err, ns, lastModified) => {
133
+ if (err) return clb(err);
134
+
135
+ if (opt.skipEmpty && Object.keys(flatten(ns)).length === 0) {
136
+ return clb(null);
73
137
  }
74
138
 
75
- if (filledMask.indexOf(path.sep) > 0) filledMask = filledMask.replace(opt.languageFolderPrefix + lng, '');
76
- const parentDir = path.dirname(path.join(opt.path, filledMask));
77
- mkdirp.sync(parentDir);
78
- fs.writeFile(path.join(opt.path, filledMask), converted, clb);
139
+ convertToDesiredFormat(opt, namespace, language, ns, lastModified, (err, converted) => {
140
+ if (err) {
141
+ err.message = 'Invalid content for "' + opt.format + '" format!\n' + (err.message || '');
142
+ return clb(err);
143
+ }
144
+ var filledMask = opt.pathMask.replace(`${opt.pathMaskInterpolationPrefix}language${opt.pathMaskInterpolationSuffix}`, language).replace(`${opt.pathMaskInterpolationPrefix}namespace${opt.pathMaskInterpolationSuffix}`, namespace) + reversedFileExtensionsMap[opt.format];
145
+ var mkdirPath;
146
+ if (filledMask.lastIndexOf(path.sep) > 0) {
147
+ mkdirPath = filledMask.substring(0, filledMask.lastIndexOf(path.sep));
148
+ }
149
+ const fileContent = opt.format !== 'xlsx' ? (converted + '\n') : converted;
150
+ if (!opt.version) {
151
+ if (mkdirPath) mkdirp.sync(path.join(opt.path, version, mkdirPath));
152
+ fs.writeFile(path.join(opt.path, version, filledMask), fileContent, clb);
153
+ return;
154
+ }
155
+ if (!opt.language) {
156
+ if (mkdirPath) mkdirp.sync(path.join(opt.path, mkdirPath));
157
+ fs.writeFile(path.join(opt.path, filledMask), fileContent, clb);
158
+ return;
159
+ }
160
+
161
+ if (filledMask.indexOf(path.sep) > 0) filledMask = filledMask.replace(opt.languageFolderPrefix + language, '');
162
+ const parentDir = path.dirname(path.join(opt.path, filledMask));
163
+ mkdirp.sync(parentDir);
164
+ fs.writeFile(path.join(opt.path, filledMask), fileContent, clb);
165
+ });
79
166
  });
80
- });
81
- }, (err) => {
82
- if (err) {
83
- if (!cb) {
84
- console.error(colors.red(err.message));
85
- process.exit(1);
167
+ }, (err) => {
168
+ if (err) {
169
+ if (!cb) {
170
+ console.error(colors.red(err.message));
171
+ process.exit(1);
172
+ }
173
+ if (cb) cb(err);
174
+ return;
86
175
  }
87
- if (cb) cb(err);
88
- return;
89
- }
90
176
 
91
- if (!cb) console.log(colors.green(`downloaded ${url} to ${opt.path}...`));
92
- if (cb) cb(null);
93
- });
177
+ if (!cb) console.log(colors.green(`downloaded ${url} to ${opt.path}...`));
178
+ if (cb) cb(null);
179
+ });
180
+ }
94
181
  }
95
182
 
96
183
  function handlePull(opt, toDownload, cb) {
97
184
  const url = opt.apiPath + '/pull/' + opt.projectId + '/' + opt.version;
98
- async.eachLimit(toDownload, 5, (download, clb) => {
99
- const lng = download.language;
100
- const namespace = download.namespace;
101
185
 
102
- getRemoteNamespace(opt, lng, namespace, (err, ns, lastModified) => {
103
- if (err) return clb(err);
186
+ if (opt.format === 'xcstrings') { // 1 file per namespace including all languages
187
+ const downloadsByNamespace = {};
188
+ toDownload.forEach((download) => {
189
+ const { namespace } = download;
190
+ downloadsByNamespace[namespace] = downloadsByNamespace[namespace] || [];
191
+ downloadsByNamespace[namespace].push(download);
192
+ });
104
193
 
105
- if (opt.skipEmpty && Object.keys(flatten(ns)).length === 0) {
106
- return clb(null);
107
- }
194
+ async.eachLimit(Object.keys(downloadsByNamespace), 5, (namespace, clb) => {
195
+ if (opt.namespace && opt.namespace !== namespace) return clb(null);
196
+ if (opt.namespaces && opt.namespaces.length > 0 && opt.namespaces.indexOf(namespace) < 0) return clb(null);
108
197
 
109
- convertToDesiredFormat(opt, namespace, lng, ns, lastModified, (err, converted) => {
110
- if (err) {
198
+ const locizeData = {
199
+ sourceLng: opt.referenceLanguage,
200
+ resources: {}
201
+ };
202
+
203
+ async.eachLimit(downloadsByNamespace[namespace], 5, (download, clb2) => {
204
+ const { language } = download;
205
+ opt.raw = true;
206
+ getRemoteNamespace(opt, language, namespace, (err, ns, lastModified) => {
207
+ if (err) return clb2(err);
208
+
209
+ if (opt.skipEmpty && Object.keys(flatten(ns)).length === 0) {
210
+ return clb2(null);
211
+ }
212
+
213
+ locizeData.resources[language] = ns;
214
+ clb2();
215
+ });
216
+ }, (err) => {
217
+ if (err) return clb(err);
218
+
219
+ try {
220
+ const result = locize2xcstrings(locizeData);
221
+ const converted = JSON.stringify(result, null, 2);
222
+
223
+ var filledMask = opt.pathMask.replace(`${opt.pathMaskInterpolationPrefix}language${opt.pathMaskInterpolationSuffix}`, '').replace(`${opt.pathMaskInterpolationPrefix}namespace${opt.pathMaskInterpolationSuffix}`, namespace) + reversedFileExtensionsMap[opt.format];
224
+ var mkdirPath;
225
+ if (filledMask.lastIndexOf(path.sep) > 0) {
226
+ mkdirPath = filledMask.substring(0, filledMask.lastIndexOf(path.sep));
227
+ }
228
+
229
+ function logAndClb(err) {
230
+ if (err) return clb(err);
231
+ if (!cb) console.log(colors.green(`downloaded ${opt.version}/${namespace} to ${opt.path}...`));
232
+ if (clb) clb(null);
233
+ }
234
+
235
+ if (mkdirPath) mkdirp.sync(path.join(opt.path, mkdirPath));
236
+ const fileContent = opt.format !== 'xlsx' ? (converted + '\n') : converted;
237
+ fs.writeFile(path.join(opt.path, filledMask), fileContent, logAndClb);
238
+ } catch (e) {
111
239
  err.message = 'Invalid content for "' + opt.format + '" format!\n' + (err.message || '');
112
240
  return clb(err);
113
241
  }
114
- var filledMask = opt.pathMask.replace(`${opt.pathMaskInterpolationPrefix}language${opt.pathMaskInterpolationSuffix}`, lng).replace(`${opt.pathMaskInterpolationPrefix}namespace${opt.pathMaskInterpolationSuffix}`, namespace) + reversedFileExtensionsMap[opt.format];
115
- var mkdirPath;
116
- if (filledMask.lastIndexOf(path.sep) > 0) {
117
- mkdirPath = filledMask.substring(0, filledMask.lastIndexOf(path.sep));
242
+ });
243
+ }, (err) => {
244
+ if (err) {
245
+ if (!cb) {
246
+ console.error(colors.red(err.message));
247
+ process.exit(1);
118
248
  }
119
- if (!opt.language) {
120
- if (mkdirPath) mkdirp.sync(path.join(opt.path, mkdirPath));
121
- fs.writeFile(path.join(opt.path, filledMask), converted, clb);
122
- return;
249
+ if (cb) cb(err);
250
+ return;
251
+ }
252
+
253
+ if (cb) cb(null);
254
+ });
255
+ } else { // 1 file per namespace/lng
256
+ async.eachLimit(toDownload, 5, (download, clb) => {
257
+ const lng = download.language;
258
+ const namespace = download.namespace;
259
+
260
+ if (opt.namespace && opt.namespace !== namespace) return clb(null);
261
+ if (opt.namespaces && opt.namespaces.length > 0 && opt.namespaces.indexOf(namespace) < 0) return clb(null);
262
+
263
+ getRemoteNamespace(opt, lng, namespace, (err, ns, lastModified) => {
264
+ if (err) return clb(err);
265
+
266
+ if (opt.skipEmpty && Object.keys(flatten(ns)).length === 0) {
267
+ return clb(null);
123
268
  }
124
269
 
125
- if (filledMask.indexOf(path.sep) > 0) filledMask = filledMask.replace(opt.languageFolderPrefix + lng, '');
126
- const parentDir = path.dirname(path.join(opt.path, filledMask));
127
- mkdirp.sync(parentDir);
128
- fs.writeFile(path.join(opt.path, filledMask), converted, clb);
270
+ convertToDesiredFormat(opt, namespace, lng, ns, lastModified, (err, converted) => {
271
+ if (err) {
272
+ err.message = 'Invalid content for "' + opt.format + '" format!\n' + (err.message || '');
273
+ return clb(err);
274
+ }
275
+ var filledMask = opt.pathMask.replace(`${opt.pathMaskInterpolationPrefix}language${opt.pathMaskInterpolationSuffix}`, lng).replace(`${opt.pathMaskInterpolationPrefix}namespace${opt.pathMaskInterpolationSuffix}`, namespace) + reversedFileExtensionsMap[opt.format];
276
+ var mkdirPath;
277
+ if (filledMask.lastIndexOf(path.sep) > 0) {
278
+ mkdirPath = filledMask.substring(0, filledMask.lastIndexOf(path.sep));
279
+ }
280
+ const fileContent = opt.format !== 'xlsx' ? (converted + '\n') : converted;
281
+ if (!opt.language) {
282
+ if (mkdirPath) mkdirp.sync(path.join(opt.path, mkdirPath));
283
+ fs.writeFile(path.join(opt.path, filledMask), fileContent, clb);
284
+ return;
285
+ }
286
+
287
+ if (filledMask.indexOf(path.sep) > 0) filledMask = filledMask.replace(opt.languageFolderPrefix + lng, '');
288
+ const parentDir = path.dirname(path.join(opt.path, filledMask));
289
+ mkdirp.sync(parentDir);
290
+ fs.writeFile(path.join(opt.path, filledMask), fileContent, clb);
291
+ });
129
292
  });
130
- });
131
- }, (err) => {
132
- if (err) {
133
- if (!cb) {
134
- console.error(colors.red(err.message));
135
- process.exit(1);
293
+ }, (err) => {
294
+ if (err) {
295
+ if (!cb) {
296
+ console.error(colors.red(err.message));
297
+ process.exit(1);
298
+ }
299
+ if (cb) cb(err);
300
+ return;
136
301
  }
137
- if (cb) cb(err);
138
- return;
139
- }
140
302
 
141
- if (!cb) console.log(colors.green(`downloaded ${url} to ${opt.path}...`));
142
- if (cb) cb(null);
143
- });
303
+ if (!cb) console.log(colors.green(`downloaded ${url} to ${opt.path}...`));
304
+ if (cb) cb(null);
305
+ });
306
+ }
144
307
  }
145
308
 
146
309
  const handleError = (err, cb) => {
package/format.js CHANGED
@@ -113,7 +113,9 @@ function writeLocalFile(opt, file, clb) {
113
113
  return clb(null, true);
114
114
  }
115
115
 
116
- fs.writeFile(file.path, file.converted, (err) => clb(err, true));
116
+ const fileContent = opt.format !== 'xlsx' ? (file.converted + '\n') : file.converted;
117
+
118
+ fs.writeFile(file.path, fileContent, (err) => clb(err, true));
117
119
  }
118
120
 
119
121
  function writeLocalFiles(opt, files, clb) {
package/formats.js CHANGED
@@ -13,7 +13,8 @@ const fileExtensionsMap = {
13
13
  '.ftl': ['fluent'],
14
14
  '.tmx': ['tmx'],
15
15
  '.php': ['laravel'],
16
- '.properties': ['properties']
16
+ '.properties': ['properties'],
17
+ '.xcstrings': ['xcstrings']
17
18
  };
18
19
 
19
20
  const acceptedFileExtensions = Object.keys(fileExtensionsMap);
@@ -4,9 +4,26 @@ const sortFlatResources = require('./sortFlatResources');
4
4
 
5
5
  const getRandomDelay = (delayFrom, delayTo) => Math.floor(Math.random() * delayTo) + delayFrom;
6
6
 
7
+ function onlyKeysFlat(resources, prefix, ret) {
8
+ if (!resources) resources;
9
+ ret = ret || {};
10
+ Object.keys(resources).forEach((k) => {
11
+ if (typeof resources[k] === 'string' || !resources[k] || typeof resources[k].value === 'string') {
12
+ if (prefix) {
13
+ ret[prefix + '.' + k] = resources[k];
14
+ } else {
15
+ ret[k] = resources[k];
16
+ }
17
+ } else {
18
+ onlyKeysFlat(resources[k], prefix ? prefix + '.' + k : k, ret);
19
+ }
20
+ });
21
+ return ret;
22
+ }
23
+
7
24
  const pullNamespacePaged = (opt, lng, ns, cb, next, retry) => {
8
25
  next = next || '';
9
- request(opt.apiPath + '/pull/' + opt.projectId + '/' + opt.version + '/' + lng + '/' + ns + '?' + 'next=' + next + '&ts=' + Date.now(), {
26
+ request(opt.apiPath + '/pull/' + opt.projectId + '/' + opt.version + '/' + lng + '/' + ns + '?' + 'next=' + next + (opt.raw ? '&raw=true' : '') + '&ts=' + Date.now(), {
10
27
  method: 'get',
11
28
  headers: {
12
29
  'Authorization': opt.apiKey
@@ -31,7 +48,7 @@ const pullNamespacePaged = (opt, lng, ns, cb, next, retry) => {
31
48
  }
32
49
 
33
50
  cb(null, {
34
- result: sortFlatResources(flatten(obj)),
51
+ result: opt.raw ? sortFlatResources(onlyKeysFlat(obj)) : sortFlatResources(flatten(obj)),
35
52
  next: res.headers.get('x-next-page'),
36
53
  lastModified: res.headers.get('last-modified') ? new Date(res.headers.get('last-modified')) : undefined
37
54
  });
package/lngs.json CHANGED
@@ -111,6 +111,7 @@
111
111
  "kg",
112
112
  "ko",
113
113
  "ku",
114
+ "ckb",
114
115
  "kj",
115
116
  "la",
116
117
  "lb",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "locize-cli",
3
- "version": "8.6.2",
3
+ "version": "8.7.1",
4
4
  "description": "locize cli to import locales",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -23,6 +23,7 @@
23
23
  "ini": "4.1.3",
24
24
  "js-yaml": "4.1.0",
25
25
  "laravelphp": "2.0.4",
26
+ "locize-xcstrings": "1.0.0",
26
27
  "lodash.clonedeep": "4.5.0",
27
28
  "mkdirp": "3.0.1",
28
29
  "node-fetch": "2.7.0",
@@ -34,9 +35,9 @@
34
35
  "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
35
36
  },
36
37
  "devDependencies": {
38
+ "@yao-pkg/pkg": "6.3.1",
37
39
  "eslint": "8.56.0",
38
- "gh-release": "7.0.2",
39
- "@yao-pkg/pkg": "6.3.1"
40
+ "gh-release": "7.0.2"
40
41
  },
41
42
  "scripts": {
42
43
  "lint": "eslint .",
@@ -6,6 +6,7 @@ const convertToFlatFormat = require('./convertToFlatFormat');
6
6
  const formats = require('./formats');
7
7
  const fileExtensionsMap = formats.fileExtensionsMap;
8
8
  const acceptedFileExtensions = formats.acceptedFileExtensions;
9
+ const xcstrings2locize = require('locize-xcstrings/cjs/xcstrings2locize');
9
10
 
10
11
  const getFiles = (srcpath) => {
11
12
  return fs.readdirSync(srcpath).filter((file) => {
@@ -21,7 +22,7 @@ const getDirectories = (srcpath) => {
21
22
 
22
23
  const parseLocalLanguage = (opt, lng, cb) => {
23
24
  const hasNamespaceInPath = opt.pathMask.indexOf(`${opt.pathMaskInterpolationPrefix}namespace${opt.pathMaskInterpolationSuffix}`) > -1;
24
- const filledLngMask = opt.pathMask.replace(`${opt.pathMaskInterpolationPrefix}language${opt.pathMaskInterpolationSuffix}`, lng);
25
+ const filledLngMask = opt.pathMask.replace(`${opt.pathMaskInterpolationPrefix}language${opt.pathMaskInterpolationSuffix}`, opt.format === 'xcstrings' ? '' : lng);
25
26
  var firstPartLngMask, lastPartLngMask;
26
27
  if (opt.pathMask.indexOf(`${opt.pathMaskInterpolationPrefix}language${opt.pathMaskInterpolationSuffix}`) > opt.pathMask.indexOf(`${opt.pathMaskInterpolationPrefix}namespace${opt.pathMaskInterpolationSuffix}`)) {
27
28
  const secondPartMask = opt.pathMask.substring(opt.pathMask.lastIndexOf(path.sep) + 1);
@@ -138,28 +139,63 @@ const parseLocalLanguage = (opt, lng, cb) => {
138
139
  }
139
140
  }
140
141
 
141
- convertToFlatFormat(opt, data, lng, (err, content) => {
142
- if (err) {
142
+ if (opt.format === 'xcstrings') { // 1 file per namespace including all languages
143
+ try {
144
+ const content = xcstrings2locize(JSON.parse(data));
145
+
146
+ fs.stat(fPath, (err, stat) => {
147
+ if (err) return clb(err);
148
+
149
+ clb(null, Object.keys(content.resources).map((l) => ({
150
+ namespace: namespace,
151
+ path: fPath,
152
+ extension: fExt,
153
+ content: content.resources[l],
154
+ language: l,
155
+ mtime: stat.mtime
156
+ })));
157
+ });
158
+ } catch (e) {
143
159
  err.message = 'Invalid content for "' + opt.format + '" format!\n' + (err.message || '');
144
160
  err.message += '\n' + fPath;
145
161
  return clb(err);
146
162
  }
163
+ } else { // 1 file per namespace/lng
164
+ convertToFlatFormat(opt, data, lng, (err, content) => {
165
+ if (err) {
166
+ err.message = 'Invalid content for "' + opt.format + '" format!\n' + (err.message || '');
167
+ err.message += '\n' + fPath;
168
+ return clb(err);
169
+ }
147
170
 
148
- fs.stat(fPath, (err, stat) => {
149
- if (err) return clb(err);
171
+ fs.stat(fPath, (err, stat) => {
172
+ if (err) return clb(err);
150
173
 
151
- clb(null, {
152
- namespace: namespace,
153
- path: fPath,
154
- extension: fExt,
155
- content: content,
156
- language: lng,
157
- mtime: stat.mtime
174
+ clb(null, {
175
+ namespace: namespace,
176
+ path: fPath,
177
+ extension: fExt,
178
+ content: content,
179
+ language: lng,
180
+ mtime: stat.mtime
181
+ });
158
182
  });
159
183
  });
160
- });
184
+ }
161
185
  });
162
- }, cb);
186
+ }, (err, ret) => {
187
+ if (err) return cb(err);
188
+ // xcstrings, returns array in array
189
+ const r = ret.reduce((prev, cur) => {
190
+ if (Array.isArray(cur)) {
191
+ prev = prev.concat(cur);
192
+ } else {
193
+ prev.push(cur);
194
+ }
195
+ return prev;
196
+ }, []);
197
+ cb(null, r);
198
+ });
163
199
  };
164
200
 
165
201
  module.exports = parseLocalLanguage;
@@ -4,7 +4,7 @@ const filterNamespaces = require('./filterNamespaces');
4
4
  const parseLocalReference = (opt, cb) => parseLocalLanguage(opt, opt.referenceLanguage, (err, nss) => {
5
5
  if (err) return cb(err);
6
6
 
7
- cb(err, filterNamespaces(opt, nss));
7
+ cb(err, filterNamespaces(opt, nss).filter((n) => n.language === opt.referenceLanguage));
8
8
  });
9
9
 
10
10
  module.exports = parseLocalReference;
package/sync.js CHANGED
@@ -17,6 +17,7 @@ const lngCodes = require('./lngs.json');
17
17
  const deleteNamespace = require('./deleteNamespace');
18
18
  const getProjectStats = require('./getProjectStats');
19
19
  const reversedFileExtensionsMap = formats.reversedFileExtensionsMap;
20
+ const locize2xcstrings = require('locize-xcstrings/cjs/locize2xcstrings');
20
21
 
21
22
  const getDirectories = (srcpath) => {
22
23
  return fs.readdirSync(srcpath).filter((file) => {
@@ -24,6 +25,14 @@ const getDirectories = (srcpath) => {
24
25
  });
25
26
  };
26
27
 
28
+ function getInfosInUrl(download) {
29
+ const splitted = download.key.split('/');
30
+ const version = splitted[download.isPrivate ? 2 : 1];
31
+ const language = splitted[download.isPrivate ? 3 : 2];
32
+ const namespace = splitted[download.isPrivate ? 4 : 3];
33
+ return { version, language, namespace };
34
+ }
35
+
27
36
  const getDownloads = (opt, cb) => {
28
37
  if (!opt.unpublished) {
29
38
  request(opt.apiPath + '/download/' + opt.projectId + '/' + opt.version, {
@@ -194,7 +203,7 @@ const downloadAll = (opt, remoteLanguages, omitRef, manipulate, cb) => {
194
203
  }
195
204
  }
196
205
 
197
- if (!opt.dry) cleanupLanguages(opt, remoteLanguages);
206
+ if (!opt.dry && opt.format !== 'xcstrings') cleanupLanguages(opt, remoteLanguages);
198
207
 
199
208
  getDownloads(opt, (err, downloads) => {
200
209
  if (err) return cb(err);
@@ -208,43 +217,103 @@ const downloadAll = (opt, remoteLanguages, omitRef, manipulate, cb) => {
208
217
  return lng !== opt.referenceLanguage;
209
218
  });
210
219
  }
211
- async.eachLimit(downloads, opt.unpublished ? 5 : 20, (download, clb) => {
212
- const splitted = download.key.split('/');
213
- const lng = splitted[download.isPrivate ? 3 : 2];
214
- const namespace = splitted[download.isPrivate ? 4 : 3];
215
- opt.isPrivate = download.isPrivate;
216
-
217
- if (opt.language && opt.language !== lng && lng !== opt.referenceLanguage) return clb(null);
218
- if (opt.languages && opt.languages.length > 0 && opt.languages.indexOf(lng) < 0 && lng !== opt.referenceLanguage) return clb(null);
219
- if (opt.namespace && opt.namespace !== namespace) return clb(null);
220
- if (opt.namespaces && opt.namespaces.length > 0 && opt.namespaces.indexOf(namespace) < 0) return clb(null);
221
-
222
- getRemoteNamespace(opt, lng, namespace, (err, ns, lastModified) => {
223
- if (err) return clb(err);
224
-
225
- if (opt.skipEmpty && Object.keys(flatten(ns)).length === 0) {
226
- return clb(null);
227
- }
228
220
 
229
- if (manipulate && typeof manipulate == 'function') manipulate(lng, namespace, ns);
221
+ if (opt.format === 'xcstrings') { // 1 file per namespace including all languages
222
+ const downloadsByNamespace = {};
223
+ downloads.forEach((download) => {
224
+ const { namespace } = getInfosInUrl(download);
225
+ downloadsByNamespace[namespace] = downloadsByNamespace[namespace] || [];
226
+ downloadsByNamespace[namespace].push(download);
227
+ });
228
+
229
+ async.eachLimit(Object.keys(downloadsByNamespace), opt.unpublished ? 5 : 20, (namespace, clb) => {
230
+ const locizeData = {
231
+ sourceLng: opt.referenceLanguage,
232
+ resources: {}
233
+ };
234
+
235
+ async.eachLimit(downloadsByNamespace[namespace], opt.unpublished ? 5 : 20, (download, clb) => {
236
+ const { language } = getInfosInUrl(download);
237
+ opt.isPrivate = download.isPrivate;
238
+
239
+ if (opt.language && opt.language !== language && language !== opt.referenceLanguage) return clb(null);
240
+ if (opt.languages && opt.languages.length > 0 && opt.languages.indexOf(language) < 0 && language !== opt.referenceLanguage) return clb(null);
241
+ if (opt.namespace && opt.namespace !== namespace) return clb(null);
242
+ if (opt.namespaces && opt.namespaces.length > 0 && opt.namespaces.indexOf(namespace) < 0) return clb(null);
243
+
244
+ if (opt.unpublished) opt.raw = true;
245
+ getRemoteNamespace(opt, language, namespace, (err, ns, lastModified) => {
246
+ if (err) return clb(err);
247
+
248
+ if (opt.skipEmpty && Object.keys(flatten(ns)).length === 0) {
249
+ return clb(null);
250
+ }
251
+
252
+ if (manipulate && typeof manipulate == 'function') manipulate(language, namespace, ns);
253
+
254
+ locizeData.resources[language] = ns;
255
+ clb();
256
+ });
257
+ }, (err) => {
258
+ if (err) return clb(err);
259
+
260
+ try {
261
+ const result = locize2xcstrings(locizeData);
262
+ const converted = JSON.stringify(result, null, 2);
230
263
 
231
- convertToDesiredFormat(opt, namespace, lng, ns, lastModified, (err, converted) => {
232
- if (err) {
264
+ const filledMask = opt.pathMask.replace(`${opt.pathMaskInterpolationPrefix}language${opt.pathMaskInterpolationSuffix}`, '').replace(`${opt.pathMaskInterpolationPrefix}namespace${opt.pathMaskInterpolationSuffix}`, namespace) + reversedFileExtensionsMap[opt.format];
265
+ if (opt.dry) return clb(null);
266
+ if (opt.pathMask.indexOf(`${opt.pathMaskInterpolationPrefix}language${opt.pathMaskInterpolationSuffix}`) > opt.pathMask.indexOf(`${opt.pathMaskInterpolationPrefix}namespace${opt.pathMaskInterpolationSuffix}`) && filledMask.lastIndexOf(path.sep) > 0) {
267
+ mkdirp.sync(path.join(opt.path, filledMask.substring(0, filledMask.lastIndexOf(path.sep))));
268
+ }
269
+ const parentDir = path.dirname(path.join(opt.path, filledMask));
270
+ mkdirp.sync(parentDir);
271
+ const fileContent = opt.format !== 'xlsx' ? (converted + '\n') : converted;
272
+ fs.writeFile(path.join(opt.path, filledMask), fileContent, clb);
273
+ } catch (e) {
233
274
  err.message = 'Invalid content for "' + opt.format + '" format!\n' + (err.message || '');
234
275
  return clb(err);
235
276
  }
236
-
237
- const filledMask = opt.pathMask.replace(`${opt.pathMaskInterpolationPrefix}language${opt.pathMaskInterpolationSuffix}`, lng).replace(`${opt.pathMaskInterpolationPrefix}namespace${opt.pathMaskInterpolationSuffix}`, namespace) + reversedFileExtensionsMap[opt.format];
238
- if (opt.dry) return clb(null);
239
- if (opt.pathMask.indexOf(`${opt.pathMaskInterpolationPrefix}language${opt.pathMaskInterpolationSuffix}`) > opt.pathMask.indexOf(`${opt.pathMaskInterpolationPrefix}namespace${opt.pathMaskInterpolationSuffix}`) && filledMask.lastIndexOf(path.sep) > 0) {
240
- mkdirp.sync(path.join(opt.path, filledMask.substring(0, filledMask.lastIndexOf(path.sep))));
277
+ });
278
+ }, cb);
279
+ } else { // 1 file per namespace/lng
280
+ async.eachLimit(downloads, opt.unpublished ? 5 : 20, (download, clb) => {
281
+ const { language, namespace } = getInfosInUrl(download);
282
+ opt.isPrivate = download.isPrivate;
283
+
284
+ if (opt.language && opt.language !== language && language !== opt.referenceLanguage) return clb(null);
285
+ if (opt.languages && opt.languages.length > 0 && opt.languages.indexOf(language) < 0 && language !== opt.referenceLanguage) return clb(null);
286
+ if (opt.namespace && opt.namespace !== namespace) return clb(null);
287
+ if (opt.namespaces && opt.namespaces.length > 0 && opt.namespaces.indexOf(namespace) < 0) return clb(null);
288
+
289
+ getRemoteNamespace(opt, language, namespace, (err, ns, lastModified) => {
290
+ if (err) return clb(err);
291
+
292
+ if (opt.skipEmpty && Object.keys(flatten(ns)).length === 0) {
293
+ return clb(null);
241
294
  }
242
- const parentDir = path.dirname(path.join(opt.path, filledMask));
243
- mkdirp.sync(parentDir);
244
- fs.writeFile(path.join(opt.path, filledMask), converted, clb);
295
+
296
+ if (manipulate && typeof manipulate == 'function') manipulate(language, namespace, ns);
297
+
298
+ convertToDesiredFormat(opt, namespace, language, ns, lastModified, (err, converted) => {
299
+ if (err) {
300
+ err.message = 'Invalid content for "' + opt.format + '" format!\n' + (err.message || '');
301
+ return clb(err);
302
+ }
303
+
304
+ const filledMask = opt.pathMask.replace(`${opt.pathMaskInterpolationPrefix}language${opt.pathMaskInterpolationSuffix}`, language).replace(`${opt.pathMaskInterpolationPrefix}namespace${opt.pathMaskInterpolationSuffix}`, namespace) + reversedFileExtensionsMap[opt.format];
305
+ if (opt.dry) return clb(null);
306
+ if (opt.pathMask.indexOf(`${opt.pathMaskInterpolationPrefix}language${opt.pathMaskInterpolationSuffix}`) > opt.pathMask.indexOf(`${opt.pathMaskInterpolationPrefix}namespace${opt.pathMaskInterpolationSuffix}`) && filledMask.lastIndexOf(path.sep) > 0) {
307
+ mkdirp.sync(path.join(opt.path, filledMask.substring(0, filledMask.lastIndexOf(path.sep))));
308
+ }
309
+ const parentDir = path.dirname(path.join(opt.path, filledMask));
310
+ mkdirp.sync(parentDir);
311
+ const fileContent = opt.format !== 'xlsx' ? (converted + '\n') : converted;
312
+ fs.writeFile(path.join(opt.path, filledMask), fileContent, clb);
313
+ });
245
314
  });
246
- });
247
- }, cb);
315
+ }, cb);
316
+ }
248
317
  });
249
318
  };
250
319
 
@@ -420,7 +489,9 @@ const backupDeleted = (opt, ns, now) => {
420
489
  return prev;
421
490
  }, {});
422
491
  mkdirp.sync(path.join(currentBackupPath, ns.language));
423
- fs.writeFileSync(path.join(currentBackupPath, ns.language, `${ns.namespace}.json`), JSON.stringify(removingRemote, null, 2));
492
+ const content = JSON.stringify(removingRemote, null, 2);
493
+ const fileContent = opt.format !== 'xlsx' ? (content + '\n') : content;
494
+ fs.writeFileSync(path.join(currentBackupPath, ns.language, `${ns.namespace}.json`), fileContent);
424
495
  };
425
496
 
426
497
  const handleSync = (opt, remoteLanguages, localNamespaces, cb) => {