@messagevisor/core 0.0.1 → 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/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +7 -0
- package/jest.config.js +8 -0
- package/lib/benchmark/index.d.ts +2 -0
- package/lib/benchmark/index.js +417 -0
- package/lib/benchmark/index.js.map +1 -0
- package/lib/builder/index.d.ts +70 -0
- package/lib/builder/index.js +831 -0
- package/lib/builder/index.js.map +1 -0
- package/lib/cli/index.d.ts +28 -0
- package/lib/cli/index.js +182 -0
- package/lib/cli/index.js.map +1 -0
- package/lib/config/index.d.ts +61 -0
- package/lib/config/index.js +255 -0
- package/lib/config/index.js.map +1 -0
- package/lib/create/index.d.ts +2 -0
- package/lib/create/index.js +405 -0
- package/lib/create/index.js.map +1 -0
- package/lib/datasource/filesystemAdapter.d.ts +44 -0
- package/lib/datasource/filesystemAdapter.js +424 -0
- package/lib/datasource/filesystemAdapter.js.map +1 -0
- package/lib/datasource/index.d.ts +39 -0
- package/lib/datasource/index.js +96 -0
- package/lib/datasource/index.js.map +1 -0
- package/lib/error.d.ts +6 -0
- package/lib/error.js +49 -0
- package/lib/error.js.map +1 -0
- package/lib/evaluate/cli.d.ts +8 -0
- package/lib/evaluate/cli.js +179 -0
- package/lib/evaluate/cli.js.map +1 -0
- package/lib/evaluate/index.d.ts +10 -0
- package/lib/evaluate/index.js +131 -0
- package/lib/evaluate/index.js.map +1 -0
- package/lib/examples/coerceExampleIsoDates.d.ts +12 -0
- package/lib/examples/coerceExampleIsoDates.js +81 -0
- package/lib/examples/coerceExampleIsoDates.js.map +1 -0
- package/lib/examples/index.d.ts +63 -0
- package/lib/examples/index.js +713 -0
- package/lib/examples/index.js.map +1 -0
- package/lib/exporter/index.d.ts +60 -0
- package/lib/exporter/index.js +610 -0
- package/lib/exporter/index.js.map +1 -0
- package/lib/find-duplicates/index.d.ts +41 -0
- package/lib/find-duplicates/index.js +297 -0
- package/lib/find-duplicates/index.js.map +1 -0
- package/lib/generate-code/index.d.ts +11 -0
- package/lib/generate-code/index.js +157 -0
- package/lib/generate-code/index.js.map +1 -0
- package/lib/generate-code/typescript.d.ts +14 -0
- package/lib/generate-code/typescript.js +307 -0
- package/lib/generate-code/typescript.js.map +1 -0
- package/lib/importer/index.d.ts +64 -0
- package/lib/importer/index.js +1092 -0
- package/lib/importer/index.js.map +1 -0
- package/lib/index.d.ts +18 -0
- package/lib/index.js +35 -0
- package/lib/index.js.map +1 -0
- package/lib/info/index.d.ts +17 -0
- package/lib/info/index.js +132 -0
- package/lib/info/index.js.map +1 -0
- package/lib/init/index.d.ts +30 -0
- package/lib/init/index.js +348 -0
- package/lib/init/index.js.map +1 -0
- package/lib/lint/index.d.ts +1 -0
- package/lib/lint/index.js +6 -0
- package/lib/lint/index.js.map +1 -0
- package/lib/linter/attributeSchema.d.ts +7 -0
- package/lib/linter/attributeSchema.js +36 -0
- package/lib/linter/attributeSchema.js.map +1 -0
- package/lib/linter/checkLocaleCircularDependency.d.ts +7 -0
- package/lib/linter/checkLocaleCircularDependency.js +42 -0
- package/lib/linter/checkLocaleCircularDependency.js.map +1 -0
- package/lib/linter/conditionSchema.d.ts +3 -0
- package/lib/linter/conditionSchema.js +283 -0
- package/lib/linter/conditionSchema.js.map +1 -0
- package/lib/linter/formatSchema.d.ts +325 -0
- package/lib/linter/formatSchema.js +165 -0
- package/lib/linter/formatSchema.js.map +1 -0
- package/lib/linter/icuStyleLint.d.ts +6 -0
- package/lib/linter/icuStyleLint.js +226 -0
- package/lib/linter/icuStyleLint.js.map +1 -0
- package/lib/linter/index.d.ts +34 -0
- package/lib/linter/index.js +557 -0
- package/lib/linter/index.js.map +1 -0
- package/lib/linter/localeSchema.d.ts +672 -0
- package/lib/linter/localeSchema.js +50 -0
- package/lib/linter/localeSchema.js.map +1 -0
- package/lib/linter/messageSchema.d.ts +35 -0
- package/lib/linter/messageSchema.js +115 -0
- package/lib/linter/messageSchema.js.map +1 -0
- package/lib/linter/printError.d.ts +8 -0
- package/lib/linter/printError.js +41 -0
- package/lib/linter/printError.js.map +1 -0
- package/lib/linter/schema.d.ts +33 -0
- package/lib/linter/schema.js +192 -0
- package/lib/linter/schema.js.map +1 -0
- package/lib/linter/segmentSchema.d.ts +8 -0
- package/lib/linter/segmentSchema.js +18 -0
- package/lib/linter/segmentSchema.js.map +1 -0
- package/lib/linter/targetSchema.d.ts +337 -0
- package/lib/linter/targetSchema.js +39 -0
- package/lib/linter/targetSchema.js.map +1 -0
- package/lib/linter/testSchema.d.ts +71 -0
- package/lib/linter/testSchema.js +165 -0
- package/lib/linter/testSchema.js.map +1 -0
- package/lib/linter/zodHelpers.d.ts +2 -0
- package/lib/linter/zodHelpers.js +15 -0
- package/lib/linter/zodHelpers.js.map +1 -0
- package/lib/list/index.d.ts +8 -0
- package/lib/list/index.js +524 -0
- package/lib/list/index.js.map +1 -0
- package/lib/matrix.d.ts +4 -0
- package/lib/matrix.js +66 -0
- package/lib/matrix.js.map +1 -0
- package/lib/promoter/index.d.ts +65 -0
- package/lib/promoter/index.js +1208 -0
- package/lib/promoter/index.js.map +1 -0
- package/lib/prune/index.d.ts +37 -0
- package/lib/prune/index.js +673 -0
- package/lib/prune/index.js.map +1 -0
- package/lib/sets.d.ts +10 -0
- package/lib/sets.js +120 -0
- package/lib/sets.js.map +1 -0
- package/lib/tester/cliFormat.d.ts +8 -0
- package/lib/tester/cliFormat.js +15 -0
- package/lib/tester/cliFormat.js.map +1 -0
- package/lib/tester/index.d.ts +35 -0
- package/lib/tester/index.js +713 -0
- package/lib/tester/index.js.map +1 -0
- package/lib/tester/matrix.d.ts +14 -0
- package/lib/tester/matrix.js +76 -0
- package/lib/tester/matrix.js.map +1 -0
- package/lib/tester/prettyDuration.d.ts +1 -0
- package/lib/tester/prettyDuration.js +30 -0
- package/lib/tester/prettyDuration.js.map +1 -0
- package/lib/tester/printTestResult.d.ts +2 -0
- package/lib/tester/printTestResult.js +32 -0
- package/lib/tester/printTestResult.js.map +1 -0
- package/lib/tester/types.d.ts +29 -0
- package/lib/tester/types.js +3 -0
- package/lib/tester/types.js.map +1 -0
- package/package.json +41 -13
- package/src/benchmark/index.spec.ts +375 -0
- package/src/benchmark/index.ts +433 -0
- package/src/builder/index.spec.ts +822 -0
- package/src/builder/index.ts +920 -0
- package/src/cli/index.spec.ts +54 -0
- package/src/cli/index.ts +150 -0
- package/src/config/index.spec.ts +70 -0
- package/src/config/index.ts +259 -0
- package/src/create/index.spec.ts +272 -0
- package/src/create/index.ts +295 -0
- package/src/datasource/filesystemAdapter.ts +313 -0
- package/src/datasource/index.ts +135 -0
- package/src/error.ts +33 -0
- package/src/evaluate/cli.spec.ts +368 -0
- package/src/evaluate/cli.ts +130 -0
- package/src/evaluate/index.ts +161 -0
- package/src/examples/coerceExampleIsoDates.spec.ts +81 -0
- package/src/examples/coerceExampleIsoDates.ts +98 -0
- package/src/examples/index.spec.ts +453 -0
- package/src/examples/index.ts +854 -0
- package/src/exporter/index.spec.ts +443 -0
- package/src/exporter/index.ts +643 -0
- package/src/find-duplicates/index.spec.ts +289 -0
- package/src/find-duplicates/index.ts +314 -0
- package/src/generate-code/index.ts +92 -0
- package/src/generate-code/typescript.spec.ts +241 -0
- package/src/generate-code/typescript.ts +284 -0
- package/src/importer/index.spec.ts +1101 -0
- package/src/importer/index.ts +1190 -0
- package/src/index.ts +18 -0
- package/src/info/index.ts +67 -0
- package/src/init/index.spec.ts +279 -0
- package/src/init/index.ts +292 -0
- package/src/lint/index.ts +1 -0
- package/src/linter/attributeSchema.ts +38 -0
- package/src/linter/checkLocaleCircularDependency.ts +51 -0
- package/src/linter/conditionSchema.ts +386 -0
- package/src/linter/formatSchema.ts +170 -0
- package/src/linter/icuStyleLint.ts +312 -0
- package/src/linter/index.spec.ts +824 -0
- package/src/linter/index.ts +460 -0
- package/src/linter/localeSchema.ts +70 -0
- package/src/linter/messageSchema.ts +152 -0
- package/src/linter/printError.ts +52 -0
- package/src/linter/schema.ts +230 -0
- package/src/linter/segmentSchema.ts +15 -0
- package/src/linter/targetSchema.ts +50 -0
- package/src/linter/testSchema.spec.ts +405 -0
- package/src/linter/testSchema.ts +239 -0
- package/src/linter/zodHelpers.ts +16 -0
- package/src/list/index.spec.ts +431 -0
- package/src/list/index.ts +463 -0
- package/src/matrix.ts +69 -0
- package/src/promoter/index.spec.ts +584 -0
- package/src/promoter/index.ts +1267 -0
- package/src/prune/index.spec.ts +418 -0
- package/src/prune/index.ts +693 -0
- package/src/sets.ts +74 -0
- package/src/tester/cliFormat.ts +11 -0
- package/src/tester/featurevisorIntegration.spec.ts +101 -0
- package/src/tester/index.spec.ts +577 -0
- package/src/tester/index.ts +679 -0
- package/src/tester/matrix.ts +106 -0
- package/src/tester/prettyDuration.ts +34 -0
- package/src/tester/printTestResult.ts +40 -0
- package/src/tester/types.ts +32 -0
- package/tsconfig.cjs.json +11 -0
- package/tsconfig.typecheck.json +4 -0
|
@@ -0,0 +1,1190 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
|
|
5
|
+
import type { Locale, Message, Override, Translation } from "@messagevisor/types";
|
|
6
|
+
|
|
7
|
+
import type { ProjectConfig } from "../config";
|
|
8
|
+
import type { Datasource } from "../datasource";
|
|
9
|
+
import { MessagevisorCLIError, printMessagevisorCLIError } from "../error";
|
|
10
|
+
import { getProjectSetExecutions } from "../sets";
|
|
11
|
+
import {
|
|
12
|
+
CLI_FORMAT_BOLD,
|
|
13
|
+
CLI_FORMAT_GREEN,
|
|
14
|
+
CLI_FORMAT_YELLOW,
|
|
15
|
+
colorize,
|
|
16
|
+
} from "../tester/cliFormat";
|
|
17
|
+
import { prettyDuration } from "../tester/prettyDuration";
|
|
18
|
+
|
|
19
|
+
export interface ImportProjectOptions {
|
|
20
|
+
input?: string;
|
|
21
|
+
set?: string | string[];
|
|
22
|
+
locale?: string | string[];
|
|
23
|
+
createMissing?: boolean;
|
|
24
|
+
apply?: boolean;
|
|
25
|
+
prune?: boolean;
|
|
26
|
+
fromJson?: boolean;
|
|
27
|
+
jsonPath?: string;
|
|
28
|
+
delimiter?: string;
|
|
29
|
+
bom?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface CsvContent {
|
|
33
|
+
headers: string[];
|
|
34
|
+
rows: Record<string, string>[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ParsedImportInput {
|
|
38
|
+
inputFilePath: string;
|
|
39
|
+
rows: ImportRow[];
|
|
40
|
+
warnings: string[];
|
|
41
|
+
rowCount: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ImportRow {
|
|
45
|
+
rowNumber: number;
|
|
46
|
+
set?: string;
|
|
47
|
+
messageKey: string;
|
|
48
|
+
overrideKey?: string;
|
|
49
|
+
values: Record<string, string>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface ImportPlanEntry {
|
|
53
|
+
set?: string;
|
|
54
|
+
key: string;
|
|
55
|
+
original?: Message;
|
|
56
|
+
updated: Message;
|
|
57
|
+
createdMessage: boolean;
|
|
58
|
+
createdOverrides: string[];
|
|
59
|
+
changedLocales: string[];
|
|
60
|
+
changedOverrideLocales: Array<{ overrideKey: string; locale: string }>;
|
|
61
|
+
prunedLocales: string[];
|
|
62
|
+
prunedOverrideLocales: Array<{ overrideKey: string; locale: string }>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ImportProjectResult {
|
|
66
|
+
inputFilePath: string;
|
|
67
|
+
apply: boolean;
|
|
68
|
+
duration: number;
|
|
69
|
+
summary: {
|
|
70
|
+
rows: number;
|
|
71
|
+
changedMessages: number;
|
|
72
|
+
changedOverrides: number;
|
|
73
|
+
createdMessages: number;
|
|
74
|
+
createdOverrides: number;
|
|
75
|
+
changedTranslations: number;
|
|
76
|
+
prunedTranslations: number;
|
|
77
|
+
skippedRows: number;
|
|
78
|
+
skippedCells: number;
|
|
79
|
+
warnings: number;
|
|
80
|
+
sets: string[];
|
|
81
|
+
};
|
|
82
|
+
warnings: string[];
|
|
83
|
+
plans: ImportPlanEntry[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function toArray(value?: string | string[]): string[] {
|
|
87
|
+
if (typeof value === "undefined") return [];
|
|
88
|
+
return Array.isArray(value) ? value : [value];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function withoutKey<T extends Record<string, unknown>>(entity: T): T {
|
|
92
|
+
const { key: _key, ...rest } = entity;
|
|
93
|
+
|
|
94
|
+
return rest as T;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function cloneMessage(message: Message): Message {
|
|
98
|
+
return JSON.parse(JSON.stringify(withoutKey(message as any)));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parseCsv(content: string, delimiter = ","): CsvContent {
|
|
102
|
+
const source = content.charCodeAt(0) === 0xfeff ? content.slice(1) : content;
|
|
103
|
+
const rows: string[][] = [];
|
|
104
|
+
let row: string[] = [];
|
|
105
|
+
let cell = "";
|
|
106
|
+
let inQuotes = false;
|
|
107
|
+
let quoteClosed = false;
|
|
108
|
+
|
|
109
|
+
for (let index = 0; index < source.length; index++) {
|
|
110
|
+
const char = source[index];
|
|
111
|
+
const next = source[index + 1];
|
|
112
|
+
|
|
113
|
+
if (inQuotes) {
|
|
114
|
+
if (char === '"' && next === '"') {
|
|
115
|
+
cell += '"';
|
|
116
|
+
index++;
|
|
117
|
+
} else if (char === '"') {
|
|
118
|
+
inQuotes = false;
|
|
119
|
+
quoteClosed = true;
|
|
120
|
+
} else if (char === "\r") {
|
|
121
|
+
if (next === "\n") index++;
|
|
122
|
+
cell += "\n";
|
|
123
|
+
} else {
|
|
124
|
+
cell += char;
|
|
125
|
+
}
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (quoteClosed) {
|
|
130
|
+
if (char === delimiter) {
|
|
131
|
+
row.push(cell);
|
|
132
|
+
cell = "";
|
|
133
|
+
quoteClosed = false;
|
|
134
|
+
} else if (char === "\n") {
|
|
135
|
+
row.push(cell);
|
|
136
|
+
rows.push(row);
|
|
137
|
+
row = [];
|
|
138
|
+
cell = "";
|
|
139
|
+
quoteClosed = false;
|
|
140
|
+
} else if (char === "\r") {
|
|
141
|
+
if (next === "\n") index++;
|
|
142
|
+
row.push(cell);
|
|
143
|
+
rows.push(row);
|
|
144
|
+
row = [];
|
|
145
|
+
cell = "";
|
|
146
|
+
quoteClosed = false;
|
|
147
|
+
} else {
|
|
148
|
+
throw new MessagevisorCLIError("Invalid CSV: unexpected character after closing quote.");
|
|
149
|
+
}
|
|
150
|
+
} else if (char === '"') {
|
|
151
|
+
if (cell.length > 0) {
|
|
152
|
+
throw new MessagevisorCLIError("Invalid CSV: unexpected quote in unquoted field.");
|
|
153
|
+
}
|
|
154
|
+
inQuotes = true;
|
|
155
|
+
} else if (char === delimiter) {
|
|
156
|
+
row.push(cell);
|
|
157
|
+
cell = "";
|
|
158
|
+
} else if (char === "\n") {
|
|
159
|
+
row.push(cell);
|
|
160
|
+
rows.push(row);
|
|
161
|
+
row = [];
|
|
162
|
+
cell = "";
|
|
163
|
+
} else if (char === "\r") {
|
|
164
|
+
if (next === "\n") index++;
|
|
165
|
+
row.push(cell);
|
|
166
|
+
rows.push(row);
|
|
167
|
+
row = [];
|
|
168
|
+
cell = "";
|
|
169
|
+
} else {
|
|
170
|
+
cell += char;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (inQuotes) {
|
|
175
|
+
throw new MessagevisorCLIError("Invalid CSV: unterminated quoted field.");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (cell.length > 0 || row.length > 0 || quoteClosed) {
|
|
179
|
+
row.push(cell);
|
|
180
|
+
rows.push(row);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const [headers = [], ...dataRows] = rows;
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
headers,
|
|
187
|
+
rows: dataRows
|
|
188
|
+
.map((dataRow, index) => ({
|
|
189
|
+
dataRow,
|
|
190
|
+
rowNumber: index + 2,
|
|
191
|
+
}))
|
|
192
|
+
.filter(({ dataRow }) => dataRow.some((value) => value !== ""))
|
|
193
|
+
.map(({ dataRow, rowNumber }) => {
|
|
194
|
+
if (dataRow.length > headers.length) {
|
|
195
|
+
throw new MessagevisorCLIError(
|
|
196
|
+
`Invalid CSV: row ${rowNumber} has ${dataRow.length} cells but only ${headers.length} headers.`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return Object.fromEntries(headers.map((header, index) => [header, dataRow[index] || ""]));
|
|
201
|
+
}),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function assertOptions(options: ImportProjectOptions) {
|
|
206
|
+
if (typeof options.delimiter !== "undefined" && options.delimiter.length !== 1) {
|
|
207
|
+
throw new MessagevisorCLIError("--delimiter must be a single character.");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (options.fromJson && typeof options.delimiter !== "undefined") {
|
|
211
|
+
throw new MessagevisorCLIError("--delimiter can only be used with CSV imports.");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (options.fromJson && typeof options.bom !== "undefined") {
|
|
215
|
+
throw new MessagevisorCLIError("--bom can only be used with CSV imports.");
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function isHttpUrl(input: string) {
|
|
220
|
+
return /^https?:\/\//i.test(input);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function getInputFilePath(
|
|
224
|
+
projectConfig: ProjectConfig,
|
|
225
|
+
options: ImportProjectOptions,
|
|
226
|
+
parsed: any,
|
|
227
|
+
) {
|
|
228
|
+
const input = options.input || parsed?._?.[1];
|
|
229
|
+
|
|
230
|
+
if (!input) {
|
|
231
|
+
throw new MessagevisorCLIError(
|
|
232
|
+
options.fromJson
|
|
233
|
+
? "Pass a JSON file path or URL: messagevisor import <jsonFilePathOrUrl> --from-json --locale=<locale>."
|
|
234
|
+
: "Pass a CSV file path: messagevisor import <csvFilePath>.",
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (options.fromJson && isHttpUrl(input)) {
|
|
239
|
+
return input;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const projectRootDirectoryPath = path.dirname(projectConfig.exportsDirectoryPath);
|
|
243
|
+
|
|
244
|
+
return path.isAbsolute(input) ? input : path.join(projectRootDirectoryPath, input);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function readAll<T>(
|
|
248
|
+
keys: string[],
|
|
249
|
+
read: (key: string) => Promise<T>,
|
|
250
|
+
): Promise<Record<string, T>> {
|
|
251
|
+
const entries = await Promise.all(keys.map(async (key) => [key, await read(key)] as const));
|
|
252
|
+
return Object.fromEntries(entries);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function resolveLocaleChain(localeKey: string, locales: Record<string, Locale>) {
|
|
256
|
+
const chain: string[] = [];
|
|
257
|
+
const seen = new Set<string>();
|
|
258
|
+
let currentKey: string | undefined = localeKey;
|
|
259
|
+
|
|
260
|
+
while (currentKey && !seen.has(currentKey)) {
|
|
261
|
+
seen.add(currentKey);
|
|
262
|
+
chain.unshift(currentKey);
|
|
263
|
+
currentKey = locales[currentKey]?.inheritTranslationsFrom;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return chain;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function resolveTranslation(
|
|
270
|
+
translations: Record<string, Translation> | undefined,
|
|
271
|
+
localeKey: string,
|
|
272
|
+
locales: Record<string, Locale>,
|
|
273
|
+
) {
|
|
274
|
+
if (typeof translations?.[localeKey] !== "undefined") {
|
|
275
|
+
return translations[localeKey];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
for (const candidate of resolveLocaleChain(localeKey, locales).reverse()) {
|
|
279
|
+
if (translations && typeof translations[candidate] !== "undefined") {
|
|
280
|
+
return translations[candidate];
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function resolveInheritedTranslationValue(
|
|
288
|
+
translations: Record<string, Translation> | undefined,
|
|
289
|
+
localeKey: string,
|
|
290
|
+
locales: Record<string, Locale>,
|
|
291
|
+
) {
|
|
292
|
+
let currentKey = locales[localeKey]?.inheritTranslationsFrom;
|
|
293
|
+
|
|
294
|
+
while (currentKey) {
|
|
295
|
+
if (translations && typeof translations[currentKey] !== "undefined") {
|
|
296
|
+
return translations[currentKey];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
currentKey = locales[currentKey]?.inheritTranslationsFrom;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function getLocaleInheritanceDepth(localeKey: string, locales: Record<string, Locale>) {
|
|
304
|
+
let depth = 0;
|
|
305
|
+
const seen = new Set<string>();
|
|
306
|
+
let currentKey = locales[localeKey]?.inheritTranslationsFrom;
|
|
307
|
+
|
|
308
|
+
while (currentKey && !seen.has(currentKey)) {
|
|
309
|
+
seen.add(currentKey);
|
|
310
|
+
depth++;
|
|
311
|
+
currentKey = locales[currentKey]?.inheritTranslationsFrom;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return depth;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function splitMessageKey(projectConfig: ProjectConfig, key: string) {
|
|
318
|
+
const separator = projectConfig.exportOverrideKeySeparator;
|
|
319
|
+
const separatorIndex = key.lastIndexOf(separator);
|
|
320
|
+
|
|
321
|
+
if (separatorIndex === -1) {
|
|
322
|
+
return { messageKey: key };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
messageKey: key.slice(0, separatorIndex),
|
|
327
|
+
overrideKey: key.slice(separatorIndex + separator.length),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function getLocaleHeaders(headers: string[], localeKeys: string[]) {
|
|
332
|
+
const reservedHeaders = new Set(["set", "messageKey", "messageDescription"]);
|
|
333
|
+
|
|
334
|
+
return headers.filter(
|
|
335
|
+
(header) =>
|
|
336
|
+
!reservedHeaders.has(header) && !header.endsWith("Status") && localeKeys.includes(header),
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function getUnknownTranslationHeaders(headers: string[], localeKeys: string[]) {
|
|
341
|
+
const reservedHeaders = new Set(["set", "messageKey", "messageDescription"]);
|
|
342
|
+
|
|
343
|
+
return headers.filter(
|
|
344
|
+
(header) =>
|
|
345
|
+
!reservedHeaders.has(header) && !header.endsWith("Status") && !localeKeys.includes(header),
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function assertKnownLocales(requestedLocales: string[], localeKeys: string[]) {
|
|
350
|
+
for (const locale of requestedLocales) {
|
|
351
|
+
if (!localeKeys.includes(locale)) {
|
|
352
|
+
throw new MessagevisorCLIError(
|
|
353
|
+
`Unknown locale "${locale}". Available locales: ${localeKeys.join(", ") || "none"}.`,
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function getSelectedLocaleHeaders(
|
|
360
|
+
headers: string[],
|
|
361
|
+
localeKeys: string[],
|
|
362
|
+
requestedLocales: string[],
|
|
363
|
+
) {
|
|
364
|
+
const localeHeaders = getLocaleHeaders(headers, localeKeys);
|
|
365
|
+
|
|
366
|
+
if (requestedLocales.length === 0) {
|
|
367
|
+
return localeHeaders;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
assertKnownLocales(requestedLocales, localeKeys);
|
|
371
|
+
|
|
372
|
+
return localeHeaders.filter((locale) => requestedLocales.includes(locale));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function toImportRows(
|
|
376
|
+
projectConfig: ProjectConfig,
|
|
377
|
+
csv: CsvContent,
|
|
378
|
+
localeHeaders: string[],
|
|
379
|
+
): ImportRow[] {
|
|
380
|
+
if (!csv.headers.includes("messageKey")) {
|
|
381
|
+
throw new MessagevisorCLIError('CSV must include a "messageKey" column.');
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return csv.rows.map((row, index) => {
|
|
385
|
+
const { messageKey, overrideKey } = splitMessageKey(projectConfig, row.messageKey || "");
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
rowNumber: index + 2,
|
|
389
|
+
set: row.set || undefined,
|
|
390
|
+
messageKey,
|
|
391
|
+
overrideKey,
|
|
392
|
+
values: Object.fromEntries(localeHeaders.map((locale) => [locale, row[locale] || ""])),
|
|
393
|
+
};
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function addWarning(warnings: string[], message: string) {
|
|
398
|
+
warnings.push(message);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function getOrCreatePlan(
|
|
402
|
+
plansByKey: Map<string, ImportPlanEntry>,
|
|
403
|
+
set: string | undefined,
|
|
404
|
+
messageKey: string,
|
|
405
|
+
original: Message | undefined,
|
|
406
|
+
) {
|
|
407
|
+
const planKey = `${set || ""}:${messageKey}`;
|
|
408
|
+
const existing = plansByKey.get(planKey);
|
|
409
|
+
|
|
410
|
+
if (existing) {
|
|
411
|
+
return existing;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const plan: ImportPlanEntry = {
|
|
415
|
+
set,
|
|
416
|
+
key: messageKey,
|
|
417
|
+
original,
|
|
418
|
+
updated: original ? cloneMessage(original) : { description: "", translations: {} },
|
|
419
|
+
createdMessage: !original,
|
|
420
|
+
createdOverrides: [],
|
|
421
|
+
changedLocales: [],
|
|
422
|
+
changedOverrideLocales: [],
|
|
423
|
+
prunedLocales: [],
|
|
424
|
+
prunedOverrideLocales: [],
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
plansByKey.set(planKey, plan);
|
|
428
|
+
|
|
429
|
+
return plan;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function shouldApplyTranslation(
|
|
433
|
+
translations: Record<string, Translation> | undefined,
|
|
434
|
+
locale: string,
|
|
435
|
+
value: string,
|
|
436
|
+
locales: Record<string, Locale>,
|
|
437
|
+
) {
|
|
438
|
+
if (value === "") {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (translations && typeof translations[locale] !== "undefined") {
|
|
443
|
+
return translations[locale] !== value;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return resolveTranslation(translations, locale, locales) !== value;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function getImportValueEntries(values: Record<string, string>, locales: Record<string, Locale>) {
|
|
450
|
+
return Object.entries(values).sort(
|
|
451
|
+
([leftLocale], [rightLocale]) =>
|
|
452
|
+
getLocaleInheritanceDepth(leftLocale, locales) -
|
|
453
|
+
getLocaleInheritanceDepth(rightLocale, locales),
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function getOrCreateOverride(
|
|
458
|
+
message: Message,
|
|
459
|
+
overrideKey: string,
|
|
460
|
+
createMissing: boolean,
|
|
461
|
+
): { override?: Override; created: boolean } {
|
|
462
|
+
const overrides = message.overrides || [];
|
|
463
|
+
const existing = overrides.find((override) => override.key === overrideKey);
|
|
464
|
+
|
|
465
|
+
if (existing) {
|
|
466
|
+
return { override: existing, created: false };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (!createMissing) {
|
|
470
|
+
return { created: false };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const created: Override = {
|
|
474
|
+
key: overrideKey,
|
|
475
|
+
segments: "*",
|
|
476
|
+
translations: {},
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
message.overrides = [...overrides, created];
|
|
480
|
+
|
|
481
|
+
return { override: created, created: true };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async function collectImportPlansForDatasource(
|
|
485
|
+
datasource: Datasource,
|
|
486
|
+
rows: ImportRow[],
|
|
487
|
+
options: Required<Pick<ImportProjectOptions, "createMissing" | "prune">>,
|
|
488
|
+
set: string | undefined,
|
|
489
|
+
warnings: string[],
|
|
490
|
+
) {
|
|
491
|
+
const [localeKeys, messageKeys] = await Promise.all([
|
|
492
|
+
datasource.listLocales(),
|
|
493
|
+
datasource.listMessages(),
|
|
494
|
+
]);
|
|
495
|
+
const locales = await readAll<Locale>(localeKeys, (key) => datasource.readLocale(key));
|
|
496
|
+
const messages = await readAll<Message>(messageKeys, (key) => datasource.readMessage(key));
|
|
497
|
+
const plansByKey = new Map<string, ImportPlanEntry>();
|
|
498
|
+
let skippedRows = 0;
|
|
499
|
+
let skippedCells = 0;
|
|
500
|
+
let prunedTranslations = 0;
|
|
501
|
+
|
|
502
|
+
const sortedRows = [...rows].sort(
|
|
503
|
+
(a, b) => Number(Boolean(a.overrideKey)) - Number(Boolean(b.overrideKey)),
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
for (const row of sortedRows) {
|
|
507
|
+
if (!row.messageKey) {
|
|
508
|
+
skippedRows++;
|
|
509
|
+
addWarning(warnings, `Row ${row.rowNumber}: missing messageKey.`);
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const planKey = `${set || ""}:${row.messageKey}`;
|
|
514
|
+
const existingPlan = plansByKey.get(planKey);
|
|
515
|
+
const message = messages[row.messageKey];
|
|
516
|
+
|
|
517
|
+
if (!message && !options.createMissing) {
|
|
518
|
+
skippedRows++;
|
|
519
|
+
addWarning(warnings, `Row ${row.rowNumber}: unknown message "${row.messageKey}".`);
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (row.overrideKey && !message && !existingPlan && options.createMissing) {
|
|
524
|
+
skippedRows++;
|
|
525
|
+
addWarning(
|
|
526
|
+
warnings,
|
|
527
|
+
`Row ${row.rowNumber}: cannot create override "${row.overrideKey}" because message "${row.messageKey}" does not exist or is not created by another row.`,
|
|
528
|
+
);
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const plan = getOrCreatePlan(plansByKey, set, row.messageKey, message);
|
|
533
|
+
|
|
534
|
+
if (row.overrideKey) {
|
|
535
|
+
const { override, created } = getOrCreateOverride(
|
|
536
|
+
plan.updated,
|
|
537
|
+
row.overrideKey,
|
|
538
|
+
options.createMissing,
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
if (!override) {
|
|
542
|
+
skippedRows++;
|
|
543
|
+
addWarning(
|
|
544
|
+
warnings,
|
|
545
|
+
`Row ${row.rowNumber}: unknown override "${row.overrideKey}" in message "${row.messageKey}".`,
|
|
546
|
+
);
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
let changed = false;
|
|
551
|
+
|
|
552
|
+
for (const [locale, value] of getImportValueEntries(row.values, locales)) {
|
|
553
|
+
if (!localeKeys.includes(locale)) {
|
|
554
|
+
skippedCells++;
|
|
555
|
+
addWarning(warnings, `Row ${row.rowNumber}: unknown locale "${locale}".`);
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const inherited = options.prune
|
|
560
|
+
? resolveInheritedTranslationValue(override.translations, locale, locales)
|
|
561
|
+
: undefined;
|
|
562
|
+
|
|
563
|
+
if (value !== "" && typeof inherited !== "undefined" && inherited === value) {
|
|
564
|
+
prunedTranslations++;
|
|
565
|
+
|
|
566
|
+
if (typeof override.translations?.[locale] !== "undefined") {
|
|
567
|
+
delete override.translations[locale];
|
|
568
|
+
plan.prunedOverrideLocales.push({ overrideKey: row.overrideKey, locale });
|
|
569
|
+
changed = true;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (!shouldApplyTranslation(override.translations, locale, value, locales)) {
|
|
576
|
+
skippedCells++;
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
override.translations = { ...override.translations, [locale]: value };
|
|
581
|
+
plan.changedOverrideLocales.push({ overrideKey: row.overrideKey, locale });
|
|
582
|
+
changed = true;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (created && changed) {
|
|
586
|
+
plan.createdOverrides.push(row.overrideKey);
|
|
587
|
+
}
|
|
588
|
+
if (created && !changed) {
|
|
589
|
+
plan.updated.overrides = (plan.updated.overrides || []).filter(
|
|
590
|
+
(overrideEntry) => overrideEntry.key !== row.overrideKey,
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
for (const [locale, value] of getImportValueEntries(row.values, locales)) {
|
|
597
|
+
if (!localeKeys.includes(locale)) {
|
|
598
|
+
skippedCells++;
|
|
599
|
+
addWarning(warnings, `Row ${row.rowNumber}: unknown locale "${locale}".`);
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const inherited = options.prune
|
|
604
|
+
? resolveInheritedTranslationValue(plan.updated.translations, locale, locales)
|
|
605
|
+
: undefined;
|
|
606
|
+
|
|
607
|
+
if (value !== "" && typeof inherited !== "undefined" && inherited === value) {
|
|
608
|
+
prunedTranslations++;
|
|
609
|
+
|
|
610
|
+
if (typeof plan.updated.translations?.[locale] !== "undefined") {
|
|
611
|
+
delete plan.updated.translations[locale];
|
|
612
|
+
plan.prunedLocales.push(locale);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (!shouldApplyTranslation(plan.updated.translations, locale, value, locales)) {
|
|
619
|
+
skippedCells++;
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
plan.updated.translations = { ...plan.updated.translations, [locale]: value };
|
|
624
|
+
plan.changedLocales.push(locale);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return {
|
|
629
|
+
plans: Array.from(plansByKey.values()).filter(
|
|
630
|
+
(plan) =>
|
|
631
|
+
plan.changedLocales.length > 0 ||
|
|
632
|
+
plan.changedOverrideLocales.length > 0 ||
|
|
633
|
+
plan.createdOverrides.length > 0 ||
|
|
634
|
+
plan.prunedLocales.length > 0 ||
|
|
635
|
+
plan.prunedOverrideLocales.length > 0,
|
|
636
|
+
),
|
|
637
|
+
skippedRows,
|
|
638
|
+
skippedCells,
|
|
639
|
+
prunedTranslations,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function deepEqual(a: unknown, b: unknown) {
|
|
644
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
async function writePlans(datasource: Datasource, plans: ImportPlanEntry[]) {
|
|
648
|
+
for (const plan of plans) {
|
|
649
|
+
if (plan.original && deepEqual(withoutKey(plan.original as any), plan.updated)) {
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
await datasource.writeMessage(plan.key, plan.updated);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function createResult(
|
|
658
|
+
inputFilePath: string,
|
|
659
|
+
apply: boolean,
|
|
660
|
+
startTime: number,
|
|
661
|
+
rows: number,
|
|
662
|
+
plans: ImportPlanEntry[],
|
|
663
|
+
skippedRows: number,
|
|
664
|
+
skippedCells: number,
|
|
665
|
+
prunedTranslations: number,
|
|
666
|
+
warnings: string[],
|
|
667
|
+
): ImportProjectResult {
|
|
668
|
+
const changedOverrideKeys = new Set(
|
|
669
|
+
plans.flatMap((plan) =>
|
|
670
|
+
[...plan.changedOverrideLocales, ...plan.prunedOverrideLocales].map(
|
|
671
|
+
(entry) => `${plan.set || ""}:${plan.key}:${entry.overrideKey}`,
|
|
672
|
+
),
|
|
673
|
+
),
|
|
674
|
+
);
|
|
675
|
+
const changedMessagePlans = plans.filter(
|
|
676
|
+
(plan) => plan.changedLocales.length > 0 || plan.prunedLocales.length > 0,
|
|
677
|
+
);
|
|
678
|
+
|
|
679
|
+
return {
|
|
680
|
+
inputFilePath,
|
|
681
|
+
apply,
|
|
682
|
+
duration: Date.now() - startTime,
|
|
683
|
+
plans,
|
|
684
|
+
warnings,
|
|
685
|
+
summary: {
|
|
686
|
+
rows,
|
|
687
|
+
changedMessages: changedMessagePlans.length,
|
|
688
|
+
changedOverrides: changedOverrideKeys.size,
|
|
689
|
+
createdMessages: plans.filter((plan) => plan.createdMessage).length,
|
|
690
|
+
createdOverrides: plans.reduce((sum, plan) => sum + plan.createdOverrides.length, 0),
|
|
691
|
+
changedTranslations:
|
|
692
|
+
plans.reduce((sum, plan) => sum + plan.changedLocales.length, 0) +
|
|
693
|
+
plans.reduce((sum, plan) => sum + plan.changedOverrideLocales.length, 0),
|
|
694
|
+
prunedTranslations,
|
|
695
|
+
skippedRows,
|
|
696
|
+
skippedCells,
|
|
697
|
+
warnings: warnings.length,
|
|
698
|
+
sets: Array.from(
|
|
699
|
+
new Set(plans.map((plan) => plan.set).filter((entry): entry is string => Boolean(entry))),
|
|
700
|
+
),
|
|
701
|
+
},
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
async function readCsv(inputFilePath: string, options: ImportProjectOptions) {
|
|
706
|
+
if (!fs.existsSync(inputFilePath)) {
|
|
707
|
+
throw new MessagevisorCLIError(`CSV file does not exist: ${inputFilePath}`);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
return parseCsv(await fs.promises.readFile(inputFilePath, "utf8"), options.delimiter || ",");
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
async function readJsonText(inputFilePath: string) {
|
|
714
|
+
if (isHttpUrl(inputFilePath)) {
|
|
715
|
+
let response: Response;
|
|
716
|
+
|
|
717
|
+
try {
|
|
718
|
+
response = await fetch(inputFilePath);
|
|
719
|
+
} catch (error) {
|
|
720
|
+
throw new MessagevisorCLIError(`Unable to fetch JSON from ${inputFilePath}.`);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
if (!response.ok) {
|
|
724
|
+
throw new MessagevisorCLIError(
|
|
725
|
+
`Unable to fetch JSON from ${inputFilePath}: ${response.status} ${response.statusText}`.trim(),
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
return response.text();
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if (!fs.existsSync(inputFilePath)) {
|
|
733
|
+
throw new MessagevisorCLIError(`JSON file does not exist: ${inputFilePath}`);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
return fs.promises.readFile(inputFilePath, "utf8");
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function parseJson(content: string) {
|
|
740
|
+
try {
|
|
741
|
+
return JSON.parse(content);
|
|
742
|
+
} catch (error) {
|
|
743
|
+
throw new MessagevisorCLIError("Invalid JSON: unable to parse input.");
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
748
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function selectJsonPath(content: unknown, jsonPath?: string) {
|
|
752
|
+
if (typeof jsonPath === "undefined") {
|
|
753
|
+
return content;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
if (jsonPath.trim() === "") {
|
|
757
|
+
throw new MessagevisorCLIError("--json-path cannot be empty.");
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
let current = content;
|
|
761
|
+
|
|
762
|
+
for (const segment of jsonPath.split(".")) {
|
|
763
|
+
if (!segment) {
|
|
764
|
+
throw new MessagevisorCLIError(`Invalid JSON path "${jsonPath}".`);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
if (!isPlainObject(current) || !(segment in current)) {
|
|
768
|
+
throw new MessagevisorCLIError(`JSON path "${jsonPath}" was not found.`);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
current = current[segment];
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
return current;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function jsonToImportRows(
|
|
778
|
+
projectConfig: ProjectConfig,
|
|
779
|
+
content: unknown,
|
|
780
|
+
locale: string,
|
|
781
|
+
jsonPath?: string,
|
|
782
|
+
): ImportRow[] {
|
|
783
|
+
const selected = selectJsonPath(content, jsonPath);
|
|
784
|
+
|
|
785
|
+
if (!isPlainObject(selected)) {
|
|
786
|
+
throw new MessagevisorCLIError(
|
|
787
|
+
jsonPath
|
|
788
|
+
? `JSON path "${jsonPath}" must resolve to a flat object.`
|
|
789
|
+
: "JSON import input must be a flat object.",
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
return Object.entries(selected).map(([key, value], index) => {
|
|
794
|
+
if (typeof value !== "string") {
|
|
795
|
+
throw new MessagevisorCLIError(`JSON translation value for "${key}" must be a string.`);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const { messageKey, overrideKey } = splitMessageKey(projectConfig, key);
|
|
799
|
+
|
|
800
|
+
return {
|
|
801
|
+
rowNumber: index + 1,
|
|
802
|
+
messageKey,
|
|
803
|
+
overrideKey,
|
|
804
|
+
values: {
|
|
805
|
+
[locale]: value,
|
|
806
|
+
},
|
|
807
|
+
};
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function getJsonImportLocale(options: ImportProjectOptions) {
|
|
812
|
+
const requestedLocales = toArray(options.locale);
|
|
813
|
+
|
|
814
|
+
if (requestedLocales.length !== 1) {
|
|
815
|
+
throw new MessagevisorCLIError("--from-json requires exactly one --locale=<locale>.");
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
return requestedLocales[0];
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
async function readJsonImportInput(
|
|
822
|
+
projectConfig: ProjectConfig,
|
|
823
|
+
inputFilePath: string,
|
|
824
|
+
locale: string,
|
|
825
|
+
options: ImportProjectOptions,
|
|
826
|
+
): Promise<ParsedImportInput> {
|
|
827
|
+
const content = parseJson(await readJsonText(inputFilePath));
|
|
828
|
+
const rows = jsonToImportRows(projectConfig, content, locale, options.jsonPath);
|
|
829
|
+
|
|
830
|
+
return {
|
|
831
|
+
inputFilePath,
|
|
832
|
+
rows,
|
|
833
|
+
warnings: [],
|
|
834
|
+
rowCount: rows.length,
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
async function readCsvImportInput(
|
|
839
|
+
projectConfig: ProjectConfig,
|
|
840
|
+
datasource: Datasource,
|
|
841
|
+
inputFilePath: string,
|
|
842
|
+
options: ImportProjectOptions,
|
|
843
|
+
): Promise<ParsedImportInput> {
|
|
844
|
+
const csv = await readCsv(inputFilePath, options);
|
|
845
|
+
|
|
846
|
+
if (csv.headers.includes("set") && hasNonEmptySetValues(csv)) {
|
|
847
|
+
throw new MessagevisorCLIError(
|
|
848
|
+
'CSV "set" column can only contain values when `sets: true` is configured.',
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const localeKeys = await datasource.listLocales();
|
|
853
|
+
const requestedLocales = toArray(options.locale);
|
|
854
|
+
const localeHeaders = getSelectedLocaleHeaders(csv.headers, localeKeys, requestedLocales);
|
|
855
|
+
const unknownHeaders = getUnknownTranslationHeaders(csv.headers, localeKeys);
|
|
856
|
+
const warnings = unknownHeaders.map((header) => `Ignoring unknown CSV column "${header}".`);
|
|
857
|
+
const rows = toImportRows(projectConfig, csv, localeHeaders);
|
|
858
|
+
|
|
859
|
+
return {
|
|
860
|
+
inputFilePath,
|
|
861
|
+
rows,
|
|
862
|
+
warnings,
|
|
863
|
+
rowCount: rows.length,
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function getAllLocaleKeysBySet(executions: Awaited<ReturnType<typeof getProjectSetExecutions>>) {
|
|
868
|
+
return Promise.all(
|
|
869
|
+
executions.map(async (execution) => ({
|
|
870
|
+
set: execution.set,
|
|
871
|
+
locales: await execution.datasource.listLocales(),
|
|
872
|
+
})),
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function hasNonEmptySetValues(csv: CsvContent) {
|
|
877
|
+
return csv.rows.some((row) => row.set && row.set.trim() !== "");
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
export async function importProject(
|
|
881
|
+
projectConfig: ProjectConfig,
|
|
882
|
+
datasource: Datasource,
|
|
883
|
+
options: ImportProjectOptions = {},
|
|
884
|
+
parsed?: any,
|
|
885
|
+
): Promise<ImportProjectResult> {
|
|
886
|
+
const startTime = Date.now();
|
|
887
|
+
assertOptions(options);
|
|
888
|
+
|
|
889
|
+
if (projectConfig.sets) {
|
|
890
|
+
return importProjectSets(projectConfig, datasource, options, parsed);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
if (toArray(options.set).length > 0) {
|
|
894
|
+
throw new MessagevisorCLIError("--set can only be used when `sets: true` is configured.");
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const inputFilePath = getInputFilePath(projectConfig, options, parsed);
|
|
898
|
+
const input = options.fromJson
|
|
899
|
+
? await (async () => {
|
|
900
|
+
const locale = getJsonImportLocale(options);
|
|
901
|
+
const localeKeys = await datasource.listLocales();
|
|
902
|
+
assertKnownLocales([locale], localeKeys);
|
|
903
|
+
|
|
904
|
+
return readJsonImportInput(projectConfig, inputFilePath, locale, options);
|
|
905
|
+
})()
|
|
906
|
+
: await readCsvImportInput(projectConfig, datasource, inputFilePath, options);
|
|
907
|
+
const collected = await collectImportPlansForDatasource(
|
|
908
|
+
datasource,
|
|
909
|
+
input.rows,
|
|
910
|
+
{
|
|
911
|
+
createMissing: options.createMissing === true,
|
|
912
|
+
prune: options.prune === true,
|
|
913
|
+
},
|
|
914
|
+
undefined,
|
|
915
|
+
input.warnings,
|
|
916
|
+
);
|
|
917
|
+
|
|
918
|
+
if (options.apply === true) {
|
|
919
|
+
await writePlans(datasource, collected.plans);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
return createResult(
|
|
923
|
+
inputFilePath,
|
|
924
|
+
options.apply === true,
|
|
925
|
+
startTime,
|
|
926
|
+
input.rowCount,
|
|
927
|
+
collected.plans,
|
|
928
|
+
collected.skippedRows,
|
|
929
|
+
collected.skippedCells,
|
|
930
|
+
collected.prunedTranslations,
|
|
931
|
+
input.warnings,
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
export async function importProjectSets(
|
|
936
|
+
projectConfig: ProjectConfig,
|
|
937
|
+
datasource: Datasource,
|
|
938
|
+
options: ImportProjectOptions = {},
|
|
939
|
+
parsed?: any,
|
|
940
|
+
): Promise<ImportProjectResult> {
|
|
941
|
+
const startTime = Date.now();
|
|
942
|
+
assertOptions(options);
|
|
943
|
+
|
|
944
|
+
if (!projectConfig.sets) {
|
|
945
|
+
return importProject(projectConfig, datasource, options, parsed);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const requestedSets = toArray(options.set);
|
|
949
|
+
const executions = await getProjectSetExecutions(projectConfig, datasource, undefined);
|
|
950
|
+
const executionSets = executions.map((execution) => execution.set);
|
|
951
|
+
const unknownRequestedSets = requestedSets.filter((set) => !executionSets.includes(set));
|
|
952
|
+
const inputFilePath = getInputFilePath(projectConfig, options, parsed);
|
|
953
|
+
|
|
954
|
+
if (unknownRequestedSets.length > 0) {
|
|
955
|
+
throw new MessagevisorCLIError(
|
|
956
|
+
`Unknown set "${unknownRequestedSets[0]}". Available sets: ${executionSets.join(", ") || "none"}.`,
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
if (options.fromJson) {
|
|
961
|
+
if (requestedSets.length !== 1) {
|
|
962
|
+
throw new MessagevisorCLIError(
|
|
963
|
+
"--from-json requires exactly one --set=<set> when `sets: true` is configured.",
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const locale = getJsonImportLocale(options);
|
|
968
|
+
const execution = executions.find(
|
|
969
|
+
(currentExecution) => currentExecution.set === requestedSets[0],
|
|
970
|
+
)!;
|
|
971
|
+
const localeKeys = await execution.datasource.listLocales();
|
|
972
|
+
assertKnownLocales([locale], localeKeys);
|
|
973
|
+
|
|
974
|
+
const input = await readJsonImportInput(projectConfig, inputFilePath, locale, options);
|
|
975
|
+
const warnings = [...input.warnings];
|
|
976
|
+
const collected = await collectImportPlansForDatasource(
|
|
977
|
+
execution.datasource,
|
|
978
|
+
input.rows.map((row) => ({ ...row, set: execution.set })),
|
|
979
|
+
{
|
|
980
|
+
createMissing: options.createMissing === true,
|
|
981
|
+
prune: options.prune === true,
|
|
982
|
+
},
|
|
983
|
+
execution.set,
|
|
984
|
+
warnings,
|
|
985
|
+
);
|
|
986
|
+
|
|
987
|
+
if (options.apply === true) {
|
|
988
|
+
await writePlans(execution.datasource, collected.plans);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
return createResult(
|
|
992
|
+
inputFilePath,
|
|
993
|
+
options.apply === true,
|
|
994
|
+
startTime,
|
|
995
|
+
input.rowCount,
|
|
996
|
+
collected.plans,
|
|
997
|
+
collected.skippedRows,
|
|
998
|
+
collected.skippedCells,
|
|
999
|
+
collected.prunedTranslations,
|
|
1000
|
+
warnings,
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
const csv = await readCsv(inputFilePath, options);
|
|
1005
|
+
|
|
1006
|
+
const selectedExecutions =
|
|
1007
|
+
requestedSets.length > 0
|
|
1008
|
+
? executions.filter((execution) => requestedSets.includes(execution.set))
|
|
1009
|
+
: executions;
|
|
1010
|
+
const hasSetColumn = csv.headers.includes("set");
|
|
1011
|
+
|
|
1012
|
+
if (!hasSetColumn && requestedSets.length !== 1) {
|
|
1013
|
+
throw new MessagevisorCLIError('CSV without a "set" column requires exactly one --set=<set>.');
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
const localeKeysBySet = await getAllLocaleKeysBySet(selectedExecutions);
|
|
1017
|
+
const allLocaleKeys = Array.from(new Set(localeKeysBySet.flatMap((entry) => entry.locales)));
|
|
1018
|
+
const requestedLocales = toArray(options.locale);
|
|
1019
|
+
const localeHeaders = getSelectedLocaleHeaders(csv.headers, allLocaleKeys, requestedLocales);
|
|
1020
|
+
const unknownHeaders = getUnknownTranslationHeaders(csv.headers, allLocaleKeys);
|
|
1021
|
+
const warnings = unknownHeaders.map((header) => `Ignoring unknown CSV column "${header}".`);
|
|
1022
|
+
const rows = toImportRows(projectConfig, csv, localeHeaders);
|
|
1023
|
+
const rowsBySet = new Map<string, ImportRow[]>();
|
|
1024
|
+
let skippedRows = 0;
|
|
1025
|
+
let skippedCells = 0;
|
|
1026
|
+
|
|
1027
|
+
for (const row of rows) {
|
|
1028
|
+
const rowSet = hasSetColumn ? row.set : requestedSets[0];
|
|
1029
|
+
|
|
1030
|
+
if (!rowSet) {
|
|
1031
|
+
skippedRows++;
|
|
1032
|
+
addWarning(warnings, `Row ${row.rowNumber}: missing set.`);
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
if (!executionSets.includes(rowSet)) {
|
|
1037
|
+
skippedRows++;
|
|
1038
|
+
addWarning(warnings, `Row ${row.rowNumber}: unknown set "${rowSet}".`);
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
if (requestedSets.length > 0 && !requestedSets.includes(rowSet)) {
|
|
1043
|
+
skippedRows++;
|
|
1044
|
+
continue;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
rowsBySet.set(rowSet, [...(rowsBySet.get(rowSet) || []), { ...row, set: rowSet }]);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
const plans: ImportPlanEntry[] = [];
|
|
1051
|
+
let prunedTranslations = 0;
|
|
1052
|
+
|
|
1053
|
+
for (const execution of selectedExecutions) {
|
|
1054
|
+
const setRows = rowsBySet.get(execution.set) || [];
|
|
1055
|
+
|
|
1056
|
+
if (setRows.length === 0) {
|
|
1057
|
+
continue;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
const collected = await collectImportPlansForDatasource(
|
|
1061
|
+
execution.datasource,
|
|
1062
|
+
setRows,
|
|
1063
|
+
{
|
|
1064
|
+
createMissing: options.createMissing === true,
|
|
1065
|
+
prune: options.prune === true,
|
|
1066
|
+
},
|
|
1067
|
+
execution.set,
|
|
1068
|
+
warnings,
|
|
1069
|
+
);
|
|
1070
|
+
|
|
1071
|
+
plans.push(...collected.plans);
|
|
1072
|
+
skippedRows += collected.skippedRows;
|
|
1073
|
+
skippedCells += collected.skippedCells;
|
|
1074
|
+
prunedTranslations += collected.prunedTranslations;
|
|
1075
|
+
|
|
1076
|
+
if (options.apply === true) {
|
|
1077
|
+
await writePlans(execution.datasource, collected.plans);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
return createResult(
|
|
1082
|
+
inputFilePath,
|
|
1083
|
+
options.apply === true,
|
|
1084
|
+
startTime,
|
|
1085
|
+
rows.length,
|
|
1086
|
+
plans,
|
|
1087
|
+
skippedRows,
|
|
1088
|
+
skippedCells,
|
|
1089
|
+
prunedTranslations,
|
|
1090
|
+
warnings,
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
function printImportResult(result: ImportProjectResult) {
|
|
1095
|
+
console.log("");
|
|
1096
|
+
console.log(CLI_FORMAT_BOLD, "Importing Messagevisor translations");
|
|
1097
|
+
const inputLabel = isHttpUrl(result.inputFilePath)
|
|
1098
|
+
? result.inputFilePath
|
|
1099
|
+
: path.relative(process.cwd(), result.inputFilePath);
|
|
1100
|
+
console.log(` Input: ${colorize(inputLabel, 36)}`);
|
|
1101
|
+
console.log(` Mode: ${result.apply ? "apply" : "preview"}`);
|
|
1102
|
+
if (result.summary.sets.length > 0) {
|
|
1103
|
+
console.log(` Sets: ${result.summary.sets.join(", ")}`);
|
|
1104
|
+
}
|
|
1105
|
+
console.log("");
|
|
1106
|
+
console.log(` Rows: ${result.summary.rows}`);
|
|
1107
|
+
console.log(` Changed messages: ${result.summary.changedMessages}`);
|
|
1108
|
+
console.log(` Changed overrides: ${result.summary.changedOverrides}`);
|
|
1109
|
+
console.log(` Created messages: ${result.summary.createdMessages}`);
|
|
1110
|
+
console.log(` Created overrides: ${result.summary.createdOverrides}`);
|
|
1111
|
+
console.log(` Changed translations: ${result.summary.changedTranslations}`);
|
|
1112
|
+
console.log(` Pruned translations: ${result.summary.prunedTranslations}`);
|
|
1113
|
+
console.log(` Skipped rows: ${result.summary.skippedRows}`);
|
|
1114
|
+
console.log(` Skipped cells: ${result.summary.skippedCells}`);
|
|
1115
|
+
console.log(` Warnings: ${result.summary.warnings}`);
|
|
1116
|
+
console.log("");
|
|
1117
|
+
|
|
1118
|
+
if (result.warnings.length > 0) {
|
|
1119
|
+
console.log(CLI_FORMAT_BOLD, "Warnings");
|
|
1120
|
+
for (const warning of result.warnings.slice(0, 12)) {
|
|
1121
|
+
console.log(CLI_FORMAT_YELLOW, ` ${warning}`);
|
|
1122
|
+
}
|
|
1123
|
+
if (result.warnings.length > 12) {
|
|
1124
|
+
console.log(` ${colorize(`...and ${result.warnings.length - 12} more`, 2)}`);
|
|
1125
|
+
}
|
|
1126
|
+
console.log("");
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
console.log(CLI_FORMAT_GREEN, result.apply ? "Import applied" : "Import preview complete");
|
|
1130
|
+
console.log(CLI_FORMAT_BOLD, `Time: ${prettyDuration(result.duration)}`);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
export const importPlugin = {
|
|
1134
|
+
command: "import [input]",
|
|
1135
|
+
handler: async ({ projectConfig, datasource, parsed }: any) => {
|
|
1136
|
+
try {
|
|
1137
|
+
const result = await importProjectSets(
|
|
1138
|
+
projectConfig,
|
|
1139
|
+
datasource,
|
|
1140
|
+
{
|
|
1141
|
+
input: parsed.input || parsed.csvFilePath,
|
|
1142
|
+
set: parsed.set,
|
|
1143
|
+
locale: parsed.locale,
|
|
1144
|
+
createMissing: parsed.createMissing,
|
|
1145
|
+
apply: parsed.apply === true || parsed.apply === "true",
|
|
1146
|
+
prune: parsed.prune === true || parsed.prune === "true",
|
|
1147
|
+
fromJson: parsed.fromJson === true || parsed.fromJson === "true",
|
|
1148
|
+
jsonPath: parsed.jsonPath,
|
|
1149
|
+
delimiter: parsed.delimiter,
|
|
1150
|
+
bom: parsed.bom,
|
|
1151
|
+
},
|
|
1152
|
+
parsed,
|
|
1153
|
+
);
|
|
1154
|
+
|
|
1155
|
+
printImportResult(result);
|
|
1156
|
+
} catch (error) {
|
|
1157
|
+
if (printMessagevisorCLIError(error)) {
|
|
1158
|
+
return false;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
throw error;
|
|
1162
|
+
}
|
|
1163
|
+
},
|
|
1164
|
+
examples: [
|
|
1165
|
+
{
|
|
1166
|
+
command: "import exports/messagevisor-export-20260419T123456.csv",
|
|
1167
|
+
description: "preview translations from a CSV file",
|
|
1168
|
+
},
|
|
1169
|
+
{
|
|
1170
|
+
command: "import --input=translator/nl.csv --apply",
|
|
1171
|
+
description: "apply an import and write message files",
|
|
1172
|
+
},
|
|
1173
|
+
{
|
|
1174
|
+
command: "import --input=translator/translations.csv --locale=nl --apply",
|
|
1175
|
+
description: "apply only one locale column from a CSV",
|
|
1176
|
+
},
|
|
1177
|
+
{
|
|
1178
|
+
command: "import --input=translator/translations.csv --locale=en-US --prune --apply",
|
|
1179
|
+
description: "apply an import while pruning translations duplicated by inheritance",
|
|
1180
|
+
},
|
|
1181
|
+
{
|
|
1182
|
+
command: "import translations.csv --set=staging",
|
|
1183
|
+
description: "preview a CSV without a set column for one set",
|
|
1184
|
+
},
|
|
1185
|
+
{
|
|
1186
|
+
command: "import translations.json --from-json --locale=nl-NL --apply",
|
|
1187
|
+
description: "apply translations from a flat JSON object",
|
|
1188
|
+
},
|
|
1189
|
+
],
|
|
1190
|
+
};
|