@fluid-app/fluid-cli-localization 0.1.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/dist/index.mjs ADDED
@@ -0,0 +1,3965 @@
1
+ import { NAV_HELP, assertInteractive, createDomainCommand, terminalPrompter } from "@fluid-app/fluid-cli";
2
+ import prompts from "prompts";
3
+ import { readFileSync } from "node:fs";
4
+ //#region src/shared/errors.ts
5
+ /** A failure the user can act on, printed as a JSON `error` on stderr. */
6
+ var LocalizationError = class extends Error {
7
+ constructor(message) {
8
+ super(message);
9
+ this.name = "LocalizationError";
10
+ }
11
+ };
12
+ //#endregion
13
+ //#region src/shared/agreements.ts
14
+ const MAX_AGREEMENT_PAGES = 50;
15
+ /** GET /api/agreements, every page. Refuses rather than returning a partial
16
+ * list, which would make an existing agreement look missing. */
17
+ async function listAgreements(client) {
18
+ const all = [];
19
+ for (let page = 1;; page++) {
20
+ const body = await client.get("/api/agreements", {
21
+ page,
22
+ per_page: 100
23
+ });
24
+ all.push(...body.agreements ?? []);
25
+ const totalPages = body.meta?.pagination?.total_pages ?? 1;
26
+ if (page >= totalPages) break;
27
+ if (page >= MAX_AGREEMENT_PAGES) throw new LocalizationError(`The company has more than ${MAX_AGREEMENT_PAGES * 100} agreements, too many to match by title. Check the agreements in Settings > Agreements.`);
28
+ }
29
+ return all;
30
+ }
31
+ /** GET /api/agreements/:id, in `lang` when given. */
32
+ async function readAgreement(client, id, lang) {
33
+ const wire = (await client.get(`/api/agreements/${id}`, lang ? { language_iso: lang } : void 0)).agreement ?? {};
34
+ return {
35
+ id,
36
+ title: wire.title ?? "",
37
+ description: wire.description ?? "",
38
+ languageIso: wire.language_iso ?? null,
39
+ active: wire.active ?? null
40
+ };
41
+ }
42
+ /**
43
+ * Whether an agreement has a translation in `iso`. A read in a language
44
+ * with no translation falls back to the default text and says so in
45
+ * `language_iso`, which is how fluid-admin's translation sheet tells them
46
+ * apart. The title falls back silently, so it can't be compared.
47
+ */
48
+ async function agreementTranslated(client, id, iso) {
49
+ return ((await readAgreement(client, id, iso)).languageIso ?? "").toLowerCase() === iso.toLowerCase();
50
+ }
51
+ /**
52
+ * Save one language's title and text. The API needs both, non-empty. Only
53
+ * these are sent, so the agreement's checkout settings and countries stay
54
+ * as they are.
55
+ */
56
+ async function writeAgreementTranslation(client, id, lang, text) {
57
+ if (text.title.trim().length === 0 || text.description.trim().length === 0) throw new LocalizationError("An agreement translation needs both a title and a description.");
58
+ await client.put(`/api/agreements/${id}?language_iso=${encodeURIComponent(lang)}`, { agreement: {
59
+ title: text.title,
60
+ description: text.description
61
+ } });
62
+ }
63
+ //#endregion
64
+ //#region src/shared/cli.ts
65
+ /**
66
+ * Changes to live configuration or content need an explicit yes. Scripts and
67
+ * agents pass `--yes`; an interactive terminal is asked. With neither,
68
+ * nothing runs.
69
+ */
70
+ async function confirmChange(question, yes, interactive = Boolean(process.stdin.isTTY)) {
71
+ if (yes) return;
72
+ if (!interactive) throw new LocalizationError(`${question} Pass --yes to confirm.`);
73
+ if (!(await prompts({
74
+ type: "confirm",
75
+ name: "ok",
76
+ message: question,
77
+ initial: false
78
+ })).ok) throw new LocalizationError("Cancelled.");
79
+ }
80
+ /**
81
+ * The API's error message. Endpoints answer in three envelopes:
82
+ * `{ error: { message, details } }`, `{ error: "..." }`, and
83
+ * `{ error_message, errors }`.
84
+ */
85
+ function errorDetail(err) {
86
+ const body = err?.body;
87
+ const error = body?.["error"];
88
+ if (error && typeof error === "object") {
89
+ const { message, details } = error;
90
+ if (typeof message === "string") return {
91
+ message,
92
+ ...details ? { details } : {}
93
+ };
94
+ }
95
+ if (typeof error === "string") return { message: error };
96
+ if (typeof body?.["error_message"] === "string") return {
97
+ message: body["error_message"],
98
+ ...body["errors"] ? { details: body["errors"] } : {}
99
+ };
100
+ if (typeof body?.["message"] === "string") return { message: body["message"] };
101
+ return { message: err instanceof Error ? err.message : String(err) };
102
+ }
103
+ /** One line for a per-item failure report: the API's message and status. */
104
+ function failureOf(err) {
105
+ const status = err?.status;
106
+ return {
107
+ error: errorDetail(err).message,
108
+ ...typeof status === "number" ? { status } : {}
109
+ };
110
+ }
111
+ /** Print a failure as JSON on stderr and exit non-zero. */
112
+ function fail(err) {
113
+ const status = err?.status;
114
+ const { message, details } = errorDetail(err);
115
+ process.stderr.write(JSON.stringify({
116
+ error: message,
117
+ ...typeof status === "number" ? { status } : {},
118
+ ...details ? { details } : {}
119
+ }) + "\n");
120
+ process.exitCode = 1;
121
+ }
122
+ function run(action) {
123
+ return action().catch(fail);
124
+ }
125
+ /** Commander collector for a repeatable option. */
126
+ const collect = (value, previous = []) => [...previous, value];
127
+ function positiveInt(raw, flag) {
128
+ const value = Number(raw);
129
+ if (!Number.isInteger(value) || value <= 0) throw new LocalizationError(`${flag} takes a whole number above 0, not "${raw}".`);
130
+ return value;
131
+ }
132
+ //#endregion
133
+ //#region src/shared/languages.ts
134
+ const LANGUAGES_PATH = "/api/settings/languages";
135
+ function toLanguage(wire) {
136
+ return {
137
+ id: wire.id,
138
+ name: wire.name ?? "",
139
+ iso: wire.iso ?? "",
140
+ active: wire.active_in_company === true,
141
+ usersCount: wire.company_users_count ?? null
142
+ };
143
+ }
144
+ /** Every language Fluid supports, with whether it is on for this company. */
145
+ async function listLanguages(client, activeOnly = false) {
146
+ return ((await client.get(LANGUAGES_PATH, activeOnly ? { active: true } : void 0)).languages ?? []).map(toLanguage);
147
+ }
148
+ function normalizeIso(iso) {
149
+ return iso.trim().toLowerCase();
150
+ }
151
+ function sameIso(a, b) {
152
+ return normalizeIso(a) === normalizeIso(b);
153
+ }
154
+ /** A supported language by ISO code from a list already read, or undefined. */
155
+ function languageIn(languages, iso) {
156
+ return languages.find((language) => sameIso(language.iso, iso));
157
+ }
158
+ /** One supported language by ISO code, or a LocalizationError naming close matches. */
159
+ async function findLanguage(client, iso) {
160
+ const languages = await listLanguages(client);
161
+ const match = languageIn(languages, iso);
162
+ if (match) return match;
163
+ const prefix = normalizeIso(iso).split("-")[0] ?? normalizeIso(iso);
164
+ const close = languages.filter((language) => normalizeIso(language.iso).startsWith(prefix)).map((language) => language.iso);
165
+ throw new LocalizationError(`"${iso}" isn't a language Fluid supports.` + (close.length > 0 ? ` Did you mean ${close.join(", ")}?` : "") + " Run `fluid translations languages` to see every code.");
166
+ }
167
+ /** The language, or a LocalizationError if it isn't on for the company. */
168
+ async function requireActiveLanguage(client, iso) {
169
+ const language = await findLanguage(client, iso);
170
+ if (!language.active) throw new LocalizationError(`${language.name} (${language.iso}) isn't on for this company, so translations can't be written to it. Run \`fluid translations enable ${language.iso} --yes\` first.`);
171
+ return language;
172
+ }
173
+ /** Turn a language on. The body is the documented and tested top-level `id`. */
174
+ async function enableLanguage(client, language) {
175
+ const data = await client.post(LANGUAGES_PATH, { id: language.id });
176
+ return data.language ? toLanguage(data.language) : {
177
+ ...language,
178
+ active: true
179
+ };
180
+ }
181
+ /** Turn a language off. Its saved translations are kept. */
182
+ async function disableLanguage(client, language) {
183
+ await client.delete(`${LANGUAGES_PATH}/${language.id}`);
184
+ return {
185
+ ...language,
186
+ active: false
187
+ };
188
+ }
189
+ //#endregion
190
+ //#region src/shared/parsing.ts
191
+ /** Keys that would reach an object's prototype instead of an own property. */
192
+ const RESERVED_KEYS = new Set([
193
+ "__proto__",
194
+ "constructor",
195
+ "prototype"
196
+ ]);
197
+ /** Split a dotted path, refusing empty and reserved segments. */
198
+ function keyPath(path) {
199
+ const parts = path.split(".");
200
+ if (parts.some((p) => p.length === 0)) throw new LocalizationError(`"${path}" has an empty key segment.`);
201
+ if (parts.some((p) => RESERVED_KEYS.has(p))) throw new LocalizationError(`"${path}" uses a reserved key name.`);
202
+ return parts;
203
+ }
204
+ function splitAssignment(assignment, example) {
205
+ const at = assignment.indexOf("=");
206
+ if (at <= 0) throw new LocalizationError(`"${assignment}" isn't key=value, e.g. ${example}.`);
207
+ return [assignment.slice(0, at).trim(), assignment.slice(at + 1)];
208
+ }
209
+ /**
210
+ * Parse `--set field=value` text pairs into a flat object. Values are kept as
211
+ * written, so translated text is never reinterpreted; only the first `=`
212
+ * separates the key, and a theme key keeps its dots (`cart.title=Panier`).
213
+ */
214
+ function parseTextAssignments(assignments) {
215
+ const out = {};
216
+ for (const assignment of assignments) {
217
+ const [key, value] = splitAssignment(assignment, "title=\"Mélange de betteraves\"");
218
+ keyPath(key);
219
+ out[key] = value;
220
+ }
221
+ return out;
222
+ }
223
+ /**
224
+ * Parse `--set key=value` setting overrides. `legal_settings.cooling_off_period_days=30`
225
+ * nests; values that read as JSON (true, 42, ["a"], {"x":1}) are parsed and
226
+ * anything else stays a string.
227
+ */
228
+ function parseSettingAssignments(assignments) {
229
+ const out = {};
230
+ for (const assignment of assignments) {
231
+ const [key, raw] = splitAssignment(assignment, "tax_inclusive_pricing=false");
232
+ const path = keyPath(key);
233
+ let node = out;
234
+ for (const part of path.slice(0, -1)) {
235
+ const next = Object.hasOwn(node, part) ? node[part] : void 0;
236
+ if (typeof next !== "object" || next === null || Array.isArray(next)) node[part] = {};
237
+ node = node[part];
238
+ }
239
+ node[path[path.length - 1]] = parseValue(raw);
240
+ }
241
+ return out;
242
+ }
243
+ function parseValue(raw) {
244
+ if (/^(true|false|null|-?\d+(\.\d+)?)$/.test(raw) || /^[[{]/.test(raw)) try {
245
+ return JSON.parse(raw);
246
+ } catch {
247
+ return raw;
248
+ }
249
+ return raw;
250
+ }
251
+ /**
252
+ * Read `--file`: a JSON object of field (or key) to string. Long or HTML
253
+ * values are easier to pass this way than on the command line.
254
+ */
255
+ function readValuesFile(path) {
256
+ let parsed;
257
+ try {
258
+ parsed = JSON.parse(readFileSync(path, "utf8"));
259
+ } catch (error) {
260
+ throw new LocalizationError(`Couldn't read ${path} as JSON: ${error instanceof Error ? error.message : String(error)}`);
261
+ }
262
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new LocalizationError(`${path} must hold a JSON object of field to text.`);
263
+ const out = {};
264
+ for (const [key, value] of Object.entries(parsed)) {
265
+ if (typeof value !== "string") throw new LocalizationError(`${path}: "${key}" must be a string.`);
266
+ keyPath(key);
267
+ out[key] = value;
268
+ }
269
+ return out;
270
+ }
271
+ /** `--set` and `--file` together, `--set` winning on a clash. */
272
+ function collectValues(sets, file) {
273
+ return {
274
+ ...file ? readValuesFile(file) : {},
275
+ ...parseTextAssignments(sets)
276
+ };
277
+ }
278
+ //#endregion
279
+ //#region src/shared/client.ts
280
+ /** The HTTP status of an API error, if it carries one. */
281
+ function statusOf(error) {
282
+ const status = error?.status;
283
+ return typeof status === "number" ? status : void 0;
284
+ }
285
+ //#endregion
286
+ //#region src/countries/atlas.ts
287
+ const ATLAS_PATH_PREFIX = "/api/v202604/country/atlas/";
288
+ /** The operating modes the atlas describes, as the CLI accepts them. */
289
+ const MODES = [
290
+ "nfr",
291
+ "otg",
292
+ "usd"
293
+ ];
294
+ function parseMode(value) {
295
+ const mode = value.trim().toLowerCase();
296
+ if (!MODES.includes(mode)) throw new LocalizationError(`"${value}" isn't an operating mode. Use one of: ${MODES.join(", ")}.`);
297
+ return mode;
298
+ }
299
+ /** Common aliases, as Mist resolves them: people type "UK" and alpha-3. */
300
+ const ALIASES = {
301
+ UK: "GB",
302
+ DEU: "DE",
303
+ FRA: "FR",
304
+ GBR: "GB",
305
+ CAN: "CA",
306
+ MEX: "MX",
307
+ AUS: "AU",
308
+ JPN: "JP",
309
+ USA: "US"
310
+ };
311
+ function normalizeCountryCode(code) {
312
+ const upper = code.trim().toUpperCase();
313
+ return ALIASES[upper] ?? upper;
314
+ }
315
+ /** The ISO alpha-2 code, or a LocalizationError for anything else. */
316
+ function countryCodeOf(code) {
317
+ const iso = normalizeCountryCode(code);
318
+ if (!/^[A-Z]{2}$/.test(iso)) throw new LocalizationError(`"${code}" isn't a two-letter ISO country code, e.g. DE or JP.`);
319
+ return iso;
320
+ }
321
+ /** Flatten the atlas's simple HTML (overviews, notes) to readable text. */
322
+ function htmlToText(html) {
323
+ return html.replace(/<\s*(br|\/p|\/li|\/ul|\/ol|\/h[1-6]|\/blockquote)[^>]*>/gi, "\n").replace(/<\s*li[^>]*>/gi, "• ").replace(/<[^>]+>/g, "").replace(/&nbsp;/gi, " ").replace(/&lt;/gi, "<").replace(/&gt;/gi, ">").replace(/&quot;/gi, "\"").replace(/&#0?39;|&apos;/gi, "'").replace(/&amp;/gi, "&").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
324
+ }
325
+ /** Whether a response body is an atlas. The endpoint answers 404 for an
326
+ * unknown ISO, and a country with no configuration renders an empty body. */
327
+ function isAtlas(body) {
328
+ return typeof body === "object" && body !== null && typeof body.modes === "object" && body.modes !== null;
329
+ }
330
+ /** Fetch a country's atlas. A 404 or an empty configuration is an uncovered
331
+ * market, not an error. Other failures throw. */
332
+ async function fetchAtlas(client, code) {
333
+ const countryCode = countryCodeOf(code);
334
+ let body;
335
+ try {
336
+ body = await client.get(`${ATLAS_PATH_PREFIX}${countryCode.toLowerCase()}`);
337
+ } catch (error) {
338
+ if (statusOf(error) === 404) return {
339
+ countryCode,
340
+ covered: false,
341
+ atlas: null
342
+ };
343
+ throw error;
344
+ }
345
+ if (!isAtlas(body)) return {
346
+ countryCode,
347
+ covered: false,
348
+ atlas: null
349
+ };
350
+ return {
351
+ countryCode,
352
+ covered: true,
353
+ atlas: body
354
+ };
355
+ }
356
+ /** The atlas, or a LocalizationError explaining that the market is uncovered. */
357
+ async function requireAtlas(client, code) {
358
+ const result = await fetchAtlas(client, code);
359
+ if (!result.covered || !result.atlas) throw new LocalizationError(`Fluid publishes no Country Atlas for ${result.countryCode} yet, so there is nothing to seed from. Set the country up by hand in Settings > Countries.`);
360
+ return {
361
+ countryCode: result.countryCode,
362
+ atlas: result.atlas
363
+ };
364
+ }
365
+ const NOTES_SUMMARY_MAX = 2e3;
366
+ /** The translation an agreement is created from: English, else the first. */
367
+ function primaryTranslation(agreement) {
368
+ return agreement.translations.find((t) => t.language_iso === "en") ?? agreement.translations[0];
369
+ }
370
+ function summarizeAgreement(agreement) {
371
+ const primary = primaryTranslation(agreement);
372
+ return {
373
+ localId: agreement._localId,
374
+ title: primary?.title ?? agreement._localId,
375
+ languages: agreement.translations.map((t) => t.language_iso),
376
+ requiredOnCheckout: agreement.required_on_checkout,
377
+ showAtCheckout: agreement.show_at_checkout,
378
+ defaultChecked: agreement.default_checked,
379
+ bodyChars: primary?.description.length ?? 0
380
+ };
381
+ }
382
+ /** The atlas without the agreement legal bodies, which run to thousands of
383
+ * characters each; `fluid countries atlas <iso> --agreement <id>` prints one. */
384
+ function summarizeAtlas(atlas) {
385
+ const notes = htmlToText(atlas.notes ?? "");
386
+ const modes = {};
387
+ for (const [key, mode] of Object.entries(atlas.modes ?? {})) modes[key] = {
388
+ overview: htmlToText(mode.overview ?? ""),
389
+ launchChecklist: mode.launchChecklist ?? []
390
+ };
391
+ return {
392
+ defaultCurrency: atlas.defaultCurrency,
393
+ majorLanguages: atlas.majorLanguages ?? [],
394
+ requires3ds: atlas.requires3ds ?? false,
395
+ marketNotes: notes.length > NOTES_SUMMARY_MAX ? `${notes.slice(0, NOTES_SUMMARY_MAX)}… [truncated]` : notes,
396
+ modes,
397
+ taxSettings: atlas.taxSettings,
398
+ legalSettings: atlas.legalSettings ?? {},
399
+ paymentMethods: sortedPaymentMethods(atlas),
400
+ enrollmentFormFields: [...atlas.enrollmentFormFields ?? []].sort((a, b) => a.order - b.order),
401
+ addressFields: atlas.addressFields ?? [],
402
+ agreements: (atlas.agreements ?? []).map(summarizeAgreement)
403
+ };
404
+ }
405
+ function sortedPaymentMethods(atlas) {
406
+ return [...atlas.paymentMethods ?? []].sort((a, b) => a.priority - b.priority);
407
+ }
408
+ /** The launch checklist for one mode, verbatim. */
409
+ function launchChecklist(atlas, mode) {
410
+ return atlas.modes?.[mode]?.launchChecklist ?? [];
411
+ }
412
+ /** One agreement's full translations, by its atlas local id. */
413
+ function atlasAgreement(atlas, localId) {
414
+ const agreement = (atlas.agreements ?? []).find((a) => a._localId === localId);
415
+ if (!agreement) throw new LocalizationError(`The atlas has no agreement "${localId}". It has: ${(atlas.agreements ?? []).map((a) => a._localId).join(", ") || "none"}.`);
416
+ return agreement;
417
+ }
418
+ //#endregion
419
+ //#region src/countries/api.ts
420
+ /** GET /api/countries: the global country catalog. */
421
+ async function listCatalogCountries(client) {
422
+ const body = await client.get("/api/countries");
423
+ return Array.isArray(body) ? body : body.countries ?? [];
424
+ }
425
+ /** One country from the global catalog. */
426
+ async function catalogCountry(client, iso) {
427
+ const match = (await listCatalogCountries(client)).find((c) => c.iso?.toUpperCase() === iso);
428
+ if (!match) throw new LocalizationError(`Fluid's country catalog has no country ${iso}.`);
429
+ return match;
430
+ }
431
+ /** GET /api/settings/company_countries: the countries the company sells in. */
432
+ async function listCompanyCountries(client) {
433
+ return (await client.get("/api/settings/company_countries")).company_countries ?? [];
434
+ }
435
+ async function findCompanyCountry(client, iso) {
436
+ return (await listCompanyCountries(client)).find((cc) => cc.country?.iso?.toUpperCase() === iso);
437
+ }
438
+ /** The company country, or a LocalizationError saying to open it first. */
439
+ async function requireCompanyCountry(client, iso) {
440
+ const found = await findCompanyCountry(client, iso);
441
+ if (!found) throw new LocalizationError(`${iso} isn't open for this company yet. Run \`fluid countries open ${iso} --mode <nfr|otg|usd>\` first.`);
442
+ return found;
443
+ }
444
+ /** POST /api/settings/company_countries. `seedLegacyDefaults: false` skips
445
+ * the server's generic country agreements and forms, so the company gets
446
+ * the atlas selection instead. */
447
+ async function createCompanyCountry(client, attributes, seedLegacyDefaults) {
448
+ const body = await client.post("/api/settings/company_countries", {
449
+ company_country: attributes,
450
+ seed_legacy_defaults: seedLegacyDefaults
451
+ });
452
+ return {
453
+ ...body.company_country,
454
+ ...body.warnings ? { warnings: body.warnings } : {}
455
+ };
456
+ }
457
+ /** PUT /api/settings/company_countries/:id, guarded by the revision read
458
+ * just before so a concurrent admin edit answers 409 instead of being lost. */
459
+ async function updateCompanyCountry(client, id, attributes, expectedRevision) {
460
+ const { country_id: _ignored, ...rest } = attributes;
461
+ return (await client.put(`/api/settings/company_countries/${id}`, {
462
+ company_country: rest,
463
+ ...expectedRevision ? { expected_revision: expectedRevision } : {}
464
+ })).company_country;
465
+ }
466
+ /**
467
+ * Create an atlas agreement for a company country. POST writes the base
468
+ * (English) text: the English translation when the atlas has one, else the
469
+ * first. Each translation that is not English is then written in its own
470
+ * locale with PUT ?language_iso=, which needs that language enabled for the
471
+ * company. A translation that fails is reported, not fatal.
472
+ */
473
+ async function createAgreementFromAtlas(client, agreement, companyCountryId) {
474
+ const primary = primaryTranslation(agreement);
475
+ if (!primary) throw new LocalizationError(`Atlas agreement ${agreement._localId} has no text.`);
476
+ const flags = {
477
+ active: true,
478
+ required_on_checkout: agreement.required_on_checkout === true,
479
+ show_at_checkout: agreement.show_at_checkout === true,
480
+ default_checked: agreement.default_checked === true,
481
+ company_country_ids: [companyCountryId]
482
+ };
483
+ const id = (await client.post("/api/agreements", { agreement: {
484
+ title: primary.title,
485
+ description: primary.description,
486
+ ...flags
487
+ } })).agreement.id;
488
+ const { translations, failedTranslations } = await writeTranslations(client, id, agreement);
489
+ return {
490
+ localId: agreement._localId,
491
+ id,
492
+ title: primary.title,
493
+ translations,
494
+ failedTranslations
495
+ };
496
+ }
497
+ /**
498
+ * Write each of an atlas agreement's translations that isn't English onto a
499
+ * company agreement, in its own locale. Only the title and text are sent, so
500
+ * the agreement's checkout settings and countries are left as they are. A
501
+ * translation that fails is reported, not fatal, and can be written again.
502
+ */
503
+ async function writeTranslations(client, agreementId, agreement) {
504
+ const translations = [];
505
+ const failedTranslations = [];
506
+ for (const t of agreement.translations) {
507
+ if (t.language_iso === "en") continue;
508
+ try {
509
+ await writeAgreementTranslation(client, agreementId, t.language_iso, t);
510
+ translations.push(t.language_iso);
511
+ } catch (error) {
512
+ failedTranslations.push({
513
+ language: t.language_iso,
514
+ ...failureOf(error)
515
+ });
516
+ }
517
+ }
518
+ return {
519
+ translations,
520
+ failedTranslations
521
+ };
522
+ }
523
+ /**
524
+ * The atlas languages an agreement has no translation in, checked the way
525
+ * `fluid translations missing --type agreements` checks them: a read in a
526
+ * language without a translation falls back and says so in `language_iso`.
527
+ * English is the base text an agreement is created from, so it isn't checked.
528
+ */
529
+ async function missingTranslations(client, agreementId, agreement) {
530
+ const missing = [];
531
+ for (const t of agreement.translations) {
532
+ if (t.language_iso === "en") continue;
533
+ if (!await agreementTranslated(client, agreementId, t.language_iso)) missing.push(t.language_iso);
534
+ }
535
+ return missing;
536
+ }
537
+ /** GET /api/settings/warehouses. */
538
+ async function listWarehouses(client) {
539
+ return (await client.get("/api/settings/warehouses")).warehouses ?? [];
540
+ }
541
+ //#endregion
542
+ //#region src/countries/compliance.ts
543
+ /** Privacy regulators matched against the market notes. Not exhaustive: an
544
+ * unmatched market reads "See market notes", never an invented name. */
545
+ const KNOWN_REGULATORS = [
546
+ "BfDI",
547
+ "CNIL",
548
+ "ICO",
549
+ "Garante",
550
+ "AEPD",
551
+ "OPC",
552
+ "CAI",
553
+ "INAI",
554
+ "OAIC",
555
+ "PPC",
556
+ "PDPC",
557
+ "FTC"
558
+ ];
559
+ /** The first statute-like reference in a legal body, or "". */
560
+ function extractCitation(bodyText) {
561
+ for (const re of [
562
+ /§\s?\d+[a-z]?/i,
563
+ /Art(?:icle)?\.?\s?\d+[-\w]*/i,
564
+ /Regulation\s+\d+/i,
565
+ /\b\d{4}[-/]\d+\b/
566
+ ]) {
567
+ const m = bodyText.match(re);
568
+ if (m) return m[0].trim();
569
+ }
570
+ return "";
571
+ }
572
+ function languageName$1(iso) {
573
+ try {
574
+ return new Intl.DisplayNames(["en"], { type: "language" }).of(iso) ?? iso;
575
+ } catch {
576
+ return iso;
577
+ }
578
+ }
579
+ function unitPriceRule(atlas, notes) {
580
+ const match = [notes, ...Object.values(atlas.modes ?? {}).flatMap((m) => (m.launchChecklist ?? []).map((c) => `${c.label} ${c.description}`))].join("\n").toLowerCase().match(/[^\n]*(unit[\s-]?pric|price marking|grundpreis)[^\n]*/);
581
+ return match ? {
582
+ required: true,
583
+ notes: match[0].trim().slice(0, 200)
584
+ } : {
585
+ required: false,
586
+ notes: ""
587
+ };
588
+ }
589
+ function complianceView(countryCode, atlas) {
590
+ const notes = htmlToText(atlas.notes ?? "");
591
+ const languages = atlas.majorLanguages ?? [];
592
+ const primaryLanguage = languages[0] ?? "en";
593
+ const cookieRequired = atlas.legalSettings?.cookieConsentRequired ?? false;
594
+ const regulator = KNOWN_REGULATORS.find((r) => new RegExp(`\\b${r}\\b`).test(notes));
595
+ return {
596
+ countryCode,
597
+ currencyCode: atlas.defaultCurrency,
598
+ languageCode: primaryLanguage,
599
+ languageName: languageName$1(primaryLanguage),
600
+ widelySpokenLanguages: languages.slice(1),
601
+ vat: atlas.taxSettings?.taxName ? {
602
+ name: atlas.taxSettings.taxName,
603
+ standardRatePct: null
604
+ } : null,
605
+ vatInclusiveDisplay: atlas.taxSettings ? atlas.taxSettings.taxInclusive ? "inclusive" : "exclusive_at_sticker" : "unspecified",
606
+ legalSettings: atlas.legalSettings ?? {},
607
+ cookieRule: {
608
+ framework: cookieRequired ? "Consent-based cookie law (see market notes)" : "None documented",
609
+ requirement: cookieRequired ? "Obtain consent before non-essential cookies; provide an equally-prominent Reject All and log consent." : "No cookie-consent mandate documented for this market.",
610
+ regulator: regulator ?? "See market notes"
611
+ },
612
+ unitPriceRule: unitPriceRule(atlas, notes),
613
+ mandatoryDisclosurePages: (atlas.agreements ?? []).map((a) => {
614
+ const primary = primaryTranslation(a);
615
+ const body = htmlToText(primary?.description ?? "");
616
+ return {
617
+ localId: a._localId,
618
+ name: primary?.title ?? a._localId,
619
+ description: body.slice(0, 160),
620
+ citation: extractCitation(body)
621
+ };
622
+ }),
623
+ paymentMethods: sortedPaymentMethods(atlas).map((m) => ({
624
+ integrationType: m.integration_type,
625
+ label: m.name,
626
+ recommended: m.enabled
627
+ }))
628
+ };
629
+ }
630
+ //#endregion
631
+ //#region src/countries/prices.ts
632
+ /** The money fields a conversion can write, as the variants API names them. */
633
+ const PRICE_FIELDS = [
634
+ "price",
635
+ "subscription_price",
636
+ "compare_price",
637
+ "wholesale",
638
+ "wholesale_subscription_price"
639
+ ];
640
+ /**
641
+ * What shoppers pay. Wholesale prices are left out by default: they drive
642
+ * member commissions, and an admin token's product reads can show retail in
643
+ * their place, so converting them is opt-in with --fields.
644
+ */
645
+ const DEFAULT_PRICE_FIELDS = [
646
+ "price",
647
+ "subscription_price",
648
+ "compare_price"
649
+ ];
650
+ /** Currencies Fluid stores with no minor unit (the API rounds the same way). */
651
+ const ZERO_DECIMAL_CURRENCIES = new Set([
652
+ "BIF",
653
+ "CLP",
654
+ "DJF",
655
+ "GNF",
656
+ "HUF",
657
+ "ISK",
658
+ "JPY",
659
+ "KMF",
660
+ "KRW",
661
+ "MGA",
662
+ "PYG",
663
+ "RWF",
664
+ "TWD",
665
+ "UGX",
666
+ "VND",
667
+ "VUV",
668
+ "XAF",
669
+ "XOF",
670
+ "XPF"
671
+ ]);
672
+ function currencyDecimals(currency) {
673
+ return ZERO_DECIMAL_CURRENCIES.has(currency.toUpperCase()) ? 0 : 2;
674
+ }
675
+ /** `amount × rate`, rounded half up to the currency's minor unit. */
676
+ function convertAmount(amount, rate, currency) {
677
+ const factor = 10 ** currencyDecimals(currency);
678
+ const scaled = amount * rate * factor;
679
+ return Math.round(scaled + Math.sign(scaled) * 1e-7) / factor;
680
+ }
681
+ function parseRate(raw) {
682
+ const rate = Number(raw);
683
+ if (!Number.isFinite(rate) || rate <= 0) throw new LocalizationError(`--rate takes a positive number, such as 0.92, not "${raw}".`);
684
+ return rate;
685
+ }
686
+ function parsePriceFields(raw) {
687
+ if (!raw) return [...DEFAULT_PRICE_FIELDS];
688
+ const fields = raw.split(",").map((f) => f.trim()).filter(Boolean);
689
+ const unknown = fields.filter((f) => !PRICE_FIELDS.includes(f));
690
+ if (unknown.length > 0 || fields.length === 0) throw new LocalizationError(`--fields takes a comma-separated list of ${PRICE_FIELDS.join(", ")}${unknown.length > 0 ? `; not ${unknown.join(", ")}` : ""}.`);
691
+ if (!fields.includes("price")) throw new LocalizationError("--fields must include price: a variant can't be sold in a country without one.");
692
+ return [...new Set(fields)];
693
+ }
694
+ function amount(value) {
695
+ if (value === null || value === void 0 || value === "") return null;
696
+ const n = typeof value === "number" ? value : Number(value);
697
+ return Number.isFinite(n) ? n : null;
698
+ }
699
+ const PRODUCTS_PATH = "/api/company/v1/products";
700
+ /** The products list renders every variant, so the API caps a page at 50. */
701
+ const PER_PAGE$1 = 50;
702
+ const MAX_PAGES$2 = 2e3;
703
+ /** Every product with its variants, following the list's pages. */
704
+ async function listProductsWithVariants(client, statuses) {
705
+ const products = [];
706
+ for (let page = 1; page <= MAX_PAGES$2; page += 1) {
707
+ const data = await client.get(PRODUCTS_PATH, {
708
+ page,
709
+ per_page: PER_PAGE$1,
710
+ status: [...statuses]
711
+ });
712
+ const batch = data.products ?? [];
713
+ for (const product of batch) if (!Array.isArray(product.variants)) {
714
+ const full = await client.get(`${PRODUCTS_PATH}/${product.id}`);
715
+ products.push(full.product ?? product);
716
+ } else products.push(product);
717
+ const totalPages = data.meta?.pagination?.total_pages ?? 1;
718
+ if (page >= totalPages || batch.length === 0) return products;
719
+ }
720
+ throw new LocalizationError(`The catalog has more than ${MAX_PAGES$2 * PER_PAGE$1} products, more than one run can read. Narrow it with --status.`);
721
+ }
722
+ /**
723
+ * Whether the price editor owns this company's prices (member-pricing
724
+ * catalog writes), or null when the token can't read the company profile.
725
+ */
726
+ async function priceEditorOwnsPrices(client) {
727
+ try {
728
+ return (await client.get("/api/company/v1/companies/me")).data?.company?.member_pricing?.catalog_writes === true;
729
+ } catch (error) {
730
+ if (statusOf(error) === 401 || statusOf(error) === 403) return null;
731
+ throw error;
732
+ }
733
+ }
734
+ /**
735
+ * Decide, per variant, what a conversion writes. A variant needs a positive
736
+ * source price. One whose target row already has a positive price is skipped
737
+ * unless `overwrite`, which is what makes a rerun safe: the first run's
738
+ * prices count as "already priced". A positive price saved in another
739
+ * currency (the country's currency changed since) is skipped too, and
740
+ * reported apart so it can be reviewed. Opening a country creates a zero-priced,
741
+ * inactive row for every variant, so a zero price counts as unpriced. Only
742
+ * the fields with a positive source amount are sent; the rest of the row is
743
+ * left as it is.
744
+ */
745
+ function planConversion(products, options) {
746
+ const plans = [];
747
+ for (const product of products) for (const variant of product.variants ?? []) {
748
+ const rows = variant.variant_countries ?? {};
749
+ const source = rowFor(rows, options.sourceIso);
750
+ const target = rowFor(rows, options.targetIso);
751
+ const before = Object.fromEntries(options.fields.map((f) => [f, amount(target?.[f])]));
752
+ const base = {
753
+ productId: product.id,
754
+ product: product.title ?? null,
755
+ variantId: variant.id,
756
+ sku: variant.sku ?? null,
757
+ rowId: typeof target?.id === "number" ? target.id : null,
758
+ active: options.activate || target?.active === true,
759
+ before
760
+ };
761
+ const sourcePrice = amount(source?.price);
762
+ if (sourcePrice === null || sourcePrice <= 0) {
763
+ plans.push({
764
+ ...base,
765
+ action: "skip",
766
+ reason: "no_source_price",
767
+ after: {}
768
+ });
769
+ continue;
770
+ }
771
+ if (!options.overwrite && (amount(target?.price) ?? 0) > 0) {
772
+ const currency = target?.currency_code;
773
+ const sameCurrency = !currency || currency.toUpperCase() === options.targetCurrency.toUpperCase();
774
+ plans.push({
775
+ ...base,
776
+ action: "skip",
777
+ reason: sameCurrency ? "already_priced" : "priced_in_other_currency",
778
+ after: {}
779
+ });
780
+ continue;
781
+ }
782
+ const after = {};
783
+ for (const field of options.fields) {
784
+ const value = amount(source?.[field]);
785
+ if (value === null || value <= 0) continue;
786
+ const converted = convertAmount(value, options.rate, options.targetCurrency);
787
+ if (converted > 0) after[field] = converted;
788
+ }
789
+ if (after.price === void 0) {
790
+ plans.push({
791
+ ...base,
792
+ action: "skip",
793
+ reason: "rounds_to_zero",
794
+ after: {}
795
+ });
796
+ continue;
797
+ }
798
+ plans.push({
799
+ ...base,
800
+ action: "convert",
801
+ after
802
+ });
803
+ }
804
+ return plans;
805
+ }
806
+ function rowFor(rows, iso) {
807
+ const key = Object.keys(rows).find((k) => k.toUpperCase() === iso.toUpperCase());
808
+ return key ? rows[key] : void 0;
809
+ }
810
+ /** The variants API body that saves one plan's prices in the target country. */
811
+ function variantBody(plan, targetCountryId) {
812
+ return { variant: { variant_countries_attributes: [{
813
+ ...plan.rowId !== null ? { id: plan.rowId } : {},
814
+ country_id: targetCountryId,
815
+ active: plan.active,
816
+ ...plan.after
817
+ }] } };
818
+ }
819
+ /** Messages the price editor's catalog writer refuses a save with. */
820
+ const PRICE_EDITOR_REFUSAL = /could not be written to the catalog|another update to these prices is in progress/i;
821
+ /**
822
+ * Save the convertible plans, one variant at a time; a failure never stops
823
+ * the run. Each product is read again just before its variants are written
824
+ * and planned afresh, so a price someone set in the country after the
825
+ * preview is skipped instead of overwritten (`changedSincePreview`).
826
+ */
827
+ async function writeConversion(client, plans, options) {
828
+ const written = [];
829
+ const failed = [];
830
+ const changedSincePreview = [];
831
+ const byProduct = /* @__PURE__ */ new Map();
832
+ for (const plan of plans) {
833
+ if (plan.action !== "convert") continue;
834
+ byProduct.set(plan.productId, [...byProduct.get(plan.productId) ?? [], plan]);
835
+ }
836
+ for (const [productId, previewed] of byProduct) {
837
+ let fresh;
838
+ try {
839
+ const data = await client.get(`${PRODUCTS_PATH}/${productId}`);
840
+ fresh = data.product ? planConversion([data.product], options) : [];
841
+ } catch (error) {
842
+ for (const plan of previewed) failed.push(failureFor(plan, error));
843
+ continue;
844
+ }
845
+ for (const plan of previewed) {
846
+ const now = fresh.find((p) => p.variantId === plan.variantId);
847
+ if (!now || now.action !== "convert") {
848
+ changedSincePreview.push({
849
+ variantId: plan.variantId,
850
+ productId,
851
+ reason: now?.reason ?? "variant_gone"
852
+ });
853
+ continue;
854
+ }
855
+ try {
856
+ await client.put(`/api/company/v1/variants/${now.variantId}`, variantBody(now, options.targetCountryId));
857
+ written.push(now.variantId);
858
+ } catch (error) {
859
+ failed.push(failureFor(now, error));
860
+ }
861
+ }
862
+ }
863
+ return {
864
+ written,
865
+ failed,
866
+ changedSincePreview
867
+ };
868
+ }
869
+ function failureFor(plan, error) {
870
+ const details = error.body?.["errors"];
871
+ const { error: message, status } = failureOf(error);
872
+ const text = `${message} ${details ? JSON.stringify(details) : ""}`;
873
+ return {
874
+ variantId: plan.variantId,
875
+ productId: plan.productId,
876
+ sku: plan.sku,
877
+ error: message,
878
+ ...status !== void 0 ? { status } : {},
879
+ refusedByPriceEditor: status === 422 && PRICE_EDITOR_REFUSAL.test(text),
880
+ ...details ? { details } : {}
881
+ };
882
+ }
883
+ /** Counts and a before→after sample, the summary a dry run prints. */
884
+ function summarizePlans(plans, sampleSize) {
885
+ const convert = plans.filter((p) => p.action === "convert");
886
+ return {
887
+ variants: plans.length,
888
+ toConvert: convert.length,
889
+ skipped: {
890
+ alreadyPriced: plans.filter((p) => p.reason === "already_priced").length,
891
+ pricedInOtherCurrency: plans.filter((p) => p.reason === "priced_in_other_currency").length,
892
+ noSourcePrice: plans.filter((p) => p.reason === "no_source_price").length,
893
+ roundsToZero: plans.filter((p) => p.reason === "rounds_to_zero").length
894
+ },
895
+ activeAfterWrite: convert.filter((p) => p.active).length,
896
+ sample: convert.slice(0, sampleSize).map((p) => ({
897
+ productId: p.productId,
898
+ product: p.product,
899
+ variantId: p.variantId,
900
+ sku: p.sku,
901
+ before: p.before,
902
+ after: p.after
903
+ }))
904
+ };
905
+ }
906
+ /** A company country by ISO, from a list already read. */
907
+ function companyCountryIn(countries, iso) {
908
+ return countries.find((cc) => cc.country?.iso?.toUpperCase() === iso.toUpperCase());
909
+ }
910
+ /**
911
+ * A reference rate to start from: the European Central Bank's daily rate,
912
+ * through the Frankfurter API. Fluid has no exchange-rate API. A write never
913
+ * uses this on its own; it needs the rate passed with --rate, so the rate
914
+ * saved is one a person or agent reviewed.
915
+ */
916
+ async function referenceRate(from, to, fetchImpl = fetch) {
917
+ const url = `https://api.frankfurter.app/latest?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`;
918
+ try {
919
+ const response = await fetchImpl(url);
920
+ if (!response.ok) return null;
921
+ const body = await response.json();
922
+ const rate = body.rates?.[to.toUpperCase()];
923
+ if (typeof rate !== "number" || !(rate > 0)) return null;
924
+ return {
925
+ rate,
926
+ date: body.date ?? "",
927
+ source: "European Central Bank reference rate (frankfurter.app)"
928
+ };
929
+ } catch {
930
+ return null;
931
+ }
932
+ }
933
+ /** How many variants have a price in the country and how many are on sale there; bundles listed apart. */
934
+ function priceCoverage(products, iso) {
935
+ const coverage = {
936
+ variants: 0,
937
+ priced: 0,
938
+ onSale: 0,
939
+ bundles: []
940
+ };
941
+ for (const product of products) {
942
+ if (product.bundle === true) {
943
+ coverage.bundles.push({
944
+ id: product.id,
945
+ title: product.title ?? null
946
+ });
947
+ continue;
948
+ }
949
+ for (const variant of product.variants ?? []) {
950
+ coverage.variants += 1;
951
+ const row = rowFor(variant.variant_countries ?? {}, iso);
952
+ if ((amount(row?.price) ?? 0) <= 0) continue;
953
+ coverage.priced += 1;
954
+ if (row?.active === true) coverage.onSale += 1;
955
+ }
956
+ }
957
+ return coverage;
958
+ }
959
+ //#endregion
960
+ //#region src/countries/plan.ts
961
+ /** Each of the market's major languages and whether the company has it on.
962
+ * A language the catalog lacks can't be enabled and is reported as such. */
963
+ function languageStates(atlas, languages) {
964
+ return (atlas.majorLanguages ?? []).map((iso) => {
965
+ const match = languageIn(languages, iso);
966
+ return {
967
+ iso,
968
+ name: match?.name ?? null,
969
+ languageId: match?.id ?? null,
970
+ enabled: match?.active === true
971
+ };
972
+ });
973
+ }
974
+ /** Match atlas agreements to company agreements by title, case-insensitively,
975
+ * so a setup never creates a second agreement with the same title. */
976
+ function agreementStates(atlas, agreements, companyCountryId) {
977
+ return (atlas.agreements ?? []).map((agreement) => {
978
+ const summary = summarizeAgreement(agreement);
979
+ const titles = new Set(agreement.translations.map((t) => t.title.trim().toLowerCase()));
980
+ const matches = agreements.filter((a) => titles.has(a.title?.trim().toLowerCase()));
981
+ const onCountry = companyCountryId === null ? [] : matches.filter((a) => (a.company_country_ids ?? []).includes(companyCountryId));
982
+ const linked = onCountry.find((a) => a.active !== false);
983
+ const inactive = linked ? void 0 : onCountry[0];
984
+ const match = linked ?? inactive ?? matches[0];
985
+ return {
986
+ localId: summary.localId,
987
+ title: summary.title,
988
+ languages: summary.languages,
989
+ requiredOnCheckout: summary.requiredOnCheckout,
990
+ showAtCheckout: summary.showAtCheckout,
991
+ status: linked ? "linked" : inactive ? "inactive" : match ? "exists" : "missing",
992
+ agreementId: match?.id ?? null
993
+ };
994
+ });
995
+ }
996
+ //#endregion
997
+ //#region src/countries/seed.ts
998
+ /** Address fields checkout both renders and stores; only these may be
999
+ * required (CompanyCountry::ContractSchema.storable_address_field_names). */
1000
+ const STORABLE_ADDRESS_FIELDS = [
1001
+ "name",
1002
+ "address1",
1003
+ "address2",
1004
+ "city",
1005
+ "state",
1006
+ "postal_code",
1007
+ "country_code",
1008
+ "phone"
1009
+ ];
1010
+ const COLUMN_WIDTHS = [
1011
+ "full",
1012
+ "half",
1013
+ "third"
1014
+ ];
1015
+ /**
1016
+ * The atlas address layout in the contract shape the API validates. The
1017
+ * atlas marks first and last name required, but checkout cannot collect a
1018
+ * required field it does not store, so those keep their layout and become
1019
+ * optional. A required field is always visible. Repeats are dropped.
1020
+ */
1021
+ function addressFieldsFromAtlas(atlas) {
1022
+ const seen = /* @__PURE__ */ new Set();
1023
+ const entries = [];
1024
+ for (const source of atlas.addressFields ?? []) {
1025
+ if (typeof source.field !== "string" || source.field === "" || seen.has(source.field)) continue;
1026
+ seen.add(source.field);
1027
+ const required = source.required === true && STORABLE_ADDRESS_FIELDS.includes(source.field);
1028
+ const entry = {
1029
+ field: source.field,
1030
+ visible: required || source.visible !== false,
1031
+ required
1032
+ };
1033
+ if (typeof source.localLabel === "string") entry.local_label = source.localLabel;
1034
+ if (typeof source.columnWidth === "string" && COLUMN_WIDTHS.includes(source.columnWidth)) entry.column_width = source.columnWidth;
1035
+ if (typeof source.placeholder === "string") entry.placeholder = source.placeholder;
1036
+ entries.push(entry);
1037
+ }
1038
+ return entries;
1039
+ }
1040
+ /** The atlas legal settings under the API's snake_case keys. Values of the
1041
+ * wrong kind are left out rather than sent to be refused. */
1042
+ function legalSettingsFromAtlas(atlas) {
1043
+ const legal = atlas.legalSettings ?? {};
1044
+ const out = {};
1045
+ const count = (v) => typeof v === "number" && Number.isInteger(v) && v >= 0;
1046
+ if (count(legal.coolingOffPeriodDays)) out["cooling_off_period_days"] = legal.coolingOffPeriodDays;
1047
+ if (count(legal.warrantyPeriodMonths)) out["warranty_period_months"] = legal.warrantyPeriodMonths;
1048
+ if (typeof legal.cookieConsentRequired === "boolean") out["cookie_consent_required"] = legal.cookieConsentRequired;
1049
+ if (typeof legal.recurringSubscriptionDisclosureRequired === "boolean") out["recurring_subscription_disclosure_required"] = legal.recurringSubscriptionDisclosureRequired;
1050
+ return out;
1051
+ }
1052
+ /** The atlas's recommended payment methods (enabled ones), in its order, as
1053
+ * the integration type names checkout ranks by. */
1054
+ function paymentPriorityFromAtlas(atlas) {
1055
+ return sortedPaymentMethods(atlas).filter((m) => m.enabled).map((m) => m.integration_type);
1056
+ }
1057
+ /** The keys `company_country` accepts on create and update, so a `--set`
1058
+ * typo is caught before it reaches the API. */
1059
+ const WRITABLE_KEYS = [
1060
+ "currency",
1061
+ "warehouse_id",
1062
+ "handling_fee",
1063
+ "default",
1064
+ "tax_business_id",
1065
+ "tax_strategy",
1066
+ "tax_class",
1067
+ "mode",
1068
+ "settlement_currency",
1069
+ "entity_id",
1070
+ "entity_legally_registered",
1071
+ "tax_inclusive_pricing",
1072
+ "tax_name",
1073
+ "requires_3ds",
1074
+ "payment_method_priority",
1075
+ "checkout_requires_terms",
1076
+ "tax_invoice_required",
1077
+ "legal_settings",
1078
+ "address_fields"
1079
+ ];
1080
+ /** The company_country attributes the atlas and the flags give. */
1081
+ function companyCountryFromAtlas(atlas, options) {
1082
+ const body = {
1083
+ country_id: options.countryId,
1084
+ mode: options.mode.toUpperCase(),
1085
+ currency: atlas.defaultCurrency,
1086
+ requires_3ds: atlas.requires3ds === true
1087
+ };
1088
+ const tax = atlas.taxSettings;
1089
+ if (tax) {
1090
+ if (typeof tax.taxName === "string" && tax.taxName !== "") body["tax_name"] = tax.taxName;
1091
+ if (typeof tax.taxInclusive === "boolean") body["tax_inclusive_pricing"] = tax.taxInclusive;
1092
+ if (typeof tax.invoiceRequired === "boolean") body["tax_invoice_required"] = tax.invoiceRequired;
1093
+ }
1094
+ const priority = paymentPriorityFromAtlas(atlas);
1095
+ if (priority.length > 0) body["payment_method_priority"] = priority;
1096
+ const legal = legalSettingsFromAtlas(atlas);
1097
+ if (Object.keys(legal).length > 0) body["legal_settings"] = legal;
1098
+ const address = addressFieldsFromAtlas(atlas);
1099
+ if (address.length > 0) body["address_fields"] = address;
1100
+ if (options.warehouseId !== void 0) body["warehouse_id"] = options.warehouseId;
1101
+ if (options.businessId !== void 0) body["tax_business_id"] = options.businessId;
1102
+ if (options.entityRegistered !== void 0) body["entity_legally_registered"] = options.entityRegistered;
1103
+ if (options.settlementCurrency !== void 0) body["settlement_currency"] = options.settlementCurrency.toUpperCase();
1104
+ return body;
1105
+ }
1106
+ /** Apply `--set` overrides on top of the seeded body, refusing unknown keys. */
1107
+ function applyOverrides(body, overrides) {
1108
+ const unknown = Object.keys(overrides).filter((key) => !WRITABLE_KEYS.includes(key));
1109
+ if (unknown.length > 0) throw new LocalizationError(`Unknown company country setting(s): ${unknown.join(", ")}. Settable: ${WRITABLE_KEYS.join(", ")}.`);
1110
+ const merged = { ...body };
1111
+ for (const [key, value] of Object.entries(overrides)) {
1112
+ const current = merged[key];
1113
+ merged[key] = isPlainObject(current) && isPlainObject(value) ? {
1114
+ ...current,
1115
+ ...value
1116
+ } : value;
1117
+ }
1118
+ return merged;
1119
+ }
1120
+ function isPlainObject(value) {
1121
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1122
+ }
1123
+ //#endregion
1124
+ //#region src/countries/status.ts
1125
+ function same(a, b) {
1126
+ return a === b || a == null && b == null;
1127
+ }
1128
+ /** Compare the company country with the atlas. A value an admin changed on
1129
+ * purpose shows as a difference to review, not as a failure to fix. */
1130
+ function settingsChecks(atlas, cc) {
1131
+ const checks = [];
1132
+ checks.push({
1133
+ id: "mode",
1134
+ ok: typeof cc.mode === "string" && cc.mode !== "",
1135
+ detail: cc.mode ? `Operating mode ${cc.mode}.` : "No operating mode set."
1136
+ });
1137
+ checks.push({
1138
+ id: "currency",
1139
+ ok: same(cc.currency, atlas.defaultCurrency),
1140
+ detail: `Currency ${cc.currency ?? "unset"}; the atlas recommends ${atlas.defaultCurrency}.`
1141
+ });
1142
+ const tax = atlas.taxSettings;
1143
+ if (tax) {
1144
+ checks.push({
1145
+ id: "tax_name",
1146
+ ok: same(cc.tax_name, tax.taxName),
1147
+ detail: `Tax name ${cc.tax_name ?? "unset"}; the atlas says ${tax.taxName}.`
1148
+ });
1149
+ checks.push({
1150
+ id: "tax_inclusive_pricing",
1151
+ ok: same(cc.tax_inclusive_pricing, tax.taxInclusive),
1152
+ detail: `Tax-inclusive prices ${String(cc.tax_inclusive_pricing ?? "unset")}; the atlas says ${String(tax.taxInclusive)}.`
1153
+ });
1154
+ }
1155
+ checks.push({
1156
+ id: "requires_3ds",
1157
+ ok: same(cc.requires_3ds ?? false, atlas.requires3ds ?? false),
1158
+ detail: `3-D Secure required ${String(cc.requires_3ds ?? false)}; the atlas says ${String(atlas.requires3ds ?? false)}.`
1159
+ });
1160
+ if (tax && typeof tax.invoiceRequired === "boolean") checks.push({
1161
+ id: "tax_invoice_required",
1162
+ ok: same(cc.tax_invoice_required, tax.invoiceRequired),
1163
+ detail: `Tax invoices required ${String(cc.tax_invoice_required ?? "unset")}; the atlas says ${String(tax.invoiceRequired)}.`
1164
+ });
1165
+ const priority = paymentPriorityFromAtlas(atlas);
1166
+ if (priority.length > 0) {
1167
+ const current = cc.payment_method_priority ?? [];
1168
+ checks.push({
1169
+ id: "payment_method_priority",
1170
+ ok: JSON.stringify(current) === JSON.stringify(priority),
1171
+ detail: `Payment order ${current.length > 0 ? current.join(", ") : "unset"}; the atlas recommends ${priority.join(", ")}.`
1172
+ });
1173
+ }
1174
+ const legal = legalSettingsFromAtlas(atlas);
1175
+ const legalDiffs = Object.entries(legal).filter(([key, value]) => cc.legal_settings?.[key] !== value).map(([key]) => key);
1176
+ checks.push({
1177
+ id: "legal_settings",
1178
+ ok: legalDiffs.length === 0,
1179
+ detail: legalDiffs.length === 0 ? "Legal settings match the atlas." : `Legal settings differ from the atlas: ${legalDiffs.join(", ")}.`
1180
+ });
1181
+ const requiredFields = addressFieldsFromAtlas(atlas).filter((f) => f.required).map((f) => f.field);
1182
+ const current = Array.isArray(cc.address_fields) ? cc.address_fields : null;
1183
+ const notRequired = requiredFields.filter((field) => !current?.some((f) => f["field"] === field && f["required"] === true));
1184
+ checks.push({
1185
+ id: "address_fields",
1186
+ ok: current !== null && current.length > 0 && notRequired.length === 0,
1187
+ detail: current === null ? "No address layout on the country; checkout uses the default fields." : notRequired.length > 0 ? `The atlas requires ${notRequired.join(", ")}, which the country doesn't.` : `${current.length} address fields set, with every field the atlas requires.`
1188
+ });
1189
+ return checks;
1190
+ }
1191
+ function statusReport(input) {
1192
+ const { countryCode, atlas, companyCountry: cc, languages, agreements } = input;
1193
+ if (!cc) return {
1194
+ country: countryCode,
1195
+ open: false,
1196
+ done: false,
1197
+ next: `fluid countries open ${countryCode} --mode <nfr|otg|usd>`
1198
+ };
1199
+ const priceChecks = input.prices ? [{
1200
+ id: "prices",
1201
+ ok: input.prices.priced === input.prices.variants,
1202
+ detail: input.prices.priced === input.prices.variants ? `All ${input.prices.variants} variants of your active products have a price in ${countryCode}${input.prices.bundles.length > 0 ? ` (bundles aren't counted; see byHand.bundles)` : ""}.` : `${input.prices.variants - input.prices.priced} of ${input.prices.variants} variants of your active products have no price in ${countryCode}. Run \`fluid countries prices ${countryCode}\`.`
1203
+ }, {
1204
+ id: "on_sale",
1205
+ ok: input.prices.onSale > 0 || input.prices.bundles.length > 0,
1206
+ detail: input.prices.onSale > 0 ? `${input.prices.onSale} variants are on sale in ${countryCode}.` : input.prices.bundles.length > 0 ? `No single products are on sale in ${countryCode}; check the bundles under byHand.bundles.` : `Nothing is on sale in ${countryCode} yet. Turn on the products you'll sell there, or run prices with --activate.`
1207
+ }] : [];
1208
+ const checks = [
1209
+ ...settingsChecks(atlas, cc),
1210
+ ...priceChecks,
1211
+ ...languages.map((l) => ({
1212
+ id: `language:${l.iso}`,
1213
+ ok: l.enabled,
1214
+ detail: l.enabled ? `${l.name ?? l.iso} is enabled.` : l.languageId === null ? `${l.iso} isn't in Fluid's language catalog.` : `${l.name ?? l.iso} isn't enabled.`
1215
+ })),
1216
+ ...agreements.map((a) => ({
1217
+ id: `agreement:${a.localId}`,
1218
+ ok: a.status === "linked" && (a.missingTranslations ?? []).length === 0,
1219
+ detail: a.status === "linked" ? (a.missingTranslations ?? []).length > 0 ? `"${a.title}" is on the country but has no ${a.missingTranslations.join(", ")} translation. Enable the language, then run agreements --translate.` : `"${a.title}" is on the country.` : a.status === "inactive" ? `"${a.title}" is on the country but inactive, so checkout doesn't show it. Activate it in Settings > Agreements.` : a.status === "exists" ? `"${a.title}" exists but isn't on this country. Add the country to it in Settings > Agreements.` : `"${a.title}" hasn't been created.`
1220
+ }))
1221
+ ];
1222
+ const mode = (cc.mode ?? "").toLowerCase();
1223
+ return {
1224
+ country: countryCode,
1225
+ open: true,
1226
+ companyCountryId: cc.id,
1227
+ testMode: cc.test_mode ?? null,
1228
+ done: checks.every((c) => c.ok),
1229
+ checks,
1230
+ translations: (atlas.majorLanguages ?? []).map((iso) => ({
1231
+ language: iso,
1232
+ report: `fluid translations missing ${iso}`,
1233
+ translate: `fluid translations auto ${iso} --write --yes`
1234
+ })),
1235
+ prices: {
1236
+ preview: `fluid countries prices ${countryCode}`,
1237
+ convert: `fluid countries prices ${countryCode} --rate <rate> --write --yes`
1238
+ },
1239
+ byHand: {
1240
+ bundles: input.prices && input.prices.bundles.length > 0 ? {
1241
+ how: `Check each bundle's price and availability in ${countryCode} in the bundle editor. Bundles can be priced from their groups, and each group can be switched off per country.`,
1242
+ items: input.prices.bundles
1243
+ } : null,
1244
+ paymentMethods: sortedPaymentMethods(atlas).map((m) => ({
1245
+ integrationType: m.integration_type,
1246
+ name: m.name,
1247
+ recommended: m.enabled
1248
+ })),
1249
+ enrollmentFields: [...atlas.enrollmentFormFields ?? []].sort((a, b) => a.order - b.order).map((f) => ({
1250
+ id: f.id,
1251
+ label: f.label,
1252
+ component: f.component,
1253
+ required: f.required
1254
+ })),
1255
+ activatePrices: "Put the products you will sell on sale in the country: run prices with --activate, or activate them per product.",
1256
+ leaveTestMode: cc.test_mode === true ? "The country is in test mode. Turn test mode off when payments are configured." : null
1257
+ },
1258
+ launchChecklist: mode ? launchChecklist(atlas, mode) : []
1259
+ };
1260
+ }
1261
+ //#endregion
1262
+ //#region src/shared/walk.ts
1263
+ /** Thrown by `ask` when the person moves instead of answering. */
1264
+ var NavSignal = class extends Error {
1265
+ constructor(nav) {
1266
+ super(`nav:${nav}`);
1267
+ this.nav = nav;
1268
+ this.name = "NavSignal";
1269
+ }
1270
+ };
1271
+ /** Run the steps in order, honouring back, skip, the menu and stop. */
1272
+ async function runSteps(prompter, steps, options = {}) {
1273
+ const history = [];
1274
+ const completed = [];
1275
+ const skipped = [];
1276
+ if (options.help !== false) prompter.note(NAV_HELP);
1277
+ let index = nextActive(steps, 0);
1278
+ const ctxFor = (step) => ({
1279
+ prompter,
1280
+ note: (text) => prompter.note(text),
1281
+ ask: async (question) => {
1282
+ const reply = await prompter.ask({
1283
+ ...question,
1284
+ section: step.title,
1285
+ canGoBack: history.length > 0
1286
+ });
1287
+ if ("nav" in reply) throw new NavSignal(reply.nav);
1288
+ return reply.value;
1289
+ },
1290
+ choose: async (message, choices, current) => {
1291
+ const reply = await prompter.ask({
1292
+ section: step.title,
1293
+ message,
1294
+ kind: "select",
1295
+ choices,
1296
+ ...current !== void 0 ? { current } : {},
1297
+ canGoBack: history.length > 0
1298
+ });
1299
+ if ("nav" in reply) throw new NavSignal(reply.nav);
1300
+ return reply.value;
1301
+ }
1302
+ });
1303
+ while (index < steps.length) {
1304
+ const step = steps[index];
1305
+ try {
1306
+ await step.run(ctxFor(step));
1307
+ if (!completed.includes(step.id)) completed.push(step.id);
1308
+ history.push(index);
1309
+ index = nextActive(steps, index + 1);
1310
+ } catch (error) {
1311
+ if (!(error instanceof NavSignal)) throw error;
1312
+ let nav = error.nav;
1313
+ let target = -1;
1314
+ if (nav === "menu") {
1315
+ const choice = await prompter.menu("What next?", [
1316
+ {
1317
+ title: "Continue",
1318
+ value: "continue"
1319
+ },
1320
+ {
1321
+ title: "Back to the previous step",
1322
+ value: "back",
1323
+ disabled: history.length === 0
1324
+ },
1325
+ {
1326
+ title: "Skip this step",
1327
+ value: "skip"
1328
+ },
1329
+ {
1330
+ title: "Go to a step",
1331
+ value: "goto"
1332
+ },
1333
+ {
1334
+ title: "Stop for now",
1335
+ value: "quit"
1336
+ }
1337
+ ]);
1338
+ if (choice === null || choice === "quit") nav = "quit";
1339
+ else if (choice === "continue") continue;
1340
+ else if (choice === "goto") {
1341
+ const picked = await prompter.menu("Which step?", steps.map((s, i) => ({
1342
+ s,
1343
+ i
1344
+ })).filter(({ s }) => !s.when || s.when()).map(({ s, i }) => ({
1345
+ title: `${completed.includes(s.id) ? "✓" : " "} ${s.title}`,
1346
+ value: String(i)
1347
+ })));
1348
+ if (picked === null) continue;
1349
+ nav = "goto";
1350
+ target = Number(picked);
1351
+ } else nav = choice;
1352
+ }
1353
+ if (nav === "quit") return {
1354
+ completed,
1355
+ skipped,
1356
+ stopped: true
1357
+ };
1358
+ if (nav === "back") {
1359
+ const previous = history.pop();
1360
+ if (previous === void 0) prompter.note("This is the first step.");
1361
+ else index = previous;
1362
+ continue;
1363
+ }
1364
+ if (nav === "skip") {
1365
+ if (!skipped.includes(step.id)) skipped.push(step.id);
1366
+ history.push(index);
1367
+ index = nextActive(steps, index + 1);
1368
+ continue;
1369
+ }
1370
+ if (nav === "goto") {
1371
+ history.push(index);
1372
+ index = target;
1373
+ }
1374
+ }
1375
+ }
1376
+ return {
1377
+ completed,
1378
+ skipped,
1379
+ stopped: false
1380
+ };
1381
+ }
1382
+ function nextActive(steps, from) {
1383
+ let i = from;
1384
+ while (i < steps.length && steps[i].when && !steps[i].when()) i += 1;
1385
+ return i;
1386
+ }
1387
+ const YES_NO = [{
1388
+ title: "Yes",
1389
+ value: "yes"
1390
+ }, {
1391
+ title: "No",
1392
+ value: "no"
1393
+ }];
1394
+ //#endregion
1395
+ //#region src/countries/walk.ts
1396
+ /** One line per mode, so the choice reads without the atlas open. */
1397
+ const MODE_LINES = {
1398
+ nfr: "Not For Resale: ship cross-border from a warehouse outside the country",
1399
+ otg: "On The Ground: a local entity, local tax registration, local fulfilment",
1400
+ usd: "Sell cross-border and charge in US dollars, usually digital goods"
1401
+ };
1402
+ const yes = (v) => v === "yes";
1403
+ /** The company_country body the walk would send: the atlas seed with the person's changes. */
1404
+ function draftAttributes(draft) {
1405
+ if (!draft.atlas || draft.countryId === void 0 || !draft.mode) return {};
1406
+ const body = companyCountryFromAtlas(draft.atlas, {
1407
+ countryId: draft.countryId,
1408
+ mode: draft.mode,
1409
+ ...draft.warehouseId !== void 0 ? { warehouseId: draft.warehouseId } : {}
1410
+ });
1411
+ for (const [key, value] of Object.entries(draft.overrides)) if (value === void 0) delete body[key];
1412
+ else body[key] = value;
1413
+ return body;
1414
+ }
1415
+ async function walkCountry(deps, options = {}) {
1416
+ const { api, prompter } = deps;
1417
+ const draft = {
1418
+ languages: [],
1419
+ createAgreements: [],
1420
+ translateAgreements: [],
1421
+ overrides: {},
1422
+ legacyDefaults: false
1423
+ };
1424
+ const result = {
1425
+ opened: false,
1426
+ reseeded: false,
1427
+ enabled: [],
1428
+ agreementsCreated: [],
1429
+ pricesWritten: 0,
1430
+ stopped: false,
1431
+ translate: [],
1432
+ failures: []
1433
+ };
1434
+ let catalog;
1435
+ let languages = [];
1436
+ let reseed = false;
1437
+ let confirmed = false;
1438
+ const run = await runSteps(prompter, [
1439
+ {
1440
+ id: "country",
1441
+ title: "Country",
1442
+ run: async (ctx) => {
1443
+ catalog ??= await listCatalogCountries(api);
1444
+ for (;;) {
1445
+ const found = await fetchAtlas(api, (await ctx.ask({
1446
+ message: "Which country are you opening?",
1447
+ kind: "search",
1448
+ hint: "type to filter",
1449
+ choices: catalog.map((c) => ({
1450
+ title: `${c.name} (${c.iso})`,
1451
+ value: c.iso
1452
+ })),
1453
+ ...draft.iso ? { current: draft.iso } : options.iso ? { current: options.iso.toUpperCase() } : {}
1454
+ })).toUpperCase());
1455
+ if (!found.covered || !found.atlas) {
1456
+ ctx.note(`Fluid has no Country Atlas for ${found.countryCode} yet, so there's nothing to seed it from. Open it by hand in Settings > Countries, or pick another country.`);
1457
+ continue;
1458
+ }
1459
+ if (draft.iso !== found.countryCode) Object.assign(draft, {
1460
+ mode: void 0,
1461
+ warehouseId: void 0,
1462
+ languages: [],
1463
+ createAgreements: [],
1464
+ translateAgreements: [],
1465
+ overrides: {},
1466
+ legacyDefaults: false
1467
+ });
1468
+ const entry = catalog.find((c) => c.iso.toUpperCase() === found.countryCode);
1469
+ draft.iso = found.countryCode;
1470
+ draft.name = entry?.name ?? found.countryCode;
1471
+ draft.countryId = entry?.id;
1472
+ draft.atlas = found.atlas;
1473
+ draft.existing = await findCompanyCountry(api, found.countryCode);
1474
+ return;
1475
+ }
1476
+ }
1477
+ },
1478
+ {
1479
+ id: "atlas",
1480
+ title: "Country Atlas",
1481
+ run: async (ctx) => {
1482
+ const atlas = draft.atlas;
1483
+ ctx.note([
1484
+ `${draft.name}: ${atlas.defaultCurrency}, ${atlas.taxSettings?.taxName ?? "no tax name"} ${atlas.taxSettings?.taxInclusive ? "included in prices" : "added at checkout"}, 3-D Secure ${atlas.requires3ds ? "required" : "not required"}.`,
1485
+ `Languages: ${(atlas.majorLanguages ?? []).join(", ") || "none listed"}. Agreements: ${(atlas.agreements ?? []).length}. Payment methods: ${(atlas.paymentMethods ?? []).filter((m) => m.enabled).map((m) => m.name).join(", ") || "none recommended"}.`,
1486
+ ...draft.existing ? [`${draft.iso} is already open for this company (${draft.existing.mode ?? "no mode"}, ${draft.existing.currency ?? "no currency"}).`] : []
1487
+ ].join("\n"));
1488
+ await ctx.choose("Continue with this country?", [{
1489
+ title: "Continue",
1490
+ value: "go"
1491
+ }]);
1492
+ }
1493
+ },
1494
+ {
1495
+ id: "mode",
1496
+ title: "Operating mode",
1497
+ run: async (ctx) => {
1498
+ const atlas = draft.atlas;
1499
+ draft.mode = await ctx.choose("How will you operate there?", MODES.map((m) => ({
1500
+ title: `${m.toUpperCase()} — ${MODE_LINES[m]}`,
1501
+ value: m,
1502
+ description: htmlToText(atlas.modes?.[m]?.overview ?? "").split("\n")[0]?.slice(0, 160)
1503
+ })), draft.mode ?? ((draft.existing?.mode ?? "").toLowerCase() || void 0));
1504
+ }
1505
+ },
1506
+ {
1507
+ id: "warehouse",
1508
+ title: "Warehouse",
1509
+ when: () => draft.mode !== "usd",
1510
+ run: async (ctx) => {
1511
+ const warehouses = await listWarehouses(api);
1512
+ if (warehouses.length === 0) {
1513
+ ctx.note("This company has no warehouses yet; add one in Settings > Warehouses later.");
1514
+ return;
1515
+ }
1516
+ const id = await ctx.choose("Which warehouse fulfils its orders?", [...warehouses.map((w) => ({
1517
+ title: `${w.name}${w.country?.iso ? ` (${w.country.iso})` : ""}${w.active === false ? " — inactive" : ""}`,
1518
+ value: String(w.id)
1519
+ })), {
1520
+ title: "Decide later",
1521
+ value: "none"
1522
+ }], draft.warehouseId !== void 0 ? String(draft.warehouseId) : void 0);
1523
+ draft.warehouseId = id === "none" ? void 0 : Number(id);
1524
+ }
1525
+ },
1526
+ {
1527
+ id: "languages",
1528
+ title: "Languages",
1529
+ run: async (ctx) => {
1530
+ languages = await listLanguages(api);
1531
+ const states = languageStates(draft.atlas, languages);
1532
+ draft.languages = [];
1533
+ for (const state of states) {
1534
+ if (state.enabled) {
1535
+ ctx.note(`${state.name ?? state.iso} is already on.`);
1536
+ continue;
1537
+ }
1538
+ if (state.languageId === null) {
1539
+ ctx.note(`${state.iso} isn't in Fluid's language catalog, so it can't be turned on.`);
1540
+ continue;
1541
+ }
1542
+ if (yes(await ctx.choose(`Turn on ${state.name ?? state.iso} for the whole company?`, YES_NO))) draft.languages.push(state.iso);
1543
+ }
1544
+ }
1545
+ },
1546
+ {
1547
+ id: "agreements",
1548
+ title: "Agreements",
1549
+ run: async (ctx) => {
1550
+ const atlas = draft.atlas;
1551
+ const states = agreementStates(atlas, await listAgreements(api), draft.existing?.id ?? null);
1552
+ draft.createAgreements = [];
1553
+ draft.translateAgreements = [];
1554
+ for (const state of states) if (state.status === "missing") {
1555
+ if (yes(await ctx.choose(`Create "${state.title}"${state.requiredOnCheckout ? " (required at checkout)" : ""} in ${state.languages.join(", ")}?`, YES_NO))) draft.createAgreements.push(state.localId);
1556
+ } else if ((state.status === "linked" || state.status === "inactive") && state.agreementId !== null) {
1557
+ if (yes(await ctx.choose(`"${state.title}" is already on ${draft.iso}. Write the atlas translations onto it?`, YES_NO))) draft.translateAgreements.push({
1558
+ localId: state.localId,
1559
+ id: state.agreementId
1560
+ });
1561
+ } else ctx.note(`"${state.title}" exists but isn't on ${draft.iso}; add the country to it in Settings > Agreements.`);
1562
+ }
1563
+ },
1564
+ {
1565
+ id: "defaults",
1566
+ title: "Atlas defaults",
1567
+ run: async (ctx) => {
1568
+ const seeded = draftAttributes({
1569
+ ...draft,
1570
+ overrides: {}
1571
+ });
1572
+ const overrides = {};
1573
+ const keep = async (label, shown) => await ctx.choose(`${label}: ${shown}`, [{
1574
+ title: "Keep",
1575
+ value: "keep"
1576
+ }, {
1577
+ title: "Change",
1578
+ value: "change"
1579
+ }]) === "keep";
1580
+ const currency = String(seeded["currency"] ?? "");
1581
+ if (!await keep("Currency", currency)) for (;;) {
1582
+ const value = (await ctx.ask({
1583
+ message: "Currency code",
1584
+ kind: "text",
1585
+ current: currency
1586
+ })).trim().toUpperCase() || currency;
1587
+ if (/^[A-Z]{3}$/.test(value)) {
1588
+ overrides["currency"] = value;
1589
+ break;
1590
+ }
1591
+ ctx.note(`"${value}" isn't a three-letter currency code, such as EUR.`);
1592
+ }
1593
+ const taxName = String(seeded["tax_name"] ?? "");
1594
+ if (!await keep("Tax name", taxName || "(none)")) overrides["tax_name"] = (await ctx.ask({
1595
+ message: "Tax name",
1596
+ kind: "text",
1597
+ current: taxName
1598
+ })).trim() || taxName;
1599
+ const inclusive = seeded["tax_inclusive_pricing"] === true;
1600
+ if (!await keep("Prices include tax", inclusive ? "yes" : "no")) overrides["tax_inclusive_pricing"] = !inclusive;
1601
+ const threeDs = seeded["requires_3ds"] === true;
1602
+ if (!await keep("Card payments need 3-D Secure", threeDs ? "yes" : "no")) overrides["requires_3ds"] = !threeDs;
1603
+ const order = seeded["payment_method_priority"] ?? [];
1604
+ if (!await keep("Payment method order", order.join(", ") || "checkout's default")) {
1605
+ const list = (await ctx.ask({
1606
+ message: "Payment methods in order, comma-separated (empty for checkout's default)",
1607
+ kind: "text",
1608
+ current: order.join(", ")
1609
+ })).split(",").map((m) => m.trim()).filter(Boolean);
1610
+ overrides["payment_method_priority"] = list.length > 0 ? list : void 0;
1611
+ }
1612
+ const fields = seeded["address_fields"] ?? [];
1613
+ if (fields.length > 0 && !await keep("Address layout", fields.map((f) => `${f.field}${f.required ? "*" : ""}`).join(", "))) {
1614
+ overrides["address_fields"] = void 0;
1615
+ ctx.note("Checkout's default address layout will be used; edit it in Settings > Countries.");
1616
+ }
1617
+ draft.overrides = overrides;
1618
+ }
1619
+ },
1620
+ {
1621
+ id: "legacy",
1622
+ title: "Fluid's generic defaults",
1623
+ when: () => !draft.existing,
1624
+ run: async (ctx) => {
1625
+ draft.legacyDefaults = await ctx.choose("Fluid can also add its generic country agreements and forms.", [{
1626
+ title: "Use the atlas agreements and forms only",
1627
+ value: "atlas"
1628
+ }, {
1629
+ title: "Also add Fluid's generic ones",
1630
+ value: "legacy"
1631
+ }], draft.legacyDefaults ? "legacy" : "atlas") === "legacy";
1632
+ }
1633
+ },
1634
+ {
1635
+ id: "confirm",
1636
+ title: "Review",
1637
+ run: async (ctx) => {
1638
+ const body = draftAttributes(draft);
1639
+ ctx.note([
1640
+ `${draft.existing ? "Update" : "Open"} ${draft.name} (${draft.iso}) in ${draft.mode?.toUpperCase()} mode.`,
1641
+ `Currency ${String(body["currency"])}, tax ${String(body["tax_name"] ?? "none")}${body["tax_inclusive_pricing"] ? " (included)" : ""}, 3-D Secure ${body["requires_3ds"] ? "on" : "off"}.`,
1642
+ `Payment order: ${(body["payment_method_priority"] ?? []).join(", ") || "checkout's default"}.`,
1643
+ `Languages to turn on: ${draft.languages.join(", ") || "none"}.`,
1644
+ `Agreements to create: ${draft.createAgreements.length}; to translate: ${draft.translateAgreements.length}.`,
1645
+ ...draft.existing ? [] : [draft.legacyDefaults ? "Fluid's generic country agreements and forms will be added too." : "Only the atlas agreements and forms; Fluid's generic ones are left out."]
1646
+ ].join("\n"));
1647
+ const choices = draft.existing ? [{
1648
+ title: `Write these settings over ${draft.iso}'s current ones`,
1649
+ value: "reseed"
1650
+ }, {
1651
+ title: `Keep ${draft.iso}'s settings; just do languages and agreements`,
1652
+ value: "keep"
1653
+ }] : [{
1654
+ title: `Open ${draft.iso} now`,
1655
+ value: "open"
1656
+ }];
1657
+ reseed = await ctx.choose("Ready?", choices) === "reseed";
1658
+ confirmed = true;
1659
+ }
1660
+ }
1661
+ ]);
1662
+ result.country = draft.iso;
1663
+ if (run.stopped || !confirmed || !draft.iso) {
1664
+ result.stopped = true;
1665
+ prompter.note("Stopped. Nothing was changed.");
1666
+ return result;
1667
+ }
1668
+ const iso = draft.iso;
1669
+ try {
1670
+ const body = draftAttributes(draft);
1671
+ if (!draft.existing) {
1672
+ draft.existing = await createCompanyCountry(api, body, draft.legacyDefaults);
1673
+ result.opened = true;
1674
+ prompter.note(`Opened ${iso}.`);
1675
+ } else if (reseed) {
1676
+ draft.existing = await updateCompanyCountry(api, draft.existing.id, body, draft.existing.revision);
1677
+ result.reseeded = true;
1678
+ prompter.note(`Updated ${iso}'s settings.`);
1679
+ }
1680
+ } catch (error) {
1681
+ result.failures.push({
1682
+ step: "open",
1683
+ error: failureOf(error).error
1684
+ });
1685
+ prompter.note(`${iso} couldn't be saved: ${failureOf(error).error}. Nothing else was changed.`);
1686
+ return result;
1687
+ }
1688
+ for (const code of draft.languages) {
1689
+ const language = languages.find((l) => l.iso.toLowerCase() === code.toLowerCase());
1690
+ if (!language) continue;
1691
+ try {
1692
+ await enableLanguage(api, language);
1693
+ result.enabled.push(language.iso);
1694
+ } catch (error) {
1695
+ result.failures.push({
1696
+ step: `language:${code}`,
1697
+ error: failureOf(error).error
1698
+ });
1699
+ }
1700
+ }
1701
+ if (result.enabled.length > 0) prompter.note(`Turned on ${result.enabled.join(", ")}.`);
1702
+ for (const localId of draft.createAgreements) try {
1703
+ const created = await createAgreementFromAtlas(api, atlasAgreement(draft.atlas, localId), draft.existing.id);
1704
+ result.agreementsCreated.push(localId);
1705
+ for (const f of created.failedTranslations) result.failures.push({
1706
+ step: `agreement:${localId}:${f.language}`,
1707
+ error: f.error
1708
+ });
1709
+ } catch (error) {
1710
+ result.failures.push({
1711
+ step: `agreement:${localId}`,
1712
+ error: failureOf(error).error
1713
+ });
1714
+ }
1715
+ for (const { localId, id } of draft.translateAgreements) {
1716
+ const written = await writeTranslations(api, id, atlasAgreement(draft.atlas, localId));
1717
+ for (const f of written.failedTranslations) result.failures.push({
1718
+ step: `agreement:${localId}:${f.language}`,
1719
+ error: f.error
1720
+ });
1721
+ }
1722
+ if (draft.createAgreements.length + draft.translateAgreements.length > 0) prompter.note(`Agreements: ${result.agreementsCreated.length} created, ${draft.translateAgreements.length} translated.`);
1723
+ for (const failure of result.failures) prompter.note(`Not saved (${failure.step}): ${failure.error}`);
1724
+ const priced = await offerPrices(deps, iso);
1725
+ result.pricesWritten = priced.written;
1726
+ for (const f of priced.failed) result.failures.push({
1727
+ step: `price:variant:${f.variantId}`,
1728
+ error: f.error
1729
+ });
1730
+ const toTranslate = (draft.atlas.majorLanguages ?? []).filter((code) => {
1731
+ const language = languages.find((l) => l.iso.toLowerCase() === code.toLowerCase());
1732
+ return language?.active || result.enabled.includes(language?.iso ?? "");
1733
+ });
1734
+ for (const code of toTranslate) {
1735
+ const answer = await prompter.menu(`Translate the storefront into ${code} now?`, [{
1736
+ title: "Yes, walk through it",
1737
+ value: "walk",
1738
+ disabled: !deps.translate
1739
+ }, {
1740
+ title: "Later",
1741
+ value: "later"
1742
+ }]);
1743
+ result.translate.push(code);
1744
+ if (answer === "walk" && deps.translate) {
1745
+ const walked = await deps.translate(code);
1746
+ for (const f of walked?.failed ?? []) result.failures.push({
1747
+ step: `translation:${code}:${f.id}`,
1748
+ error: f.error
1749
+ });
1750
+ } else prompter.note(`Later: fluid translations walk ${code}`);
1751
+ }
1752
+ const checklist = draft.mode ? launchChecklist(draft.atlas, draft.mode) : [];
1753
+ if (checklist.length > 0) prompter.note(`Before you launch (${draft.mode.toUpperCase()}):\n${checklist.map((c) => `- ${c.label}`).join("\n")}`);
1754
+ prompter.note(`Check the result any time with: fluid countries status ${iso}`);
1755
+ return result;
1756
+ }
1757
+ /** Show the conversion preview, ask for the rate, and write on a yes. */
1758
+ async function offerPrices(deps, iso) {
1759
+ const none = {
1760
+ written: 0,
1761
+ failed: []
1762
+ };
1763
+ const { api, prompter } = deps;
1764
+ const countries = await listCompanyCountries(api);
1765
+ const target = companyCountryIn(countries, iso);
1766
+ const source = companyCountryIn(countries, "US");
1767
+ if (!target?.country || !source?.currency || !target.currency) {
1768
+ prompter.note(`No US prices to convert from${source ? "" : " (the company doesn't sell in the US)"}. Convert from another country later with: fluid countries prices ${iso} --from-country <iso>`);
1769
+ return none;
1770
+ }
1771
+ const from = source.currency.toUpperCase();
1772
+ const to = target.currency.toUpperCase();
1773
+ const reference = from === to ? {
1774
+ rate: 1,
1775
+ date: "",
1776
+ source: "same currency"
1777
+ } : await (deps.fetchRate ?? referenceRate)(from, to);
1778
+ const editor = await priceEditorOwnsPrices(api);
1779
+ const products = await listProductsWithVariants(api, ["active"]);
1780
+ for (;;) {
1781
+ const raw = await prompter.ask({
1782
+ section: "Prices",
1783
+ message: `Exchange rate, ${to} per ${from}${reference ? ` (reference ${reference.rate}${reference.date ? `, ${reference.date}` : ""})` : ""}`,
1784
+ kind: "text",
1785
+ hint: "Enter to use it, > to skip prices",
1786
+ ...reference ? { current: String(reference.rate) } : {},
1787
+ canGoBack: false
1788
+ });
1789
+ if ("nav" in raw) {
1790
+ prompter.note(`Prices skipped. Later: fluid countries prices ${iso}`);
1791
+ return none;
1792
+ }
1793
+ const rate = Number(raw.value.trim() || reference?.rate);
1794
+ if (!Number.isFinite(rate) || rate <= 0) {
1795
+ prompter.note("Type a positive number, such as 0.92.");
1796
+ continue;
1797
+ }
1798
+ const options = {
1799
+ sourceIso: "US",
1800
+ targetIso: iso,
1801
+ targetCountryId: target.country.id,
1802
+ targetCurrency: to,
1803
+ rate,
1804
+ fields: DEFAULT_PRICE_FIELDS,
1805
+ overwrite: false,
1806
+ activate: false
1807
+ };
1808
+ const plans = planConversion(products, options);
1809
+ const summary = summarizePlans(plans, 5);
1810
+ prompter.note([
1811
+ `At ${rate} ${to} per ${from}, rounded half up to ${currencyDecimals(to)} decimals:`,
1812
+ `${summary.toConvert} of ${summary.variants} variants get a price; ${summary.skipped.alreadyPriced} already have one, ${summary.skipped.noSourcePrice} have no US price, ${summary.skipped.roundsToZero} would round to zero.`,
1813
+ ...summary.sample.map((s) => ` ${s.product ?? s.productId}${s.sku ? ` (${s.sku})` : ""}: ${String(s.before.price ?? "—")} → ${String(s.after.price)} ${to}`),
1814
+ ...editor ? ["Your prices are managed in the price editor; any it can't take are listed."] : []
1815
+ ].join("\n"));
1816
+ if (summary.toConvert === 0) return none;
1817
+ const answer = await prompter.menu(`Save ${summary.toConvert} prices in ${to}?`, [
1818
+ {
1819
+ title: "Save them",
1820
+ value: "write"
1821
+ },
1822
+ {
1823
+ title: "Use a different rate",
1824
+ value: "rate"
1825
+ },
1826
+ {
1827
+ title: "Not now",
1828
+ value: "later"
1829
+ }
1830
+ ]);
1831
+ if (answer === "rate") continue;
1832
+ if (answer !== "write") {
1833
+ prompter.note(`Prices skipped. Later: fluid countries prices ${iso} --rate ${rate} --write --yes`);
1834
+ return none;
1835
+ }
1836
+ const written = await writeConversion(api, plans, options);
1837
+ prompter.note(`Saved ${written.written.length} prices${written.failed.length > 0 ? `; ${written.failed.length} refused:\n${written.failed.map((f) => ` variant ${f.variantId}: ${f.error}`).join("\n")}` : "."}`);
1838
+ return {
1839
+ written: written.written.length,
1840
+ failed: written.failed.map((f) => ({
1841
+ variantId: f.variantId,
1842
+ error: f.error
1843
+ }))
1844
+ };
1845
+ }
1846
+ }
1847
+ //#endregion
1848
+ //#region src/translations/resources.ts
1849
+ /** Types whose source text is in the store's default language (theme types use --from). */
1850
+ function readsInStoreLanguage(type) {
1851
+ return type.kind !== "theme" && type.kind !== "theme-sections";
1852
+ }
1853
+ function storefront(type, label, bodyKey, fields, urlFields = ["image_url"], showKey = bodyKey) {
1854
+ return {
1855
+ kind: "storefront",
1856
+ type,
1857
+ label,
1858
+ path: type,
1859
+ bodyKey,
1860
+ showKey,
1861
+ listKey: type.replace(/-/g, "_"),
1862
+ fields: [...fields, ...urlFields],
1863
+ textFields: fields
1864
+ };
1865
+ }
1866
+ /**
1867
+ * The field sets match what fluid-admin's translation sheets read and write,
1868
+ * which in turn match each v2026-04 write schema's locale-scoped fields.
1869
+ * Enrollment packs have no per-locale image on that schema.
1870
+ */
1871
+ const RESOURCE_TYPES = [
1872
+ storefront("products", "Products", "product", [
1873
+ "title",
1874
+ "introduction",
1875
+ "description",
1876
+ "feature_text"
1877
+ ]),
1878
+ storefront("categories", "Categories", "category", ["title", "description"]),
1879
+ storefront("collections", "Collections", "collection", ["title", "description"]),
1880
+ storefront("pages", "Pages", "page", ["title", "description"]),
1881
+ storefront("posts", "Posts", "post", [
1882
+ "title",
1883
+ "description",
1884
+ "summary"
1885
+ ]),
1886
+ storefront("media", "Media", "medium", ["title", "description"], [
1887
+ "image_url",
1888
+ "video_url",
1889
+ "pdf_url"
1890
+ ]),
1891
+ storefront("playlists", "Playlists", "library", ["title", "description"], ["image_url"], "playlist"),
1892
+ storefront("enrollment-packs", "Enrollment packs", "enrollment_pack", ["title", "description"], []),
1893
+ {
1894
+ kind: "menu-items",
1895
+ type: "menu-items",
1896
+ label: "Navigation menu items",
1897
+ fields: ["title"],
1898
+ textFields: ["title"]
1899
+ },
1900
+ {
1901
+ kind: "theme",
1902
+ type: "theme",
1903
+ label: "Theme strings (locales/<iso>.json)",
1904
+ fields: ["<key.path>"],
1905
+ textFields: ["<key.path>"]
1906
+ },
1907
+ {
1908
+ kind: "theme-sections",
1909
+ type: "theme-sections",
1910
+ label: "Theme page strings (a template's own translations)",
1911
+ fields: ["<key.path>"],
1912
+ textFields: ["<key.path>"]
1913
+ },
1914
+ {
1915
+ kind: "agreements",
1916
+ type: "agreements",
1917
+ label: "Agreements",
1918
+ fields: ["title", "description"],
1919
+ textFields: ["title", "description"]
1920
+ },
1921
+ {
1922
+ kind: "labels",
1923
+ type: "labels",
1924
+ label: "Product labels",
1925
+ fields: ["title"],
1926
+ textFields: ["title"]
1927
+ }
1928
+ ];
1929
+ const TYPE_NAMES = RESOURCE_TYPES.map((t) => t.type);
1930
+ function resourceType(name) {
1931
+ const wanted = name.trim().toLowerCase();
1932
+ const match = RESOURCE_TYPES.find((t) => t.type === wanted);
1933
+ if (!match) throw new LocalizationError(`"${name}" isn't a translatable type. Use one of: ${TYPE_NAMES.join(", ")}.`);
1934
+ return match;
1935
+ }
1936
+ /** The types `--type` selects, or every type when none is given. */
1937
+ function selectTypes(names) {
1938
+ if (names.length === 0) return [...RESOURCE_TYPES];
1939
+ const picked = names.flatMap((name) => name.split(",")).filter((name) => name.trim().length > 0).map(resourceType);
1940
+ return RESOURCE_TYPES.filter((t) => picked.includes(t));
1941
+ }
1942
+ /** Reject fields a type can't translate, so a typo never reaches the API. */
1943
+ function checkFields(type, fields) {
1944
+ const unknown = Object.keys(fields).filter((f) => !type.fields.includes(f));
1945
+ if (unknown.length > 0) throw new LocalizationError(`${type.label} can't translate ${unknown.join(", ")}. Translatable fields: ${type.fields.join(", ")}.`);
1946
+ if (Object.keys(fields).length === 0) throw new LocalizationError(`Pass at least one field, e.g. --set title=... (${type.fields.join(", ")}).`);
1947
+ }
1948
+ //#endregion
1949
+ //#region src/translations/storefront.ts
1950
+ const PAGE_LIMIT = 100;
1951
+ /** A guard against a cursor that never ends; 1,000 pages is 100,000 rows. */
1952
+ const MAX_PAGES$1 = 1e3;
1953
+ function companyPath(type, id) {
1954
+ return `/api/v202604/company/${type.path}${id === void 0 ? "" : `/${id}`}`;
1955
+ }
1956
+ /** Every row of a type, following the cursor. Rows are in the default locale. */
1957
+ async function listAll(client, type) {
1958
+ const rows = [];
1959
+ let cursor;
1960
+ for (let page = 0; page < MAX_PAGES$1; page += 1) {
1961
+ const data = await client.get(companyPath(type), {
1962
+ "page[limit]": PAGE_LIMIT,
1963
+ ...cursor ? { "page[cursor]": cursor } : {}
1964
+ });
1965
+ const batch = data[type.listKey];
1966
+ if (Array.isArray(batch)) rows.push(...batch);
1967
+ cursor = data.meta?.pagination?.next_cursor;
1968
+ if (!cursor) return rows;
1969
+ }
1970
+ throw new LocalizationError(`${type.label} has more than ${MAX_PAGES$1 * PAGE_LIMIT} rows, more than one run can list. Translate it in fluid-admin or through the API.`);
1971
+ }
1972
+ /** One row, read in `lang` (fields with no translation fall back to `en`). */
1973
+ async function getRow(client, type, id, lang) {
1974
+ let data;
1975
+ try {
1976
+ data = await client.get(companyPath(type, id), lang ? { lang } : void 0);
1977
+ } catch (error) {
1978
+ if (statusOf(error) === 404) throw new LocalizationError(`No ${type.label.toLowerCase()} row with id ${id}.`);
1979
+ throw error;
1980
+ }
1981
+ const row = data[type.showKey];
1982
+ if (!row || typeof row !== "object") throw new LocalizationError(`The API returned no ${type.showKey} for id ${id}.`);
1983
+ return row;
1984
+ }
1985
+ /** The locales a row is translated into, always including the default. */
1986
+ function rowLanguages(row) {
1987
+ const languages = row["languages"];
1988
+ return Array.isArray(languages) ? languages.filter((l) => typeof l === "string") : [];
1989
+ }
1990
+ function isTranslated(row, iso) {
1991
+ const wanted = iso.toLowerCase();
1992
+ return rowLanguages(row).some((l) => l.toLowerCase() === wanted);
1993
+ }
1994
+ /**
1995
+ * Rich-text fields (descriptions on products, media, and enrollment packs)
1996
+ * can come back as a string or as `{ body }`. Normalize to a string, or
1997
+ * null when there is no value.
1998
+ */
1999
+ function fieldText(value) {
2000
+ if (typeof value === "string") return value.length > 0 ? value : null;
2001
+ if (value && typeof value === "object") {
2002
+ const body = value.body;
2003
+ if (typeof body === "string") return body.length > 0 ? body : null;
2004
+ }
2005
+ return null;
2006
+ }
2007
+ /** A row's translatable fields as strings (or null), in `fields` order. */
2008
+ function pickFields(row, fields) {
2009
+ return Object.fromEntries(fields.map((f) => [f, fieldText(row[f])]));
2010
+ }
2011
+ function rowTitle(row) {
2012
+ return fieldText(row["title"]);
2013
+ }
2014
+ /**
2015
+ * Write one locale's translated fields. A translation PATCH writes only the
2016
+ * translated fields on every resource except products, where any other field
2017
+ * sent would also apply; the caller sends translated fields only.
2018
+ */
2019
+ async function writeTranslation(client, type, id, lang, fields) {
2020
+ const data = await client.patch(`${companyPath(type, id)}?lang=${encodeURIComponent(lang)}`, { [type.bodyKey]: fields });
2021
+ const row = data[type.showKey] ?? data[type.bodyKey];
2022
+ return row && typeof row === "object" ? row : { id };
2023
+ }
2024
+ /**
2025
+ * The store's default language. Every row's `languages` lists it first, so
2026
+ * the first row of any storefront type answers; null for an empty catalog.
2027
+ */
2028
+ async function storeDefaultIso(client, types) {
2029
+ for (const type of types) {
2030
+ let data;
2031
+ try {
2032
+ data = await client.get(companyPath(type), { "page[limit]": 1 });
2033
+ } catch {
2034
+ continue;
2035
+ }
2036
+ const batch = data[type.listKey];
2037
+ const first = Array.isArray(batch) ? batch[0] : void 0;
2038
+ const iso = first ? rowLanguages(first)[0] : void 0;
2039
+ if (iso) return iso;
2040
+ }
2041
+ return null;
2042
+ }
2043
+ //#endregion
2044
+ //#region src/translations/auto-languages.ts
2045
+ /**
2046
+ * The languages a machine translation runs between. Storefront rows, menu
2047
+ * items, agreements and labels read back in the store's default language,
2048
+ * so that is the language the translator is told they're in; it's read from
2049
+ * the rows (selected storefront types first), `storeLanguage` only fills in
2050
+ * when they can't be read, and it's never guessed. Theme strings translate
2051
+ * from `from`.
2052
+ */
2053
+ async function autoLanguages(api, types, options) {
2054
+ const target = options.write ? await requireActiveLanguage(api, options.iso) : await findLanguage(api, options.iso);
2055
+ const source = await findLanguage(api, options.from);
2056
+ let store = source;
2057
+ if (types.some(readsInStoreLanguage)) {
2058
+ const storeIso = await storeDefaultIso(api, [...types.filter((t) => t.kind === "storefront"), ...RESOURCE_TYPES.filter((t) => t.kind === "storefront" && !types.includes(t))]) ?? options.storeLanguage;
2059
+ if (!storeIso) throw new LocalizationError("Couldn't read the store's default language from its storefront rows. Pass --store-language <iso>.");
2060
+ store = sameIso(storeIso, source.iso) ? source : await findLanguage(api, storeIso);
2061
+ if (sameIso(store.iso, target.iso)) throw new LocalizationError(`${target.name} is the store's default language, so storefront content in it is the source, not a translation. Use --type theme --type theme-sections to translate only theme strings.`);
2062
+ }
2063
+ if (types.some((t) => !readsInStoreLanguage(t)) && sameIso(source.iso, target.iso)) throw new LocalizationError(`--from and the target are both ${target.iso}; pass --from with the theme's source locale.`);
2064
+ return {
2065
+ target,
2066
+ source,
2067
+ store
2068
+ };
2069
+ }
2070
+ //#endregion
2071
+ //#region src/translations/labels.ts
2072
+ const LABELS_PATH = "/api/company/labels";
2073
+ /**
2074
+ * The company's labels in the default language. The list isn't paginated and
2075
+ * holds only the labels an admin created; Fluid's own labels ("New",
2076
+ * "Trending") are translated by Fluid and can't be changed.
2077
+ */
2078
+ async function listLabels(client) {
2079
+ return ((await client.get(LABELS_PATH)).label ?? []).map((wire) => ({
2080
+ id: wire.id,
2081
+ title: wire.title ?? ""
2082
+ }));
2083
+ }
2084
+ /** One label's title in `lang`, or in the default language without one. */
2085
+ async function getLabelTitle(client, id, lang) {
2086
+ try {
2087
+ return (await client.get(`${LABELS_PATH}/${id}`, lang ? { lang } : void 0)).label?.title ?? "";
2088
+ } catch (error) {
2089
+ if (statusOf(error) === 404) throw new LocalizationError(`No label with id ${id}, or it's one of Fluid's own labels, which Fluid translates.`);
2090
+ throw error;
2091
+ }
2092
+ }
2093
+ /** Whether a label's title in `lang` differs from its default title. */
2094
+ function labelTranslated(source, translated) {
2095
+ return translated.trim().length > 0 && translated.trim() !== source.trim();
2096
+ }
2097
+ async function writeLabelTitle(client, id, lang, title) {
2098
+ await client.put(`${LABELS_PATH}/${id}?lang=${encodeURIComponent(lang)}`, { label: { title } });
2099
+ }
2100
+ /**
2101
+ * Machine-translate id-keyed strings with Fluid AI
2102
+ * (`POST /api/v202604/ai/translate`). Languages are named, not coded
2103
+ * ("english", "french"), which is what the endpoint expects. Nothing is
2104
+ * saved; the caller writes the results back.
2105
+ */
2106
+ async function machineTranslate(client, sourceLanguage, targetLanguage, fields, model) {
2107
+ const entries = Object.entries(fields).filter(([, v]) => v.trim().length > 0);
2108
+ const result = {
2109
+ translations: {},
2110
+ usage: {
2111
+ inputTokens: 0,
2112
+ outputTokens: 0
2113
+ }
2114
+ };
2115
+ for (let start = 0; start < entries.length; start += 40) {
2116
+ const batch = Object.fromEntries(entries.slice(start, start + 40));
2117
+ const data = await client.post("/api/v202604/ai/translate", {
2118
+ source_language: languageName(sourceLanguage),
2119
+ target_language: languageName(targetLanguage),
2120
+ fields: batch,
2121
+ ...model ? { model } : {}
2122
+ });
2123
+ for (const id of Object.keys(batch)) {
2124
+ const value = data.translations?.[id];
2125
+ if (typeof value === "string" && value.trim().length > 0) result.translations[id] = value;
2126
+ }
2127
+ result.usage.inputTokens += data.usage?.input_tokens ?? 0;
2128
+ result.usage.outputTokens += data.usage?.output_tokens ?? 0;
2129
+ }
2130
+ return result;
2131
+ }
2132
+ /** "Spanish" → "spanish"; the endpoint takes lowercase names up to 64 characters. */
2133
+ function languageName(name) {
2134
+ const value = name.trim().toLowerCase();
2135
+ if (value.length === 0 || value.length > 64) throw new LocalizationError(`"${name}" isn't a usable language name.`);
2136
+ return value;
2137
+ }
2138
+ //#endregion
2139
+ //#region src/translations/menus.ts
2140
+ const PER_PAGE = 100;
2141
+ const MAX_PAGES = 50;
2142
+ /**
2143
+ * Every menu item of every menu, flattened. With `lang`, titles come back
2144
+ * without the English fallback, so an item with no translation reads null.
2145
+ */
2146
+ async function listMenuItems(client, lang) {
2147
+ const items = [];
2148
+ for (let page = 1; page <= MAX_PAGES; page += 1) {
2149
+ const data = await client.get("/api/menus", {
2150
+ page,
2151
+ per_page: PER_PAGE,
2152
+ ...lang ? { lang } : {}
2153
+ });
2154
+ const menus = data.menus ?? [];
2155
+ for (const menu of menus) flatten(menu, menu.menu_items ?? [], items);
2156
+ const totalPages = data.meta?.total_pages ?? 1;
2157
+ if (page >= totalPages || menus.length < PER_PAGE) return items;
2158
+ }
2159
+ throw new LocalizationError(`More than ${MAX_PAGES * PER_PAGE} menus.`);
2160
+ }
2161
+ function flatten(menu, nodes, out) {
2162
+ for (const node of nodes) {
2163
+ out.push({
2164
+ id: `${menu.id}/${node.id}`,
2165
+ menuId: menu.id,
2166
+ itemId: node.id,
2167
+ menu: menu.title ?? null,
2168
+ title: typeof node.title === "string" && node.title.length > 0 ? node.title : null
2169
+ });
2170
+ flatten(menu, node.sub_menu_items ?? [], out);
2171
+ }
2172
+ }
2173
+ /** Parse a `<menu id>/<item id>` reference. */
2174
+ function parseMenuItemId(ref) {
2175
+ const match = /^(\d+)\/(\d+)$/.exec(ref.trim());
2176
+ if (!match) throw new LocalizationError(`"${ref}" isn't a menu item id. Use <menu id>/<item id>, as \`fluid translations missing\` prints it, e.g. 12/340.`);
2177
+ return {
2178
+ menuId: Number(match[1]),
2179
+ itemId: Number(match[2])
2180
+ };
2181
+ }
2182
+ /** One menu item's title in the default locale and, without fallback, in `lang`. */
2183
+ async function getMenuItem(client, ref, lang) {
2184
+ const { menuId, itemId } = parseMenuItemId(ref);
2185
+ const path = `/api/menus/${menuId}/menu_items/${itemId}`;
2186
+ const [source, translated] = await Promise.all([client.get(path), client.get(path, { lang })]);
2187
+ const text = (wire) => typeof wire?.title === "string" && wire.title.length > 0 ? wire.title : null;
2188
+ return {
2189
+ source: text(source.menu_item),
2190
+ translated: text(translated.menu_item)
2191
+ };
2192
+ }
2193
+ async function writeMenuItemTitle(client, ref, lang, title) {
2194
+ const { menuId, itemId } = parseMenuItemId(ref);
2195
+ const data = await client.patch(`/api/menus/${menuId}/menu_items/${itemId}?lang=${encodeURIComponent(lang)}`, { menu_item: { title } });
2196
+ return {
2197
+ id: `${menuId}/${itemId}`,
2198
+ title: data.menu_item?.title ?? null
2199
+ };
2200
+ }
2201
+ //#endregion
2202
+ //#region src/translations/theme.ts
2203
+ /** The theme to work on: `--theme <id>`, else the company's active theme. */
2204
+ async function resolveThemeId(client, themeId) {
2205
+ if (themeId !== void 0) {
2206
+ const id = Number(themeId);
2207
+ if (!Number.isInteger(id) || id <= 0) throw new LocalizationError(`"${themeId}" isn't a theme id.`);
2208
+ return id;
2209
+ }
2210
+ try {
2211
+ const data = await client.get("/api/v202604/themes/active");
2212
+ if (typeof data.theme?.id === "number") return data.theme.id;
2213
+ } catch (error) {
2214
+ if (statusOf(error) !== 404) throw error;
2215
+ }
2216
+ throw new LocalizationError("This company has no active theme. Pass --theme <id> to work on another theme.");
2217
+ }
2218
+ /**
2219
+ * Every `locales/<iso>.json` file on the theme, plus the theme's
2220
+ * `content_version_sha`, from one read of the theme resources API.
2221
+ */
2222
+ async function readThemeLocales(client, themeId) {
2223
+ let data;
2224
+ try {
2225
+ data = await client.get(`/api/application_themes/${themeId}/resources`);
2226
+ } catch (error) {
2227
+ if (statusOf(error) !== 422) throw error;
2228
+ return readLocaleTemplates(client, themeId);
2229
+ }
2230
+ const files = /* @__PURE__ */ new Map();
2231
+ for (const resource of data.application_theme_resources ?? []) {
2232
+ const match = /^locales\/([^/]+)\.json$/.exec(resource.key ?? "");
2233
+ if (!match) continue;
2234
+ const iso = match[1];
2235
+ files.set(iso.toLowerCase(), {
2236
+ iso,
2237
+ doc: parseLocale(resource.content, resource.key)
2238
+ });
2239
+ }
2240
+ return {
2241
+ themeId,
2242
+ sha: data.content_version_sha ?? null,
2243
+ files
2244
+ };
2245
+ }
2246
+ /** Locale files through the v2026-04 templates endpoints, with no theme version. */
2247
+ async function readLocaleTemplates(client, themeId) {
2248
+ const list = await client.get(`/api/v202604/themes/${themeId}/templates`, { themeable_type: "locales" });
2249
+ const files = /* @__PURE__ */ new Map();
2250
+ for (const summary of list.templates ?? []) {
2251
+ if (!summary.name) continue;
2252
+ const data = await client.get(`/api/v202604/themes/${themeId}/templates/${summary.id}`);
2253
+ const key = `locales/${summary.name}.json`;
2254
+ files.set(summary.name.toLowerCase(), {
2255
+ iso: summary.name,
2256
+ doc: parseLocale(data.template?.content, key)
2257
+ });
2258
+ }
2259
+ return {
2260
+ themeId,
2261
+ sha: null,
2262
+ files
2263
+ };
2264
+ }
2265
+ /** One locale file from a read, or null when the theme has none. */
2266
+ function localeOf(locales, iso) {
2267
+ return locales.files.get(iso.toLowerCase())?.doc ?? null;
2268
+ }
2269
+ function parseLocale(content, key) {
2270
+ if (content === null || content === void 0 || content === "") return {};
2271
+ if (typeof content === "object" && !Array.isArray(content)) return content;
2272
+ if (typeof content === "string") try {
2273
+ const parsed = JSON.parse(content);
2274
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
2275
+ } catch {}
2276
+ throw new LocalizationError(`${key} isn't a JSON object; fix it before translating.`);
2277
+ }
2278
+ /** Leaf strings keyed by dotted path: `{ cart: { title: "Cart" } }` → `cart.title`. */
2279
+ function flattenLocale(doc, prefix = "") {
2280
+ const out = {};
2281
+ for (const [key, value] of Object.entries(doc)) {
2282
+ const path = prefix ? `${prefix}.${key}` : key;
2283
+ if (typeof value === "string") out[path] = value;
2284
+ else if (value && typeof value === "object" && !Array.isArray(value)) Object.assign(out, flattenLocale(value, path));
2285
+ }
2286
+ return out;
2287
+ }
2288
+ /** Keys the source locale has that the target lacks or leaves blank. */
2289
+ function missingKeys(source, target) {
2290
+ const have = flattenLocale(target ?? {});
2291
+ const missing = {};
2292
+ for (const [key, value] of Object.entries(flattenLocale(source))) {
2293
+ if (value.trim().length === 0) continue;
2294
+ if ((have[key] ?? "").trim().length === 0) missing[key] = value;
2295
+ }
2296
+ return missing;
2297
+ }
2298
+ /**
2299
+ * Keys whose target value is still the source text word for word. Some are
2300
+ * right as they are (a brand name, "OK", a SKU); the rest were copied and
2301
+ * never translated. They aren't counted as missing, so they're listed for
2302
+ * review instead.
2303
+ */
2304
+ function sameAsSourceKeys(source, target) {
2305
+ const have = flattenLocale(target ?? {});
2306
+ return Object.entries(flattenLocale(source)).filter(([key, value]) => /\p{L}/u.test(value) && have[key] === value).map(([key]) => key);
2307
+ }
2308
+ /** A copy of `doc` with each dotted key set to its value. */
2309
+ function mergeLocale(doc, updates) {
2310
+ const out = structuredClone(doc);
2311
+ for (const [path, value] of Object.entries(updates)) {
2312
+ const parts = keyPath(path);
2313
+ let node = out;
2314
+ for (const part of parts.slice(0, -1)) {
2315
+ const next = Object.hasOwn(node, part) ? node[part] : void 0;
2316
+ if (typeof next === "string") throw new LocalizationError(`"${path}" would replace the string at "${part}" with an object.`);
2317
+ if (!next || typeof next !== "object" || Array.isArray(next)) node[part] = {};
2318
+ node = node[part];
2319
+ }
2320
+ const leaf = parts[parts.length - 1];
2321
+ if (Object.hasOwn(node, leaf) && node[leaf] && typeof node[leaf] === "object") throw new LocalizationError(`"${path}" is a group of keys, not a string.`);
2322
+ node[leaf] = value;
2323
+ }
2324
+ return out;
2325
+ }
2326
+ /**
2327
+ * Save `locales/<iso>.json` through the theme resources API, creating the
2328
+ * file if the theme has none. `base_sha` is the version the file was read
2329
+ * at, so the server refuses the write (409) if the theme changed since,
2330
+ * rather than overwriting someone else's edit. A read with no version
2331
+ * (the fallback for an invalid member configuration) can't be saved. The
2332
+ * write publishes immediately.
2333
+ */
2334
+ async function writeLocale(client, locales, iso, doc) {
2335
+ if (!locales.sha) throw new LocalizationError(`Theme ${locales.themeId}'s files can't be listed (its member configuration is invalid), so the CLI can read its locale files but can't save them safely. Fix the theme's member configuration, or edit the locale file in the theme editor.`);
2336
+ const key = `locales/${locales.files.get(iso.toLowerCase())?.iso ?? iso}.json`;
2337
+ try {
2338
+ await client.put(`/api/application_themes/${locales.themeId}/resources`, {
2339
+ application_theme_resource: {
2340
+ key,
2341
+ content: JSON.stringify(doc, null, 2)
2342
+ },
2343
+ base_sha: locales.sha
2344
+ });
2345
+ } catch (error) {
2346
+ if (statusOf(error) === 409) throw Object.assign(new LocalizationError(`Theme ${locales.themeId} changed after it was read, so ${key} wasn't saved. Run the command again to merge onto the latest version.`), { status: 409 });
2347
+ throw error;
2348
+ }
2349
+ return {
2350
+ key,
2351
+ saved: true
2352
+ };
2353
+ }
2354
+ //#endregion
2355
+ //#region src/translations/sections.ts
2356
+ /**
2357
+ * Template kinds that never render as a page of their own, so their
2358
+ * `translations` aren't read: shared sections, blocks and components, the
2359
+ * locale files themselves, layouts, and configuration.
2360
+ */
2361
+ const NOT_PAGES = new Set([
2362
+ "sections",
2363
+ "blocks",
2364
+ "components",
2365
+ "locales",
2366
+ "layouts",
2367
+ "config"
2368
+ ]);
2369
+ function asTranslations(value) {
2370
+ if (typeof value === "string") try {
2371
+ return asTranslations(JSON.parse(value));
2372
+ } catch {
2373
+ return {};
2374
+ }
2375
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
2376
+ const out = {};
2377
+ for (const [locale, doc] of Object.entries(value)) if (doc && typeof doc === "object" && !Array.isArray(doc)) out[locale] = doc;
2378
+ return out;
2379
+ }
2380
+ /** One locale's strings from a template's translations, matching the code's casing. */
2381
+ function localeIn(translations, iso) {
2382
+ const key = Object.keys(translations).find((k) => k.toLowerCase() === iso.toLowerCase());
2383
+ return key ? translations[key] ?? null : null;
2384
+ }
2385
+ async function readTemplateTranslations(client, themeId, templateId) {
2386
+ const data = await client.get(`/api/v202604/themes/${themeId}/templates/${templateId}`);
2387
+ return {
2388
+ templateId,
2389
+ name: data.template?.name ?? null,
2390
+ translations: asTranslations(data.template?.translations)
2391
+ };
2392
+ }
2393
+ /**
2394
+ * Every page template of the theme that has translations. The template list
2395
+ * carries no translations, so each page template is read on its own.
2396
+ */
2397
+ async function listTemplateTranslations(client, themeId) {
2398
+ const list = await client.get(`/api/v202604/themes/${themeId}/templates`);
2399
+ const out = [];
2400
+ for (const summary of list.templates ?? []) {
2401
+ if (NOT_PAGES.has(summary.themeable_type ?? "")) continue;
2402
+ const template = await readTemplateTranslations(client, themeId, summary.id);
2403
+ if (Object.keys(template.translations).length > 0) out.push(template);
2404
+ }
2405
+ return out;
2406
+ }
2407
+ /** The keys a template's source locale has and the target lacks, as `<template>/<key>` ids. */
2408
+ function missingSectionKeys(templates, sourceIso, iso) {
2409
+ let total = 0;
2410
+ const missing = [];
2411
+ for (const template of templates) {
2412
+ const source = localeIn(template.translations, sourceIso);
2413
+ if (!source) continue;
2414
+ total += Object.keys(missingKeys(source, null)).length;
2415
+ for (const [key, value] of Object.entries(missingKeys(source, localeIn(template.translations, iso)))) missing.push({
2416
+ id: `${template.templateId}/${key}`,
2417
+ templateId: template.templateId,
2418
+ key,
2419
+ source: value
2420
+ });
2421
+ }
2422
+ return {
2423
+ total,
2424
+ missing
2425
+ };
2426
+ }
2427
+ /** Parse a `<template id>/<key.path>` reference. */
2428
+ function parseSectionRef(ref) {
2429
+ const match = /^([1-9]\d*)\/(.+)$/.exec(ref.trim());
2430
+ if (!match) throw new LocalizationError(`"${ref}" isn't a theme section string. Use <template id>/<key>, as \`fluid translations missing\` prints it, e.g. 412/hero.title.`);
2431
+ return {
2432
+ templateId: Number(match[1]),
2433
+ key: match[2]
2434
+ };
2435
+ }
2436
+ /** A section string's source and translated text. */
2437
+ function sectionText(template, key, sourceIso, iso) {
2438
+ const source = flattenLocale(localeIn(template.translations, sourceIso) ?? {})[key] ?? null;
2439
+ const translation = flattenLocale(localeIn(template.translations, iso) ?? {})[key] ?? null;
2440
+ return {
2441
+ source,
2442
+ translation: translation && translation.trim().length > 0 ? translation : null
2443
+ };
2444
+ }
2445
+ /**
2446
+ * Save strings into one template's translations for `iso`. The template is
2447
+ * read again just before the write and the strings merged into what it holds
2448
+ * then, because the API replaces the whole translations object.
2449
+ */
2450
+ async function writeSectionStrings(client, themeId, templateId, iso, updates) {
2451
+ const fresh = await readTemplateTranslations(client, themeId, templateId);
2452
+ const key = Object.keys(fresh.translations).find((k) => k.toLowerCase() === iso.toLowerCase()) ?? iso;
2453
+ const merged = mergeLocale(fresh.translations[key] ?? {}, updates);
2454
+ await client.patch(`/api/application_theme_templates/${templateId}`, { application_theme_template: { translations: {
2455
+ ...fresh.translations,
2456
+ [key]: merged
2457
+ } } });
2458
+ return {
2459
+ templateId,
2460
+ keys: Object.keys(updates)
2461
+ };
2462
+ }
2463
+ //#endregion
2464
+ //#region src/translations/report.ts
2465
+ /**
2466
+ * What still needs translating into `iso`, per type. Storefront rows count as
2467
+ * translated once `languages` lists the locale; menu items once their title
2468
+ * has a value in the locale; theme keys once `locales/<iso>.json` has a
2469
+ * non-blank string for every key the source locale file has; theme page
2470
+ * strings the same way within each template's translations; agreements once
2471
+ * a read in the locale is served in it; labels once their title in the
2472
+ * locale differs from the default.
2473
+ */
2474
+ async function missingReport(client, iso, types, options) {
2475
+ const reports = [];
2476
+ for (const type of types) if (type.kind === "storefront") reports.push(await storefrontReport(client, type, iso, options));
2477
+ else if (type.kind === "menu-items") reports.push(await menuReport(client, iso));
2478
+ else if (type.kind === "theme") reports.push(await unlessNoTheme(type, () => themeReport(client, iso, options)));
2479
+ else if (type.kind === "theme-sections") reports.push(await unlessNoTheme(type, () => sectionsReport(client, iso, options)));
2480
+ else if (type.kind === "agreements") reports.push(await agreementsReport(client, iso, options));
2481
+ else reports.push(await labelsReport(client, iso));
2482
+ return {
2483
+ done: reports.every((r) => r.missingCount === 0 && !r.skipped),
2484
+ types: reports
2485
+ };
2486
+ }
2487
+ /** A theme type on a company with no theme to read is reported as skipped, not as a failure. */
2488
+ async function unlessNoTheme(type, report) {
2489
+ try {
2490
+ return await report();
2491
+ } catch (error) {
2492
+ if (!(error instanceof LocalizationError) || !/no active theme/.test(error.message)) throw error;
2493
+ return {
2494
+ type: type.type,
2495
+ label: type.label,
2496
+ total: 0,
2497
+ missingCount: 0,
2498
+ missing: [],
2499
+ skipped: error.message
2500
+ };
2501
+ }
2502
+ }
2503
+ /** Whether shoppers can see a row: published (when it has a status) and not switched off. */
2504
+ function shopperVisible(row) {
2505
+ const status = row["status"];
2506
+ if (typeof status === "string" && status !== "published") return false;
2507
+ return row["active"] !== false;
2508
+ }
2509
+ async function storefrontReport(client, type, iso, options) {
2510
+ const rows = (await listAll(client, type)).filter((row) => !options.publishedOnly || shopperVisible(row));
2511
+ const missing = rows.filter((row) => !isTranslated(row, iso)).map((row) => ({
2512
+ id: row.id,
2513
+ source: rowTitle(row),
2514
+ status: typeof row["status"] === "string" ? row["status"] : null
2515
+ }));
2516
+ return {
2517
+ type: type.type,
2518
+ label: type.label,
2519
+ total: rows.length,
2520
+ missingCount: missing.length,
2521
+ missing
2522
+ };
2523
+ }
2524
+ /** Menu items with a default title, and those with no title in `iso`. */
2525
+ async function missingMenuItems(client, iso) {
2526
+ const [source, translated] = await Promise.all([listMenuItems(client), listMenuItems(client, iso)]);
2527
+ const have = new Map(translated.map((item) => [item.id, item.title]));
2528
+ const titled = source.filter((item) => item.title !== null);
2529
+ return {
2530
+ titled,
2531
+ missing: titled.filter((item) => !have.get(item.id))
2532
+ };
2533
+ }
2534
+ async function menuReport(client, iso) {
2535
+ const { titled, missing } = await missingMenuItems(client, iso);
2536
+ return {
2537
+ type: "menu-items",
2538
+ label: "Navigation menu items",
2539
+ total: titled.length,
2540
+ missingCount: missing.length,
2541
+ missing: missing.map((item) => ({
2542
+ id: item.id,
2543
+ source: item.title,
2544
+ menu: item.menu
2545
+ }))
2546
+ };
2547
+ }
2548
+ /** Theme keys the source locale has and `iso` lacks, with the source strings. */
2549
+ async function missingThemeKeys(client, iso, options) {
2550
+ const themeId = await resolveThemeId(client, options.themeId);
2551
+ const locales = await readThemeLocales(client, themeId);
2552
+ const source = localeOf(locales, options.sourceIso);
2553
+ if (!source) return {
2554
+ themeId,
2555
+ locales,
2556
+ total: 0,
2557
+ missing: {},
2558
+ sameAsSource: [],
2559
+ skipped: `The theme has no locales/${options.sourceIso}.json to translate from. Pass --from with the theme's source locale.`
2560
+ };
2561
+ const target = localeOf(locales, iso);
2562
+ const missing = missingKeys(source, target);
2563
+ return {
2564
+ themeId,
2565
+ locales,
2566
+ total: Object.keys(missingKeys(source, null)).length,
2567
+ missing,
2568
+ sameAsSource: sameAsSourceKeys(source, target)
2569
+ };
2570
+ }
2571
+ async function themeReport(client, iso, options) {
2572
+ const result = await missingThemeKeys(client, iso, options);
2573
+ return {
2574
+ type: "theme",
2575
+ label: `Theme ${result.themeId} strings (locales/${iso}.json)`,
2576
+ total: result.total,
2577
+ missingCount: Object.keys(result.missing).length,
2578
+ missing: Object.entries(result.missing).map(([key, value]) => ({
2579
+ id: key,
2580
+ source: value
2581
+ })),
2582
+ ...result.sameAsSource.length > 0 ? { sameAsSource: {
2583
+ count: result.sameAsSource.length,
2584
+ sample: result.sameAsSource.slice(0, 20)
2585
+ } } : {},
2586
+ ...result.skipped ? { skipped: result.skipped } : {}
2587
+ };
2588
+ }
2589
+ /** Theme page strings each template's source locale has and `iso` lacks. */
2590
+ async function missingSections(client, iso, options) {
2591
+ const themeId = await resolveThemeId(client, options.themeId);
2592
+ return {
2593
+ themeId,
2594
+ ...missingSectionKeys(await listTemplateTranslations(client, themeId), options.sourceIso, iso)
2595
+ };
2596
+ }
2597
+ async function sectionsReport(client, iso, options) {
2598
+ const result = await missingSections(client, iso, options);
2599
+ return {
2600
+ type: "theme-sections",
2601
+ label: `Theme ${result.themeId} page strings (template translations)`,
2602
+ total: result.total,
2603
+ missingCount: result.missing.length,
2604
+ missing: result.missing.map((m) => ({
2605
+ id: m.id,
2606
+ source: m.source
2607
+ }))
2608
+ };
2609
+ }
2610
+ /** Agreements with no translation in `iso`; with `publishedOnly`, active ones only. */
2611
+ async function missingAgreements(client, iso, publishedOnly) {
2612
+ const checked = (await listAgreements(client)).filter((agreement) => !publishedOnly || agreement.active !== false);
2613
+ const missing = [];
2614
+ for (const agreement of checked) if (!await agreementTranslated(client, agreement.id, iso)) missing.push(agreement);
2615
+ return {
2616
+ checked,
2617
+ missing
2618
+ };
2619
+ }
2620
+ async function agreementsReport(client, iso, options) {
2621
+ const { checked, missing } = await missingAgreements(client, iso, options.publishedOnly);
2622
+ return {
2623
+ type: "agreements",
2624
+ label: "Agreements",
2625
+ total: checked.length,
2626
+ missingCount: missing.length,
2627
+ missing: missing.map((a) => ({
2628
+ id: a.id,
2629
+ source: a.title ?? null,
2630
+ status: a.active === false ? "inactive" : "active"
2631
+ }))
2632
+ };
2633
+ }
2634
+ /** Labels whose title in `iso` still reads as the default title. */
2635
+ async function missingLabels(client, iso) {
2636
+ const checked = (await listLabels(client)).filter((label) => label.title.trim().length > 0);
2637
+ const missing = [];
2638
+ for (const label of checked) if (!labelTranslated(label.title, await getLabelTitle(client, label.id, iso))) missing.push(label);
2639
+ return {
2640
+ checked,
2641
+ missing
2642
+ };
2643
+ }
2644
+ async function labelsReport(client, iso) {
2645
+ const { checked, missing } = await missingLabels(client, iso);
2646
+ return {
2647
+ type: "labels",
2648
+ label: "Product labels",
2649
+ total: checked.length,
2650
+ missingCount: missing.length,
2651
+ missing: missing.map((l) => ({
2652
+ id: l.id,
2653
+ source: l.title
2654
+ }))
2655
+ };
2656
+ }
2657
+ //#endregion
2658
+ //#region src/translations/autotranslate.ts
2659
+ /** Run one save; a refusal is recorded against the item instead of ending the run. */
2660
+ async function save(failed, id, write) {
2661
+ try {
2662
+ await write();
2663
+ return true;
2664
+ } catch (error) {
2665
+ failed.push({
2666
+ id,
2667
+ ...failureOf(error)
2668
+ });
2669
+ return false;
2670
+ }
2671
+ }
2672
+ /**
2673
+ * Machine-translate what's missing in `iso` and, with `write`, save it.
2674
+ * Storefront rows translate their text fields from the default locale
2675
+ * (URL fields are left for a person); menu items translate their title;
2676
+ * theme keys translate from the source locale file and save in one write.
2677
+ */
2678
+ async function autoTranslate(client, types, options) {
2679
+ const usage = {
2680
+ inputTokens: 0,
2681
+ outputTokens: 0
2682
+ };
2683
+ const results = [];
2684
+ const call = async (sourceName, fields) => {
2685
+ const out = await machineTranslate(client, sourceName, options.targetName, fields, options.model);
2686
+ usage.inputTokens += out.usage.inputTokens;
2687
+ usage.outputTokens += out.usage.outputTokens;
2688
+ return out.translations;
2689
+ };
2690
+ const translator = (sourceName) => async (fields) => {
2691
+ const first = await call(sourceName, fields);
2692
+ const omitted = Object.fromEntries(Object.entries(fields).filter(([id, v]) => v.trim().length > 0 && first[id] === void 0));
2693
+ if (Object.keys(omitted).length === 0) return first;
2694
+ return {
2695
+ ...first,
2696
+ ...await call(sourceName, omitted)
2697
+ };
2698
+ };
2699
+ const skipped = new Set(options.skip ?? []);
2700
+ for (const type of types) if (type.kind === "storefront") results.push(await autoStorefront(client, type, options, skipped, translator(options.storeName)));
2701
+ else if (type.kind === "menu-items") results.push(await autoMenus(client, options, skipped, translator(options.storeName)));
2702
+ else if (type.kind === "theme") results.push(await autoTheme(client, options, skipped, translator(options.sourceName)));
2703
+ else if (type.kind === "theme-sections") results.push(await autoSections(client, options, skipped, translator(options.sourceName)));
2704
+ else if (type.kind === "agreements") results.push(await autoAgreements(client, options, skipped, translator(options.storeName)));
2705
+ else results.push(await autoLabels(client, options, skipped, translator(options.storeName)));
2706
+ return {
2707
+ written: options.write,
2708
+ types: results,
2709
+ usage
2710
+ };
2711
+ }
2712
+ async function autoStorefront(client, type, options, skipped, translate) {
2713
+ const rows = (await listAll(client, type)).filter((row) => !skipped.has(`${type.type}:${row.id}`) && !isTranslated(row, options.iso) && (!options.publishedOnly || shopperVisible(row)));
2714
+ const picked = rows.slice(0, options.limit);
2715
+ const source = {};
2716
+ const fieldsOf = /* @__PURE__ */ new Map();
2717
+ for (const summary of picked) {
2718
+ const row = await getRow(client, type, summary.id);
2719
+ for (const [field, value] of Object.entries(pickFields(row, type.textFields))) {
2720
+ if (value === null || value.trim().length === 0) continue;
2721
+ source[`${summary.id}:${field}`] = value;
2722
+ fieldsOf.set(summary.id, [...fieldsOf.get(summary.id) ?? [], field]);
2723
+ }
2724
+ }
2725
+ const translations = await translate(source);
2726
+ const entries = [];
2727
+ const incomplete = [];
2728
+ const failed = [];
2729
+ for (const summary of picked) {
2730
+ const wanted = fieldsOf.get(summary.id) ?? [];
2731
+ if (wanted.length === 0) continue;
2732
+ const fields = {};
2733
+ for (const field of wanted) {
2734
+ const value = translations[`${summary.id}:${field}`];
2735
+ if (value !== void 0) fields[field] = value;
2736
+ }
2737
+ if (Object.keys(fields).length < wanted.length) {
2738
+ incomplete.push(summary.id);
2739
+ continue;
2740
+ }
2741
+ if (options.write && !await save(failed, summary.id, () => writeTranslation(client, type, summary.id, options.iso, fields))) continue;
2742
+ entries.push({
2743
+ id: summary.id,
2744
+ fields,
2745
+ source: Object.fromEntries(wanted.map((f) => [f, source[`${summary.id}:${f}`] ?? ""])),
2746
+ saved: options.write
2747
+ });
2748
+ }
2749
+ return {
2750
+ type: type.type,
2751
+ translated: entries,
2752
+ incomplete,
2753
+ failed,
2754
+ remaining: rows.length - picked.length + incomplete.length + failed.length
2755
+ };
2756
+ }
2757
+ async function autoMenus(client, options, skipped, translate) {
2758
+ return autoTitles("menu-items", (await missingMenuItems(client, options.iso)).missing.filter((item) => !skipped.has(`menu-items:${item.id}`)).map((item) => ({
2759
+ id: item.id,
2760
+ title: item.title ?? ""
2761
+ })), options, translate, (id, title) => writeMenuItemTitle(client, String(id), options.iso, title));
2762
+ }
2763
+ async function autoLabels(client, options, skipped, translate) {
2764
+ const { missing } = await missingLabels(client, options.iso);
2765
+ const pending = missing.filter((label) => !skipped.has(`labels:${label.id}`));
2766
+ const sources = [];
2767
+ for (const label of pending.slice(0, options.limit)) {
2768
+ const title = await getLabelTitle(client, label.id, options.storeIso);
2769
+ sources.push({
2770
+ id: label.id,
2771
+ title: title || label.title
2772
+ });
2773
+ }
2774
+ const result = await autoTitles("labels", sources, options, translate, (id, title) => writeLabelTitle(client, Number(id), options.iso, title));
2775
+ return {
2776
+ ...result,
2777
+ remaining: result.remaining + pending.length - sources.length
2778
+ };
2779
+ }
2780
+ /** Translate and save a title per item: menu items and labels. */
2781
+ async function autoTitles(type, items, options, translate, write) {
2782
+ const picked = items.slice(0, options.limit);
2783
+ const translations = await translate(Object.fromEntries(picked.map((item) => [String(item.id), item.title])));
2784
+ const entries = [];
2785
+ const incomplete = [];
2786
+ const failed = [];
2787
+ for (const item of picked) {
2788
+ if (item.title.trim().length === 0) continue;
2789
+ const title = translations[String(item.id)];
2790
+ if (title === void 0) {
2791
+ incomplete.push(item.id);
2792
+ continue;
2793
+ }
2794
+ if (options.write && !await save(failed, item.id, () => write(item.id, title))) continue;
2795
+ entries.push({
2796
+ id: item.id,
2797
+ fields: { title },
2798
+ source: { title: item.title },
2799
+ saved: options.write
2800
+ });
2801
+ }
2802
+ return {
2803
+ type,
2804
+ translated: entries,
2805
+ incomplete,
2806
+ failed,
2807
+ remaining: items.length - picked.length + incomplete.length + failed.length
2808
+ };
2809
+ }
2810
+ async function autoAgreements(client, options, skipped, translate) {
2811
+ const { missing } = await missingAgreements(client, options.iso, options.publishedOnly);
2812
+ const pending = missing.filter((a) => !skipped.has(`agreements:${a.id}`));
2813
+ const picked = pending.slice(0, options.limit);
2814
+ const source = {};
2815
+ for (const agreement of picked) {
2816
+ const text = await readAgreement(client, agreement.id, options.storeIso);
2817
+ if (text.title.trim()) source[`${agreement.id}:title`] = text.title;
2818
+ if (text.description.trim()) source[`${agreement.id}:description`] = text.description;
2819
+ }
2820
+ const translations = await translate(source);
2821
+ const entries = [];
2822
+ const incomplete = [];
2823
+ const failed = [];
2824
+ for (const agreement of picked) {
2825
+ const title = translations[`${agreement.id}:title`];
2826
+ const description = translations[`${agreement.id}:description`];
2827
+ if (title === void 0 || description === void 0) {
2828
+ incomplete.push(agreement.id);
2829
+ continue;
2830
+ }
2831
+ if (options.write && !await save(failed, agreement.id, () => writeAgreementTranslation(client, agreement.id, options.iso, {
2832
+ title,
2833
+ description
2834
+ }))) continue;
2835
+ entries.push({
2836
+ id: agreement.id,
2837
+ fields: {
2838
+ title,
2839
+ description
2840
+ },
2841
+ source: {
2842
+ title: source[`${agreement.id}:title`] ?? "",
2843
+ description: source[`${agreement.id}:description`] ?? ""
2844
+ },
2845
+ saved: options.write
2846
+ });
2847
+ }
2848
+ return {
2849
+ type: "agreements",
2850
+ translated: entries,
2851
+ incomplete,
2852
+ failed,
2853
+ remaining: pending.length - picked.length + incomplete.length + failed.length
2854
+ };
2855
+ }
2856
+ async function autoTheme(client, options, skipped, translate) {
2857
+ const found = await missingThemeKeys(client, options.iso, options);
2858
+ if (found.skipped) return {
2859
+ type: "theme",
2860
+ translated: [],
2861
+ incomplete: [],
2862
+ failed: [],
2863
+ remaining: 0,
2864
+ skipped: found.skipped
2865
+ };
2866
+ const keys = Object.keys(found.missing).filter((key) => !skipped.has(`theme:${key}`));
2867
+ const picked = keys.slice(0, options.limit);
2868
+ const translations = await translate(Object.fromEntries(picked.map((key) => [key, found.missing[key] ?? ""])));
2869
+ const updates = Object.fromEntries(picked.filter((key) => translations[key] !== void 0).map((key) => [key, translations[key]]));
2870
+ const failed = [];
2871
+ let saved = false;
2872
+ if (options.write && Object.keys(updates).length > 0) {
2873
+ const current = localeOf(found.locales, options.iso) ?? {};
2874
+ const attempt = [];
2875
+ saved = await save(attempt, "theme", () => writeLocale(client, found.locales, options.iso, mergeLocale(current, updates)));
2876
+ if (!saved) for (const key of Object.keys(updates)) failed.push({
2877
+ ...attempt[0],
2878
+ id: key
2879
+ });
2880
+ }
2881
+ const incomplete = picked.filter((key) => translations[key] === void 0);
2882
+ return {
2883
+ type: "theme",
2884
+ translated: saved || !options.write ? Object.entries(updates).map(([key, value]) => ({
2885
+ id: key,
2886
+ fields: { value },
2887
+ source: { value: found.missing[key] ?? "" },
2888
+ saved
2889
+ })) : [],
2890
+ incomplete,
2891
+ failed,
2892
+ remaining: keys.length - picked.length + incomplete.length + failed.length
2893
+ };
2894
+ }
2895
+ /** Theme page strings: translated per key, saved one template at a time. */
2896
+ async function autoSections(client, options, skipped, translate) {
2897
+ const found = await missingSections(client, options.iso, options);
2898
+ const pending = found.missing.filter((m) => !skipped.has(`theme-sections:${m.id}`));
2899
+ const picked = pending.slice(0, options.limit);
2900
+ const translations = await translate(Object.fromEntries(picked.map((m) => [m.id, m.source])));
2901
+ const byTemplate = /* @__PURE__ */ new Map();
2902
+ const incomplete = [];
2903
+ for (const m of picked) {
2904
+ const value = translations[m.id];
2905
+ if (value === void 0) {
2906
+ incomplete.push(m.id);
2907
+ continue;
2908
+ }
2909
+ byTemplate.set(m.templateId, {
2910
+ ...byTemplate.get(m.templateId),
2911
+ [m.key]: value
2912
+ });
2913
+ }
2914
+ const entries = [];
2915
+ const failed = [];
2916
+ for (const [templateId, updates] of byTemplate) {
2917
+ const ok = !options.write || await save(failed, templateId, () => writeSectionStrings(client, found.themeId, templateId, options.iso, updates));
2918
+ const keys = Object.entries(updates);
2919
+ if (!ok) {
2920
+ const last = failed[failed.length - 1];
2921
+ failed.splice(failed.length - 1, 1, ...keys.map(([key]) => ({
2922
+ ...last,
2923
+ id: `${templateId}/${key}`
2924
+ })));
2925
+ continue;
2926
+ }
2927
+ for (const [key, value] of keys) {
2928
+ const id = `${templateId}/${key}`;
2929
+ entries.push({
2930
+ id,
2931
+ fields: { value },
2932
+ source: { value: picked.find((m) => m.id === id)?.source ?? "" },
2933
+ saved: options.write
2934
+ });
2935
+ }
2936
+ }
2937
+ return {
2938
+ type: "theme-sections",
2939
+ translated: entries,
2940
+ incomplete,
2941
+ failed,
2942
+ remaining: pending.length - picked.length + incomplete.length + failed.length
2943
+ };
2944
+ }
2945
+ //#endregion
2946
+ //#region src/translations/save.ts
2947
+ /**
2948
+ * One item's translated fields: a row's fields, a title, or a theme
2949
+ * string's `value`. Theme strings are read again just before the write, so
2950
+ * the theme's latest version is the one guarded.
2951
+ */
2952
+ async function saveTranslation(api, type, id, iso, fields, themeId) {
2953
+ switch (type.kind) {
2954
+ case "storefront":
2955
+ await writeTranslation(api, type, Number(id), iso, fields);
2956
+ return;
2957
+ case "menu-items":
2958
+ await writeMenuItemTitle(api, String(id), iso, fields["title"] ?? "");
2959
+ return;
2960
+ case "labels":
2961
+ await writeLabelTitle(api, Number(id), iso, fields["title"] ?? "");
2962
+ return;
2963
+ case "agreements":
2964
+ await writeAgreementTranslation(api, Number(id), iso, {
2965
+ title: fields["title"] ?? "",
2966
+ description: fields["description"] ?? ""
2967
+ });
2968
+ return;
2969
+ case "theme": {
2970
+ const locales = await readThemeLocales(api, await resolveThemeId(api, themeId));
2971
+ await writeLocale(api, locales, iso, mergeLocale(localeOf(locales, iso) ?? {}, { [String(id)]: fields["value"] ?? "" }));
2972
+ return;
2973
+ }
2974
+ case "theme-sections": {
2975
+ const { templateId, key } = parseSectionRef(String(id));
2976
+ await writeSectionStrings(api, await resolveThemeId(api, themeId), templateId, iso, { [key]: fields["value"] ?? "" });
2977
+ return;
2978
+ }
2979
+ }
2980
+ }
2981
+ //#endregion
2982
+ //#region src/translations/walk.ts
2983
+ var Stop = class extends Error {};
2984
+ const FIELD_LABELS = { value: "Text" };
2985
+ async function walkTranslations(deps, options = {}) {
2986
+ const { api, prompter } = deps;
2987
+ const from = options.from ?? "en";
2988
+ const batch = options.batch ?? 10;
2989
+ const result = {
2990
+ saved: [],
2991
+ edited: [],
2992
+ skipped: [],
2993
+ failed: [],
2994
+ stopped: false
2995
+ };
2996
+ prompter.note(NAV_HELP);
2997
+ try {
2998
+ const language = await pickLanguage(deps, options.iso);
2999
+ if (!language) {
3000
+ result.stopped = true;
3001
+ return result;
3002
+ }
3003
+ result.language = language.iso;
3004
+ const report = await missingReport(api, language.iso, RESOURCE_TYPES, {
3005
+ publishedOnly: false,
3006
+ sourceIso: from,
3007
+ themeId: options.themeId
3008
+ });
3009
+ prompter.note([`What isn't in ${language.name} yet:`, ...report.types.map((t) => ` ${t.label}: ${t.skipped ? `not checked (${t.skipped})` : `${t.missingCount} of ${t.total} missing`}`)].join("\n"));
3010
+ const pending = report.types.filter((t) => t.missingCount > 0 && !t.skipped);
3011
+ if (pending.length === 0) {
3012
+ prompter.note(`Everything is translated into ${language.name}.`);
3013
+ return result;
3014
+ }
3015
+ const languages = await resolveLanguages(deps, pending.map((t) => RESOURCE_TYPES.find((r) => r.type === t.type)), language.iso, from);
3016
+ if (!languages) {
3017
+ result.stopped = true;
3018
+ return result;
3019
+ }
3020
+ const skip = /* @__PURE__ */ new Set();
3021
+ for (const entry of pending) {
3022
+ const type = RESOURCE_TYPES.find((r) => r.type === entry.type);
3023
+ const start = await prompter.menu(`${entry.label}: ${entry.missingCount} missing.`, [
3024
+ {
3025
+ title: "Translate with Fluid AI and review each",
3026
+ value: "go"
3027
+ },
3028
+ {
3029
+ title: "Skip this type",
3030
+ value: "skip"
3031
+ },
3032
+ {
3033
+ title: "Stop",
3034
+ value: "stop"
3035
+ }
3036
+ ]);
3037
+ if (start === null || start === "stop") throw new Stop();
3038
+ if (start === "skip") continue;
3039
+ await reviewType(deps, type, languages, {
3040
+ ...options,
3041
+ from,
3042
+ batch
3043
+ }, skip, result);
3044
+ }
3045
+ prompter.note(`Saved ${result.saved.length} (${result.edited.length} edited), skipped ${result.skipped.length}, ${result.failed.length} refused. Check with: fluid translations missing ${language.iso}`);
3046
+ } catch (error) {
3047
+ if (!(error instanceof Stop)) throw error;
3048
+ result.stopped = true;
3049
+ prompter.note("Stopped. Everything accepted so far is saved.");
3050
+ }
3051
+ return result;
3052
+ }
3053
+ async function pickLanguage(deps, iso) {
3054
+ const { api, prompter } = deps;
3055
+ const all = await listLanguages(api);
3056
+ let language = iso ? all.find((l) => sameIso(l.iso, iso)) : void 0;
3057
+ if (!language) {
3058
+ if (iso) prompter.note(`"${iso}" isn't a language Fluid supports.`);
3059
+ const reply = await prompter.ask({
3060
+ section: "Language",
3061
+ message: "Which language are you translating into?",
3062
+ kind: "search",
3063
+ hint: "type to filter",
3064
+ choices: [...all].sort((a, b) => Number(b.active) - Number(a.active)).map((l) => ({
3065
+ title: `${l.name} (${l.iso})${l.active ? "" : " — off"}`,
3066
+ value: l.iso
3067
+ })),
3068
+ canGoBack: false
3069
+ });
3070
+ if ("nav" in reply) return null;
3071
+ language = all.find((l) => sameIso(l.iso, reply.value));
3072
+ if (!language) return null;
3073
+ }
3074
+ if (language.active) return language;
3075
+ if (await prompter.menu(`${language.name} isn't on for this company, so translations can't be saved to it.`, [{
3076
+ title: `Turn on ${language.name}`,
3077
+ value: "enable"
3078
+ }, {
3079
+ title: "Stop",
3080
+ value: "stop"
3081
+ }]) !== "enable") return null;
3082
+ return enableLanguage(api, language);
3083
+ }
3084
+ /** The languages to translate between; asks for the store's language when the rows can't say. */
3085
+ async function resolveLanguages(deps, types, iso, from) {
3086
+ let storeLanguage;
3087
+ for (;;) try {
3088
+ return await autoLanguages(deps.api, types, {
3089
+ iso,
3090
+ from,
3091
+ storeLanguage,
3092
+ write: true
3093
+ });
3094
+ } catch (error) {
3095
+ if (storeLanguage !== void 0 || !/store's default language from its storefront rows/.test(String(error.message))) {
3096
+ deps.prompter.note(failureOf(error).error);
3097
+ return null;
3098
+ }
3099
+ const reply = await deps.prompter.ask({
3100
+ section: "Language",
3101
+ message: "Which language is your store's own content written in?",
3102
+ kind: "text",
3103
+ hint: "a code such as en",
3104
+ canGoBack: false
3105
+ });
3106
+ if ("nav" in reply) return null;
3107
+ storeLanguage = reply.value.trim();
3108
+ }
3109
+ }
3110
+ /** Translate a type batch by batch, reviewing each proposal. */
3111
+ async function reviewType(deps, type, languages, options, skip, result) {
3112
+ const { api, prompter } = deps;
3113
+ for (;;) {
3114
+ const outcome = (await autoTranslate(api, [type], {
3115
+ iso: languages.target.iso,
3116
+ storeName: languages.store.name,
3117
+ sourceName: languages.source.name,
3118
+ targetName: languages.target.name,
3119
+ sourceIso: languages.source.iso,
3120
+ storeIso: languages.store.iso,
3121
+ limit: options.batch,
3122
+ write: false,
3123
+ publishedOnly: false,
3124
+ themeId: options.themeId,
3125
+ skip: [...skip]
3126
+ })).types[0];
3127
+ for (const id of outcome.incomplete) {
3128
+ skip.add(`${type.type}:${id}`);
3129
+ prompter.note(`Fluid AI left part of ${type.label.toLowerCase()} ${id} untranslated; set it with \`fluid translations set\`.`);
3130
+ }
3131
+ if (outcome.translated.length === 0) return;
3132
+ if (await reviewBatch(deps, type, outcome.translated, languages, options, skip, result) === "next-type") return;
3133
+ const left = outcome.remaining - outcome.incomplete.length;
3134
+ if (left <= 0) return;
3135
+ const more = await prompter.menu(`${type.label}: ${left} more to translate.`, [
3136
+ {
3137
+ title: "Translate the next batch",
3138
+ value: "more"
3139
+ },
3140
+ {
3141
+ title: "Next type",
3142
+ value: "next"
3143
+ },
3144
+ {
3145
+ title: "Stop",
3146
+ value: "stop"
3147
+ }
3148
+ ]);
3149
+ if (more === null || more === "stop") throw new Stop();
3150
+ if (more === "next") return;
3151
+ }
3152
+ }
3153
+ async function reviewBatch(deps, type, entries, languages, options, skip, result) {
3154
+ const { api, prompter } = deps;
3155
+ const savedFields = /* @__PURE__ */ new Map();
3156
+ let i = 0;
3157
+ while (i < entries.length) {
3158
+ const proposal = entries[i];
3159
+ const entry = {
3160
+ ...proposal,
3161
+ fields: savedFields.get(i) ?? proposal.fields
3162
+ };
3163
+ const key = `${type.type}:${entry.id}`;
3164
+ prompter.note([`${type.label} ${entry.id} (${i + 1} of ${entries.length}):`, ...Object.entries(entry.fields).map(([field, value]) => ` ${FIELD_LABELS[field] ?? field}: ${clip(entry.source?.[field] ?? "")}\n → ${clip(value)}`)].join("\n"));
3165
+ const reply = await prompter.ask({
3166
+ section: `${type.label} → ${languages.target.name}`,
3167
+ message: "Save this translation?",
3168
+ kind: "select",
3169
+ choices: [
3170
+ {
3171
+ title: "Accept",
3172
+ value: "accept"
3173
+ },
3174
+ {
3175
+ title: "Edit",
3176
+ value: "edit"
3177
+ },
3178
+ {
3179
+ title: "Skip",
3180
+ value: "skip"
3181
+ }
3182
+ ],
3183
+ canGoBack: i > 0
3184
+ });
3185
+ if ("nav" in reply) {
3186
+ if (reply.nav === "quit") throw new Stop();
3187
+ if (reply.nav === "back") {
3188
+ i = Math.max(0, i - 1);
3189
+ continue;
3190
+ }
3191
+ if (reply.nav === "skip") {
3192
+ skipItem(key, skip, result);
3193
+ i += 1;
3194
+ continue;
3195
+ }
3196
+ const choice = await prompter.menu("What next?", [
3197
+ {
3198
+ title: "Continue",
3199
+ value: "continue"
3200
+ },
3201
+ {
3202
+ title: `Skip the rest of ${type.label.toLowerCase()}`,
3203
+ value: "next-type"
3204
+ },
3205
+ {
3206
+ title: "Stop",
3207
+ value: "stop"
3208
+ }
3209
+ ]);
3210
+ if (choice === null || choice === "stop") throw new Stop();
3211
+ if (choice === "next-type") return "next-type";
3212
+ continue;
3213
+ }
3214
+ if (reply.value === "skip") {
3215
+ skipItem(key, skip, result);
3216
+ i += 1;
3217
+ continue;
3218
+ }
3219
+ let fields = entry.fields;
3220
+ if (reply.value === "edit") {
3221
+ const edited = {};
3222
+ for (const [field, value] of Object.entries(entry.fields)) {
3223
+ const answer = await prompter.ask({
3224
+ section: `${type.label} ${entry.id}`,
3225
+ message: FIELD_LABELS[field] ?? field,
3226
+ kind: "text",
3227
+ current: value,
3228
+ canGoBack: false
3229
+ });
3230
+ if ("nav" in answer) {
3231
+ if (answer.nav === "quit") throw new Stop();
3232
+ edited[field] = value;
3233
+ } else edited[field] = answer.value.trim() === "" ? value : answer.value;
3234
+ }
3235
+ fields = edited;
3236
+ }
3237
+ try {
3238
+ await saveTranslation(api, type, entry.id, languages.target.iso, fields, options.themeId);
3239
+ savedFields.set(i, fields);
3240
+ if (!result.saved.includes(key)) result.saved.push(key);
3241
+ if (reply.value === "edit" && !result.edited.includes(key)) result.edited.push(key);
3242
+ const skippedAt = result.skipped.indexOf(key);
3243
+ if (skippedAt >= 0) result.skipped.splice(skippedAt, 1);
3244
+ result.failed = result.failed.filter((f) => f.id !== key);
3245
+ skip.delete(key);
3246
+ } catch (error) {
3247
+ const { error: message } = failureOf(error);
3248
+ result.failed.push({
3249
+ id: key,
3250
+ error: message
3251
+ });
3252
+ skip.add(key);
3253
+ prompter.note(`Not saved: ${message}`);
3254
+ }
3255
+ i += 1;
3256
+ }
3257
+ return "done";
3258
+ }
3259
+ function skipItem(key, skip, result) {
3260
+ skip.add(key);
3261
+ if (!result.skipped.includes(key)) result.skipped.push(key);
3262
+ }
3263
+ /** One line of a long value, for the review. */
3264
+ function clip(text, max = 200) {
3265
+ const flat = text.replace(/\s+/g, " ").trim();
3266
+ return flat.length > max ? `${flat.slice(0, max)}…` : flat;
3267
+ }
3268
+ //#endregion
3269
+ //#region src/countries/commands.ts
3270
+ async function client$1(ctx) {
3271
+ return await ctx.getClient();
3272
+ }
3273
+ /** The company_country attributes `plan` shows and `open` sends. */
3274
+ async function seededAttributes(api, iso, opts) {
3275
+ const mode = parseMode(opts.mode);
3276
+ const { countryCode, atlas } = await requireAtlas(api, iso);
3277
+ const country = await catalogCountry(api, countryCode);
3278
+ return {
3279
+ countryCode,
3280
+ atlas,
3281
+ country,
3282
+ mode,
3283
+ attributes: applyOverrides(companyCountryFromAtlas(atlas, {
3284
+ countryId: country.id,
3285
+ mode,
3286
+ ...opts.warehouseId ? { warehouseId: positiveInt(opts.warehouseId, "--warehouse-id") } : {},
3287
+ ...opts.businessId ? { businessId: opts.businessId } : {},
3288
+ ...opts.entityRegistered !== void 0 ? { entityRegistered: opts.entityRegistered } : {},
3289
+ ...opts.settlementCurrency ? { settlementCurrency: opts.settlementCurrency } : {}
3290
+ }), parseSettingAssignments(opts.set))
3291
+ };
3292
+ }
3293
+ function withOpenOptions(command) {
3294
+ return command.requiredOption("--mode <mode>", "How you will operate: nfr, otg, or usd").option("--warehouse-id <id>", "The warehouse that fulfills orders for the country (NFR and OTG)").option("--business-id <id>", "Your local tax or business registration number (OTG), e.g. a VAT ID").option("--entity-registered", "You already have a legally registered local entity (OTG)").option("--settlement-currency <code>", "Settle in another currency than the one you charge in, e.g. USD").option("--set <key=value>", "Override a seeded value, e.g. tax_inclusive_pricing=false or legal_settings.cooling_off_period_days=30 (repeatable)", collect, []);
3295
+ }
3296
+ /** Check each agreement on the country for the atlas translations it lacks. */
3297
+ async function withTranslationGaps(api, atlas, states) {
3298
+ return Promise.all(states.map(async (state) => state.status === "linked" && state.agreementId !== null ? {
3299
+ ...state,
3300
+ missingTranslations: await missingTranslations(api, state.agreementId, atlasAgreement(atlas, state.localId))
3301
+ } : state));
3302
+ }
3303
+ /** Price coverage of the active products in a country, bundles listed apart. */
3304
+ async function countryPriceCoverage(api, iso) {
3305
+ return priceCoverage(await listProductsWithVariants(api, ["active"]), iso);
3306
+ }
3307
+ function registerCountriesCommands(parent, ctx) {
3308
+ parent.command("atlas <iso>").description("Show Fluid's Country Atlas for a market: modes, tax, legal, address, payments, languages, agreements").option("--agreement <localId>", "Print one atlas agreement's full text in every language instead").action((iso, opts) => run(async () => {
3309
+ const result = await fetchAtlas(await client$1(ctx), iso);
3310
+ if (!result.covered || !result.atlas) {
3311
+ ctx.output({
3312
+ country: result.countryCode,
3313
+ covered: false,
3314
+ note: "Fluid publishes no Country Atlas for this market yet. Set it up by hand and don't assume tax, legal, or agreement defaults."
3315
+ });
3316
+ return;
3317
+ }
3318
+ if (opts.agreement) {
3319
+ ctx.output({
3320
+ country: result.countryCode,
3321
+ covered: true,
3322
+ agreement: atlasAgreement(result.atlas, opts.agreement)
3323
+ });
3324
+ return;
3325
+ }
3326
+ ctx.output({
3327
+ country: result.countryCode,
3328
+ covered: true,
3329
+ ...summarizeAtlas(result.atlas)
3330
+ });
3331
+ }));
3332
+ parent.command("compliance <iso>").description("Show the compliance view of the atlas: disclosure pages, cookie rule, tax display, unit pricing").action((iso) => run(async () => {
3333
+ const { countryCode, atlas } = await requireAtlas(await client$1(ctx), iso);
3334
+ ctx.output(complianceView(countryCode, atlas));
3335
+ }));
3336
+ parent.command("list").description("List the countries this company sells in").action(() => run(async () => {
3337
+ const countries = await listCompanyCountries(await client$1(ctx));
3338
+ ctx.output(countries.map((cc) => ({
3339
+ companyCountryId: cc.id,
3340
+ iso: cc.country?.iso ?? null,
3341
+ name: cc.country?.name ?? null,
3342
+ mode: cc.mode ?? null,
3343
+ currency: cc.currency ?? null,
3344
+ taxName: cc.tax_name ?? null,
3345
+ taxInclusivePricing: cc.tax_inclusive_pricing ?? null,
3346
+ requires3ds: cc.requires_3ds ?? null,
3347
+ testMode: cc.test_mode ?? null,
3348
+ warehouse: cc.warehouse ? {
3349
+ id: cc.warehouse.id,
3350
+ name: cc.warehouse.name ?? null
3351
+ } : null
3352
+ })));
3353
+ }));
3354
+ withOpenOptions(parent.command("plan <iso>").description("Preview a country setup without changing anything: the seeded settings, languages, agreements, and checklist")).action((iso, opts) => run(async () => {
3355
+ const api = await client$1(ctx);
3356
+ const { countryCode, atlas, mode, attributes } = await seededAttributes(api, iso, opts);
3357
+ const [existing, languages, agreements, warehouses] = await Promise.all([
3358
+ findCompanyCountry(api, countryCode),
3359
+ listLanguages(api),
3360
+ listAgreements(api),
3361
+ listWarehouses(api)
3362
+ ]);
3363
+ ctx.output({
3364
+ country: countryCode,
3365
+ mode,
3366
+ alreadyOpen: existing ? {
3367
+ companyCountryId: existing.id,
3368
+ mode: existing.mode ?? null
3369
+ } : false,
3370
+ companyCountry: attributes,
3371
+ languages: languageStates(atlas, languages),
3372
+ agreements: agreementStates(atlas, agreements, existing?.id ?? null),
3373
+ warehouses: warehouses.map((w) => ({
3374
+ id: w.id,
3375
+ name: w.name,
3376
+ country: w.country?.iso ?? null,
3377
+ active: w.active ?? null
3378
+ })),
3379
+ launchChecklist: launchChecklist(atlas, mode)
3380
+ });
3381
+ }));
3382
+ withOpenOptions(parent.command("open <iso>").description("Add the country to the company, seeded from the atlas: currency, mode, tax, 3-D Secure, legal, address layout, payment order (changes live config)").option("--legacy-defaults", "Also let Fluid add its generic country agreements and forms", false).option("--reseed", "If the country is already open, write the atlas values over its current settings", false).option("--yes", "Confirm without a prompt", false)).action((iso, opts) => run(async () => {
3383
+ const api = await client$1(ctx);
3384
+ const { countryCode, attributes } = await seededAttributes(api, iso, opts);
3385
+ const existing = await findCompanyCountry(api, countryCode);
3386
+ if (existing && !opts.reseed) {
3387
+ ctx.output({
3388
+ country: countryCode,
3389
+ alreadyOpen: true,
3390
+ companyCountryId: existing.id,
3391
+ note: "Nothing changed. Pass --reseed to write the atlas values over the current settings."
3392
+ });
3393
+ return;
3394
+ }
3395
+ if (existing) {
3396
+ await confirmChange(`Overwrite ${countryCode}'s settings with the atlas values? Changes an admin made are replaced.`, opts.yes);
3397
+ const updated = await updateCompanyCountry(api, existing.id, attributes, existing.revision);
3398
+ ctx.output({
3399
+ country: countryCode,
3400
+ reseeded: true,
3401
+ companyCountry: updated
3402
+ });
3403
+ return;
3404
+ }
3405
+ await confirmChange(`Open ${countryCode} for this company in ${String(attributes["mode"])} mode?`, opts.yes);
3406
+ const created = await createCompanyCountry(api, attributes, opts.legacyDefaults);
3407
+ ctx.output({
3408
+ country: countryCode,
3409
+ opened: true,
3410
+ companyCountry: created
3411
+ });
3412
+ }));
3413
+ parent.command("languages <iso>").description("Compare the market's major languages with the company's; `fluid translations enable` turns the missing ones on").action((iso) => run(async () => {
3414
+ const api = await client$1(ctx);
3415
+ const { countryCode, atlas } = await requireAtlas(api, iso);
3416
+ const states = languageStates(atlas, await listLanguages(api));
3417
+ const off = states.filter((s) => !s.enabled && s.languageId !== null);
3418
+ ctx.output({
3419
+ country: countryCode,
3420
+ done: states.every((s) => s.enabled),
3421
+ languages: states,
3422
+ ...off.length > 0 ? { next: `fluid translations enable ${off.map((s) => s.iso).join(" ")} --yes` } : {}
3423
+ });
3424
+ }));
3425
+ parent.command("agreements <iso>").description("Match the atlas agreements to the company's; --create adds the missing ones to the country in every atlas language, --translate rewrites the atlas translations onto ones already on it (changes live config)").option("--create", "Create each missing atlas agreement on the country", false).option("--translate", "Write the atlas translations onto the agreements already on the country, e.g. after enabling a language", false).option("--only <localId>", "With --create or --translate, just this atlas agreement, e.g. agreement_2 (repeatable)", collect, []).option("--yes", "Confirm without a prompt", false).action((iso, opts) => run(async () => {
3426
+ const api = await client$1(ctx);
3427
+ const { countryCode, atlas } = await requireAtlas(api, iso);
3428
+ const cc = await requireCompanyCountry(api, countryCode);
3429
+ const states = agreementStates(atlas, await listAgreements(api), cc.id);
3430
+ if (opts.create && opts.translate) throw new LocalizationError("Use --create or --translate, not both.");
3431
+ if (opts.only.length > 0 && !opts.create && !opts.translate) throw new LocalizationError("--only goes with --create or --translate.");
3432
+ for (const id of opts.only) atlasAgreement(atlas, id);
3433
+ const picked = (s) => opts.only.length === 0 || opts.only.includes(s.localId);
3434
+ if (opts.translate) {
3435
+ const withTranslations = states.filter((s) => (s.status === "linked" || s.status === "inactive") && s.agreementId !== null && picked(s)).filter((s) => atlasAgreement(atlas, s.localId).translations.some((t) => t.language_iso !== "en"));
3436
+ if (withTranslations.length === 0) {
3437
+ ctx.output({
3438
+ country: countryCode,
3439
+ translated: [],
3440
+ agreements: states
3441
+ });
3442
+ return;
3443
+ }
3444
+ await confirmChange(`Write the atlas translations onto ${withTranslations.length} agreement(s)? Translations an admin edited are replaced.`, opts.yes);
3445
+ const translated = [];
3446
+ for (const state of withTranslations) {
3447
+ const result = await writeTranslations(api, state.agreementId, atlasAgreement(atlas, state.localId));
3448
+ translated.push({
3449
+ localId: state.localId,
3450
+ id: state.agreementId,
3451
+ ...result
3452
+ });
3453
+ }
3454
+ ctx.output({
3455
+ country: countryCode,
3456
+ translated
3457
+ });
3458
+ if (translated.some((t) => t.failedTranslations.length > 0)) process.exitCode = 1;
3459
+ return;
3460
+ }
3461
+ if (!opts.create) {
3462
+ ctx.output({
3463
+ country: countryCode,
3464
+ done: states.every((s) => s.status === "linked"),
3465
+ agreements: states
3466
+ });
3467
+ return;
3468
+ }
3469
+ const toCreate = states.filter((s) => s.status === "missing" && picked(s));
3470
+ if (toCreate.length === 0) {
3471
+ ctx.output({
3472
+ country: countryCode,
3473
+ created: [],
3474
+ agreements: states
3475
+ });
3476
+ return;
3477
+ }
3478
+ await confirmChange(`Create ${toCreate.length} agreement(s) on ${countryCode}: ${toCreate.map((s) => `"${s.title}"`).join(", ")}?`, opts.yes);
3479
+ const created = [];
3480
+ const failed = [];
3481
+ for (const state of toCreate) try {
3482
+ created.push(await createAgreementFromAtlas(api, atlasAgreement(atlas, state.localId), cc.id));
3483
+ } catch (error) {
3484
+ failed.push({
3485
+ localId: state.localId,
3486
+ error: error instanceof Error ? error.message : String(error)
3487
+ });
3488
+ }
3489
+ ctx.output({
3490
+ country: countryCode,
3491
+ created,
3492
+ failed,
3493
+ agreements: agreementStates(atlas, await listAgreements(api), cc.id)
3494
+ });
3495
+ if (failed.length > 0 || created.some((c) => c.failedTranslations.length > 0)) process.exitCode = 1;
3496
+ }));
3497
+ parent.command("prices <iso>").description("Convert every variant's price from another country into this one's currency at an exchange rate. A dry run unless --write (changes live prices)").option("--from-country <iso>", "The country whose prices are converted", "US").option("--rate <rate>", "Units of the new currency per unit of the source currency, e.g. 0.92; --write needs it").option("--fields <list>", `Price fields to convert, comma-separated, from ${PRICE_FIELDS.join(", ")}`, DEFAULT_PRICE_FIELDS.join(",")).option("--status <status>", "Product status to include (repeatable)", collect, []).option("--overwrite", "Also replace prices the country already has", false).option("--activate", "Put each converted variant on sale in the country", false).option("--sample <n>", "How many before→after examples to print", "10").option("--write", "Save the converted prices; without it nothing is written", false).option("--yes", "Confirm --write without a prompt", false).action((iso, opts) => run(async () => {
3498
+ const api = await client$1(ctx);
3499
+ const targetIso = countryCodeOf(iso);
3500
+ const sourceIso = countryCodeOf(opts.fromCountry);
3501
+ if (targetIso === sourceIso) throw new LocalizationError("--from-country is the country being priced.");
3502
+ const fields = parsePriceFields(opts.fields);
3503
+ const sample = positiveInt(opts.sample, "--sample");
3504
+ const statuses = opts.status.length > 0 ? opts.status : ["active"];
3505
+ const countries = await listCompanyCountries(api);
3506
+ const target = companyCountryIn(countries, targetIso);
3507
+ if (!target?.country) throw new LocalizationError(`${targetIso} isn't open for this company yet. Run \`fluid countries open ${targetIso} --mode <nfr|otg|usd>\` first.`);
3508
+ const source = companyCountryIn(countries, sourceIso);
3509
+ if (!source) throw new LocalizationError(`${sourceIso} isn't one of this company's countries, so it has no prices to convert.`);
3510
+ const targetCurrency = (target.currency ?? "").toUpperCase();
3511
+ const sourceCurrency = (source.currency ?? "").toUpperCase();
3512
+ if (!targetCurrency || !sourceCurrency) throw new LocalizationError("Both countries need a currency. Check them with `fluid countries list`.");
3513
+ let rate;
3514
+ if (opts.rate !== void 0) rate = {
3515
+ value: parseRate(opts.rate),
3516
+ source: "--rate"
3517
+ };
3518
+ else if (sourceCurrency === targetCurrency) rate = {
3519
+ value: 1,
3520
+ source: "same currency"
3521
+ };
3522
+ else {
3523
+ const reference = await referenceRate(sourceCurrency, targetCurrency);
3524
+ if (opts.write) throw new LocalizationError(`--write needs the rate you reviewed: pass --rate.${reference ? ` The ${reference.date} reference rate is ${reference.rate} ${targetCurrency} per ${sourceCurrency}.` : ""}`);
3525
+ if (!reference) throw new LocalizationError(`Couldn't look up a ${sourceCurrency} to ${targetCurrency} reference rate. Pass --rate.`);
3526
+ rate = {
3527
+ value: reference.rate,
3528
+ source: reference.source,
3529
+ date: reference.date
3530
+ };
3531
+ }
3532
+ const priceEditor = await priceEditorOwnsPrices(api);
3533
+ if (priceEditor === null && opts.overwrite) throw new LocalizationError("Your token can't read the company profile, so it can't tell whether the price editor manages your prices. --overwrite needs to know; run it with a token that can view the company.");
3534
+ if (priceEditor && opts.overwrite) throw new LocalizationError("This company's prices are managed in the price editor, so --overwrite could replace prices set there. Change existing prices in the price editor; without --overwrite this command fills only prices the country doesn't have.");
3535
+ const planOptions = {
3536
+ sourceIso,
3537
+ targetIso,
3538
+ targetCountryId: target.country.id,
3539
+ targetCurrency,
3540
+ rate: rate.value,
3541
+ fields,
3542
+ overwrite: opts.overwrite,
3543
+ activate: opts.activate
3544
+ };
3545
+ const plans = planConversion(await listProductsWithVariants(api, statuses), planOptions);
3546
+ const summary = summarizePlans(plans, sample);
3547
+ const header = {
3548
+ country: targetIso,
3549
+ from: {
3550
+ country: sourceIso,
3551
+ currency: sourceCurrency
3552
+ },
3553
+ to: {
3554
+ country: targetIso,
3555
+ currency: targetCurrency
3556
+ },
3557
+ rate,
3558
+ rounding: {
3559
+ decimals: currencyDecimals(targetCurrency),
3560
+ mode: "half up"
3561
+ },
3562
+ fields,
3563
+ statuses,
3564
+ priceEditor
3565
+ };
3566
+ if (!opts.write || summary.toConvert === 0) {
3567
+ ctx.output({
3568
+ ...header,
3569
+ written: false,
3570
+ ...summary,
3571
+ ...summary.toConvert > 0 ? { next: `fluid countries prices ${targetIso} --from-country ${sourceIso} --rate ${rate.value} --fields ${fields.join(",")} --write --yes` } : {}
3572
+ });
3573
+ return;
3574
+ }
3575
+ await confirmChange(`Write ${summary.toConvert} converted price(s) into ${targetIso} (${targetCurrency}) at ${rate.value} ${targetCurrency} per ${sourceCurrency}?`, opts.yes);
3576
+ const result = await writeConversion(api, plans, planOptions);
3577
+ ctx.output({
3578
+ ...header,
3579
+ written: true,
3580
+ ...summary,
3581
+ converted: result.written.length,
3582
+ changedSincePreview: result.changedSincePreview,
3583
+ failed: result.failed,
3584
+ refusedByPriceEditor: result.failed.filter((f) => f.refusedByPriceEditor).length
3585
+ });
3586
+ if (result.failed.length > 0) process.exitCode = 1;
3587
+ }));
3588
+ parent.command("walk [iso]").description("Open a country by answering questions: the atlas, mode, languages, agreements and defaults, then prices and translations (changes live config)").action((iso) => run(async () => {
3589
+ assertInteractive("`plan`, `open`, `prices`, `agreements` and `fluid translations enable`");
3590
+ const api = await client$1(ctx);
3591
+ const prompter = terminalPrompter();
3592
+ const result = await walkCountry({
3593
+ api,
3594
+ prompter,
3595
+ translate: (language) => walkTranslations({
3596
+ api,
3597
+ prompter
3598
+ }, { iso: language })
3599
+ }, iso ? { iso } : {});
3600
+ ctx.output(result);
3601
+ if (result.failures.length > 0) process.exitCode = 1;
3602
+ }));
3603
+ parent.command("status <iso>").description("Check a country's setup against the atlas, including whether your active products have prices in it, and list what's left, ending with the launch checklist").action((iso) => run(async () => {
3604
+ const api = await client$1(ctx);
3605
+ const { countryCode, atlas } = await requireAtlas(api, iso);
3606
+ const cc = await findCompanyCountry(api, countryCode);
3607
+ const [languages, agreements] = cc ? await Promise.all([listLanguages(api), listAgreements(api)]) : [[], []];
3608
+ ctx.output(statusReport({
3609
+ countryCode,
3610
+ atlas,
3611
+ companyCountry: cc,
3612
+ languages: languageStates(atlas, languages),
3613
+ agreements: await withTranslationGaps(api, atlas, agreementStates(atlas, agreements, cc?.id ?? null)),
3614
+ ...cc ? { prices: await countryPriceCoverage(api, countryCode) } : {}
3615
+ }));
3616
+ }));
3617
+ }
3618
+ //#endregion
3619
+ //#region src/translations/commands.ts
3620
+ async function client(ctx) {
3621
+ return await ctx.getClient();
3622
+ }
3623
+ function rowId(raw, type) {
3624
+ const value = Number(raw);
3625
+ if (raw === void 0 || !Number.isInteger(value) || value <= 0) throw new LocalizationError(`Pass the ${type} row id, as \`fluid translations missing\` prints it.`);
3626
+ return value;
3627
+ }
3628
+ /** `--skip` items must match a report id exactly: `products:42`, `menu-items:12/340`, `theme:cart.title`. */
3629
+ function validSkip(item) {
3630
+ const at = item.indexOf(":");
3631
+ if (at <= 0) return false;
3632
+ const type = item.slice(0, at);
3633
+ const id = item.slice(at + 1);
3634
+ if (!TYPE_NAMES.includes(type)) return false;
3635
+ if (type === "theme") return id.length > 0;
3636
+ if (type === "menu-items") return /^[1-9]\d*\/[1-9]\d*$/.test(id);
3637
+ if (type === "theme-sections") return /^[1-9]\d*\/.+$/.test(id);
3638
+ return /^[1-9]\d*$/.test(id);
3639
+ }
3640
+ function requireLang(lang) {
3641
+ if (!lang) throw new LocalizationError("Pass the language with --lang <iso>, e.g. --lang fr.");
3642
+ return lang;
3643
+ }
3644
+ function registerTranslationsCommands(parent, ctx) {
3645
+ parent.command("languages").description("List every language Fluid supports and whether it's on for this company").option("--active", "Only the languages that are on", false).action((opts) => run(async () => ctx.output(await listLanguages(await client(ctx), opts.active))));
3646
+ parent.command("enable <iso...>").description("Turn one or more languages on so shoppers can choose them and translations can be saved (changes live config)").option("--yes", "Confirm without a prompt", false).action((isos, opts) => run(async () => {
3647
+ const api = await client(ctx);
3648
+ const all = await listLanguages(api);
3649
+ const wanted = [];
3650
+ for (const iso of isos) {
3651
+ const language = all.find((l) => l.iso.toLowerCase() === iso.trim().toLowerCase());
3652
+ wanted.push(language ?? await findLanguage(api, iso));
3653
+ }
3654
+ const off = wanted.filter((l, i) => !l.active && wanted.indexOf(l) === i);
3655
+ const alreadyActive = wanted.filter((l) => l.active).map((l) => l.iso);
3656
+ if (off.length === 0) {
3657
+ ctx.output({
3658
+ enabled: [],
3659
+ alreadyActive,
3660
+ failed: []
3661
+ });
3662
+ return;
3663
+ }
3664
+ await confirmChange(`Turn on ${off.map((l) => `${l.name} (${l.iso})`).join(", ")} for this company?`, opts.yes);
3665
+ const enabled = [];
3666
+ const failed = [];
3667
+ for (const language of off) try {
3668
+ enabled.push((await enableLanguage(api, language)).iso);
3669
+ } catch (error) {
3670
+ failed.push({
3671
+ iso: language.iso,
3672
+ ...failureOf(error)
3673
+ });
3674
+ }
3675
+ ctx.output({
3676
+ enabled,
3677
+ alreadyActive,
3678
+ failed
3679
+ });
3680
+ if (failed.length > 0) process.exitCode = 1;
3681
+ }));
3682
+ parent.command("disable <iso>").description("Turn a language off; its saved translations are kept (changes live config)").option("--yes", "Confirm without a prompt", false).action((iso, opts) => run(async () => {
3683
+ const api = await client(ctx);
3684
+ const language = await findLanguage(api, iso);
3685
+ if (!language.active) {
3686
+ ctx.output({
3687
+ ...language,
3688
+ alreadyInactive: true
3689
+ });
3690
+ return;
3691
+ }
3692
+ await confirmChange(`Turn off ${language.name} (${language.iso}) for this company?`, opts.yes);
3693
+ ctx.output(await disableLanguage(api, language));
3694
+ }));
3695
+ parent.command("types").description("List the translatable types and the fields each one translates").action(() => run(async () => ctx.output(RESOURCE_TYPES.map((t) => ({
3696
+ type: t.type,
3697
+ label: t.label,
3698
+ fields: t.fields,
3699
+ machineTranslated: t.textFields
3700
+ })))));
3701
+ parent.command("missing <iso>").description("Report what isn't translated into a language yet, per type, so you can fill only the gaps").option("--type <type>", `Limit to a type: ${TYPE_NAMES.join(", ")} (repeatable)`, collect, []).option("--published-only", "Skip drafts, scheduled, and archived rows", false).option("--from <iso>", "The theme's source locale file", "en").option("--theme <id>", "Check this theme instead of the active one").action((iso, opts) => run(async () => {
3702
+ const api = await client(ctx);
3703
+ const language = await findLanguage(api, iso);
3704
+ const report = await missingReport(api, language.iso, selectTypes(opts.type), {
3705
+ publishedOnly: opts.publishedOnly,
3706
+ sourceIso: opts.from,
3707
+ themeId: opts.theme
3708
+ });
3709
+ ctx.output({
3710
+ language: {
3711
+ iso: language.iso,
3712
+ name: language.name,
3713
+ active: language.active
3714
+ },
3715
+ done: report.done,
3716
+ types: report.types
3717
+ });
3718
+ }));
3719
+ parent.command("show <type> <id>").description("Show one item's default text beside its translation (null when untranslated); for theme, <id> is the key, for theme-sections <template id>/<key>").option("--lang <iso>", "The translation to show, e.g. fr").option("--from <iso>", "theme and theme-sections: the source locale", "en").option("--theme <id>", "theme and theme-sections: a theme other than the active one").action((typeName, id, opts) => run(async () => {
3720
+ const api = await client(ctx);
3721
+ const type = resourceType(typeName);
3722
+ const language = await findLanguage(api, requireLang(opts.lang));
3723
+ if (type.kind === "storefront") {
3724
+ const rid = rowId(id, type.type);
3725
+ const source = await getRow(api, type, rid);
3726
+ const translated = isTranslated(source, language.iso);
3727
+ const target = translated ? await getRow(api, type, rid, language.iso) : null;
3728
+ ctx.output({
3729
+ type: type.type,
3730
+ id: rid,
3731
+ lang: language.iso,
3732
+ translated,
3733
+ source: pickFields(source, type.fields),
3734
+ translation: target ? pickFields(target, type.fields) : null
3735
+ });
3736
+ } else if (type.kind === "menu-items") {
3737
+ const item = await getMenuItem(api, id, language.iso);
3738
+ ctx.output({
3739
+ type: type.type,
3740
+ id,
3741
+ lang: language.iso,
3742
+ translated: item.translated !== null,
3743
+ source: { title: item.source },
3744
+ translation: item.translated === null ? null : { title: item.translated }
3745
+ });
3746
+ } else if (type.kind === "agreements") {
3747
+ const aid = rowId(id, type.type);
3748
+ const source = await readAgreement(api, aid);
3749
+ const target = await readAgreement(api, aid, language.iso);
3750
+ const translated = (target.languageIso ?? "").toLowerCase() === language.iso.toLowerCase();
3751
+ ctx.output({
3752
+ type: type.type,
3753
+ id: aid,
3754
+ lang: language.iso,
3755
+ translated,
3756
+ source: {
3757
+ title: source.title,
3758
+ description: source.description
3759
+ },
3760
+ translation: translated ? {
3761
+ title: target.title,
3762
+ description: target.description
3763
+ } : null
3764
+ });
3765
+ } else if (type.kind === "labels") {
3766
+ const lid = rowId(id, type.type);
3767
+ const source = await getLabelTitle(api, lid);
3768
+ const target = await getLabelTitle(api, lid, language.iso);
3769
+ const translated = labelTranslated(source, target);
3770
+ ctx.output({
3771
+ type: type.type,
3772
+ id: lid,
3773
+ lang: language.iso,
3774
+ translated,
3775
+ source: { title: source },
3776
+ translation: translated ? { title: target } : null
3777
+ });
3778
+ } else if (type.kind === "theme-sections") {
3779
+ const { templateId, key } = parseSectionRef(id);
3780
+ const themeId = await resolveThemeId(api, opts.theme);
3781
+ const template = await readTemplateTranslations(api, themeId, templateId);
3782
+ const text = sectionText(template, key, opts.from, language.iso);
3783
+ ctx.output({
3784
+ type: type.type,
3785
+ themeId,
3786
+ id: `${templateId}/${key}`,
3787
+ template: template.name,
3788
+ lang: language.iso,
3789
+ translated: text.translation !== null,
3790
+ ...text
3791
+ });
3792
+ } else {
3793
+ const themeId = await resolveThemeId(api, opts.theme);
3794
+ const locales = await readThemeLocales(api, themeId);
3795
+ const source = flattenLocale(localeOf(locales, opts.from) ?? {});
3796
+ const target = flattenLocale(localeOf(locales, language.iso) ?? {});
3797
+ ctx.output({
3798
+ type: "theme",
3799
+ themeId,
3800
+ key: id,
3801
+ lang: language.iso,
3802
+ translated: (target[id] ?? "").trim().length > 0,
3803
+ source: source[id] ?? null,
3804
+ translation: target[id] ?? null
3805
+ });
3806
+ }
3807
+ }));
3808
+ parent.command("set <type> [id]").description("Save a translation: --set field=value for a row, menu item, agreement or label; --set key.path=value for theme strings, or for theme-sections with the template id (changes live content)").option("--lang <iso>", "The language to write, e.g. fr").option("--set <field=value>", "A translated field or theme key (repeatable)", collect, []).option("--file <path>", "A JSON object of field (or theme key) to text, for long or HTML values").option("--theme <id>", "theme and theme-sections: a theme other than the active one").option("--yes", "Confirm without a prompt", false).action((typeName, id, opts) => run(async () => {
3809
+ const api = await client(ctx);
3810
+ const type = resourceType(typeName);
3811
+ const values = collectValues(opts.set, opts.file);
3812
+ const language = await requireActiveLanguage(api, requireLang(opts.lang));
3813
+ if (type.kind === "storefront") {
3814
+ const rid = rowId(id, type.type);
3815
+ checkFields(type, values);
3816
+ const fields = Object.fromEntries(Object.entries(values).map(([field, value]) => [field, field.endsWith("_url") && (value === "" || value === "null") ? null : value]));
3817
+ await confirmChange(`Save the ${language.name} translation of ${type.label.toLowerCase()} ${rid}?`, opts.yes);
3818
+ const row = await writeTranslation(api, type, rid, language.iso, fields);
3819
+ ctx.output({
3820
+ type: type.type,
3821
+ id: rid,
3822
+ lang: language.iso,
3823
+ saved: Object.keys(fields),
3824
+ languages: Array.isArray(row["languages"]) ? row["languages"] : void 0
3825
+ });
3826
+ } else if (type.kind === "menu-items") {
3827
+ if (!id) throw new LocalizationError("Pass the menu item id as <menu id>/<item id>.");
3828
+ checkFields(type, values);
3829
+ const title = values["title"];
3830
+ if (!title) throw new LocalizationError("Pass --set title=... for a menu item.");
3831
+ await confirmChange(`Save the ${language.name} title of menu item ${id}?`, opts.yes);
3832
+ ctx.output({
3833
+ ...await writeMenuItemTitle(api, id, language.iso, title),
3834
+ lang: language.iso
3835
+ });
3836
+ } else if (type.kind === "agreements") {
3837
+ const aid = rowId(id, type.type);
3838
+ checkFields(type, values);
3839
+ const current = await readAgreement(api, aid, language.iso);
3840
+ const translated = (current.languageIso ?? "").toLowerCase() === language.iso.toLowerCase();
3841
+ const title = values["title"] ?? (translated ? current.title : void 0);
3842
+ const description = values["description"] ?? (translated ? current.description : void 0);
3843
+ if (!title || !description) throw new LocalizationError(`Agreement ${aid} has no ${language.name} translation yet, so pass both --set title=... and --set description=... (or --file).`);
3844
+ await confirmChange(`Save the ${language.name} translation of agreement ${aid}?`, opts.yes);
3845
+ await writeAgreementTranslation(api, aid, language.iso, {
3846
+ title,
3847
+ description
3848
+ });
3849
+ ctx.output({
3850
+ type: type.type,
3851
+ id: aid,
3852
+ lang: language.iso,
3853
+ saved: Object.keys(values)
3854
+ });
3855
+ } else if (type.kind === "labels") {
3856
+ const lid = rowId(id, type.type);
3857
+ checkFields(type, values);
3858
+ const title = values["title"];
3859
+ if (!title) throw new LocalizationError("Pass --set title=... for a label.");
3860
+ await confirmChange(`Save the ${language.name} title of label ${lid}?`, opts.yes);
3861
+ await writeLabelTitle(api, lid, language.iso, title);
3862
+ ctx.output({
3863
+ type: type.type,
3864
+ id: lid,
3865
+ lang: language.iso,
3866
+ saved: ["title"]
3867
+ });
3868
+ } else if (type.kind === "theme-sections") {
3869
+ const templateId = rowId(id, "template");
3870
+ if (Object.keys(values).length === 0) throw new LocalizationError("Pass the page's keys with --set key.path=value or --file.");
3871
+ const themeId = await resolveThemeId(api, opts.theme);
3872
+ await confirmChange(`Save ${Object.keys(values).length} key(s) to template ${templateId}'s ${language.name} translations? They show on the storefront at once.`, opts.yes);
3873
+ const saved = await writeSectionStrings(api, themeId, templateId, language.iso, values);
3874
+ ctx.output({
3875
+ type: type.type,
3876
+ themeId,
3877
+ ...saved,
3878
+ lang: language.iso
3879
+ });
3880
+ } else {
3881
+ if (id) throw new LocalizationError("Theme strings take no id; pass keys with --set key.path=value.");
3882
+ if (Object.keys(values).length === 0) throw new LocalizationError("Pass theme keys with --set key.path=value or --file.");
3883
+ const themeId = await resolveThemeId(api, opts.theme);
3884
+ const locales = await readThemeLocales(api, themeId);
3885
+ const merged = mergeLocale(localeOf(locales, language.iso) ?? {}, values);
3886
+ await confirmChange(`Save ${Object.keys(values).length} key(s) to locales/${language.iso}.json on theme ${themeId}? Theme changes publish immediately.`, opts.yes);
3887
+ const saved = await writeLocale(api, locales, language.iso, merged);
3888
+ ctx.output({
3889
+ ...saved,
3890
+ themeId,
3891
+ lang: language.iso,
3892
+ keys: Object.keys(values)
3893
+ });
3894
+ }
3895
+ }));
3896
+ parent.command("auto <iso>").description("Machine-translate what's missing with Fluid AI and print it; --write saves it (changes live content)").option("--type <type>", `Limit to a type: ${TYPE_NAMES.join(", ")} (repeatable)`, collect, []).option("--limit <n>", "Most items to translate per type in this run", "25").option("--write", "Save the translations; without it nothing is written", false).option("--published-only", "Skip drafts, scheduled, and archived rows", false).option("--from <iso>", "The theme's source locale file; storefront rows and menus translate from the store's default language", "en").option("--theme <id>", "Translate this theme's strings instead of the active theme's").option("--model <provider/name>", "A translation model other than Fluid's default").option("--skip <type:id>", "Leave out one item, e.g. products:42, menu-items:12/340, or theme:cart.title (repeatable)", collect, []).option("--store-language <iso>", "The store's default language, used only when it can't be read from the storefront rows").option("--yes", "Confirm --write without a prompt", false).action((iso, opts) => run(async () => {
3897
+ const api = await client(ctx);
3898
+ const limit = positiveInt(opts.limit, "--limit");
3899
+ for (const item of opts.skip) if (!validSkip(item)) throw new LocalizationError(`--skip takes <type>:<id>, e.g. products:42 or menu-items:12/340, not "${item}".`);
3900
+ const types = selectTypes(opts.type);
3901
+ const { target, source, store } = await autoLanguages(api, types, {
3902
+ iso,
3903
+ from: opts.from,
3904
+ storeLanguage: opts.storeLanguage,
3905
+ write: opts.write
3906
+ });
3907
+ if (opts.write) await confirmChange(`Machine-translate and save up to ${limit} item(s) per type into ${target.name}? Theme changes publish immediately.`, opts.yes);
3908
+ const result = await autoTranslate(api, types, {
3909
+ iso: target.iso,
3910
+ storeName: store.name,
3911
+ sourceName: source.name,
3912
+ targetName: target.name,
3913
+ sourceIso: source.iso,
3914
+ storeIso: store.iso,
3915
+ limit,
3916
+ write: opts.write,
3917
+ publishedOnly: opts.publishedOnly,
3918
+ themeId: opts.theme,
3919
+ model: opts.model,
3920
+ skip: opts.skip
3921
+ });
3922
+ ctx.output({
3923
+ language: {
3924
+ iso: target.iso,
3925
+ name: target.name
3926
+ },
3927
+ ...result
3928
+ });
3929
+ if (result.types.some((t) => t.failed.length > 0)) process.exitCode = 1;
3930
+ }));
3931
+ parent.command("walk [iso]").description("Translate a language by answering questions: what's missing, Fluid AI proposals, and accept, edit or skip each one (changes live content)").option("--from <iso>", "The theme's source locale", "en").option("--theme <id>", "Translate this theme's strings instead of the active theme's").option("--batch <n>", "Proposals per Fluid AI call", "10").action((iso, opts) => run(async () => {
3932
+ assertInteractive("`missing`, `auto` and `set`");
3933
+ const result = await walkTranslations({
3934
+ api: await client(ctx),
3935
+ prompter: terminalPrompter()
3936
+ }, {
3937
+ ...iso ? { iso } : {},
3938
+ from: opts.from,
3939
+ ...opts.theme ? { themeId: opts.theme } : {},
3940
+ batch: positiveInt(opts.batch, "--batch")
3941
+ });
3942
+ ctx.output(result);
3943
+ if (result.failed.length > 0) process.exitCode = 1;
3944
+ }));
3945
+ }
3946
+ //#endregion
3947
+ //#region src/index.ts
3948
+ /**
3949
+ * One plugin, two command groups: `fluid countries` opens a market and
3950
+ * `fluid translations` translates what its shoppers see. The country flow
3951
+ * hands language and translation work to `fluid translations`, so each step
3952
+ * has one implementation.
3953
+ */
3954
+ const plugin = {
3955
+ name: "@fluid-app/fluid-cli-localization",
3956
+ version: "0.1.0",
3957
+ register(ctx) {
3958
+ ctx.program.addCommand(createDomainCommand("countries", "Open a country from Fluid's Country Atlas: settings, prices in its currency, agreements, and a status check", registerCountriesCommands));
3959
+ ctx.program.addCommand(createDomainCommand("translations", "Add a language and translate what shoppers see: storefront, navigation, theme, agreements, and more", registerTranslationsCommands));
3960
+ }
3961
+ };
3962
+ //#endregion
3963
+ export { plugin as default, registerCountriesCommands, registerTranslationsCommands };
3964
+
3965
+ //# sourceMappingURL=index.mjs.map