@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/README.md +179 -0
- package/dist/index.d.mts +20 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +3965 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +84 -0
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(/ /gi, " ").replace(/</gi, "<").replace(/>/gi, ">").replace(/"/gi, "\"").replace(/�?39;|'/gi, "'").replace(/&/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
|