@el-j/google-sheet-translations 2.2.0 → 3.0.0-beta.2
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 +42 -0
- package/dist/action-entrypoint.d.ts.map +1 -1
- package/dist/core/rowTransformer.d.ts +26 -0
- package/dist/core/rowTransformer.d.ts.map +1 -0
- package/dist/esm/index.js +4118 -2524
- package/dist/getMultipleSpreadSheetsData.d.ts.map +1 -1
- package/dist/getSpreadSheetData.d.ts +3 -3
- package/dist/getSpreadSheetData.d.ts.map +1 -1
- package/dist/index.d.ts +13 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4232 -2611
- package/dist/migration/migrateV3.d.ts +57 -0
- package/dist/migration/migrateV3.d.ts.map +1 -0
- package/dist/providers/assetContracts.d.ts +88 -0
- package/dist/providers/assetContracts.d.ts.map +1 -0
- package/dist/providers/capabilities.d.ts +50 -0
- package/dist/providers/capabilities.d.ts.map +1 -0
- package/dist/providers/catalog.d.ts +73 -0
- package/dist/providers/catalog.d.ts.map +1 -0
- package/dist/providers/config.d.ts +56 -0
- package/dist/providers/config.d.ts.map +1 -0
- package/dist/providers/contracts.d.ts +138 -0
- package/dist/providers/contracts.d.ts.map +1 -0
- package/dist/providers/cryptpad/assetProvider.d.ts +36 -0
- package/dist/providers/cryptpad/assetProvider.d.ts.map +1 -0
- package/dist/providers/cryptpad/fullProvider.d.ts +50 -0
- package/dist/providers/cryptpad/fullProvider.d.ts.map +1 -0
- package/dist/providers/cryptpad/index.d.ts +8 -0
- package/dist/providers/cryptpad/index.d.ts.map +1 -0
- package/dist/providers/cryptpad/provider.d.ts +37 -0
- package/dist/providers/cryptpad/provider.d.ts.map +1 -0
- package/dist/providers/google/index.d.ts +4 -0
- package/dist/providers/google/index.d.ts.map +1 -0
- package/dist/providers/google/providers.d.ts +105 -0
- package/dist/providers/google/providers.d.ts.map +1 -0
- package/dist/providers/index.d.ts +25 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/orchestrator.d.ts +74 -0
- package/dist/providers/orchestrator.d.ts.map +1 -0
- package/dist/providers/runtime.d.ts +30 -0
- package/dist/providers/runtime.d.ts.map +1 -0
- package/dist/providers/syncEngine.d.ts +106 -0
- package/dist/providers/syncEngine.d.ts.map +1 -0
- package/dist/setup/gcpTransport.d.ts +25 -0
- package/dist/setup/gcpTransport.d.ts.map +1 -1
- package/dist/setup/migrateV3Cli.d.ts +2 -0
- package/dist/setup/migrateV3Cli.d.ts.map +1 -0
- package/dist/setup/providerCli.d.ts +2 -0
- package/dist/setup/providerCli.d.ts.map +1 -0
- package/dist/setup/wifSetup.d.ts +1 -1
- package/dist/setup/wifSetup.d.ts.map +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/auth.d.ts +1 -1
- package/dist/utils/auth.d.ts.map +1 -1
- package/dist/utils/configurationHandler.d.ts.map +1 -1
- package/dist/utils/dataConverter/convertFromDataJsonFormat.d.ts +1 -1
- package/dist/utils/dataConverter/convertFromDataJsonFormat.d.ts.map +1 -1
- package/dist/utils/dataConverter/convertToDataJsonFormat.d.ts +1 -1
- package/dist/utils/dataConverter/convertToDataJsonFormat.d.ts.map +1 -1
- package/dist/utils/dataConverter/findLocalChanges.d.ts +1 -1
- package/dist/utils/dataConverter/findLocalChanges.d.ts.map +1 -1
- package/dist/utils/docIngester.d.ts.map +1 -1
- package/dist/utils/docParser.d.ts.map +1 -1
- package/dist/utils/driveDocScanner.d.ts.map +1 -1
- package/dist/utils/driveFolderScanner.d.ts.map +1 -1
- package/dist/utils/driveImageSync.d.ts.map +1 -1
- package/dist/utils/driveSpreadsheetBootstrap.d.ts.map +1 -1
- package/dist/utils/fileWriter.d.ts +1 -1
- package/dist/utils/fileWriter.d.ts.map +1 -1
- package/dist/utils/getDriveTranslations.d.ts.map +1 -1
- package/dist/utils/isDataJsonNewer.d.ts.map +1 -1
- package/dist/utils/localImageUtils.d.ts.map +1 -1
- package/dist/utils/localeFilter.d.ts.map +1 -1
- package/dist/utils/localeNormalizer.d.ts.map +1 -1
- package/dist/utils/multiSpreadsheetMerger.d.ts.map +1 -1
- package/dist/utils/publicSheetReader.d.ts +1 -1
- package/dist/utils/publicSheetReader.d.ts.map +1 -1
- package/dist/utils/rateLimiter.d.ts.map +1 -1
- package/dist/utils/readDataJson.d.ts +1 -1
- package/dist/utils/sheetProcessor.d.ts +4 -9
- package/dist/utils/sheetProcessor.d.ts.map +1 -1
- package/dist/utils/spreadsheetCreator.d.ts +1 -1
- package/dist/utils/spreadsheetCreator.d.ts.map +1 -1
- package/dist/utils/spreadsheetFormulas.d.ts +1 -1
- package/dist/utils/spreadsheetFormulas.d.ts.map +1 -1
- package/dist/utils/spreadsheetUpdater.d.ts +3 -3
- package/dist/utils/spreadsheetUpdater.d.ts.map +1 -1
- package/dist/utils/syncManager.d.ts +2 -2
- package/dist/utils/syncManager.d.ts.map +1 -1
- package/dist/utils/translationHelpers.d.ts.map +1 -1
- package/dist/utils/validateEnv.d.ts +1 -1
- package/dist/utils/validateEnv.d.ts.map +1 -1
- package/dist/utils/wait.d.ts.map +1 -1
- package/dist-cli/migrate-v3.mjs +406 -0
- package/dist-cli/run-provider.mjs +2025 -0
- package/dist-cli/setup-wif.mjs +334 -18949
- package/package.json +25 -20
|
@@ -0,0 +1,2025 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { GoogleSpreadsheet } from "google-spreadsheet";
|
|
5
|
+
import { GoogleAuth } from "google-auth-library";
|
|
6
|
+
import https from "node:https";
|
|
7
|
+
import http from "node:http";
|
|
8
|
+
import { setTimeout } from "node:timers/promises";
|
|
9
|
+
import fs$1 from "node:fs/promises";
|
|
10
|
+
import { parse } from "csv-parse/sync";
|
|
11
|
+
import crypto from "node:crypto";
|
|
12
|
+
//#region src/providers/capabilities.ts
|
|
13
|
+
/**
|
|
14
|
+
* Baseline capability set where all capabilities are explicitly disabled (`false`).
|
|
15
|
+
*/
|
|
16
|
+
const EMPTY_PROVIDER_CAPABILITIES = {
|
|
17
|
+
readTables: false,
|
|
18
|
+
writeTables: false,
|
|
19
|
+
syncBack: false,
|
|
20
|
+
readAssets: false,
|
|
21
|
+
writeAssets: false,
|
|
22
|
+
autoTranslateFormula: false,
|
|
23
|
+
discoverByFolder: false,
|
|
24
|
+
assetSync: false,
|
|
25
|
+
publicReadNoAuth: false
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Mapping defining which capabilities are mandatory for each {@link ProviderOperation}.
|
|
29
|
+
*/
|
|
30
|
+
const OPERATION_CAPABILITY_REQUIREMENTS = {
|
|
31
|
+
"read-input": ["readTables"],
|
|
32
|
+
"write-output": ["writeTables"],
|
|
33
|
+
"sync-back": ["syncBack"],
|
|
34
|
+
"read-assets": ["readAssets"],
|
|
35
|
+
"write-assets": ["writeAssets"],
|
|
36
|
+
"discover-sources": ["discoverByFolder"],
|
|
37
|
+
"sync-assets": ["assetSync"],
|
|
38
|
+
"public-read": ["readTables", "publicReadNoAuth"]
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Builds a full {@link ProviderCapabilitySet}, defaulting every unspecified
|
|
42
|
+
* capability to `false`. Provider factories use this so they only need to
|
|
43
|
+
* name the capabilities they actually support.
|
|
44
|
+
*/
|
|
45
|
+
function createCapabilitySet(overrides = {}) {
|
|
46
|
+
return {
|
|
47
|
+
...EMPTY_PROVIDER_CAPABILITIES,
|
|
48
|
+
...overrides
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** Returns the subset of `required` that `capabilities` does not have set to `true`. */
|
|
52
|
+
function missingCapabilities(capabilities, required) {
|
|
53
|
+
return required.filter((capability) => !capabilities[capability]);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Throws a descriptive error naming `providerName`, `operation` (if given), and every
|
|
57
|
+
* missing capability, unless `capabilities` already satisfies all of `required`.
|
|
58
|
+
*/
|
|
59
|
+
function assertRequiredCapabilities(providerName, capabilities, required, operation) {
|
|
60
|
+
const missing = missingCapabilities(capabilities, required);
|
|
61
|
+
if (missing.length === 0) return;
|
|
62
|
+
const operationPart = operation ? ` for operation "${operation}"` : "";
|
|
63
|
+
throw new Error(`Provider "${providerName}" is missing required capabilities${operationPart}: ${missing.join(", ")}`);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Convenience wrapper around {@link assertRequiredCapabilities} that looks up the
|
|
67
|
+
* required capabilities for a named {@link ProviderOperation} via
|
|
68
|
+
* {@link OPERATION_CAPABILITY_REQUIREMENTS}. This is the gate every pipeline stage
|
|
69
|
+
* (read/write/sync/asset-sync/discovery) runs through before it executes.
|
|
70
|
+
*/
|
|
71
|
+
function assertOperationCapabilities(providerName, capabilities, operation) {
|
|
72
|
+
assertRequiredCapabilities(providerName, capabilities, OPERATION_CAPABILITY_REQUIREMENTS[operation], operation);
|
|
73
|
+
}
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region src/utils/validateEnv.ts
|
|
76
|
+
/**
|
|
77
|
+
* Validates that Google service-account credentials are present.
|
|
78
|
+
* Does NOT require GOOGLE_SPREADSHEET_ID — the caller may create one on first run.
|
|
79
|
+
*
|
|
80
|
+
* When `GOOGLE_APPLICATION_CREDENTIALS` is set (Workload Identity Federation / ADC),
|
|
81
|
+
* `GOOGLE_CLIENT_EMAIL` and `GOOGLE_PRIVATE_KEY` are not required.
|
|
82
|
+
*/
|
|
83
|
+
function validateCredentials() {
|
|
84
|
+
if (process.env.GOOGLE_APPLICATION_CREDENTIALS) return {
|
|
85
|
+
GOOGLE_CLIENT_EMAIL: process.env.GOOGLE_CLIENT_EMAIL ?? "",
|
|
86
|
+
GOOGLE_PRIVATE_KEY: process.env.GOOGLE_PRIVATE_KEY ?? ""
|
|
87
|
+
};
|
|
88
|
+
const missing = ["GOOGLE_CLIENT_EMAIL", "GOOGLE_PRIVATE_KEY"].filter((v) => !process.env[v]);
|
|
89
|
+
if (missing.length > 0) throw new Error(`Missing required environment variables: ${missing.join(", ")}\n\nMake sure these are set in your .env file or environment.\nAlternatively, set GOOGLE_APPLICATION_CREDENTIALS for Workload Identity Federation.`);
|
|
90
|
+
return {
|
|
91
|
+
GOOGLE_CLIENT_EMAIL: process.env.GOOGLE_CLIENT_EMAIL,
|
|
92
|
+
GOOGLE_PRIVATE_KEY: process.env.GOOGLE_PRIVATE_KEY
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region src/utils/auth.ts
|
|
97
|
+
/**
|
|
98
|
+
* Normalizes a private key string from the many different ways secret-storage
|
|
99
|
+
* systems (GitHub Secrets, CI env vars, secret managers) encode it.
|
|
100
|
+
*
|
|
101
|
+
* Handles all of the following formats so the caller doesn't need to care
|
|
102
|
+
* whether the value came from a GitHub Secret, a plain env var, a `.env` file
|
|
103
|
+
* or any other source:
|
|
104
|
+
*
|
|
105
|
+
* - Real newlines → left as-is
|
|
106
|
+
* - Literal `\n` two-char sequences → converted to real newlines
|
|
107
|
+
* - Surrounding double or single quotes added by some tools → stripped
|
|
108
|
+
* (leading/trailing whitespace outside the quotes is also stripped)
|
|
109
|
+
* - Windows-style `\r\n` line endings → normalised to `\n`
|
|
110
|
+
*/
|
|
111
|
+
function normalizePrivateKey(key) {
|
|
112
|
+
let normalized = key;
|
|
113
|
+
const outer = key.trim();
|
|
114
|
+
if (outer.startsWith("\"") && outer.endsWith("\"") || outer.startsWith("'") && outer.endsWith("'")) normalized = outer.slice(1, -1);
|
|
115
|
+
normalized = normalized.replace(/\\n/g, "\n");
|
|
116
|
+
normalized = normalized.replace(/\r\n/g, "\n");
|
|
117
|
+
return normalized;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Low-level factory: creates a `GoogleAuth` instance for a given set of scopes.
|
|
121
|
+
*
|
|
122
|
+
* - When `credentials` are supplied, uses them directly (service-account key mode).
|
|
123
|
+
* - When `credentials` is omitted, the instance relies on Application Default
|
|
124
|
+
* Credentials, i.e. the file pointed to by `GOOGLE_APPLICATION_CREDENTIALS`
|
|
125
|
+
* (Workload Identity Federation, `gcloud auth application-default login`, etc.).
|
|
126
|
+
*
|
|
127
|
+
* @internal Shared by Drive utilities and `createAuthClient()`. Import
|
|
128
|
+
* `createAuthClient()` for the standard Sheets use-case.
|
|
129
|
+
*/
|
|
130
|
+
function buildGoogleAuth(scopes, credentials) {
|
|
131
|
+
if (credentials) return new GoogleAuth({
|
|
132
|
+
credentials,
|
|
133
|
+
scopes
|
|
134
|
+
});
|
|
135
|
+
return new GoogleAuth({ scopes });
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Creates and returns a GoogleAuth client for Google Sheets API.
|
|
139
|
+
*
|
|
140
|
+
* Supports two authentication modes (checked in order):
|
|
141
|
+
*
|
|
142
|
+
* 1. **Workload Identity Federation / Application Default Credentials (ADC)**:
|
|
143
|
+
* Set `GOOGLE_APPLICATION_CREDENTIALS` to the path of a credential JSON file
|
|
144
|
+
* (e.g. written by `google-github-actions/auth`). No service-account key needed.
|
|
145
|
+
*
|
|
146
|
+
* 2. **Service account key** (classic):
|
|
147
|
+
* Set `GOOGLE_CLIENT_EMAIL` and `GOOGLE_PRIVATE_KEY` environment variables.
|
|
148
|
+
*
|
|
149
|
+
* @returns GoogleAuth client usable with google-spreadsheet and other Google APIs
|
|
150
|
+
*/
|
|
151
|
+
function createAuthClient() {
|
|
152
|
+
if (process.env.GOOGLE_APPLICATION_CREDENTIALS) return buildGoogleAuth(["https://www.googleapis.com/auth/spreadsheets"]);
|
|
153
|
+
const { GOOGLE_CLIENT_EMAIL, GOOGLE_PRIVATE_KEY } = validateCredentials();
|
|
154
|
+
return buildGoogleAuth(["https://www.googleapis.com/auth/spreadsheets"], {
|
|
155
|
+
client_email: GOOGLE_CLIENT_EMAIL,
|
|
156
|
+
private_key: normalizePrivateKey(GOOGLE_PRIVATE_KEY)
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
//#endregion
|
|
160
|
+
//#region src/utils/publicSheetReader.ts
|
|
161
|
+
/**
|
|
162
|
+
* Fetches the raw response body from a URL using the built-in http/https module.
|
|
163
|
+
* Follows a single redirect if the server issues one (3xx).
|
|
164
|
+
*/
|
|
165
|
+
function fetchUrl(url) {
|
|
166
|
+
return new Promise((resolve, reject) => {
|
|
167
|
+
const req = (url.startsWith("https") ? https : http).get(url, (res) => {
|
|
168
|
+
if (res.statusCode !== void 0 && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
169
|
+
fetchUrl(res.headers.location).then(resolve).catch(reject);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (res.statusCode !== void 0 && res.statusCode >= 400) {
|
|
173
|
+
reject(/* @__PURE__ */ new Error(`HTTP ${res.statusCode} while fetching ${url}`));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
let data = "";
|
|
177
|
+
res.on("data", (chunk) => {
|
|
178
|
+
data += chunk.toString();
|
|
179
|
+
});
|
|
180
|
+
res.on("end", () => resolve(data));
|
|
181
|
+
res.on("error", reject);
|
|
182
|
+
});
|
|
183
|
+
req.on("error", reject);
|
|
184
|
+
req.end();
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Strips the JSONP wrapper that the Google Visualization API adds and parses
|
|
189
|
+
* the inner JSON object.
|
|
190
|
+
*
|
|
191
|
+
* Expected wrapper format:
|
|
192
|
+
* `/*O_o*\/\ngoogle.visualization.Query.setResponse({...});`
|
|
193
|
+
*/
|
|
194
|
+
function parseGvizResponse(raw) {
|
|
195
|
+
const match = raw.match(/google\.visualization\.Query\.setResponse\((\{[\s\S]*?\})\)/);
|
|
196
|
+
if (!match) throw new Error("Unexpected response format from Google Visualization API. Make sure the spreadsheet is shared as \"Anyone with link can view\".");
|
|
197
|
+
return JSON.parse(match[1]);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Reads rows from a *publicly accessible* Google Spreadsheet sheet without
|
|
201
|
+
* requiring any service-account credentials or API key.
|
|
202
|
+
*
|
|
203
|
+
* The spreadsheet must be shared with **"Anyone with link can view"** (or
|
|
204
|
+
* broader). Works via Google's Visualization (gviz) query endpoint which
|
|
205
|
+
* is available at no cost for public sheets.
|
|
206
|
+
*
|
|
207
|
+
* @param spreadsheetId - The Google Spreadsheet ID (from the URL)
|
|
208
|
+
* @param sheetName - The sheet tab name to fetch
|
|
209
|
+
* @returns An array of row objects keyed by column header
|
|
210
|
+
* @throws If the sheet is not accessible or the response cannot be parsed
|
|
211
|
+
*/
|
|
212
|
+
async function readPublicSheet(spreadsheetId, sheetName) {
|
|
213
|
+
const url = `https://docs.google.com/spreadsheets/d/${encodeURIComponent(spreadsheetId)}/gviz/tq?tqx=out:json&headers=1&sheet=${encodeURIComponent(sheetName)}`;
|
|
214
|
+
let raw;
|
|
215
|
+
try {
|
|
216
|
+
raw = await fetchUrl(url);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
throw new Error(`Failed to fetch public sheet "${sheetName}" from spreadsheet "${spreadsheetId}"`, { cause: err });
|
|
219
|
+
}
|
|
220
|
+
let data;
|
|
221
|
+
try {
|
|
222
|
+
data = parseGvizResponse(raw);
|
|
223
|
+
} catch (err) {
|
|
224
|
+
throw new Error(`Failed to parse response for sheet "${sheetName}" in spreadsheet "${spreadsheetId}"`, { cause: err });
|
|
225
|
+
}
|
|
226
|
+
if (data.status !== "ok") {
|
|
227
|
+
const message = data.errors?.[0]?.message ?? "Unknown error";
|
|
228
|
+
throw new Error(`Google Visualization API returned an error for sheet "${sheetName}": ${message}`);
|
|
229
|
+
}
|
|
230
|
+
if (!data.table) return [];
|
|
231
|
+
const { cols, rows } = data.table;
|
|
232
|
+
const headers = cols.map((col) => col.label || col.id);
|
|
233
|
+
return rows.filter((row) => row && row.c).map((row) => {
|
|
234
|
+
const obj = {};
|
|
235
|
+
for (let i = 0; i < headers.length; i++) {
|
|
236
|
+
const cell = row.c?.[i];
|
|
237
|
+
obj[headers[i]] = cell?.v != null ? String(cell.v) : "";
|
|
238
|
+
}
|
|
239
|
+
return obj;
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
//#endregion
|
|
243
|
+
//#region src/utils/rateLimiter.ts
|
|
244
|
+
const DEFAULT_RETRIES = 3;
|
|
245
|
+
const DEFAULT_MAX_DELAY_MS = 3e4;
|
|
246
|
+
/**
|
|
247
|
+
* Returns true when the error looks like a Google Sheets API rate-limit or
|
|
248
|
+
* transient server error (HTTP 429 or 503).
|
|
249
|
+
*/
|
|
250
|
+
function isRateLimitError(err) {
|
|
251
|
+
if (!err || typeof err !== "object") return false;
|
|
252
|
+
const e = err;
|
|
253
|
+
const response = e["response"];
|
|
254
|
+
const status = typeof e["status"] === "number" ? e["status"] : typeof response?.["status"] === "number" ? response["status"] : void 0;
|
|
255
|
+
return status === 429 || status === 503;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Calls `fn` and, on a rate-limit error (HTTP 429 / 503), retries with
|
|
259
|
+
* exponential back-off. Any other error is re-thrown immediately.
|
|
260
|
+
*
|
|
261
|
+
* @param fn - The async operation to execute (and potentially retry)
|
|
262
|
+
* @param label - Human-readable label used in warning logs
|
|
263
|
+
* @param baseDelayMs - Base back-off delay in milliseconds (default: 1 000)
|
|
264
|
+
* @param retries - Maximum number of retry attempts (default: 3)
|
|
265
|
+
* @param maxDelayMs - Back-off ceiling in milliseconds (default: 30 000)
|
|
266
|
+
* @returns The resolved value of `fn`
|
|
267
|
+
* @throws The last error if all retries are exhausted, or any non-rate-limit error immediately
|
|
268
|
+
*/
|
|
269
|
+
async function withRetry(fn, label, baseDelayMs = 1e3, retries = DEFAULT_RETRIES, maxDelayMs = DEFAULT_MAX_DELAY_MS) {
|
|
270
|
+
for (let attempt = 0; attempt <= retries; attempt++) try {
|
|
271
|
+
return await fn();
|
|
272
|
+
} catch (err) {
|
|
273
|
+
if (!isRateLimitError(err) || attempt === retries) throw err;
|
|
274
|
+
const backoff = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
|
|
275
|
+
console.warn(`[rate-limit] ${label}: retry ${attempt + 1}/${retries} in ${backoff} ms`);
|
|
276
|
+
await setTimeout(backoff);
|
|
277
|
+
}
|
|
278
|
+
/* c8 ignore next */
|
|
279
|
+
throw new Error("withRetry: unreachable");
|
|
280
|
+
}
|
|
281
|
+
//#endregion
|
|
282
|
+
//#region src/utils/localeNormalizer.ts
|
|
283
|
+
/**
|
|
284
|
+
* Locale normalization utilities for converting simple language codes to full locale codes
|
|
285
|
+
* and maintaining mappings between normalized codes and original spreadsheet headers
|
|
286
|
+
*/
|
|
287
|
+
/**
|
|
288
|
+
* Returns the language prefix of a locale code (the part before the first
|
|
289
|
+
* `-` or `_` separator), lowercased.
|
|
290
|
+
*
|
|
291
|
+
* Examples:
|
|
292
|
+
* - `'en-US'` → `'en'`
|
|
293
|
+
* - `'zh_CN'` → `'zh'`
|
|
294
|
+
* - `'de'` → `'de'`
|
|
295
|
+
*
|
|
296
|
+
* Used for language-family matching when an exact locale code is not found.
|
|
297
|
+
*/
|
|
298
|
+
function getLanguagePrefix(locale) {
|
|
299
|
+
return locale.toLowerCase().split(/[-_]/)[0];
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Common language to country mappings for normalization
|
|
303
|
+
* Maps language codes to their most common country variants
|
|
304
|
+
*/
|
|
305
|
+
const LANGUAGE_TO_COUNTRY_MAP = {
|
|
306
|
+
en: "en-GB",
|
|
307
|
+
de: "de-DE",
|
|
308
|
+
fr: "fr-FR",
|
|
309
|
+
es: "es-ES",
|
|
310
|
+
it: "it-IT",
|
|
311
|
+
pt: "pt-PT",
|
|
312
|
+
pl: "pl-PL",
|
|
313
|
+
ru: "ru-RU",
|
|
314
|
+
zh: "zh-CN",
|
|
315
|
+
ja: "ja-JP",
|
|
316
|
+
ko: "ko-KR",
|
|
317
|
+
ar: "ar-SA",
|
|
318
|
+
hi: "hi-IN",
|
|
319
|
+
th: "th-TH",
|
|
320
|
+
vi: "vi-VN",
|
|
321
|
+
tr: "tr-TR",
|
|
322
|
+
nl: "nl-NL",
|
|
323
|
+
sv: "sv-SE",
|
|
324
|
+
da: "da-DK",
|
|
325
|
+
no: "no-NO",
|
|
326
|
+
fi: "fi-FI",
|
|
327
|
+
cs: "cs-CZ",
|
|
328
|
+
sk: "sk-SK",
|
|
329
|
+
hu: "hu-HU",
|
|
330
|
+
ro: "ro-RO",
|
|
331
|
+
bg: "bg-BG",
|
|
332
|
+
hr: "hr-HR",
|
|
333
|
+
sl: "sl-SI",
|
|
334
|
+
et: "et-EE",
|
|
335
|
+
lv: "lv-LV",
|
|
336
|
+
lt: "lt-LT",
|
|
337
|
+
el: "el-GR",
|
|
338
|
+
he: "he-IL",
|
|
339
|
+
uk: "uk-UA",
|
|
340
|
+
be: "be-BY"
|
|
341
|
+
};
|
|
342
|
+
/**
|
|
343
|
+
* Normalizes a language code to include country code if missing
|
|
344
|
+
* @param locale The original locale code from spreadsheet header
|
|
345
|
+
* @returns Normalized locale with country code
|
|
346
|
+
*/
|
|
347
|
+
function normalizeLocaleCode(locale) {
|
|
348
|
+
if (!locale || typeof locale !== "string") return "";
|
|
349
|
+
const normalized = locale.toLowerCase().trim();
|
|
350
|
+
if (normalized.includes("-") || normalized.includes("_")) return normalized;
|
|
351
|
+
const withCountry = LANGUAGE_TO_COUNTRY_MAP[normalized];
|
|
352
|
+
if (withCountry) return withCountry;
|
|
353
|
+
if (normalized.length === 2 && /^[a-z]{2}$/.test(normalized)) return `${normalized}-${normalized.toUpperCase()}`;
|
|
354
|
+
return normalized;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Creates a mapping between normalized locale codes and their original spreadsheet headers
|
|
358
|
+
* @param originalHeaders Array of original header names from spreadsheet
|
|
359
|
+
* @param keyColumn The key column name to exclude
|
|
360
|
+
* @returns Object with normalized locales and header mapping
|
|
361
|
+
*/
|
|
362
|
+
function createLocaleMapping(originalHeaders, keyColumn) {
|
|
363
|
+
const localeMapping = {};
|
|
364
|
+
const originalMapping = {};
|
|
365
|
+
const normalizedLocales = [];
|
|
366
|
+
for (const header of originalHeaders) {
|
|
367
|
+
const headerLower = header.toLowerCase();
|
|
368
|
+
if (headerLower === keyColumn.toLowerCase()) continue;
|
|
369
|
+
if (!/^[a-z]{2}([_-][a-z]{2})?([_-][a-z]+)?$/.test(headerLower)) continue;
|
|
370
|
+
const normalized = normalizeLocaleCode(headerLower);
|
|
371
|
+
localeMapping[normalized] = header;
|
|
372
|
+
originalMapping[headerLower] = normalized;
|
|
373
|
+
normalizedLocales.push(normalized);
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
normalizedLocales: [...new Set(normalizedLocales)],
|
|
377
|
+
localeMapping,
|
|
378
|
+
originalMapping
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Finds the original header name for a given normalized locale.
|
|
383
|
+
*
|
|
384
|
+
* Lookup order (most-specific → most-lenient):
|
|
385
|
+
* 1. Direct key match (`'en-us'` → `'en-US'`)
|
|
386
|
+
* 2. Lowercase key match (`'EN-US'` → key `'en-us'`)
|
|
387
|
+
* 3. Case-insensitive key comparison
|
|
388
|
+
* 4. Language-family prefix match – e.g. `'en'` or `'en-GB'` finds `'en-US'`
|
|
389
|
+
* when `'en-US'` is the only English variant present in the mapping.
|
|
390
|
+
*
|
|
391
|
+
* @param normalizedLocale The normalized locale code (e.g., `'en-GB'`, `'en'`)
|
|
392
|
+
* @param localeMapping Mapping from normalized locales to original headers
|
|
393
|
+
* @returns Original header name or undefined if not found
|
|
394
|
+
*/
|
|
395
|
+
function getOriginalHeaderForLocale(normalizedLocale, localeMapping) {
|
|
396
|
+
let result = localeMapping[normalizedLocale];
|
|
397
|
+
if (result) return result;
|
|
398
|
+
const lowercaseLocale = normalizedLocale.toLowerCase();
|
|
399
|
+
result = localeMapping[lowercaseLocale];
|
|
400
|
+
if (result) return result;
|
|
401
|
+
for (const [key, value] of Object.entries(localeMapping)) if (key.toLowerCase() === lowercaseLocale) return value;
|
|
402
|
+
const inputLangCode = getLanguagePrefix(normalizedLocale);
|
|
403
|
+
for (const [key, value] of Object.entries(localeMapping)) if (getLanguagePrefix(key) === inputLangCode) return value;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Resolves a locale code to the closest matching locale in an available list
|
|
407
|
+
* using a three-step fallback strategy:
|
|
408
|
+
*
|
|
409
|
+
* 1. **Exact match** — `'en-us'` → `'en-us'`
|
|
410
|
+
* 2. **Lowercase match** — `'en-US'` → `'en-us'`
|
|
411
|
+
* 3. **Language-family prefix** — `'en'` or `'en-GB'` → `'en-us'`
|
|
412
|
+
* when `'en-us'` is the only English variant in `availableLocales`
|
|
413
|
+
*
|
|
414
|
+
* Returns `undefined` when no matching locale is found.
|
|
415
|
+
*
|
|
416
|
+
* This is the same strategy used internally by `findLocalChanges()` when
|
|
417
|
+
* mapping local `languageData.json` keys to spreadsheet locale columns.
|
|
418
|
+
*
|
|
419
|
+
* @param locale - The locale code to resolve (e.g. `'en'`, `'en-GB'`).
|
|
420
|
+
* @param availableLocales - Array of locale codes to search (e.g. from
|
|
421
|
+
* `Object.keys(translations)` or the `locales` array from `getSpreadSheetData`).
|
|
422
|
+
* @returns The matched locale code from `availableLocales`, or `undefined`.
|
|
423
|
+
*
|
|
424
|
+
* @example
|
|
425
|
+
* ```ts
|
|
426
|
+
* import { resolveLocaleWithFallback } from '@el-j/google-sheet-translations';
|
|
427
|
+
*
|
|
428
|
+
* const locales = ['en-us', 'de-de', 'fr-fr'];
|
|
429
|
+
* resolveLocaleWithFallback('en', locales); // → 'en-us'
|
|
430
|
+
* resolveLocaleWithFallback('en-GB', locales); // → 'en-us'
|
|
431
|
+
* resolveLocaleWithFallback('de-DE', locales); // → 'de-de'
|
|
432
|
+
* resolveLocaleWithFallback('ja', locales); // → undefined
|
|
433
|
+
* ```
|
|
434
|
+
*/
|
|
435
|
+
function resolveLocaleWithFallback(locale, availableLocales) {
|
|
436
|
+
if (availableLocales.includes(locale)) return locale;
|
|
437
|
+
const lower = locale.toLowerCase();
|
|
438
|
+
const lowerMatch = availableLocales.find((l) => l === lower);
|
|
439
|
+
if (lowerMatch) return lowerMatch;
|
|
440
|
+
const langCode = getLanguagePrefix(lower);
|
|
441
|
+
return availableLocales.find((l) => getLanguagePrefix(l) === langCode);
|
|
442
|
+
}
|
|
443
|
+
//#endregion
|
|
444
|
+
//#region src/utils/spreadsheetFormulas.ts
|
|
445
|
+
/**
|
|
446
|
+
* Converts a 0-based column index to a spreadsheet column letter (A, B, ..., Z, AA, AB, ...)
|
|
447
|
+
*
|
|
448
|
+
* @param index - 0-based index of the column
|
|
449
|
+
* @returns Spreadsheet column letter (e.g. 0 -> 'A', 26 -> 'AA')
|
|
450
|
+
*/
|
|
451
|
+
function columnIndexToLetter(index) {
|
|
452
|
+
let result = "";
|
|
453
|
+
let i = index;
|
|
454
|
+
do {
|
|
455
|
+
result = String.fromCharCode(65 + i % 26) + result;
|
|
456
|
+
i = Math.floor(i / 26) - 1;
|
|
457
|
+
} while (i >= 0);
|
|
458
|
+
return result;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Determines the formula argument separator based on the spreadsheet's locale.
|
|
462
|
+
*
|
|
463
|
+
* Google Sheets uses `,` for English, CJK and a few other locales, but `;` for
|
|
464
|
+
* the majority of European locales (German, French, Spanish, Italian, …).
|
|
465
|
+
* The Google Sheets API (`valueInputOption: USER_ENTERED`) parses formulas
|
|
466
|
+
* according to the spreadsheet's locale, so we must match the separator.
|
|
467
|
+
*
|
|
468
|
+
* @param doc - GoogleSpreadsheet document instance
|
|
469
|
+
* @returns `","` or `";"` – the argument separator to use in generated formulas.
|
|
470
|
+
*/
|
|
471
|
+
function getFormulaSeparator(doc) {
|
|
472
|
+
try {
|
|
473
|
+
const locale = doc._rawProperties?.locale || "";
|
|
474
|
+
if (/^(en|ja|ko|zh|th|id|ms)/i.test(locale)) return ",";
|
|
475
|
+
} catch {}
|
|
476
|
+
return ";";
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Wraps a spreadsheet cell reference in a formula that extracts the
|
|
480
|
+
* GOOGLETRANSLATE-compatible language code from a locale header cell.
|
|
481
|
+
*
|
|
482
|
+
* For most locales the ISO 639-1 prefix before the first `"-"` is extracted
|
|
483
|
+
* (e.g. header `"tr-TR"` → `"tr"`, `"en-US"` → `"en"`).
|
|
484
|
+
*
|
|
485
|
+
* For Chinese variants (`zh-TW`, `zh-CN`) the full lowercased code is
|
|
486
|
+
* preserved because GOOGLETRANSLATE distinguishes Simplified from Traditional
|
|
487
|
+
* Chinese.
|
|
488
|
+
*
|
|
489
|
+
* Bare codes without a `"-"` are returned as-is (e.g. `"en"` → `"en"`).
|
|
490
|
+
*
|
|
491
|
+
* All inner function calls use the supplied `sep` so the generated fragment
|
|
492
|
+
* is consistent with the spreadsheet's locale.
|
|
493
|
+
*
|
|
494
|
+
* @param cellRef - A spreadsheet cell reference string, e.g. `$B$1` or `C$1`
|
|
495
|
+
* @param sep - Formula argument separator (`","` or `";"`)
|
|
496
|
+
* @returns Formula fragment string for language code extraction
|
|
497
|
+
*/
|
|
498
|
+
function langCodeFormula(cellRef, sep) {
|
|
499
|
+
const prefix = `LOWER(IFERROR(LEFT(${cellRef}${sep}FIND("-"${sep}${cellRef})-1)${sep}${cellRef}))`;
|
|
500
|
+
return `IF(LOWER(LEFT(${cellRef}${sep}3))="zh-"${sep}${`LOWER(${cellRef})`}${sep}${prefix})`;
|
|
501
|
+
}
|
|
502
|
+
//#endregion
|
|
503
|
+
//#region src/utils/spreadsheetUpdater.ts
|
|
504
|
+
/**
|
|
505
|
+
* Updates the Google Spreadsheet with new keys from local data.
|
|
506
|
+
*
|
|
507
|
+
* When autoTranslate is enabled:
|
|
508
|
+
* - For each new key added to the spreadsheet, the system checks which languages have translations
|
|
509
|
+
* - For languages missing translations, it automatically adds Google Translate formulas
|
|
510
|
+
* - The formula dynamically extracts the GOOGLETRANSLATE-compatible language code from the header
|
|
511
|
+
* cell at spreadsheet-evaluation time (e.g. header "tr-TR" → "tr") and also preserves Chinese
|
|
512
|
+
* variants (zh-TW, zh-CN) where the region is meaningful
|
|
513
|
+
* - The formula format is:
|
|
514
|
+
* =GOOGLETRANSLATE(INDIRECT(srcCol&ROW()); langCodeFormula($srcCol$1); langCodeFormula(tgtCol$1))
|
|
515
|
+
* - All formula separators are set to match the spreadsheet's locale (`;` or `,`)
|
|
516
|
+
* - For **existing** keys the same logic applies: empty cells in other language columns receive a
|
|
517
|
+
* formula; cells that already contain a translation are only overwritten when `override` is true.
|
|
518
|
+
*
|
|
519
|
+
* A proactive inter-request delay of `waitSeconds` seconds is inserted between each
|
|
520
|
+
* individual row save and between each addRows chunk to avoid exceeding Google's
|
|
521
|
+
* "Write requests per minute per user" quota before a 429 is even returned.
|
|
522
|
+
*
|
|
523
|
+
* If a sheet named `sheetTitle` does not yet exist in the document and `localeMapping` is
|
|
524
|
+
* non-empty, the sheet is **created automatically** with "key" as the first column followed by
|
|
525
|
+
* the original locale-header names from `localeMapping`. This ensures that new feature sheets
|
|
526
|
+
* (e.g. "ui") are bootstrapped on the first sync without requiring manual spreadsheet setup.
|
|
527
|
+
*
|
|
528
|
+
* Example:
|
|
529
|
+
* If a new key "welcome" has an English translation in column B but no German translation in column C,
|
|
530
|
+
* and autoTranslate is enabled, the system will add a formula that translates from English ("en")
|
|
531
|
+
* to the language code extracted from the German column header to the German column.
|
|
532
|
+
*
|
|
533
|
+
* @param doc - The Google Spreadsheet instance
|
|
534
|
+
* @param changes - Object containing new keys to add to the spreadsheet
|
|
535
|
+
* @param waitSeconds - Seconds to wait between consecutive write API calls (throttle) and as the
|
|
536
|
+
* base back-off delay when retrying rate-limited calls (HTTP 429/503). Must be ≥ 1.
|
|
537
|
+
* @param autoTranslate - Whether to add Google Translate formulas for missing translations (default: false)
|
|
538
|
+
* @param localeMapping - Mapping from normalized locale codes to original spreadsheet headers
|
|
539
|
+
* @param override - When true AND autoTranslate is true, existing translations in other language
|
|
540
|
+
* columns are overwritten with GOOGLETRANSLATE formulas. When false (default) only empty cells
|
|
541
|
+
* receive a formula.
|
|
542
|
+
* @returns Promise that resolves when the update is complete
|
|
543
|
+
*/
|
|
544
|
+
async function updateSpreadsheetWithLocalChanges(doc, changes, waitSeconds, autoTranslate = false, localeMapping = {}, override = false) {
|
|
545
|
+
console.log("Updating spreadsheet with local changes...");
|
|
546
|
+
const baseDelayMs = waitSeconds * 1e3;
|
|
547
|
+
const sep = getFormulaSeparator(doc);
|
|
548
|
+
for (const sheetTitle of new Set(Object.values(changes).flatMap((locale) => Object.keys(locale)))) {
|
|
549
|
+
if (sheetTitle === "i18n") {
|
|
550
|
+
console.log(`Skipping reserved metadata sheet "${sheetTitle}" – its content is managed separately.`);
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
console.log(`Processing sheet: ${sheetTitle}`);
|
|
554
|
+
let sheet = doc.sheetsByTitle[sheetTitle];
|
|
555
|
+
if (!sheet) {
|
|
556
|
+
const localeHeaders = Object.values(localeMapping);
|
|
557
|
+
if (localeHeaders.length === 0) {
|
|
558
|
+
console.warn(`Sheet "${sheetTitle}" not found in the document, cannot update`);
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
console.log(`Sheet "${sheetTitle}" not found — creating it with ${localeHeaders.length} locale column(s).`);
|
|
562
|
+
sheet = await withRetry(() => doc.addSheet({
|
|
563
|
+
title: sheetTitle,
|
|
564
|
+
headerValues: ["key", ...localeHeaders]
|
|
565
|
+
}), `addSheet: ${sheetTitle}`, baseDelayMs);
|
|
566
|
+
}
|
|
567
|
+
if (!sheet) {
|
|
568
|
+
console.warn(`Sheet "${sheetTitle}" could not be found or created, skipping.`);
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
const rows = await withRetry(() => sheet.getRows(), `getRows: ${sheetTitle}`, baseDelayMs);
|
|
572
|
+
let headerRow;
|
|
573
|
+
let originalHeaders;
|
|
574
|
+
if (rows.length > 0) {
|
|
575
|
+
originalHeaders = Object.keys(rows[0].toObject());
|
|
576
|
+
headerRow = originalHeaders.map((h) => h.toLowerCase());
|
|
577
|
+
} else {
|
|
578
|
+
const localeHeaders = Object.values(localeMapping);
|
|
579
|
+
if (localeHeaders.length === 0) {
|
|
580
|
+
console.warn(`No rows found in sheet "${sheetTitle}", cannot update`);
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
originalHeaders = ["key", ...localeHeaders];
|
|
584
|
+
headerRow = originalHeaders.map((h) => h.toLowerCase());
|
|
585
|
+
}
|
|
586
|
+
const keyColumn = headerRow[0];
|
|
587
|
+
const locales = headerRow.filter((key) => key !== keyColumn);
|
|
588
|
+
const existingKeys = /* @__PURE__ */ new Map();
|
|
589
|
+
rows.forEach((row, index) => {
|
|
590
|
+
const rowData = row.toObject();
|
|
591
|
+
const keyField = Object.keys(rowData).find((k) => k.toLowerCase() === keyColumn);
|
|
592
|
+
if (keyField && rowData[keyField]) existingKeys.set(rowData[keyField].toString().toLowerCase(), index);
|
|
593
|
+
});
|
|
594
|
+
const newKeys = /* @__PURE__ */ new Map();
|
|
595
|
+
const keyLocalesMap = /* @__PURE__ */ new Map();
|
|
596
|
+
const pushedLocaleHeadersPerKey = /* @__PURE__ */ new Map();
|
|
597
|
+
if (autoTranslate) for (const pushedLocale of Object.keys(changes)) {
|
|
598
|
+
if (!changes[pushedLocale]?.[sheetTitle]) continue;
|
|
599
|
+
for (const pushedKey of Object.keys(changes[pushedLocale][sheetTitle])) {
|
|
600
|
+
const pushedKeyLower = pushedKey.toLowerCase();
|
|
601
|
+
if (!existingKeys.has(pushedKeyLower)) continue;
|
|
602
|
+
let pushedHeader = getOriginalHeaderForLocale(pushedLocale, localeMapping);
|
|
603
|
+
if (!pushedHeader) {
|
|
604
|
+
const prefix = getLanguagePrefix(pushedLocale);
|
|
605
|
+
pushedHeader = originalHeaders.find((h) => getLanguagePrefix(h) === prefix);
|
|
606
|
+
}
|
|
607
|
+
if (pushedHeader) {
|
|
608
|
+
if (!pushedLocaleHeadersPerKey.has(pushedKeyLower)) pushedLocaleHeadersPerKey.set(pushedKeyLower, /* @__PURE__ */ new Set());
|
|
609
|
+
pushedLocaleHeadersPerKey.get(pushedKeyLower).add(pushedHeader.toLowerCase());
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
for (const locale of Object.keys(changes)) {
|
|
614
|
+
if (!changes[locale]?.[sheetTitle]) continue;
|
|
615
|
+
const localeData = changes[locale][sheetTitle];
|
|
616
|
+
for (const key of Object.keys(localeData)) {
|
|
617
|
+
const keyLower = key.toLowerCase();
|
|
618
|
+
if (!existingKeys.has(keyLower)) {
|
|
619
|
+
if (!newKeys.has(keyLower)) {
|
|
620
|
+
newKeys.set(keyLower, { [keyColumn]: key });
|
|
621
|
+
keyLocalesMap.set(keyLower, /* @__PURE__ */ new Map());
|
|
622
|
+
}
|
|
623
|
+
let localeHeader = getOriginalHeaderForLocale(locale, localeMapping);
|
|
624
|
+
if (!localeHeader) {
|
|
625
|
+
const localeLang = getLanguagePrefix(locale);
|
|
626
|
+
localeHeader = originalHeaders.find((h) => getLanguagePrefix(h) === localeLang);
|
|
627
|
+
}
|
|
628
|
+
if (localeHeader) {
|
|
629
|
+
const theKey = newKeys.get(keyLower);
|
|
630
|
+
if (!theKey) {
|
|
631
|
+
console.warn(`Key "${key}" not found in newKeys map, skipping...`);
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
const value = String(localeData[key]);
|
|
635
|
+
theKey[localeHeader] = value;
|
|
636
|
+
const localesForKey = keyLocalesMap.get(keyLower);
|
|
637
|
+
if (localesForKey) localesForKey.set(locale.toLowerCase(), localeHeader);
|
|
638
|
+
}
|
|
639
|
+
} else {
|
|
640
|
+
const rowIndex = existingKeys.get(keyLower);
|
|
641
|
+
const row = rows[rowIndex];
|
|
642
|
+
let localeHeader = getOriginalHeaderForLocale(locale, localeMapping);
|
|
643
|
+
if (!localeHeader) {
|
|
644
|
+
const localeLang = getLanguagePrefix(locale);
|
|
645
|
+
localeHeader = Object.keys(row.toObject()).find((h) => getLanguagePrefix(h) === localeLang);
|
|
646
|
+
}
|
|
647
|
+
if (localeHeader) {
|
|
648
|
+
row.set(localeHeader, String(localeData[key]));
|
|
649
|
+
if (autoTranslate) {
|
|
650
|
+
const sourceHeaderLower = localeHeader.toLowerCase();
|
|
651
|
+
const sourceHeaderIndex = headerRow.indexOf(sourceHeaderLower);
|
|
652
|
+
if (sourceHeaderIndex >= 0) {
|
|
653
|
+
const sourceColumnLetter = columnIndexToLetter(sourceHeaderIndex);
|
|
654
|
+
const rowObj = row.toObject();
|
|
655
|
+
const pushedHeaders = pushedLocaleHeadersPerKey.get(keyLower) ?? /* @__PURE__ */ new Set();
|
|
656
|
+
for (const targetLocaleHeader of locales) {
|
|
657
|
+
const targetLower = targetLocaleHeader.toLowerCase();
|
|
658
|
+
if (targetLower === sourceHeaderLower) continue;
|
|
659
|
+
if (pushedHeaders.has(targetLower)) continue;
|
|
660
|
+
const targetHeaderIndex = headerRow.indexOf(targetLower);
|
|
661
|
+
if (targetHeaderIndex < 0) continue;
|
|
662
|
+
const exactTargetHeader = originalHeaders.find((h) => h.toLowerCase() === targetLower);
|
|
663
|
+
if (!exactTargetHeader) continue;
|
|
664
|
+
const existingValue = rowObj[exactTargetHeader];
|
|
665
|
+
if (!existingValue || existingValue.toString().trim() === "" || override) {
|
|
666
|
+
const targetColumnLetter = columnIndexToLetter(targetHeaderIndex);
|
|
667
|
+
row.set(exactTargetHeader, `=GOOGLETRANSLATE(INDIRECT("${sourceColumnLetter}"&ROW())${sep}${langCodeFormula(`$${sourceColumnLetter}$1`, sep)}${sep}${langCodeFormula(`${targetColumnLetter}$1`, sep)})`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
try {
|
|
673
|
+
await withRetry(() => row.save(), `save row ${rowIndex} in ${sheetTitle}`, baseDelayMs);
|
|
674
|
+
if (baseDelayMs > 0) await setTimeout(baseDelayMs);
|
|
675
|
+
} catch (err) {
|
|
676
|
+
console.error(`Failed to save row for key "${keyLower}" in sheet "${sheetTitle}":`, err);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
if (newKeys.size > 0) {
|
|
683
|
+
console.log(`Adding ${newKeys.size} new keys to sheet ${sheetTitle}...`);
|
|
684
|
+
if (autoTranslate) for (const [keyLower, rowData] of newKeys.entries()) {
|
|
685
|
+
const localesWithValues = keyLocalesMap.get(keyLower);
|
|
686
|
+
if (localesWithValues && localesWithValues.size > 0) {
|
|
687
|
+
const [, sourceHeader] = [...localesWithValues.entries()][0];
|
|
688
|
+
for (const localeHeader of locales) {
|
|
689
|
+
const localeLower = localeHeader.toLowerCase();
|
|
690
|
+
const rowDataKey = Object.keys(rowData).find((k) => k.toLowerCase() === localeLower);
|
|
691
|
+
if (localesWithValues.has(localeLower) || rowDataKey && rowData[rowDataKey]) continue;
|
|
692
|
+
const exactHeaderName = originalHeaders.find((h) => h.toLowerCase() === localeLower);
|
|
693
|
+
if (exactHeaderName) {
|
|
694
|
+
const sourceHeaderIndex = headerRow.indexOf(sourceHeader.toLowerCase());
|
|
695
|
+
const targetHeaderIndex = headerRow.indexOf(exactHeaderName.toLowerCase());
|
|
696
|
+
if (sourceHeaderIndex < 0 || targetHeaderIndex < 0) continue;
|
|
697
|
+
const sourceColumnLetter = columnIndexToLetter(sourceHeaderIndex);
|
|
698
|
+
const targetColumnLetter = columnIndexToLetter(targetHeaderIndex);
|
|
699
|
+
rowData[exactHeaderName] = `=GOOGLETRANSLATE(INDIRECT("${sourceColumnLetter}"&ROW())${sep}${langCodeFormula(`$${sourceColumnLetter}$1`, sep)}${sep}${langCodeFormula(`${targetColumnLetter}$1`, sep)})`;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
const newRows = Array.from(newKeys.values());
|
|
705
|
+
const CHUNK_SIZE = 5;
|
|
706
|
+
for (let i = 0; i < newRows.length; i += CHUNK_SIZE) {
|
|
707
|
+
const chunk = newRows.slice(i, i + CHUNK_SIZE);
|
|
708
|
+
await withRetry(() => sheet.addRows(chunk), `addRows chunk ${Math.floor(i / CHUNK_SIZE) + 1} in ${sheetTitle}`, baseDelayMs);
|
|
709
|
+
if (baseDelayMs > 0 && i + CHUNK_SIZE < newRows.length) await setTimeout(baseDelayMs);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
console.log("Finished updating spreadsheet with local changes.");
|
|
714
|
+
}
|
|
715
|
+
//#endregion
|
|
716
|
+
//#region src/utils/dataConverter/findLocalChanges.ts
|
|
717
|
+
/**
|
|
718
|
+
* Compares local languageData.json with spreadsheet data to find new keys.
|
|
719
|
+
*
|
|
720
|
+
* A key is considered "new" when:
|
|
721
|
+
* - No matching locale exists in `spreadsheetData` for that locale, OR
|
|
722
|
+
* - The sheet or key is absent for the resolved locale.
|
|
723
|
+
*
|
|
724
|
+
* Locale matching is fuzzy: `'en'` and `'en-GB'` will both match against
|
|
725
|
+
* an `'en-us'` entry in `spreadsheetData` (language-family resolution via
|
|
726
|
+
* `resolveLocaleWithFallback`).
|
|
727
|
+
*
|
|
728
|
+
* @param localData - Data from local languageData.json file
|
|
729
|
+
* @param spreadsheetData - Data fetched from the spreadsheet
|
|
730
|
+
* @returns Object with new keys that are in localData but not in spreadsheetData
|
|
731
|
+
*/
|
|
732
|
+
function findLocalChanges(localData, spreadsheetData) {
|
|
733
|
+
const changes = {};
|
|
734
|
+
for (const locale of Object.keys(localData)) {
|
|
735
|
+
if (!localData[locale]) continue;
|
|
736
|
+
const resolvedLocale = resolveLocaleWithFallback(locale, Object.keys(spreadsheetData));
|
|
737
|
+
for (const sheet of Object.keys(localData[locale])) {
|
|
738
|
+
if (!localData[locale][sheet]) continue;
|
|
739
|
+
if (sheet === "i18n") continue;
|
|
740
|
+
for (const key of Object.keys(localData[locale][sheet])) if (!resolvedLocale || !spreadsheetData[resolvedLocale]?.[sheet] || !spreadsheetData[resolvedLocale][sheet][key]) {
|
|
741
|
+
if (!changes[locale]) changes[locale] = {};
|
|
742
|
+
if (!changes[locale][sheet]) changes[locale][sheet] = {};
|
|
743
|
+
changes[locale][sheet][key] = localData[locale][sheet][key];
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return changes;
|
|
748
|
+
}
|
|
749
|
+
//#endregion
|
|
750
|
+
//#region src/providers/google/providers.ts
|
|
751
|
+
const INPUT_CAPABILITIES = createCapabilitySet({
|
|
752
|
+
readTables: true,
|
|
753
|
+
publicReadNoAuth: true,
|
|
754
|
+
discoverByFolder: true
|
|
755
|
+
});
|
|
756
|
+
const OUTPUT_CAPABILITIES = createCapabilitySet({
|
|
757
|
+
writeTables: true,
|
|
758
|
+
autoTranslateFormula: true
|
|
759
|
+
});
|
|
760
|
+
const SYNC_CAPABILITIES = createCapabilitySet({
|
|
761
|
+
syncBack: true,
|
|
762
|
+
writeTables: true,
|
|
763
|
+
autoTranslateFormula: true
|
|
764
|
+
});
|
|
765
|
+
function createDefaultDeps$4() {
|
|
766
|
+
return {
|
|
767
|
+
createAuthClient,
|
|
768
|
+
createSpreadsheetClient: (spreadsheetId, authClient) => new GoogleSpreadsheet(spreadsheetId, authClient),
|
|
769
|
+
readPublicSheet,
|
|
770
|
+
withRetry,
|
|
771
|
+
updateSpreadsheetWithLocalChanges,
|
|
772
|
+
findLocalChanges,
|
|
773
|
+
logger: console
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
function resolveSpreadsheetId(spreadsheetId) {
|
|
777
|
+
const resolved = spreadsheetId ?? process.env.GOOGLE_SPREADSHEET_ID;
|
|
778
|
+
if (!resolved) throw new Error("No spreadsheet ID provided. Set GOOGLE_SPREADSHEET_ID or pass spreadsheetId in provider options.");
|
|
779
|
+
return resolved;
|
|
780
|
+
}
|
|
781
|
+
function getWaitSeconds(waitSeconds) {
|
|
782
|
+
return waitSeconds ?? 1;
|
|
783
|
+
}
|
|
784
|
+
function countTranslationLeafKeys(data) {
|
|
785
|
+
return Object.values(data).flatMap((localeData) => Object.values(localeData)).reduce((total, sheetData) => total + Object.keys(sheetData).length, 0);
|
|
786
|
+
}
|
|
787
|
+
function hasAnyChanges(data) {
|
|
788
|
+
return Object.keys(data).length > 0 && Object.values(data).some((l) => Object.keys(l).length > 0);
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Creates a {@link TranslationInputProvider} backed by Google Sheets. When
|
|
792
|
+
* `options.publicSheet` is true, reads via the no-auth public CSV export path
|
|
793
|
+
* (`publicReadNoAuth` capability); otherwise authenticates via
|
|
794
|
+
* {@link createAuthClient} and reads through the Google Sheets API, skipping
|
|
795
|
+
* (and warning about) any requested table name that has no matching sheet.
|
|
796
|
+
*/
|
|
797
|
+
function createGoogleSheetsInputProvider(options = {}, depsOverrides = {}) {
|
|
798
|
+
const deps = {
|
|
799
|
+
...createDefaultDeps$4(),
|
|
800
|
+
...depsOverrides
|
|
801
|
+
};
|
|
802
|
+
return {
|
|
803
|
+
kind: "input",
|
|
804
|
+
providerId: options.providerId ?? "google-sheets",
|
|
805
|
+
displayName: options.displayName ?? "Google Sheets Input",
|
|
806
|
+
capabilities: INPUT_CAPABILITIES,
|
|
807
|
+
async readTables(request) {
|
|
808
|
+
const tableNames = (request.tableNames ?? []).filter(Boolean);
|
|
809
|
+
if (tableNames.length === 0) return { tables: [] };
|
|
810
|
+
const spreadsheetId = resolveSpreadsheetId(options.spreadsheetId);
|
|
811
|
+
const baseDelayMs = getWaitSeconds(options.waitSeconds) * 1e3;
|
|
812
|
+
if (options.publicSheet) return {
|
|
813
|
+
tables: await Promise.all(tableNames.map(async (tableName) => {
|
|
814
|
+
const rows = await deps.withRetry(() => deps.readPublicSheet(spreadsheetId, tableName), `readPublicSheet: ${tableName}`, baseDelayMs);
|
|
815
|
+
return {
|
|
816
|
+
tableId: `${spreadsheetId}:${tableName}`,
|
|
817
|
+
tableName,
|
|
818
|
+
rows,
|
|
819
|
+
metadata: { sourceMode: "public" }
|
|
820
|
+
};
|
|
821
|
+
})),
|
|
822
|
+
metadata: {
|
|
823
|
+
spreadsheetId,
|
|
824
|
+
sourceMode: "public"
|
|
825
|
+
}
|
|
826
|
+
};
|
|
827
|
+
const authClient = deps.createAuthClient();
|
|
828
|
+
const doc = deps.createSpreadsheetClient(spreadsheetId, authClient);
|
|
829
|
+
await deps.withRetry(() => doc.loadInfo(true), "loadInfo", baseDelayMs);
|
|
830
|
+
const tables = [];
|
|
831
|
+
for (const tableName of tableNames) {
|
|
832
|
+
const sheet = doc.sheetsByTitle[tableName];
|
|
833
|
+
if (!sheet) {
|
|
834
|
+
deps.logger.warn(`Sheet "${tableName}" not found in the document`);
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
const googleRows = await deps.withRetry(() => sheet.getRows({ limit: options.rowLimit ?? 100 }), `getRows: ${tableName}`, baseDelayMs);
|
|
838
|
+
tables.push({
|
|
839
|
+
tableId: `${spreadsheetId}:${tableName}`,
|
|
840
|
+
tableName,
|
|
841
|
+
rows: googleRows.map((row) => row.toObject()),
|
|
842
|
+
metadata: { sourceMode: "authenticated" }
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
return {
|
|
846
|
+
tables,
|
|
847
|
+
metadata: {
|
|
848
|
+
spreadsheetId,
|
|
849
|
+
sourceMode: "authenticated"
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Creates a {@link TranslationOutputProvider} that authenticates and writes the full
|
|
857
|
+
* translation set to a Google Sheets spreadsheet via {@link updateSpreadsheetWithLocalChanges},
|
|
858
|
+
* optionally auto-translating missing cells with a formula (`autoTranslate`) and
|
|
859
|
+
* optionally overwriting existing non-empty cells (`override`).
|
|
860
|
+
*/
|
|
861
|
+
function createGoogleSheetsOutputProvider(options = {}, depsOverrides = {}) {
|
|
862
|
+
const deps = {
|
|
863
|
+
...createDefaultDeps$4(),
|
|
864
|
+
...depsOverrides
|
|
865
|
+
};
|
|
866
|
+
return {
|
|
867
|
+
kind: "output",
|
|
868
|
+
providerId: options.providerId ?? "google-sheets",
|
|
869
|
+
displayName: options.displayName ?? "Google Sheets Output",
|
|
870
|
+
capabilities: OUTPUT_CAPABILITIES,
|
|
871
|
+
async writeTranslations(payload) {
|
|
872
|
+
const spreadsheetId = resolveSpreadsheetId(options.spreadsheetId);
|
|
873
|
+
const authClient = deps.createAuthClient();
|
|
874
|
+
const doc = deps.createSpreadsheetClient(spreadsheetId, authClient);
|
|
875
|
+
await deps.withRetry(() => doc.loadInfo(true), "loadInfo", getWaitSeconds(options.waitSeconds) * 1e3);
|
|
876
|
+
await deps.updateSpreadsheetWithLocalChanges(doc, payload.translations, getWaitSeconds(options.waitSeconds), options.autoTranslate ?? false, options.localeMapping ?? payload.localeMapping ?? {}, options.override ?? false);
|
|
877
|
+
const updatedSheets = new Set(Object.values(payload.translations).flatMap((localeData) => Object.keys(localeData)));
|
|
878
|
+
return {
|
|
879
|
+
wroteFiles: [],
|
|
880
|
+
metadata: {
|
|
881
|
+
spreadsheetId,
|
|
882
|
+
updatedSheets: Array.from(updatedSheets),
|
|
883
|
+
estimatedUpdatedKeys: countTranslationLeafKeys(payload.translations)
|
|
884
|
+
}
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Creates a {@link TranslationSyncProvider} that computes local-only changes via
|
|
891
|
+
* {@link findLocalChanges} (comparing `localTranslations` against `remoteTranslations`)
|
|
892
|
+
* and, if any exist, writes just those changed keys back to the spreadsheet. Returns
|
|
893
|
+
* early with `changedKeys: 0` when there is no local diff, avoiding an unnecessary
|
|
894
|
+
* authenticated round-trip.
|
|
895
|
+
*/
|
|
896
|
+
function createGoogleSheetsSyncProvider(options = {}, depsOverrides = {}) {
|
|
897
|
+
const deps = {
|
|
898
|
+
...createDefaultDeps$4(),
|
|
899
|
+
...depsOverrides
|
|
900
|
+
};
|
|
901
|
+
return {
|
|
902
|
+
kind: "sync",
|
|
903
|
+
providerId: options.providerId ?? "google-sheets",
|
|
904
|
+
displayName: options.displayName ?? "Google Sheets Sync",
|
|
905
|
+
capabilities: SYNC_CAPABILITIES,
|
|
906
|
+
async syncTranslations(payload) {
|
|
907
|
+
const changes = deps.findLocalChanges(payload.localTranslations, payload.remoteTranslations);
|
|
908
|
+
if (!hasAnyChanges(changes)) return {
|
|
909
|
+
changedKeys: 0,
|
|
910
|
+
skippedKeys: 0,
|
|
911
|
+
metadata: { reason: "no-local-diff" }
|
|
912
|
+
};
|
|
913
|
+
const spreadsheetId = resolveSpreadsheetId(options.spreadsheetId);
|
|
914
|
+
const authClient = deps.createAuthClient();
|
|
915
|
+
const doc = deps.createSpreadsheetClient(spreadsheetId, authClient);
|
|
916
|
+
await deps.withRetry(() => doc.loadInfo(true), "loadInfo", getWaitSeconds(options.waitSeconds) * 1e3);
|
|
917
|
+
await deps.updateSpreadsheetWithLocalChanges(doc, changes, getWaitSeconds(options.waitSeconds), options.autoTranslate ?? false, options.localeMapping ?? {}, options.override ?? false);
|
|
918
|
+
return {
|
|
919
|
+
changedKeys: countTranslationLeafKeys(changes),
|
|
920
|
+
skippedKeys: 0,
|
|
921
|
+
metadata: { spreadsheetId }
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
//#endregion
|
|
927
|
+
//#region src/providers/cryptpad/provider.ts
|
|
928
|
+
const CRYPTPAD_CSV_INPUT_CAPABILITIES = createCapabilitySet({
|
|
929
|
+
readTables: true,
|
|
930
|
+
publicReadNoAuth: true
|
|
931
|
+
});
|
|
932
|
+
function createDefaultDeps$3() {
|
|
933
|
+
return {
|
|
934
|
+
async fetchCsv(url, signal) {
|
|
935
|
+
const response = await fetch(url, { signal });
|
|
936
|
+
if (!response.ok) throw new Error(`Failed to fetch CSV from "${url}" (HTTP ${response.status})`);
|
|
937
|
+
return response.text();
|
|
938
|
+
},
|
|
939
|
+
async readCsvFile(filePath) {
|
|
940
|
+
return fs$1.readFile(filePath, "utf8");
|
|
941
|
+
}
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
function sourceId(source) {
|
|
945
|
+
return source.tableId ?? source.filePath ?? source.url ?? source.tableName;
|
|
946
|
+
}
|
|
947
|
+
function parseCsvRows(csvText, delimiter) {
|
|
948
|
+
return parse(csvText, {
|
|
949
|
+
columns: true,
|
|
950
|
+
skip_empty_lines: true,
|
|
951
|
+
trim: true,
|
|
952
|
+
bom: true,
|
|
953
|
+
relax_column_count: true,
|
|
954
|
+
delimiter
|
|
955
|
+
}).map((record) => {
|
|
956
|
+
const row = {};
|
|
957
|
+
for (const [key, value] of Object.entries(record)) row[key] = value == null ? "" : String(value);
|
|
958
|
+
return row;
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Creates a read-only {@link TranslationInputProvider} that reads one or more CSV
|
|
963
|
+
* sources (local files or public export URLs) as canonical tables. This is a CSV-only
|
|
964
|
+
* MVP: it has no output/sync capability, so it is always paired with another provider
|
|
965
|
+
* (e.g. `cryptpad-workspace`) for write-back. Public URLs require no authentication,
|
|
966
|
+
* reflected in `publicReadNoAuth: true`.
|
|
967
|
+
*/
|
|
968
|
+
function createCryptPadCsvInputProvider(options, depsOverrides = {}) {
|
|
969
|
+
if (!options.sources || options.sources.length === 0) throw new Error("CryptPad CSV provider requires at least one source.");
|
|
970
|
+
const deps = {
|
|
971
|
+
...createDefaultDeps$3(),
|
|
972
|
+
...depsOverrides
|
|
973
|
+
};
|
|
974
|
+
return {
|
|
975
|
+
kind: "input",
|
|
976
|
+
providerId: options.providerId ?? "cryptpad-csv",
|
|
977
|
+
displayName: options.displayName ?? "CryptPad CSV Input",
|
|
978
|
+
capabilities: CRYPTPAD_CSV_INPUT_CAPABILITIES,
|
|
979
|
+
async readTables(request) {
|
|
980
|
+
const requested = new Set((request.tableNames ?? []).filter(Boolean));
|
|
981
|
+
const selectedSources = requested.size === 0 ? options.sources : options.sources.filter((source) => requested.has(source.tableName));
|
|
982
|
+
return {
|
|
983
|
+
tables: await Promise.all(selectedSources.map(async (source) => {
|
|
984
|
+
const csvText = source.url ? await deps.fetchCsv(source.url, request.signal) : source.filePath ? await deps.readCsvFile(source.filePath) : "";
|
|
985
|
+
if (!csvText) throw new Error(`Source "${source.tableName}" must define either url or filePath.`);
|
|
986
|
+
return {
|
|
987
|
+
tableId: sourceId(source),
|
|
988
|
+
tableName: source.tableName,
|
|
989
|
+
rows: parseCsvRows(csvText, options.delimiter),
|
|
990
|
+
sourcePath: source.url ?? source.filePath,
|
|
991
|
+
metadata: { sourceKind: source.url ? "url" : "file" }
|
|
992
|
+
};
|
|
993
|
+
})),
|
|
994
|
+
metadata: {
|
|
995
|
+
provider: "cryptpad-csv",
|
|
996
|
+
sourceCount: selectedSources.length
|
|
997
|
+
}
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
//#endregion
|
|
1003
|
+
//#region src/providers/syncEngine.ts
|
|
1004
|
+
function pathKey(locale, sheet, key) {
|
|
1005
|
+
return `${locale}::${sheet}::${key}`;
|
|
1006
|
+
}
|
|
1007
|
+
function splitPath(key) {
|
|
1008
|
+
const [locale, sheet, nestedKey] = key.split("::");
|
|
1009
|
+
return {
|
|
1010
|
+
locale,
|
|
1011
|
+
sheet,
|
|
1012
|
+
key: nestedKey
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
function flattenTranslations(translations) {
|
|
1016
|
+
const result = /* @__PURE__ */ new Map();
|
|
1017
|
+
for (const [locale, sheets] of Object.entries(translations)) for (const [sheet, entries] of Object.entries(sheets)) for (const [key, rawValue] of Object.entries(entries)) result.set(pathKey(locale, sheet, key), {
|
|
1018
|
+
locale,
|
|
1019
|
+
sheet,
|
|
1020
|
+
key,
|
|
1021
|
+
value: String(rawValue ?? "")
|
|
1022
|
+
});
|
|
1023
|
+
return result;
|
|
1024
|
+
}
|
|
1025
|
+
function getChangeType(beforeValue, afterValue) {
|
|
1026
|
+
if (beforeValue === void 0 && afterValue !== void 0) return "insert";
|
|
1027
|
+
if (beforeValue !== void 0 && afterValue === void 0) return "delete";
|
|
1028
|
+
return "update";
|
|
1029
|
+
}
|
|
1030
|
+
function diffChanges(base, next) {
|
|
1031
|
+
const keys = /* @__PURE__ */ new Set([...base.keys(), ...next.keys()]);
|
|
1032
|
+
const changes = [];
|
|
1033
|
+
for (const key of keys) {
|
|
1034
|
+
const baseValue = base.get(key)?.value;
|
|
1035
|
+
const nextValue = next.get(key)?.value;
|
|
1036
|
+
if (baseValue === nextValue) continue;
|
|
1037
|
+
const details = splitPath(key);
|
|
1038
|
+
changes.push({
|
|
1039
|
+
path: key,
|
|
1040
|
+
locale: details.locale,
|
|
1041
|
+
sheet: details.sheet,
|
|
1042
|
+
key: details.key,
|
|
1043
|
+
type: getChangeType(baseValue, nextValue),
|
|
1044
|
+
before: baseValue,
|
|
1045
|
+
after: nextValue
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
return changes.sort((a, b) => a.path.localeCompare(b.path));
|
|
1049
|
+
}
|
|
1050
|
+
function isConflict(baseValue, localValue, remoteValue) {
|
|
1051
|
+
if (localValue === remoteValue) return;
|
|
1052
|
+
if (baseValue === localValue || baseValue === remoteValue) return;
|
|
1053
|
+
if (localValue === void 0 || remoteValue === void 0) return "delete-vs-update";
|
|
1054
|
+
return "diverged-update";
|
|
1055
|
+
}
|
|
1056
|
+
function setValue(target, locale, sheet, key, value) {
|
|
1057
|
+
if (value === void 0) {
|
|
1058
|
+
if (!target[locale]?.[sheet]) return;
|
|
1059
|
+
delete target[locale][sheet][key];
|
|
1060
|
+
if (Object.keys(target[locale][sheet]).length === 0) delete target[locale][sheet];
|
|
1061
|
+
if (Object.keys(target[locale]).length === 0) delete target[locale];
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
if (!target[locale]) target[locale] = {};
|
|
1065
|
+
if (!target[locale][sheet]) target[locale][sheet] = {};
|
|
1066
|
+
target[locale][sheet][key] = value;
|
|
1067
|
+
}
|
|
1068
|
+
function cloneTranslations(input) {
|
|
1069
|
+
return JSON.parse(JSON.stringify(input));
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* Diffs `localTranslations` and `remoteTranslations` each against `baseTranslations`
|
|
1073
|
+
* (flattened to `locale::sheet::key` paths) and returns the resulting local changes,
|
|
1074
|
+
* remote changes, and any keys that changed on both sides to a genuinely different
|
|
1075
|
+
* value (conflicts). Does not merge anything — see {@link resolveSyncPlan} for that.
|
|
1076
|
+
*/
|
|
1077
|
+
function buildSyncPlan(input) {
|
|
1078
|
+
const base = flattenTranslations(input.baseTranslations);
|
|
1079
|
+
const local = flattenTranslations(input.localTranslations);
|
|
1080
|
+
const remote = flattenTranslations(input.remoteTranslations);
|
|
1081
|
+
const localChanges = diffChanges(base, local);
|
|
1082
|
+
const remoteChanges = diffChanges(base, remote);
|
|
1083
|
+
const localChanged = new Map(localChanges.map((change) => [change.path, change]));
|
|
1084
|
+
const remoteChanged = new Map(remoteChanges.map((change) => [change.path, change]));
|
|
1085
|
+
const overlap = new Set([...localChanged.keys()].filter((path) => remoteChanged.has(path)));
|
|
1086
|
+
const conflicts = [];
|
|
1087
|
+
for (const path of overlap) {
|
|
1088
|
+
const details = splitPath(path);
|
|
1089
|
+
const baseValue = base.get(path)?.value;
|
|
1090
|
+
const localValue = local.get(path)?.value;
|
|
1091
|
+
const remoteValue = remote.get(path)?.value;
|
|
1092
|
+
const reason = isConflict(baseValue, localValue, remoteValue);
|
|
1093
|
+
if (!reason) continue;
|
|
1094
|
+
conflicts.push({
|
|
1095
|
+
path,
|
|
1096
|
+
locale: details.locale,
|
|
1097
|
+
sheet: details.sheet,
|
|
1098
|
+
key: details.key,
|
|
1099
|
+
baseValue,
|
|
1100
|
+
localValue,
|
|
1101
|
+
remoteValue,
|
|
1102
|
+
reason
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
return {
|
|
1106
|
+
localChanges,
|
|
1107
|
+
remoteChanges,
|
|
1108
|
+
conflicts: conflicts.sort((a, b) => a.path.localeCompare(b.path))
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Runs {@link buildSyncPlan} and merges the result into a copy of `remoteTranslations`
|
|
1113
|
+
* according to `policy`:
|
|
1114
|
+
* - `remote-wins` — non-conflicting local changes apply; conflicting keys keep the remote value.
|
|
1115
|
+
* - `local-wins` — every local change applies, including over conflicts.
|
|
1116
|
+
* - `manual` — non-conflicting local changes apply; conflicts are left as-is and counted in `skippedConflicts` for human review.
|
|
1117
|
+
*/
|
|
1118
|
+
function resolveSyncPlan(input, policy) {
|
|
1119
|
+
const plan = buildSyncPlan(input);
|
|
1120
|
+
const merged = cloneTranslations(input.remoteTranslations);
|
|
1121
|
+
const conflictMap = new Map(plan.conflicts.map((conflict) => [conflict.path, conflict]));
|
|
1122
|
+
let appliedLocalChanges = 0;
|
|
1123
|
+
for (const change of plan.localChanges) {
|
|
1124
|
+
if (conflictMap.get(change.path)) {
|
|
1125
|
+
if (policy === "remote-wins" || policy === "manual") continue;
|
|
1126
|
+
setValue(merged, change.locale, change.sheet, change.key, change.after);
|
|
1127
|
+
appliedLocalChanges++;
|
|
1128
|
+
continue;
|
|
1129
|
+
}
|
|
1130
|
+
setValue(merged, change.locale, change.sheet, change.key, change.after);
|
|
1131
|
+
appliedLocalChanges++;
|
|
1132
|
+
}
|
|
1133
|
+
if (policy === "local-wins") return {
|
|
1134
|
+
policy,
|
|
1135
|
+
mergedTranslations: merged,
|
|
1136
|
+
appliedLocalChanges,
|
|
1137
|
+
appliedRemoteChanges: 0,
|
|
1138
|
+
skippedConflicts: 0
|
|
1139
|
+
};
|
|
1140
|
+
return {
|
|
1141
|
+
policy,
|
|
1142
|
+
mergedTranslations: merged,
|
|
1143
|
+
appliedLocalChanges,
|
|
1144
|
+
appliedRemoteChanges: 0,
|
|
1145
|
+
skippedConflicts: policy === "manual" ? plan.conflicts.length : 0
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
//#endregion
|
|
1149
|
+
//#region src/providers/cryptpad/fullProvider.ts
|
|
1150
|
+
const CRYPTPAD_WORKSPACE_OUTPUT_CAPABILITIES = createCapabilitySet({ writeTables: true });
|
|
1151
|
+
const CRYPTPAD_WORKSPACE_SYNC_CAPABILITIES = createCapabilitySet({
|
|
1152
|
+
syncBack: true,
|
|
1153
|
+
writeTables: true
|
|
1154
|
+
});
|
|
1155
|
+
function clone(value) {
|
|
1156
|
+
return JSON.parse(JSON.stringify(value));
|
|
1157
|
+
}
|
|
1158
|
+
function mergeTranslations$1(base, patch) {
|
|
1159
|
+
const result = clone(base);
|
|
1160
|
+
for (const [locale, sheets] of Object.entries(patch)) {
|
|
1161
|
+
if (!result[locale]) result[locale] = {};
|
|
1162
|
+
for (const [sheet, keys] of Object.entries(sheets)) {
|
|
1163
|
+
if (!result[locale][sheet]) result[locale][sheet] = {};
|
|
1164
|
+
for (const [key, value] of Object.entries(keys)) result[locale][sheet][key] = value;
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
return result;
|
|
1168
|
+
}
|
|
1169
|
+
function countChangedKeys(data) {
|
|
1170
|
+
return Object.values(data).flatMap((sheets) => Object.values(sheets)).reduce((count, keys) => count + Object.keys(keys).length, 0);
|
|
1171
|
+
}
|
|
1172
|
+
function normalizeRevision(value) {
|
|
1173
|
+
return Number.isFinite(value) ? Number(value) : 0;
|
|
1174
|
+
}
|
|
1175
|
+
function createDefaultDeps$2() {
|
|
1176
|
+
return {
|
|
1177
|
+
async readSnapshot(filePath) {
|
|
1178
|
+
try {
|
|
1179
|
+
const payload = await fs$1.readFile(filePath, "utf8");
|
|
1180
|
+
const parsed = JSON.parse(payload);
|
|
1181
|
+
return {
|
|
1182
|
+
revision: normalizeRevision(parsed.revision),
|
|
1183
|
+
translations: parsed.translations ?? {},
|
|
1184
|
+
metadata: parsed.metadata
|
|
1185
|
+
};
|
|
1186
|
+
} catch (error) {
|
|
1187
|
+
if (error.code === "ENOENT") return {
|
|
1188
|
+
revision: 0,
|
|
1189
|
+
translations: {}
|
|
1190
|
+
};
|
|
1191
|
+
throw error;
|
|
1192
|
+
}
|
|
1193
|
+
},
|
|
1194
|
+
async writeSnapshot(filePath, snapshot) {
|
|
1195
|
+
await fs$1.mkdir(path.dirname(filePath), { recursive: true });
|
|
1196
|
+
await fs$1.writeFile(filePath, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8");
|
|
1197
|
+
}
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
function assertRevision(snapshot, expectedRevision) {
|
|
1201
|
+
if (expectedRevision === void 0) return;
|
|
1202
|
+
if (snapshot.revision !== expectedRevision) throw new Error(`CryptPad revision mismatch: expected ${expectedRevision}, received ${snapshot.revision}.`);
|
|
1203
|
+
}
|
|
1204
|
+
/**
|
|
1205
|
+
* Creates a {@link TranslationOutputProvider} that writes translations into a local
|
|
1206
|
+
* JSON snapshot file representing CryptPad workspace state. Each write reads the
|
|
1207
|
+
* current snapshot, checks `expectedRevision` (if set) for optimistic-concurrency
|
|
1208
|
+
* safety, deep-merges the new translations over the existing ones, and writes back
|
|
1209
|
+
* with the revision incremented by one.
|
|
1210
|
+
*/
|
|
1211
|
+
function createCryptPadWorkspaceOutputProvider(options, depsOverrides = {}) {
|
|
1212
|
+
const deps = {
|
|
1213
|
+
...createDefaultDeps$2(),
|
|
1214
|
+
...depsOverrides
|
|
1215
|
+
};
|
|
1216
|
+
return {
|
|
1217
|
+
kind: "output",
|
|
1218
|
+
providerId: options.providerId ?? "cryptpad-workspace",
|
|
1219
|
+
displayName: options.displayName ?? "CryptPad Workspace Output",
|
|
1220
|
+
capabilities: CRYPTPAD_WORKSPACE_OUTPUT_CAPABILITIES,
|
|
1221
|
+
async writeTranslations(payload) {
|
|
1222
|
+
const snapshot = await deps.readSnapshot(options.filePath, options.authToken);
|
|
1223
|
+
assertRevision(snapshot, options.expectedRevision);
|
|
1224
|
+
const merged = mergeTranslations$1(snapshot.translations, payload.translations);
|
|
1225
|
+
const nextRevision = snapshot.revision + 1;
|
|
1226
|
+
await deps.writeSnapshot(options.filePath, {
|
|
1227
|
+
revision: nextRevision,
|
|
1228
|
+
translations: merged,
|
|
1229
|
+
metadata: {
|
|
1230
|
+
...snapshot.metadata,
|
|
1231
|
+
lastWriteProvider: "cryptpad-workspace-output"
|
|
1232
|
+
}
|
|
1233
|
+
}, options.authToken);
|
|
1234
|
+
return {
|
|
1235
|
+
wroteFiles: [options.filePath],
|
|
1236
|
+
metadata: {
|
|
1237
|
+
revision: nextRevision,
|
|
1238
|
+
changedKeys: countChangedKeys(payload.translations)
|
|
1239
|
+
}
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
function buildSyncInput(payload) {
|
|
1245
|
+
return {
|
|
1246
|
+
baseTranslations: payload.metadata?.baseTranslations ?? payload.remoteTranslations,
|
|
1247
|
+
localTranslations: payload.localTranslations,
|
|
1248
|
+
remoteTranslations: payload.remoteTranslations
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* Creates a {@link TranslationSyncProvider} that reconciles local translation changes
|
|
1253
|
+
* against the CryptPad workspace snapshot using {@link resolveSyncPlan} (three-way
|
|
1254
|
+
* diff against `payload.metadata.baseTranslations`, falling back to the remote
|
|
1255
|
+
* snapshot as base) and the configured `conflictPolicy`. Like the output provider,
|
|
1256
|
+
* it checks `expectedRevision` before writing and increments the revision on write.
|
|
1257
|
+
*/
|
|
1258
|
+
function createCryptPadWorkspaceSyncProvider(options, depsOverrides = {}) {
|
|
1259
|
+
const deps = {
|
|
1260
|
+
...createDefaultDeps$2(),
|
|
1261
|
+
...depsOverrides
|
|
1262
|
+
};
|
|
1263
|
+
return {
|
|
1264
|
+
kind: "sync",
|
|
1265
|
+
providerId: options.providerId ?? "cryptpad-workspace",
|
|
1266
|
+
displayName: options.displayName ?? "CryptPad Workspace Sync",
|
|
1267
|
+
capabilities: CRYPTPAD_WORKSPACE_SYNC_CAPABILITIES,
|
|
1268
|
+
async syncTranslations(payload) {
|
|
1269
|
+
const snapshot = await deps.readSnapshot(options.filePath, options.authToken);
|
|
1270
|
+
assertRevision(snapshot, options.expectedRevision ?? (Number.isFinite(payload.metadata?.expectedRevision) ? Number(payload.metadata?.expectedRevision) : void 0));
|
|
1271
|
+
const resolution = resolveSyncPlan(buildSyncInput(payload), options.conflictPolicy ?? "manual");
|
|
1272
|
+
const nextRevision = snapshot.revision + 1;
|
|
1273
|
+
await deps.writeSnapshot(options.filePath, {
|
|
1274
|
+
revision: nextRevision,
|
|
1275
|
+
translations: resolution.mergedTranslations,
|
|
1276
|
+
metadata: {
|
|
1277
|
+
...snapshot.metadata,
|
|
1278
|
+
lastSyncProvider: "cryptpad-workspace-sync",
|
|
1279
|
+
policy: resolution.policy
|
|
1280
|
+
}
|
|
1281
|
+
}, options.authToken);
|
|
1282
|
+
return {
|
|
1283
|
+
changedKeys: resolution.appliedLocalChanges,
|
|
1284
|
+
skippedKeys: resolution.skippedConflicts,
|
|
1285
|
+
metadata: {
|
|
1286
|
+
revision: nextRevision,
|
|
1287
|
+
policy: resolution.policy
|
|
1288
|
+
}
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
//#endregion
|
|
1294
|
+
//#region src/providers/cryptpad/assetProvider.ts
|
|
1295
|
+
const CRYPTPAD_ASSET_SYNC_CAPABILITIES = createCapabilitySet({
|
|
1296
|
+
assetSync: true,
|
|
1297
|
+
discoverByFolder: true
|
|
1298
|
+
});
|
|
1299
|
+
function toSha256(content) {
|
|
1300
|
+
return crypto.createHash("sha256").update(content).digest("hex");
|
|
1301
|
+
}
|
|
1302
|
+
/**
|
|
1303
|
+
* Guards against path traversal: returns false if `relativePath` would resolve
|
|
1304
|
+
* to a location outside `targetDirectory` (e.g. via `../` segments).
|
|
1305
|
+
*/
|
|
1306
|
+
function isSafePath(targetDirectory, relativePath) {
|
|
1307
|
+
const resolvedRoot = path.resolve(targetDirectory);
|
|
1308
|
+
const resolvedTarget = path.resolve(targetDirectory, relativePath);
|
|
1309
|
+
return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(`${resolvedRoot}${path.sep}`);
|
|
1310
|
+
}
|
|
1311
|
+
async function walkFiles(directory) {
|
|
1312
|
+
if (!fs.existsSync(directory)) return [];
|
|
1313
|
+
const entries = await fs$1.readdir(directory, { withFileTypes: true });
|
|
1314
|
+
const files = [];
|
|
1315
|
+
for (const entry of entries) {
|
|
1316
|
+
const fullPath = path.join(directory, entry.name);
|
|
1317
|
+
if (entry.isDirectory()) {
|
|
1318
|
+
files.push(...await walkFiles(fullPath));
|
|
1319
|
+
continue;
|
|
1320
|
+
}
|
|
1321
|
+
files.push(fullPath);
|
|
1322
|
+
}
|
|
1323
|
+
return files;
|
|
1324
|
+
}
|
|
1325
|
+
function createDefaultDeps$1() {
|
|
1326
|
+
return {
|
|
1327
|
+
async readManifest(manifestPath) {
|
|
1328
|
+
const content = await fs$1.readFile(manifestPath, "utf8");
|
|
1329
|
+
const parsed = JSON.parse(content);
|
|
1330
|
+
if (!Array.isArray(parsed)) throw new Error("CryptPad asset manifest must be a JSON array.");
|
|
1331
|
+
return parsed;
|
|
1332
|
+
},
|
|
1333
|
+
async readAssetBuffer(asset, signal) {
|
|
1334
|
+
if (asset.sourcePath) return fs$1.readFile(asset.sourcePath);
|
|
1335
|
+
if (asset.sourceUrl) {
|
|
1336
|
+
const response = await fetch(asset.sourceUrl, { signal });
|
|
1337
|
+
if (!response.ok) throw new Error(`Failed to fetch asset from ${asset.sourceUrl} (HTTP ${response.status})`);
|
|
1338
|
+
return Buffer.from(await response.arrayBuffer());
|
|
1339
|
+
}
|
|
1340
|
+
throw new Error(`Asset "${asset.assetId}" must define sourcePath or sourceUrl.`);
|
|
1341
|
+
},
|
|
1342
|
+
async fileExists(filePath) {
|
|
1343
|
+
try {
|
|
1344
|
+
await fs$1.access(filePath);
|
|
1345
|
+
return true;
|
|
1346
|
+
} catch {
|
|
1347
|
+
return false;
|
|
1348
|
+
}
|
|
1349
|
+
},
|
|
1350
|
+
readFile: (filePath) => fs$1.readFile(filePath),
|
|
1351
|
+
async writeFile(filePath, content) {
|
|
1352
|
+
await fs$1.mkdir(path.dirname(filePath), { recursive: true });
|
|
1353
|
+
await fs$1.writeFile(filePath, content);
|
|
1354
|
+
},
|
|
1355
|
+
listFiles: walkFiles,
|
|
1356
|
+
deleteFile: (filePath) => fs$1.unlink(filePath)
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
1359
|
+
/**
|
|
1360
|
+
* Creates an {@link AssetSyncProvider} that syncs binary/static assets described by a
|
|
1361
|
+
* JSON manifest (`manifestPath`) into a local target directory. For each manifest
|
|
1362
|
+
* entry it downloads a missing file, overwrites a file whose SHA-256 hash differs,
|
|
1363
|
+
* or skips a file that is already up to date; entries sharing the same declared
|
|
1364
|
+
* `hash` reuse one fetched buffer. When {@link AssetSyncRequest.deleteMissing} is
|
|
1365
|
+
* set, local files under the target directory that are not named by the manifest
|
|
1366
|
+
* are removed. All target paths are checked with {@link isSafePath} to reject
|
|
1367
|
+
* manifest entries that would write outside the target directory.
|
|
1368
|
+
*/
|
|
1369
|
+
function createCryptPadAssetSyncProvider(options, depsOverrides = {}) {
|
|
1370
|
+
const deps = {
|
|
1371
|
+
...createDefaultDeps$1(),
|
|
1372
|
+
...depsOverrides
|
|
1373
|
+
};
|
|
1374
|
+
return {
|
|
1375
|
+
kind: "asset-sync",
|
|
1376
|
+
providerId: options.providerId ?? "cryptpad-assets",
|
|
1377
|
+
displayName: options.displayName ?? "CryptPad Asset Sync",
|
|
1378
|
+
capabilities: CRYPTPAD_ASSET_SYNC_CAPABILITIES,
|
|
1379
|
+
async syncAssets(request) {
|
|
1380
|
+
const manifest = await deps.readManifest(options.manifestPath);
|
|
1381
|
+
const downloaded = [];
|
|
1382
|
+
const updated = [];
|
|
1383
|
+
const skipped = [];
|
|
1384
|
+
const deleted = [];
|
|
1385
|
+
const desiredTargets = /* @__PURE__ */ new Set();
|
|
1386
|
+
const hashCache = /* @__PURE__ */ new Map();
|
|
1387
|
+
for (const asset of manifest) {
|
|
1388
|
+
if (!isSafePath(request.targetDirectory, asset.relativePath)) throw new Error(`Unsafe asset path blocked: ${asset.relativePath}`);
|
|
1389
|
+
const targetPath = path.resolve(request.targetDirectory, asset.relativePath);
|
|
1390
|
+
desiredTargets.add(targetPath);
|
|
1391
|
+
let content;
|
|
1392
|
+
if (asset.hash && hashCache.has(asset.hash)) content = hashCache.get(asset.hash);
|
|
1393
|
+
else {
|
|
1394
|
+
content = await deps.readAssetBuffer(asset, request.signal);
|
|
1395
|
+
const hash = asset.hash ?? toSha256(content);
|
|
1396
|
+
hashCache.set(hash, content);
|
|
1397
|
+
}
|
|
1398
|
+
if (await deps.fileExists(targetPath)) {
|
|
1399
|
+
if (toSha256(await deps.readFile(targetPath)) === toSha256(content)) {
|
|
1400
|
+
skipped.push(asset.relativePath);
|
|
1401
|
+
continue;
|
|
1402
|
+
}
|
|
1403
|
+
await deps.writeFile(targetPath, content);
|
|
1404
|
+
updated.push(asset.relativePath);
|
|
1405
|
+
continue;
|
|
1406
|
+
}
|
|
1407
|
+
await deps.writeFile(targetPath, content);
|
|
1408
|
+
downloaded.push(asset.relativePath);
|
|
1409
|
+
}
|
|
1410
|
+
if (request.deleteMissing) {
|
|
1411
|
+
const existingFiles = await deps.listFiles(request.targetDirectory);
|
|
1412
|
+
for (const existingFile of existingFiles) {
|
|
1413
|
+
const resolved = path.resolve(existingFile);
|
|
1414
|
+
if (!desiredTargets.has(resolved)) {
|
|
1415
|
+
await deps.deleteFile(resolved);
|
|
1416
|
+
deleted.push(path.relative(request.targetDirectory, resolved));
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
return {
|
|
1421
|
+
manifestCount: manifest.length,
|
|
1422
|
+
downloaded,
|
|
1423
|
+
updated,
|
|
1424
|
+
deleted,
|
|
1425
|
+
skipped
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
};
|
|
1429
|
+
}
|
|
1430
|
+
//#endregion
|
|
1431
|
+
//#region src/core/rowTransformer.ts
|
|
1432
|
+
/**
|
|
1433
|
+
* Pure transformation core: converts canonical rows into TranslationData for one sheet.
|
|
1434
|
+
*/
|
|
1435
|
+
function transformRowsToSheetData(rows, sheetTitle, deps) {
|
|
1436
|
+
const logger = deps.logger ?? console;
|
|
1437
|
+
const result = {
|
|
1438
|
+
translations: {},
|
|
1439
|
+
locales: [],
|
|
1440
|
+
localeMapping: {},
|
|
1441
|
+
originalMapping: {},
|
|
1442
|
+
success: false
|
|
1443
|
+
};
|
|
1444
|
+
try {
|
|
1445
|
+
if (!rows || rows.length === 0) {
|
|
1446
|
+
logger.warn(`No rows found in sheet "${sheetTitle}"`);
|
|
1447
|
+
return result;
|
|
1448
|
+
}
|
|
1449
|
+
const headerRow = Object.keys(rows[0]).map((key) => key.toLowerCase());
|
|
1450
|
+
logger.log(`Header row for sheet "${sheetTitle}":`, headerRow);
|
|
1451
|
+
const keyColumn = headerRow[0];
|
|
1452
|
+
if (deps.filterValidLocales(headerRow, keyColumn).length === 0) {
|
|
1453
|
+
logger.warn(`No valid locale columns found in sheet "${sheetTitle}"`);
|
|
1454
|
+
return result;
|
|
1455
|
+
}
|
|
1456
|
+
const originalHeaders = Object.keys(rows[0]);
|
|
1457
|
+
const { normalizedLocales, localeMapping, originalMapping } = deps.createLocaleMapping(originalHeaders, keyColumn);
|
|
1458
|
+
result.localeMapping = localeMapping;
|
|
1459
|
+
result.originalMapping = originalMapping;
|
|
1460
|
+
for (const normalizedLocale of normalizedLocales) {
|
|
1461
|
+
const originalHeader = localeMapping[normalizedLocale];
|
|
1462
|
+
if (!originalHeader) continue;
|
|
1463
|
+
const nonEmptyLanguageCells = rows.map((row) => {
|
|
1464
|
+
const keyField = Object.keys(row).find((k) => k.toLowerCase() === keyColumn);
|
|
1465
|
+
if (!keyField || !row[keyField] || !row[originalHeader]) return {};
|
|
1466
|
+
const rowLocal = {};
|
|
1467
|
+
rowLocal[row[keyField].toString().toLowerCase()] = row[originalHeader];
|
|
1468
|
+
return rowLocal;
|
|
1469
|
+
}).filter((cell) => Object.keys(cell).length > 0);
|
|
1470
|
+
const prepareObj = {};
|
|
1471
|
+
prepareObj[sheetTitle] = nonEmptyLanguageCells.reduce((acc, cell) => Object.assign(acc, cell), {});
|
|
1472
|
+
if (result.translations[normalizedLocale]) result.translations[normalizedLocale] = {
|
|
1473
|
+
...result.translations[normalizedLocale],
|
|
1474
|
+
...prepareObj
|
|
1475
|
+
};
|
|
1476
|
+
else result.translations[normalizedLocale] = { ...prepareObj };
|
|
1477
|
+
}
|
|
1478
|
+
result.locales = normalizedLocales;
|
|
1479
|
+
result.success = true;
|
|
1480
|
+
} catch (error) {
|
|
1481
|
+
logger.error(`Error processing sheet "${sheetTitle}":`, error);
|
|
1482
|
+
}
|
|
1483
|
+
return result;
|
|
1484
|
+
}
|
|
1485
|
+
//#endregion
|
|
1486
|
+
//#region src/utils/localeFilter.ts
|
|
1487
|
+
/**
|
|
1488
|
+
* Locale validation utilities
|
|
1489
|
+
*/
|
|
1490
|
+
const COMMON_LOCALE_PATTERNS = [
|
|
1491
|
+
/^[a-z]{2}$/,
|
|
1492
|
+
/^[a-z]{2}-[a-z]{2}$/,
|
|
1493
|
+
/^[a-z]{2}_[a-z]{2}$/,
|
|
1494
|
+
/^[a-z]{2}-[a-z]{2}-[a-z]+$/
|
|
1495
|
+
];
|
|
1496
|
+
const NON_LOCALE_KEYWORDS = [
|
|
1497
|
+
"key",
|
|
1498
|
+
"keys",
|
|
1499
|
+
"id",
|
|
1500
|
+
"identifier",
|
|
1501
|
+
"name",
|
|
1502
|
+
"title",
|
|
1503
|
+
"label",
|
|
1504
|
+
"description",
|
|
1505
|
+
"comment",
|
|
1506
|
+
"note",
|
|
1507
|
+
"context",
|
|
1508
|
+
"category",
|
|
1509
|
+
"type",
|
|
1510
|
+
"status",
|
|
1511
|
+
"updated",
|
|
1512
|
+
"created",
|
|
1513
|
+
"modified",
|
|
1514
|
+
"version",
|
|
1515
|
+
"source",
|
|
1516
|
+
"i18n",
|
|
1517
|
+
"translation",
|
|
1518
|
+
"namespace",
|
|
1519
|
+
"section"
|
|
1520
|
+
];
|
|
1521
|
+
/**
|
|
1522
|
+
* Determines if a string represents a valid locale identifier
|
|
1523
|
+
* @param value The string to test
|
|
1524
|
+
* @returns true if the string appears to be a locale identifier
|
|
1525
|
+
*/
|
|
1526
|
+
function isValidLocale(value) {
|
|
1527
|
+
if (!value || typeof value !== "string") return false;
|
|
1528
|
+
const normalized = value.toLowerCase().trim();
|
|
1529
|
+
if (NON_LOCALE_KEYWORDS.includes(normalized)) return false;
|
|
1530
|
+
return COMMON_LOCALE_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
1531
|
+
}
|
|
1532
|
+
/**
|
|
1533
|
+
* Filters header row to only include valid locale columns, excluding the key column
|
|
1534
|
+
* @param headerRow Array of column names from the sheet header
|
|
1535
|
+
* @param keyColumn The name of the key column to exclude
|
|
1536
|
+
* @returns Array of valid locale identifiers
|
|
1537
|
+
*/
|
|
1538
|
+
function filterValidLocales(headerRow, keyColumn) {
|
|
1539
|
+
return headerRow.filter((column) => column.toLowerCase() !== keyColumn.toLowerCase()).filter((column) => isValidLocale(column)).map((locale) => locale.toLowerCase());
|
|
1540
|
+
}
|
|
1541
|
+
//#endregion
|
|
1542
|
+
//#region src/providers/orchestrator.ts
|
|
1543
|
+
function createDefaultDeps() {
|
|
1544
|
+
return {
|
|
1545
|
+
transformRows: (rows, sheetTitle) => transformRowsToSheetData(rows, sheetTitle, {
|
|
1546
|
+
filterValidLocales,
|
|
1547
|
+
createLocaleMapping,
|
|
1548
|
+
logger: console
|
|
1549
|
+
}),
|
|
1550
|
+
logger: console
|
|
1551
|
+
};
|
|
1552
|
+
}
|
|
1553
|
+
function mergeTranslations(target, next) {
|
|
1554
|
+
for (const [locale, sheets] of Object.entries(next)) {
|
|
1555
|
+
if (!target[locale]) target[locale] = {};
|
|
1556
|
+
for (const [sheetName, keys] of Object.entries(sheets)) {
|
|
1557
|
+
if (!target[locale][sheetName]) target[locale][sheetName] = {};
|
|
1558
|
+
target[locale][sheetName] = {
|
|
1559
|
+
...target[locale][sheetName],
|
|
1560
|
+
...keys
|
|
1561
|
+
};
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
/**
|
|
1566
|
+
* Runs the full provider-driven translation pipeline: read tables from the input
|
|
1567
|
+
* provider, transform rows into canonical {@link TranslationData}, optionally write
|
|
1568
|
+
* to an output provider, optionally sync local changes back, and optionally sync
|
|
1569
|
+
* assets via an asset-sync provider. Each optional stage is gated by a capability
|
|
1570
|
+
* check on the corresponding provider so unsupported operations fail fast instead
|
|
1571
|
+
* of silently doing partial work.
|
|
1572
|
+
*/
|
|
1573
|
+
async function runProviderPipeline(options, depsOverrides = {}) {
|
|
1574
|
+
const deps = {
|
|
1575
|
+
...createDefaultDeps(),
|
|
1576
|
+
...depsOverrides
|
|
1577
|
+
};
|
|
1578
|
+
assertOperationCapabilities(options.inputProvider.providerId, options.inputProvider.capabilities, "read-input");
|
|
1579
|
+
const inputResult = await options.inputProvider.readTables({
|
|
1580
|
+
tableNames: options.tableNames,
|
|
1581
|
+
signal: options.signal
|
|
1582
|
+
});
|
|
1583
|
+
const mergedTranslations = {};
|
|
1584
|
+
const localeSet = /* @__PURE__ */ new Set();
|
|
1585
|
+
const localeMapping = {};
|
|
1586
|
+
const originalLocaleMapping = {};
|
|
1587
|
+
for (const table of inputResult.tables) {
|
|
1588
|
+
const processed = deps.transformRows(table.rows, table.tableName);
|
|
1589
|
+
if (!processed.success) {
|
|
1590
|
+
deps.logger.warn(`Skipping table "${table.tableName}" because transformation returned unsuccessful result.`);
|
|
1591
|
+
continue;
|
|
1592
|
+
}
|
|
1593
|
+
mergeTranslations(mergedTranslations, processed.translations);
|
|
1594
|
+
for (const locale of processed.locales) localeSet.add(locale);
|
|
1595
|
+
for (const [normalized, original] of Object.entries(processed.localeMapping)) if (!localeMapping[normalized]) localeMapping[normalized] = original;
|
|
1596
|
+
for (const [original, normalized] of Object.entries(processed.originalMapping)) if (!originalLocaleMapping[original]) originalLocaleMapping[original] = normalized;
|
|
1597
|
+
}
|
|
1598
|
+
let outputResult;
|
|
1599
|
+
if (options.outputProvider) {
|
|
1600
|
+
assertOperationCapabilities(options.outputProvider.providerId, options.outputProvider.capabilities, "write-output");
|
|
1601
|
+
outputResult = await options.outputProvider.writeTranslations({
|
|
1602
|
+
translations: mergedTranslations,
|
|
1603
|
+
locales: Array.from(localeSet),
|
|
1604
|
+
localeMapping,
|
|
1605
|
+
originalLocaleMapping,
|
|
1606
|
+
metadata: inputResult.metadata
|
|
1607
|
+
});
|
|
1608
|
+
}
|
|
1609
|
+
let syncResult;
|
|
1610
|
+
if (options.syncProvider && options.localTranslationsForSync) {
|
|
1611
|
+
assertOperationCapabilities(options.syncProvider.providerId, options.syncProvider.capabilities, "sync-back");
|
|
1612
|
+
syncResult = await options.syncProvider.syncTranslations({
|
|
1613
|
+
localTranslations: options.localTranslationsForSync,
|
|
1614
|
+
remoteTranslations: mergedTranslations,
|
|
1615
|
+
metadata: inputResult.metadata
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1618
|
+
let assetSyncResult;
|
|
1619
|
+
if (options.assetSyncProvider && options.assetSync) {
|
|
1620
|
+
assertOperationCapabilities(options.assetSyncProvider.providerId, options.assetSyncProvider.capabilities, "sync-assets");
|
|
1621
|
+
assetSyncResult = await options.assetSyncProvider.syncAssets({
|
|
1622
|
+
targetDirectory: options.assetSync.targetDirectory,
|
|
1623
|
+
deleteMissing: options.assetSync.deleteMissing,
|
|
1624
|
+
signal: options.signal
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1627
|
+
return {
|
|
1628
|
+
translations: mergedTranslations,
|
|
1629
|
+
locales: Array.from(localeSet),
|
|
1630
|
+
localeMapping,
|
|
1631
|
+
originalLocaleMapping,
|
|
1632
|
+
inputTableCount: inputResult.tables.length,
|
|
1633
|
+
outputResult,
|
|
1634
|
+
syncResult,
|
|
1635
|
+
assetSyncResult,
|
|
1636
|
+
metadata: inputResult.metadata
|
|
1637
|
+
};
|
|
1638
|
+
}
|
|
1639
|
+
//#endregion
|
|
1640
|
+
//#region src/providers/config.ts
|
|
1641
|
+
function isObject(value) {
|
|
1642
|
+
return typeof value === "object" && value !== null;
|
|
1643
|
+
}
|
|
1644
|
+
/**
|
|
1645
|
+
* Validates the shape of an untrusted runtime config value (typically parsed from JSON)
|
|
1646
|
+
* before it is trusted as a {@link ProviderRuntimeConfig}. Collects every error found
|
|
1647
|
+
* rather than failing on the first one, so a config file can be fixed in one pass.
|
|
1648
|
+
*/
|
|
1649
|
+
function validateProviderRuntimeConfig(config) {
|
|
1650
|
+
const errors = [];
|
|
1651
|
+
if (!isObject(config)) return {
|
|
1652
|
+
valid: false,
|
|
1653
|
+
errors: ["Provider config must be an object."]
|
|
1654
|
+
};
|
|
1655
|
+
const input = config.input;
|
|
1656
|
+
if (!isObject(input)) errors.push("Provider config must include an \"input\" provider object.");
|
|
1657
|
+
else if (typeof input.provider !== "string" || input.provider.trim().length === 0) errors.push("Input provider must define a non-empty \"provider\" string.");
|
|
1658
|
+
const output = config.output;
|
|
1659
|
+
if (output !== void 0) {
|
|
1660
|
+
if (!isObject(output)) errors.push("Output provider must be an object when provided.");
|
|
1661
|
+
else if (typeof output.provider !== "string" || output.provider.trim().length === 0) errors.push("Output provider must define a non-empty \"provider\" string.");
|
|
1662
|
+
}
|
|
1663
|
+
const sync = config.sync;
|
|
1664
|
+
if (sync !== void 0) {
|
|
1665
|
+
if (!isObject(sync)) errors.push("Sync provider must be an object when provided.");
|
|
1666
|
+
else if (typeof sync.provider !== "string" || sync.provider.trim().length === 0) errors.push("Sync provider must define a non-empty \"provider\" string.");
|
|
1667
|
+
}
|
|
1668
|
+
const assetSync = config.assetSync;
|
|
1669
|
+
if (assetSync !== void 0) {
|
|
1670
|
+
if (!isObject(assetSync)) errors.push("Asset sync provider must be an object when provided.");
|
|
1671
|
+
else if (typeof assetSync.provider !== "string" || assetSync.provider.trim().length === 0) errors.push("Asset sync provider must define a non-empty \"provider\" string.");
|
|
1672
|
+
}
|
|
1673
|
+
if (isObject(input) && typeof input.provider === "string" && input.provider === "cryptpad-csv" && isObject(sync) && typeof sync.provider === "string" && sync.provider !== "cryptpad-workspace") errors.push("Invalid provider combination: \"cryptpad-csv\" only supports sync mode via \"cryptpad-workspace\" (or use another sync-capable provider).");
|
|
1674
|
+
return {
|
|
1675
|
+
valid: errors.length === 0,
|
|
1676
|
+
errors
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
/**
|
|
1680
|
+
* Validates `config` and returns it typed as {@link ProviderRuntimeConfig}, or throws
|
|
1681
|
+
* with all collected validation errors joined into a single message. Use this at
|
|
1682
|
+
* process boundaries (CLI, Action inputs) where a throw-on-invalid contract is wanted.
|
|
1683
|
+
*/
|
|
1684
|
+
function assertValidProviderRuntimeConfig(config) {
|
|
1685
|
+
const validation = validateProviderRuntimeConfig(config);
|
|
1686
|
+
if (!validation.valid) throw new Error(`Invalid provider configuration: ${validation.errors.join(" | ")}`);
|
|
1687
|
+
return config;
|
|
1688
|
+
}
|
|
1689
|
+
//#endregion
|
|
1690
|
+
//#region src/providers/runtime.ts
|
|
1691
|
+
function asRecord(value) {
|
|
1692
|
+
if (!value || typeof value !== "object") return {};
|
|
1693
|
+
return value;
|
|
1694
|
+
}
|
|
1695
|
+
function createInputProvider(providerId, options) {
|
|
1696
|
+
switch (providerId) {
|
|
1697
|
+
case "google-sheets": return createGoogleSheetsInputProvider(options);
|
|
1698
|
+
case "cryptpad-csv": {
|
|
1699
|
+
const sources = options.sources;
|
|
1700
|
+
if (!Array.isArray(sources)) throw new Error("cryptpad-csv input provider requires an array \"sources\" option.");
|
|
1701
|
+
return createCryptPadCsvInputProvider({
|
|
1702
|
+
sources,
|
|
1703
|
+
delimiter: typeof options.delimiter === "string" ? options.delimiter : void 0,
|
|
1704
|
+
providerId: typeof options.providerId === "string" ? options.providerId : void 0,
|
|
1705
|
+
displayName: typeof options.displayName === "string" ? options.displayName : void 0
|
|
1706
|
+
});
|
|
1707
|
+
}
|
|
1708
|
+
default: throw new Error(`Unsupported input provider: "${providerId}"`);
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
function createOutputProvider(providerId, options) {
|
|
1712
|
+
switch (providerId) {
|
|
1713
|
+
case "google-sheets": return createGoogleSheetsOutputProvider(options);
|
|
1714
|
+
case "cryptpad-workspace":
|
|
1715
|
+
if (typeof options.filePath !== "string" || options.filePath.trim().length === 0) throw new Error("cryptpad-workspace output provider requires a non-empty \"filePath\" option.");
|
|
1716
|
+
return createCryptPadWorkspaceOutputProvider({
|
|
1717
|
+
filePath: options.filePath,
|
|
1718
|
+
authToken: typeof options.authToken === "string" ? options.authToken : void 0,
|
|
1719
|
+
expectedRevision: typeof options.expectedRevision === "number" ? options.expectedRevision : void 0,
|
|
1720
|
+
providerId: typeof options.providerId === "string" ? options.providerId : void 0,
|
|
1721
|
+
displayName: typeof options.displayName === "string" ? options.displayName : void 0
|
|
1722
|
+
});
|
|
1723
|
+
default: throw new Error(`Unsupported output provider: "${providerId}"`);
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
function createSyncProvider(providerId, options) {
|
|
1727
|
+
switch (providerId) {
|
|
1728
|
+
case "google-sheets": return createGoogleSheetsSyncProvider(options);
|
|
1729
|
+
case "cryptpad-workspace":
|
|
1730
|
+
if (typeof options.filePath !== "string" || options.filePath.trim().length === 0) throw new Error("cryptpad-workspace sync provider requires a non-empty \"filePath\" option.");
|
|
1731
|
+
return createCryptPadWorkspaceSyncProvider({
|
|
1732
|
+
filePath: options.filePath,
|
|
1733
|
+
authToken: typeof options.authToken === "string" ? options.authToken : void 0,
|
|
1734
|
+
expectedRevision: typeof options.expectedRevision === "number" ? options.expectedRevision : void 0,
|
|
1735
|
+
conflictPolicy: options.conflictPolicy === "remote-wins" || options.conflictPolicy === "local-wins" || options.conflictPolicy === "manual" ? options.conflictPolicy : void 0,
|
|
1736
|
+
providerId: typeof options.providerId === "string" ? options.providerId : void 0,
|
|
1737
|
+
displayName: typeof options.displayName === "string" ? options.displayName : void 0
|
|
1738
|
+
});
|
|
1739
|
+
default: throw new Error(`Unsupported sync provider: "${providerId}"`);
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
function createAssetSyncProvider(providerId, options) {
|
|
1743
|
+
switch (providerId) {
|
|
1744
|
+
case "cryptpad-assets":
|
|
1745
|
+
if (typeof options.manifestPath !== "string" || options.manifestPath.trim().length === 0) throw new Error("cryptpad-assets sync provider requires a non-empty \"manifestPath\" option.");
|
|
1746
|
+
return createCryptPadAssetSyncProvider({
|
|
1747
|
+
manifestPath: options.manifestPath,
|
|
1748
|
+
providerId: typeof options.providerId === "string" ? options.providerId : void 0,
|
|
1749
|
+
displayName: typeof options.displayName === "string" ? options.displayName : void 0
|
|
1750
|
+
});
|
|
1751
|
+
default: throw new Error(`Unsupported asset sync provider: "${providerId}"`);
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
/**
|
|
1755
|
+
* Builds a {@link ProviderRuntimeSelection} by resolving each configured provider slot
|
|
1756
|
+
* (`input`, `output`, `sync`, `assetSync`) to a concrete provider instance via its factory.
|
|
1757
|
+
* Throws if a slot names a provider ID with no matching factory, or if a provider's
|
|
1758
|
+
* required options are missing.
|
|
1759
|
+
*/
|
|
1760
|
+
function createProvidersFromRuntimeConfig(config) {
|
|
1761
|
+
return {
|
|
1762
|
+
inputProvider: createInputProvider(config.input.provider, asRecord(config.input.options)),
|
|
1763
|
+
outputProvider: config.output ? createOutputProvider(config.output.provider, asRecord(config.output.options)) : void 0,
|
|
1764
|
+
syncProvider: config.sync ? createSyncProvider(config.sync.provider, asRecord(config.sync.options)) : void 0,
|
|
1765
|
+
assetSyncProvider: config.assetSync ? createAssetSyncProvider(config.assetSync.provider, asRecord(config.assetSync.options)) : void 0
|
|
1766
|
+
};
|
|
1767
|
+
}
|
|
1768
|
+
//#endregion
|
|
1769
|
+
//#region src/utils/dataConverter/convertToDataJsonFormat.ts
|
|
1770
|
+
/**
|
|
1771
|
+
* Converts the translation object to the expected languageData.json format
|
|
1772
|
+
* @param translationObj - The translation object with locale->sheet->key->value structure
|
|
1773
|
+
* @param locales - Array of locale identifiers
|
|
1774
|
+
* @returns Converted data in the format expected for languageData.json
|
|
1775
|
+
*/
|
|
1776
|
+
function convertToDataJsonFormat(translationObj, locales) {
|
|
1777
|
+
const result = [];
|
|
1778
|
+
console.log("Converting translation object to languageData.json format...");
|
|
1779
|
+
const allSheets = /* @__PURE__ */ new Set();
|
|
1780
|
+
for (const locale of Object.keys(translationObj)) if (translationObj[locale]) for (const sheet of Object.keys(translationObj[locale])) allSheets.add(sheet);
|
|
1781
|
+
console.log(`Found ${allSheets.size} sheets across all locales`);
|
|
1782
|
+
for (const sheetTitle of allSheets) {
|
|
1783
|
+
const projectData = {};
|
|
1784
|
+
projectData[sheetTitle] = {};
|
|
1785
|
+
for (const locale of locales) if (translationObj?.[locale]?.[sheetTitle]) {
|
|
1786
|
+
const translations = translationObj[locale][sheetTitle];
|
|
1787
|
+
projectData[sheetTitle][locale] = { ...translations };
|
|
1788
|
+
console.log(`Found ${Object.keys(translations).length} keys for locale ${locale} in sheet ${sheetTitle}`);
|
|
1789
|
+
}
|
|
1790
|
+
if (Object.keys(projectData[sheetTitle]).length > 0) result.push(projectData);
|
|
1791
|
+
}
|
|
1792
|
+
console.log(`Created ${result.length} sheet entries for languageData.json`);
|
|
1793
|
+
return result;
|
|
1794
|
+
}
|
|
1795
|
+
//#endregion
|
|
1796
|
+
//#region src/utils/fileWriter.ts
|
|
1797
|
+
/**
|
|
1798
|
+
* Writes locale files to the translations directory
|
|
1799
|
+
* @param translations Translation data organized by locale
|
|
1800
|
+
* @param locales Array of locale identifiers
|
|
1801
|
+
* @param translationsOutputDir Directory to write translation files to
|
|
1802
|
+
*/
|
|
1803
|
+
function writeTranslationFiles(translations, locales, translationsOutputDir) {
|
|
1804
|
+
if (!fs.existsSync(translationsOutputDir)) try {
|
|
1805
|
+
fs.mkdirSync(translationsOutputDir, { recursive: true });
|
|
1806
|
+
} catch (err) {
|
|
1807
|
+
throw new Error(`Failed to create translations directory "${translationsOutputDir}"`, { cause: err });
|
|
1808
|
+
}
|
|
1809
|
+
for (const locale of locales) {
|
|
1810
|
+
if (!translations[locale] || Object.keys(translations[locale]).length === 0) {
|
|
1811
|
+
console.warn(`No translations found for locale "${locale}"`);
|
|
1812
|
+
continue;
|
|
1813
|
+
}
|
|
1814
|
+
const safeName = locale.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
|
|
1815
|
+
if (safeName !== locale.toLowerCase()) console.warn(`Locale "${locale}" contained unsafe characters; sanitised to "${safeName}"`);
|
|
1816
|
+
try {
|
|
1817
|
+
fs.writeFileSync(path.join(translationsOutputDir, `${safeName}.json`), JSON.stringify(translations[locale], null, 2), "utf8");
|
|
1818
|
+
console.log(`Successfully wrote translations for ${locale}`);
|
|
1819
|
+
} catch (err) {
|
|
1820
|
+
console.error(`Failed to write translation file for locale "${locale}" at "${path.join(translationsOutputDir, `${safeName}.json`)}":`, err);
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
/**
|
|
1825
|
+
* Writes the locales.ts file containing the array of available locales and header mapping
|
|
1826
|
+
* @param locales Array of normalized locale identifiers (filtered to only include valid locales)
|
|
1827
|
+
* @param localeMapping Mapping from normalized locales to original spreadsheet headers
|
|
1828
|
+
* @param localesOutputPath Path to write the locales.ts file
|
|
1829
|
+
*/
|
|
1830
|
+
function writeLocalesFile(locales, localeMapping, localesOutputPath) {
|
|
1831
|
+
const localesOutputDir = path.dirname(localesOutputPath);
|
|
1832
|
+
if (!fs.existsSync(localesOutputDir)) try {
|
|
1833
|
+
fs.mkdirSync(localesOutputDir, { recursive: true });
|
|
1834
|
+
} catch (err) {
|
|
1835
|
+
throw new Error(`Failed to create directory "${localesOutputDir}"`, { cause: err });
|
|
1836
|
+
}
|
|
1837
|
+
const validLocales = locales.filter((locale) => locale && locale.trim().length > 0);
|
|
1838
|
+
const content = `/**
|
|
1839
|
+
* This file is auto-generated from the Google Spreadsheet package.
|
|
1840
|
+
* It contains the list of available locales and their mappings to original spreadsheet headers.
|
|
1841
|
+
* Do not edit this file manually, it will be overwritten by the package.
|
|
1842
|
+
*/
|
|
1843
|
+
|
|
1844
|
+
export const locales = ${JSON.stringify(validLocales)};
|
|
1845
|
+
|
|
1846
|
+
/**
|
|
1847
|
+
* Mapping from normalized locale codes to original spreadsheet headers
|
|
1848
|
+
* Used for syncing changes back to the spreadsheet
|
|
1849
|
+
*/
|
|
1850
|
+
export const localeHeaderMapping = ${JSON.stringify(localeMapping, null, 2)};
|
|
1851
|
+
|
|
1852
|
+
export default locales;
|
|
1853
|
+
`;
|
|
1854
|
+
try {
|
|
1855
|
+
fs.writeFileSync(localesOutputPath, content, "utf8");
|
|
1856
|
+
} catch (err) {
|
|
1857
|
+
throw new Error(`Failed to write locales file at "${localesOutputPath}"`, { cause: err });
|
|
1858
|
+
}
|
|
1859
|
+
console.log(`Successfully wrote locales file with ${validLocales.length} locales:`, validLocales);
|
|
1860
|
+
console.log("Header mapping includes:", Object.keys(localeMapping).length, "mappings");
|
|
1861
|
+
}
|
|
1862
|
+
/**
|
|
1863
|
+
* Writes the languageData.json file containing all translation data
|
|
1864
|
+
* @param translations Translation data organized by locale
|
|
1865
|
+
* @param locales Array of locale identifiers
|
|
1866
|
+
* @param dataJsonPath Path to write the languageData.json file
|
|
1867
|
+
*/
|
|
1868
|
+
function writeLanguageDataFile(translations, locales, dataJsonPath) {
|
|
1869
|
+
const dataJsonDir = path.dirname(dataJsonPath);
|
|
1870
|
+
if (!fs.existsSync(dataJsonDir)) try {
|
|
1871
|
+
fs.mkdirSync(dataJsonDir, { recursive: true });
|
|
1872
|
+
} catch (err) {
|
|
1873
|
+
throw new Error(`Failed to create directory "${dataJsonDir}"`, { cause: err });
|
|
1874
|
+
}
|
|
1875
|
+
const dataJsonContent = convertToDataJsonFormat(translations, locales);
|
|
1876
|
+
try {
|
|
1877
|
+
fs.writeFileSync(dataJsonPath, JSON.stringify(dataJsonContent, null, 2), "utf8");
|
|
1878
|
+
} catch (err) {
|
|
1879
|
+
throw new Error(`Failed to write language data file at "${dataJsonPath}"`, { cause: err });
|
|
1880
|
+
}
|
|
1881
|
+
console.log("Successfully updated languageData.json with fresh spreadsheet data");
|
|
1882
|
+
}
|
|
1883
|
+
//#endregion
|
|
1884
|
+
//#region src/utils/dataConverter/convertFromDataJsonFormat.ts
|
|
1885
|
+
/**
|
|
1886
|
+
* Type-guard: checks that a value has the shape of sheet data
|
|
1887
|
+
* (an object mapping locale → translation record)
|
|
1888
|
+
*/
|
|
1889
|
+
function isSheetData(v) {
|
|
1890
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1891
|
+
}
|
|
1892
|
+
/**
|
|
1893
|
+
* Converts languageData.json format back to the translation object structure
|
|
1894
|
+
* @param dataJson - Data in the languageData.json format
|
|
1895
|
+
* @returns Converted data in the TranslationData format
|
|
1896
|
+
*/
|
|
1897
|
+
function convertFromDataJsonFormat(dataJson) {
|
|
1898
|
+
const result = {};
|
|
1899
|
+
for (const projectData of dataJson) for (const sheetTitle of Object.keys(projectData)) {
|
|
1900
|
+
const raw = projectData[sheetTitle];
|
|
1901
|
+
if (!isSheetData(raw)) {
|
|
1902
|
+
console.warn(`Skipping malformed entry for sheet "${sheetTitle}"`);
|
|
1903
|
+
continue;
|
|
1904
|
+
}
|
|
1905
|
+
for (const locale of Object.keys(raw)) {
|
|
1906
|
+
if (!result[locale]) result[locale] = {};
|
|
1907
|
+
if (!result[locale][sheetTitle]) result[locale][sheetTitle] = {};
|
|
1908
|
+
const localeData = raw[locale];
|
|
1909
|
+
if (typeof localeData !== "object" || localeData === null) continue;
|
|
1910
|
+
for (const key of Object.keys(localeData)) result[locale][sheetTitle][key] = localeData[key];
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
return result;
|
|
1914
|
+
}
|
|
1915
|
+
//#endregion
|
|
1916
|
+
//#region src/utils/readDataJson.ts
|
|
1917
|
+
/**
|
|
1918
|
+
* Reads and parses the languageData.json file
|
|
1919
|
+
* @param dataJsonPath - Path to languageData.json
|
|
1920
|
+
* @returns Parsed languageData.json contents, or null if file doesn't exist or is invalid
|
|
1921
|
+
*/
|
|
1922
|
+
function readDataJson(dataJsonPath) {
|
|
1923
|
+
try {
|
|
1924
|
+
if (!fs.existsSync(dataJsonPath)) return null;
|
|
1925
|
+
const dataJsonContent = fs.readFileSync(dataJsonPath, "utf8");
|
|
1926
|
+
return convertFromDataJsonFormat(JSON.parse(dataJsonContent));
|
|
1927
|
+
} catch (error) {
|
|
1928
|
+
console.warn("Error reading or parsing languageData.json:", error);
|
|
1929
|
+
return null;
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
//#endregion
|
|
1933
|
+
//#region src/setup/providerCli.ts
|
|
1934
|
+
/**
|
|
1935
|
+
* `gst-run-provider` CLI entrypoint. Reads a JSON {@link ProviderRuntimeConfig} from
|
|
1936
|
+
* `--config`, resolves it to concrete providers, runs the full pipeline (input →
|
|
1937
|
+
* transform → output → sync → optional asset sync), and writes the resulting
|
|
1938
|
+
* translation files to disk.
|
|
1939
|
+
*/
|
|
1940
|
+
function parseArgs(argv) {
|
|
1941
|
+
const result = {};
|
|
1942
|
+
for (const arg of argv.slice(2)) {
|
|
1943
|
+
const match = arg.match(/^--([^=]+)(?:=(.*))?$/);
|
|
1944
|
+
if (match) result[match[1]] = match[2] ?? "true";
|
|
1945
|
+
}
|
|
1946
|
+
return result;
|
|
1947
|
+
}
|
|
1948
|
+
function printHelp() {
|
|
1949
|
+
console.log(`
|
|
1950
|
+
gst-run-provider - Run translation sync with v3 provider runtime config
|
|
1951
|
+
|
|
1952
|
+
USAGE
|
|
1953
|
+
npx -p @el-j/google-sheet-translations gst-run-provider --config=provider.config.json [options]
|
|
1954
|
+
|
|
1955
|
+
OPTIONS
|
|
1956
|
+
--config=PATH Path to provider runtime config JSON (required)
|
|
1957
|
+
--sheet-titles=CSV Comma-separated table names (default: i18n)
|
|
1958
|
+
--translations-output-dir=DIR Output dir for locale json files (default: translations)
|
|
1959
|
+
--locales-output-path=PATH Output path for locales.ts (default: src/i18n/locales.ts)
|
|
1960
|
+
--data-json-path=PATH Output path for languageData.json (default: src/lib/languageData.json)
|
|
1961
|
+
--asset-target-dir=DIR Run asset sync (requires "assetSync" in config) and write assets here
|
|
1962
|
+
--asset-delete-missing Delete local assets not present in the asset manifest
|
|
1963
|
+
--help Show this help
|
|
1964
|
+
|
|
1965
|
+
EXAMPLE
|
|
1966
|
+
gst-run-provider \
|
|
1967
|
+
--config=provider.config.json \
|
|
1968
|
+
--sheet-titles=home,about,pricing \
|
|
1969
|
+
--asset-target-dir=public/assets
|
|
1970
|
+
`);
|
|
1971
|
+
}
|
|
1972
|
+
async function main() {
|
|
1973
|
+
const args = parseArgs(process.argv);
|
|
1974
|
+
if (args.help || args.h) {
|
|
1975
|
+
printHelp();
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
const configPath = args.config;
|
|
1979
|
+
if (!configPath) {
|
|
1980
|
+
console.error("Missing required argument: --config=provider.config.json");
|
|
1981
|
+
process.exit(1);
|
|
1982
|
+
}
|
|
1983
|
+
const cwd = process.cwd();
|
|
1984
|
+
const absConfigPath = path.resolve(cwd, configPath);
|
|
1985
|
+
const rawConfig = fs.readFileSync(absConfigPath, "utf8");
|
|
1986
|
+
const config = assertValidProviderRuntimeConfig(JSON.parse(rawConfig));
|
|
1987
|
+
const sheetTitles = (args["sheet-titles"] ?? "i18n").split(",").map((s) => s.trim()).filter(Boolean);
|
|
1988
|
+
const translationsOutputDir = path.resolve(cwd, args["translations-output-dir"] ?? "translations");
|
|
1989
|
+
const localesOutputPath = path.resolve(cwd, args["locales-output-path"] ?? "src/i18n/locales.ts");
|
|
1990
|
+
const dataJsonPath = path.resolve(cwd, args["data-json-path"] ?? "src/lib/languageData.json");
|
|
1991
|
+
const assetTargetDirArg = args["asset-target-dir"];
|
|
1992
|
+
const configAssetTargetDir = typeof config.assetSync?.options?.targetDirectory === "string" && config.assetSync.options.targetDirectory.trim().length > 0 ? config.assetSync.options.targetDirectory.trim() : void 0;
|
|
1993
|
+
const effectiveAssetTargetDir = assetTargetDirArg || configAssetTargetDir;
|
|
1994
|
+
const effectiveAssetDeleteMissing = assetTargetDirArg ? args["asset-delete-missing"] === "true" : Boolean(config.assetSync?.options?.deleteMissing);
|
|
1995
|
+
const assetSync = effectiveAssetTargetDir ? {
|
|
1996
|
+
targetDirectory: path.resolve(cwd, effectiveAssetTargetDir),
|
|
1997
|
+
deleteMissing: effectiveAssetDeleteMissing
|
|
1998
|
+
} : void 0;
|
|
1999
|
+
const providers = createProvidersFromRuntimeConfig(config);
|
|
2000
|
+
const localDataForSync = readDataJson(dataJsonPath) ?? void 0;
|
|
2001
|
+
const result = await runProviderPipeline({
|
|
2002
|
+
inputProvider: providers.inputProvider,
|
|
2003
|
+
outputProvider: providers.outputProvider,
|
|
2004
|
+
syncProvider: providers.syncProvider,
|
|
2005
|
+
assetSyncProvider: providers.assetSyncProvider,
|
|
2006
|
+
assetSync,
|
|
2007
|
+
tableNames: sheetTitles,
|
|
2008
|
+
localTranslationsForSync: localDataForSync
|
|
2009
|
+
});
|
|
2010
|
+
writeTranslationFiles(result.translations, result.locales, translationsOutputDir);
|
|
2011
|
+
writeLocalesFile(result.locales, result.localeMapping, localesOutputPath);
|
|
2012
|
+
if (result.locales.length > 0) writeLanguageDataFile(result.translations, result.locales, dataJsonPath);
|
|
2013
|
+
console.log(`Provider pipeline completed with ${result.locales.length} locale(s).`);
|
|
2014
|
+
if (result.assetSyncResult) {
|
|
2015
|
+
const { manifestCount, downloaded, updated, deleted, skipped } = result.assetSyncResult;
|
|
2016
|
+
console.log(`Asset sync completed: ${manifestCount} manifest entr${manifestCount === 1 ? "y" : "ies"}, ${downloaded.length} downloaded, ${updated.length} updated, ${deleted.length} deleted, ${skipped.length} skipped.`);
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
main().catch((err) => {
|
|
2020
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2021
|
+
console.error(message);
|
|
2022
|
+
process.exit(1);
|
|
2023
|
+
});
|
|
2024
|
+
//#endregion
|
|
2025
|
+
export {};
|