@tandem-language-exchange/content-store 1.1.2 → 1.2.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/dist/node.js CHANGED
@@ -2,7 +2,6 @@
2
2
  import {
3
3
  S3Client,
4
4
  PutObjectCommand,
5
- CopyObjectCommand,
6
5
  GetObjectCommand
7
6
  } from "@aws-sdk/client-s3";
8
7
  var ContentStore = class {
@@ -18,14 +17,6 @@ var ContentStore = class {
18
17
  });
19
18
  this.bucket = cfg.bucket;
20
19
  }
21
- /** {cms}-{contentType}-{timestamp}.json */
22
- buildVersionedKey(cms, contentType, timestamp) {
23
- return `${cms}-${contentType}-${timestamp}.json`;
24
- }
25
- /** {cms}-{contentType}.json (always points at the latest version) */
26
- buildLatestKey(cms, contentType) {
27
- return `${cms}-${contentType}.json`;
28
- }
29
20
  async upload(key, data) {
30
21
  await this.client.send(
31
22
  new PutObjectCommand({
@@ -37,6 +28,18 @@ var ContentStore = class {
37
28
  );
38
29
  return key;
39
30
  }
31
+ /** Raw UTF-8 body (e.g. Lingohub file bytes as text). */
32
+ async uploadRaw(key, body, contentType) {
33
+ await this.client.send(
34
+ new PutObjectCommand({
35
+ Bucket: this.bucket,
36
+ Key: key,
37
+ Body: body,
38
+ ContentType: contentType
39
+ })
40
+ );
41
+ return key;
42
+ }
40
43
  async download(key) {
41
44
  const response = await this.client.send(
42
45
  new GetObjectCommand({ Bucket: this.bucket, Key: key })
@@ -45,27 +48,104 @@ var ContentStore = class {
45
48
  if (!body) throw new Error(`Empty response for key: ${key}`);
46
49
  return JSON.parse(body);
47
50
  }
48
- /**
49
- * Copies a versioned object to the "latest" key so that it always reflects
50
- * the most recent sync while older timestamped versions are retained.
51
- */
52
- async copyToLatest(sourceKey, cms, contentType) {
53
- const latestKey = this.buildLatestKey(cms, contentType);
54
- await this.client.send(
55
- new CopyObjectCommand({
56
- Bucket: this.bucket,
57
- CopySource: `${this.bucket}/${sourceKey}`,
58
- Key: latestKey,
59
- ContentType: "application/json"
60
- })
51
+ /** Raw UTF-8 body from S3 (no JSON.parse). */
52
+ async downloadRaw(key) {
53
+ const response = await this.client.send(
54
+ new GetObjectCommand({ Bucket: this.bucket, Key: key })
61
55
  );
62
- return latestKey;
56
+ const body = await response.Body?.transformToString();
57
+ if (!body) throw new Error(`Empty response for key: ${key}`);
58
+ return body;
63
59
  }
64
60
  };
61
+ var buildCmsObjectKey = (cms, contentType) => {
62
+ return `${cms}-${contentType}.json`;
63
+ };
64
+ var buildTranslationObjectKey = (project, fileName, locale) => {
65
+ return `lingohub-${project}.${fileName.replaceAll("[locale]", locale)}`;
66
+ };
65
67
 
66
68
  // src/shared/bundles.ts
67
69
  import fs from "fs/promises";
68
- import path from "path";
70
+ import path2 from "path";
71
+
72
+ // src/shared/s3-retry.ts
73
+ function computeDelay(attempt, baseDelayMs, maxDelayMs) {
74
+ const exponential = baseDelayMs * Math.pow(2, attempt);
75
+ const jitter = Math.random() * baseDelayMs;
76
+ return Math.min(exponential + jitter, maxDelayMs);
77
+ }
78
+ function getDefaultS3RetryConfig() {
79
+ return {
80
+ maxRetries: parseInt(
81
+ process.env.S3_RETRY_MAX_RETRIES ?? process.env.RETRY_MAX_RETRIES ?? "5",
82
+ 10
83
+ ),
84
+ baseDelayMs: parseInt(
85
+ process.env.S3_RETRY_BASE_DELAY_MS ?? process.env.RETRY_BASE_DELAY_MS ?? "1000",
86
+ 10
87
+ ),
88
+ maxDelayMs: parseInt(
89
+ process.env.S3_RETRY_MAX_DELAY_MS ?? process.env.RETRY_MAX_DELAY_MS ?? "60000",
90
+ 10
91
+ )
92
+ };
93
+ }
94
+ function isRetryableS3DownloadError(err) {
95
+ if (err === null || err === void 0) return false;
96
+ if (typeof err === "object") {
97
+ const e = err;
98
+ const status = e.$metadata?.httpStatusCode;
99
+ if (status === 404 || status === 403 || status === 401 || status === 400) {
100
+ return false;
101
+ }
102
+ if (status !== void 0 && (status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504)) {
103
+ return true;
104
+ }
105
+ const name = e.name ?? "";
106
+ const code = e.Code ?? "";
107
+ if (/SlowDown|Throttl|Timeout|TooManyRequests|ServiceUnavailable|InternalError/i.test(
108
+ name
109
+ ) || /SlowDown|Throttl/i.test(code)) {
110
+ return true;
111
+ }
112
+ }
113
+ if (err instanceof Error) {
114
+ const m = err.message;
115
+ if (/ECONNRESET|ETIMEDOUT|EPIPE|ECONNREFUSED|socket hang up|getaddrinfo/i.test(
116
+ m
117
+ )) {
118
+ return true;
119
+ }
120
+ }
121
+ return false;
122
+ }
123
+ async function withS3Retry(fn, { maxRetries, baseDelayMs, maxDelayMs }) {
124
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
125
+ try {
126
+ return await fn();
127
+ } catch (err) {
128
+ if (!isRetryableS3DownloadError(err)) {
129
+ throw err;
130
+ }
131
+ if (attempt === maxRetries) {
132
+ throw err;
133
+ }
134
+ const delay = computeDelay(attempt, baseDelayMs, maxDelayMs);
135
+ console.warn(
136
+ ` S3 request failed (attempt ${attempt + 1}/${maxRetries + 1}): ${err instanceof Error ? err.message : String(err)}. Retrying in ${Math.round(delay)}ms\u2026`
137
+ );
138
+ await new Promise((resolve) => setTimeout(resolve, delay));
139
+ }
140
+ }
141
+ throw new Error("withS3Retry: unreachable");
142
+ }
143
+ async function downloadWithRetry(store, key, cfg) {
144
+ return withS3Retry(() => store.download(key), cfg);
145
+ }
146
+ async function downloadRawWithRetry(store, key, cfg) {
147
+ return withS3Retry(() => store.downloadRaw(key), cfg);
148
+ }
69
149
 
70
150
  // src/shared/trimDepth.ts
71
151
  function trimDepth(value, remaining) {
@@ -96,6 +176,301 @@ function trimDepth(value, remaining) {
96
176
  return result;
97
177
  }
98
178
 
179
+ // src/shared/lingohub.ts
180
+ var defaultLocales = ["en", "fr", "de", "es", "it", "pt-br", "ru", "zh-hans", "zh-hant", "ko", "ja"];
181
+ var localeMapping = {
182
+ ios: {
183
+ "pt-br": "pt",
184
+ "zh-hans": "zh-Hans",
185
+ "zh-hant": "zh-Hant"
186
+ },
187
+ android: {
188
+ "pt-br": "pt",
189
+ "zh-hans": "zh-Hans-CN",
190
+ "zh-hant": "zh-TW"
191
+ }
192
+ };
193
+ var allProjects = {
194
+ "tandem-(new-website)": [
195
+ {
196
+ fileName: "Website.[locale].json",
197
+ type: "json"
198
+ }
199
+ ],
200
+ "tandem-(website)": [
201
+ {
202
+ fileName: "[locale].json",
203
+ type: "json"
204
+ },
205
+ {
206
+ fileName: "AI.[locale].json",
207
+ type: "json"
208
+ },
209
+ {
210
+ fileName: "languages.[locale].json",
211
+ type: "json"
212
+ }
213
+ ],
214
+ "tandem": [
215
+ {
216
+ fileName: "InfoPlist.[locale].strings",
217
+ type: "strings",
218
+ localeMapping: localeMapping.ios
219
+ },
220
+ {
221
+ fileName: "Localizable.[locale].strings",
222
+ type: "strings",
223
+ localeMapping: localeMapping.ios
224
+ },
225
+ {
226
+ fileName: "MainiPad.[locale].strings",
227
+ type: "strings",
228
+ localeMapping: localeMapping.ios
229
+ },
230
+ {
231
+ fileName: "Main.[locale].strings",
232
+ type: "strings",
233
+ localeMapping: localeMapping.ios
234
+ }
235
+ ],
236
+ "tandem-(android)": [
237
+ {
238
+ fileName: "accessibility_localizable.[locale].xml",
239
+ type: "xml",
240
+ localeMapping: localeMapping.android
241
+ },
242
+ {
243
+ fileName: "call_localizable.[locale].xml",
244
+ type: "xml",
245
+ localeMapping: localeMapping.android
246
+ },
247
+ {
248
+ fileName: "cert_localizable.[locale].xml",
249
+ type: "xml",
250
+ localeMapping: localeMapping.android
251
+ },
252
+ {
253
+ fileName: "chat_localizable.[locale].xml",
254
+ type: "xml",
255
+ localeMapping: localeMapping.android
256
+ },
257
+ {
258
+ fileName: "checklist_localizable.[locale].xml",
259
+ type: "xml",
260
+ localeMapping: localeMapping.android
261
+ },
262
+ {
263
+ fileName: "clubs_localizable.[locale].xml",
264
+ type: "xml",
265
+ localeMapping: localeMapping.android
266
+ },
267
+ {
268
+ fileName: "common_localizable.[locale].xml",
269
+ type: "xml",
270
+ localeMapping: localeMapping.android
271
+ },
272
+ {
273
+ fileName: "community_localizable.[locale].xml",
274
+ type: "xml",
275
+ localeMapping: localeMapping.android
276
+ },
277
+ {
278
+ fileName: "correction_localizable.[locale].xml",
279
+ type: "xml",
280
+ localeMapping: localeMapping.android
281
+ },
282
+ {
283
+ fileName: "country_names.[locale].xml",
284
+ type: "xml",
285
+ localeMapping: localeMapping.android
286
+ },
287
+ {
288
+ fileName: "emoji_localizable.[locale].xml",
289
+ type: "xml",
290
+ localeMapping: localeMapping.android
291
+ },
292
+ {
293
+ fileName: "errors_localizable.[locale].xml",
294
+ type: "xml",
295
+ localeMapping: localeMapping.android
296
+ },
297
+ {
298
+ fileName: "expressions_localizable.[locale].xml",
299
+ type: "xml",
300
+ localeMapping: localeMapping.android
301
+ },
302
+ {
303
+ fileName: "gif_localizable.[locale].xml",
304
+ type: "xml",
305
+ localeMapping: localeMapping.android
306
+ },
307
+ {
308
+ fileName: "guidelines_localizable.[locale].xml",
309
+ type: "xml",
310
+ localeMapping: localeMapping.android
311
+ },
312
+ {
313
+ fileName: "languages_localizable.[locale].xml",
314
+ type: "xml",
315
+ localeMapping: localeMapping.android
316
+ },
317
+ {
318
+ fileName: "localizable.[locale].xml",
319
+ type: "xml",
320
+ localeMapping: localeMapping.android
321
+ },
322
+ {
323
+ fileName: "localizable2.[locale].xml",
324
+ type: "xml",
325
+ localeMapping: localeMapping.android
326
+ },
327
+ {
328
+ fileName: "login_localizable.[locale].xml",
329
+ type: "xml",
330
+ localeMapping: localeMapping.android
331
+ },
332
+ {
333
+ fileName: "myprofile_localizable.[locale].xml",
334
+ type: "xml",
335
+ localeMapping: localeMapping.android
336
+ },
337
+ {
338
+ fileName: "onb_localizable.[locale].xml",
339
+ type: "xml",
340
+ localeMapping: localeMapping.android
341
+ },
342
+ {
343
+ fileName: "parties_localizable.[locale].xml",
344
+ type: "xml",
345
+ localeMapping: localeMapping.android
346
+ },
347
+ {
348
+ fileName: "pro_localizable.[locale].xml",
349
+ type: "xml",
350
+ localeMapping: localeMapping.android
351
+ },
352
+ {
353
+ fileName: "pro_screen_localizable.[locale].xml",
354
+ type: "xml",
355
+ localeMapping: localeMapping.android
356
+ },
357
+ {
358
+ fileName: "push_notification_localizable.[locale].xml",
359
+ type: "xml",
360
+ localeMapping: localeMapping.android
361
+ },
362
+ {
363
+ fileName: "reporting_localizable.[locale].xml",
364
+ type: "xml",
365
+ localeMapping: localeMapping.android
366
+ },
367
+ {
368
+ fileName: "translation_localizable.[locale].xml",
369
+ type: "xml",
370
+ localeMapping: localeMapping.android
371
+ }
372
+ ],
373
+ "tandem-(web-invites)": [
374
+ {
375
+ fileName: "[locale].json",
376
+ type: "json"
377
+ }
378
+ ]
379
+ };
380
+
381
+ // src/shared/translationResource.ts
382
+ import path from "path";
383
+
384
+ // src/shared/utils.ts
385
+ import convert from "xml-js";
386
+ import set from "lodash.set";
387
+ import merge from "lodash.merge";
388
+ var transformObjectToFlat = (data) => {
389
+ const result = {};
390
+ const flatten = (obj, path3 = []) => {
391
+ Object.entries(obj).forEach(([key, value]) => {
392
+ if (typeof value === "object") {
393
+ flatten(value, path3.concat(key));
394
+ } else {
395
+ result[path3.concat(key).join(".")] = value;
396
+ }
397
+ });
398
+ };
399
+ flatten(data);
400
+ return result;
401
+ };
402
+ var convertXMLToJS = (xml) => {
403
+ const converted = convert.xml2js(xml, {
404
+ ignoreComment: true,
405
+ ignoreDeclaration: true,
406
+ ignoreInstruction: true,
407
+ compact: true
408
+ });
409
+ let mapped = {};
410
+ converted.resources.string.forEach((item) => {
411
+ mapped = {
412
+ ...mapped,
413
+ [item._attributes.name]: item._text
414
+ };
415
+ });
416
+ return mapped;
417
+ };
418
+ var parseIOSStrings = (strings) => {
419
+ const parsedObj = {};
420
+ strings.split("\n").filter((line) => line.startsWith('"') && line.endsWith(";")).map((line) => line.trim().slice(0, -1)).forEach((line) => {
421
+ let [key, value] = line.split(" = ");
422
+ if (!key || !value) return;
423
+ key = key.slice(1, -1);
424
+ value = value.slice(1, -1);
425
+ parsedObj[key] = value;
426
+ });
427
+ return parsedObj;
428
+ };
429
+
430
+ // src/shared/translationResource.ts
431
+ function parseTranslationResourceRaw(raw, resource) {
432
+ if (resource.type === "json") {
433
+ return JSON.parse(raw);
434
+ }
435
+ if (resource.type === "strings") {
436
+ return parseIOSStrings(raw);
437
+ }
438
+ if (resource.type === "xml") {
439
+ return convertXMLToJS(raw);
440
+ }
441
+ throw new Error(`Unsupported resource type: ${resource.type}`);
442
+ }
443
+ function toFlatStringMap(parsed) {
444
+ if (parsed === null || parsed === void 0) return {};
445
+ if (typeof parsed === "string") return { value: parsed };
446
+ if (typeof parsed !== "object") return { value: String(parsed) };
447
+ if (Array.isArray(parsed)) {
448
+ const out2 = {};
449
+ parsed.forEach((v, i) => {
450
+ out2[String(i)] = typeof v === "object" && v !== null ? JSON.stringify(v) : String(v);
451
+ });
452
+ return out2;
453
+ }
454
+ const flat = transformObjectToFlat(parsed);
455
+ const out = {};
456
+ for (const [k, v] of Object.entries(flat)) {
457
+ if (v === null || v === void 0) {
458
+ out[k] = "";
459
+ } else if (typeof v === "object") {
460
+ out[k] = JSON.stringify(v);
461
+ } else {
462
+ out[k] = String(v);
463
+ }
464
+ }
465
+ return out;
466
+ }
467
+ function translationJsonOutputPath(outputDir, objectKey) {
468
+ if (objectKey.endsWith(".json")) {
469
+ return path.resolve(outputDir, objectKey);
470
+ }
471
+ return path.resolve(outputDir, `${objectKey}.json`);
472
+ }
473
+
99
474
  // src/shared/bundles.ts
100
475
  function getAtPath(obj, dottedPath) {
101
476
  const parts = dottedPath.split(".").filter((p) => p.length > 0);
@@ -126,8 +501,8 @@ function isEmptyValue(value) {
126
501
  return typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0;
127
502
  }
128
503
  function matchesFieldFilter(item, key, expected) {
129
- const { path: path2, operator } = parseFieldKey(key);
130
- const at = getAtPath(item, path2);
504
+ const { path: path3, operator } = parseFieldKey(key);
505
+ const at = getAtPath(item, path3);
131
506
  if (operator === "exists") {
132
507
  if (expected !== true && expected !== false) return false;
133
508
  const empty = !at.found || isEmptyValue(at.value);
@@ -153,26 +528,117 @@ function setNestedAt(target, dottedPath, value) {
153
528
  }
154
529
  setNestedAt(nested, rest, value);
155
530
  }
156
- async function fetchBundles(store, outputDir, options) {
531
+ async function fetchCmsBundles(store, outputDir, options) {
157
532
  const { cms, contentTypes } = options;
533
+ const retry = options.retry ?? getDefaultS3RetryConfig();
158
534
  await fs.mkdir(outputDir, { recursive: true });
159
535
  const result = {};
160
536
  await Promise.all(
161
537
  contentTypes.map(async (contentType) => {
162
- const key = store.buildLatestKey(cms, contentType);
163
- const data = await store.download(key);
164
- const filePath = path.resolve(
165
- outputDir,
166
- `${cms}-${contentType}.json`
167
- );
538
+ const key = buildCmsObjectKey(cms, contentType);
539
+ const data = await downloadWithRetry(store, key, retry);
540
+ const filePath = path2.resolve(outputDir, key);
168
541
  await fs.writeFile(filePath, JSON.stringify(data, null, 2), "utf-8");
169
542
  result[contentType] = filePath;
170
543
  })
171
544
  );
172
545
  return result;
173
546
  }
174
- async function queryBundle(outputDir, cms, contentType, options = {}) {
175
- const filePath = path.resolve(
547
+ async function fetchTranslationBundles(store, outputDir, options) {
548
+ const { projects, locales } = options;
549
+ const retry = options.retry ?? getDefaultS3RetryConfig();
550
+ await fs.mkdir(outputDir, { recursive: true });
551
+ const result = {};
552
+ const localesToSync = locales && locales.length ? locales : defaultLocales;
553
+ await Promise.all(
554
+ projects.map(async (project) => {
555
+ const resources = allProjects[project];
556
+ if (!resources?.length) {
557
+ return;
558
+ }
559
+ const resourceTasks = [];
560
+ for (const resource of resources) {
561
+ for (const loc of localesToSync) {
562
+ const locale = resource.localeMapping && resource.localeMapping[loc] ? resource.localeMapping[loc] : loc;
563
+ const objectKey = buildTranslationObjectKey(
564
+ project,
565
+ resource.fileName,
566
+ locale
567
+ );
568
+ resourceTasks.push({ objectKey, resource });
569
+ }
570
+ }
571
+ await Promise.all(
572
+ resourceTasks.map(async ({ objectKey, resource }) => {
573
+ const raw = await downloadRawWithRetry(store, objectKey, retry);
574
+ const parsed = parseTranslationResourceRaw(raw, resource);
575
+ const filePath = translationJsonOutputPath(outputDir, objectKey);
576
+ await fs.mkdir(path2.dirname(filePath), { recursive: true });
577
+ await fs.writeFile(
578
+ filePath,
579
+ JSON.stringify(parsed, null, 2),
580
+ "utf-8"
581
+ );
582
+ if (!result[project]) {
583
+ result[project] = {};
584
+ }
585
+ result[project][objectKey] = filePath;
586
+ })
587
+ );
588
+ })
589
+ );
590
+ return result;
591
+ }
592
+ async function fetchMergedTranslationBundles(store, outputDir, options) {
593
+ const { projects, locales } = options;
594
+ const retry = options.retry ?? getDefaultS3RetryConfig();
595
+ await fs.mkdir(outputDir, { recursive: true });
596
+ const localesToSync = locales && locales.length ? locales : defaultLocales;
597
+ const tasks = [];
598
+ for (const project of projects) {
599
+ const resources = allProjects[project];
600
+ if (!resources?.length) continue;
601
+ for (const resource of resources) {
602
+ for (const loc of localesToSync) {
603
+ const mappedLocale = resource.localeMapping && resource.localeMapping[loc] ? resource.localeMapping[loc] : loc;
604
+ const objectKey = buildTranslationObjectKey(
605
+ project,
606
+ resource.fileName,
607
+ mappedLocale
608
+ );
609
+ tasks.push({ catalogLocale: loc, objectKey, resource });
610
+ }
611
+ }
612
+ }
613
+ const results = await Promise.all(
614
+ tasks.map(async ({ catalogLocale, objectKey, resource }) => {
615
+ const raw = await downloadRawWithRetry(store, objectKey, retry);
616
+ const parsed = parseTranslationResourceRaw(raw, resource);
617
+ const stringMap = toFlatStringMap(parsed);
618
+ return { catalogLocale, stringMap };
619
+ })
620
+ );
621
+ const merged = {};
622
+ for (const loc of localesToSync) {
623
+ merged[loc] = {};
624
+ }
625
+ for (const { catalogLocale, stringMap } of results) {
626
+ Object.assign(merged[catalogLocale], stringMap);
627
+ }
628
+ const out = {};
629
+ for (const loc of localesToSync) {
630
+ const filePath = path2.resolve(outputDir, `${loc}.json`);
631
+ await fs.writeFile(
632
+ filePath,
633
+ JSON.stringify(merged[loc], null, 2),
634
+ "utf-8"
635
+ );
636
+ out[loc] = filePath;
637
+ }
638
+ return out;
639
+ }
640
+ async function queryCmsBundle(outputDir, cms, contentType, options = {}) {
641
+ const filePath = path2.resolve(
176
642
  outputDir,
177
643
  `${cms}-${contentType}.json`
178
644
  );
@@ -222,21 +688,41 @@ var ContentStoreSDK = class {
222
688
  *
223
689
  * @returns A map of contentType to absolute file path.
224
690
  */
225
- async fetchBundles(options) {
226
- return fetchBundles(this.store, this.outputDir, options);
691
+ async fetchCmsBundles(options) {
692
+ return fetchCmsBundles(this.store, this.outputDir, options);
693
+ }
694
+ /**
695
+ * Downloads translation bundles from S3 and writes them as JSON files
696
+ * to `outputDir`.
697
+ *
698
+ * @returns Per project, a map of S3 object key to absolute file path.
699
+ */
700
+ async fetchTranslationBundles(options) {
701
+ return fetchTranslationBundles(this.store, this.outputDir, options);
702
+ }
703
+ /**
704
+ * Downloads all translation resources for the given projects/locales, parses them,
705
+ * and writes one merged `{locale}.json` per locale (string key/value map; duplicate keys:
706
+ * last wins).
707
+ */
708
+ async fetchMergedTranslationBundles(options) {
709
+ return fetchMergedTranslationBundles(this.store, this.outputDir, options);
227
710
  }
228
711
  /**
229
712
  * Queries a previously fetched bundle from the local filesystem.
230
713
  */
231
- async queryBundle(cms, contentType, options = {}) {
232
- return queryBundle(this.outputDir, cms, contentType, options);
714
+ async queryCmsBundle(cms, contentType, options = {}) {
715
+ return queryCmsBundle(this.outputDir, cms, contentType, options);
233
716
  }
234
717
  };
235
718
  export {
236
719
  ContentStore,
237
720
  ContentStoreSDK,
238
- fetchBundles,
239
- queryBundle,
721
+ fetchCmsBundles,
722
+ fetchMergedTranslationBundles,
723
+ fetchTranslationBundles,
724
+ getDefaultS3RetryConfig,
725
+ queryCmsBundle,
240
726
  trimDepth
241
727
  };
242
728
  //# sourceMappingURL=node.js.map