@el-j/google-sheet-translations 3.0.0-beta.4 → 3.0.0-beta.5

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.
Files changed (32) hide show
  1. package/dist/esm/index.js +501 -16
  2. package/dist/index.js +501 -16
  3. package/dist/providers/cryptpad/assetProvider.d.ts +5 -4
  4. package/dist/providers/cryptpad/assetProvider.d.ts.map +1 -1
  5. package/dist/providers/cryptpad/client.d.ts +21 -4
  6. package/dist/providers/cryptpad/client.d.ts.map +1 -1
  7. package/dist/providers/cryptpad/crypto.d.ts +5 -0
  8. package/dist/providers/cryptpad/crypto.d.ts.map +1 -1
  9. package/dist/providers/cryptpad/driveCatalogProvider.d.ts +23 -0
  10. package/dist/providers/cryptpad/driveCatalogProvider.d.ts.map +1 -0
  11. package/dist/providers/cryptpad/driveClient.d.ts +42 -0
  12. package/dist/providers/cryptpad/driveClient.d.ts.map +1 -0
  13. package/dist/providers/cryptpad/index.d.ts +13 -5
  14. package/dist/providers/cryptpad/index.d.ts.map +1 -1
  15. package/dist/providers/cryptpad/netflux.d.ts +10 -0
  16. package/dist/providers/cryptpad/netflux.d.ts.map +1 -1
  17. package/dist/providers/cryptpad/sheetOutputProvider.d.ts +34 -0
  18. package/dist/providers/cryptpad/sheetOutputProvider.d.ts.map +1 -0
  19. package/dist/providers/cryptpad/sheetParser.d.ts +27 -0
  20. package/dist/providers/cryptpad/sheetParser.d.ts.map +1 -1
  21. package/dist/providers/cryptpad/sheetProvider.d.ts.map +1 -1
  22. package/dist/providers/cryptpad/sheetSyncProvider.d.ts +29 -0
  23. package/dist/providers/cryptpad/sheetSyncProvider.d.ts.map +1 -0
  24. package/dist/providers/index.d.ts +2 -2
  25. package/dist/providers/index.d.ts.map +1 -1
  26. package/dist/providers/runtime.d.ts.map +1 -1
  27. package/dist/setup/cryptpadCli.d.ts +2 -0
  28. package/dist/setup/cryptpadCli.d.ts.map +1 -0
  29. package/dist/utils/localeFilter.d.ts.map +1 -1
  30. package/dist-cli/cryptpad.mjs +1755 -0
  31. package/dist-cli/run-provider.mjs +501 -16
  32. package/package.json +21 -8
package/dist/esm/index.js CHANGED
@@ -265,6 +265,10 @@ const COMMON_LOCALE_PATTERNS = [
265
265
  /^[a-z]{2}-[a-z]{2}-[a-z]+$/
266
266
  ];
267
267
  const NON_LOCALE_KEYWORDS = [
268
+ "var",
269
+ "vars",
270
+ "variable",
271
+ "variables",
268
272
  "key",
269
273
  "keys",
270
274
  "id",
@@ -3269,6 +3273,10 @@ function b64Decode(str) {
3269
3273
  if (pad) s += "=".repeat(4 - pad);
3270
3274
  return new Uint8Array(Buffer.from(s, "base64"));
3271
3275
  }
3276
+ /** Encodes bytes to standard base64 string. */
3277
+ function b64Encode(bytes) {
3278
+ return Buffer.from(bytes).toString("base64");
3279
+ }
3272
3280
  /** Decodes UTF-8 string to Uint8Array. */
3273
3281
  function decodeUTF8(str) {
3274
3282
  return new Uint8Array(Buffer.from(str, "utf8"));
@@ -3353,6 +3361,16 @@ function decryptCryptPadPayload(payload, cryptKey) {
3353
3361
  }
3354
3362
  return null;
3355
3363
  }
3364
+ /**
3365
+ * Encrypts a plaintext message payload using TweetNaCl secretbox (XSalsa20-Poly1305).
3366
+ * Formats output as `base64(nonce)|base64(ciphertext)`.
3367
+ */
3368
+ function encryptCryptPadPayload(plaintext, cryptKey) {
3369
+ const nonce = nacl.randomBytes(24);
3370
+ const msgBytes = decodeUTF8(plaintext);
3371
+ const cipher = nacl.secretbox(msgBytes, nonce, cryptKey);
3372
+ return `${b64Encode(nonce)}|${b64Encode(cipher)}`;
3373
+ }
3356
3374
  //#endregion
3357
3375
  //#region src/providers/cryptpad/netflux.ts
3358
3376
  /**
@@ -3461,6 +3479,69 @@ function fetchChannelHistory(wsUrl, channelHex, cryptKey, options = {}) {
3461
3479
  };
3462
3480
  });
3463
3481
  }
3482
+ /**
3483
+ * Connects to the CryptPad Netflux WebSocket server, joins a channel,
3484
+ * encrypts the given message with TweetNaCl, and broadcasts it to all peers
3485
+ * in the channel (including historyKeeper).
3486
+ */
3487
+ function broadcastChannelMessage(wsUrl, channelHex, cryptKey, message, options = {}) {
3488
+ const { timeoutMs = 8e3, signal } = options;
3489
+ return new Promise((resolve, reject) => {
3490
+ if (signal?.aborted) return reject(/* @__PURE__ */ new Error("Operation aborted"));
3491
+ const ws = new WebSocket(wsUrl);
3492
+ let seq = 1;
3493
+ let timeout = null;
3494
+ let sent = false;
3495
+ const cleanup = () => {
3496
+ if (timeout) clearTimeout(timeout);
3497
+ try {
3498
+ ws.close();
3499
+ } catch {}
3500
+ };
3501
+ timeout = setTimeout(() => {
3502
+ cleanup();
3503
+ if (sent) resolve();
3504
+ else reject(/* @__PURE__ */ new Error(`Timeout after ${timeoutMs}ms broadcasting message to CryptPad channel "${channelHex}".`));
3505
+ }, timeoutMs);
3506
+ if (signal) signal.addEventListener("abort", () => {
3507
+ cleanup();
3508
+ reject(/* @__PURE__ */ new Error("Operation aborted"));
3509
+ });
3510
+ ws.onopen = () => {
3511
+ ws.send(JSON.stringify([
3512
+ seq++,
3513
+ "JOIN",
3514
+ channelHex
3515
+ ]));
3516
+ };
3517
+ ws.onmessage = (event) => {
3518
+ try {
3519
+ const raw = typeof event.data === "string" ? event.data : event.data.toString();
3520
+ const msg = JSON.parse(raw);
3521
+ if (!Array.isArray(msg)) return;
3522
+ const [, peerId, cmd] = msg;
3523
+ if (cmd === "JOIN" && typeof peerId === "string" && !sent) {
3524
+ sent = true;
3525
+ const encrypted = encryptCryptPadPayload(message, cryptKey);
3526
+ ws.send(JSON.stringify([
3527
+ seq++,
3528
+ "MSG",
3529
+ channelHex,
3530
+ encrypted
3531
+ ]));
3532
+ setTimeout(() => {
3533
+ cleanup();
3534
+ resolve();
3535
+ }, 350);
3536
+ }
3537
+ } catch {}
3538
+ };
3539
+ ws.onerror = (err) => {
3540
+ cleanup();
3541
+ reject(/* @__PURE__ */ new Error(`CryptPad WebSocket broadcast error: ${String(err)}`));
3542
+ };
3543
+ });
3544
+ }
3464
3545
  //#endregion
3465
3546
  //#region src/providers/cryptpad/sheetParser.ts
3466
3547
  /** Converts column index (0-based) to letter: 0 -> 'A', 25 -> 'Z', 26 -> 'AA'. */
@@ -3525,9 +3606,9 @@ function parseOnlyOfficeChanges(rtMessages) {
3525
3606
  const strLen = buf.readUInt32LE(i + 1);
3526
3607
  if (strLen > 0 && strLen < 4e3 && i + 5 + strLen <= buf.length) {
3527
3608
  const str = buf.subarray(i + 5, i + 5 + strLen).toString("utf16le");
3528
- const cellMatch = str.match(/^(?:Sheet\d+!)?([A-Z]+)(\d+)$/);
3609
+ const cellMatch = str.match(/^(?:([^!]+)!)?([A-Z]+)(\d+)$/);
3529
3610
  if (cellMatch) {
3530
- const cellRef = `${cellMatch[1]}${cellMatch[2]}`;
3611
+ const cellRef = `${cellMatch[1] ? `${cellMatch[1]}!` : ""}${cellMatch[2]}${cellMatch[3]}`;
3531
3612
  const searchStart = i + 5 + strLen;
3532
3613
  for (let j = searchStart; j < Math.min(searchStart + 30, buf.length - 5); j++) if (buf[j] === 8) {
3533
3614
  const nextLen = buf.readUInt32LE(j + 1);
@@ -3577,10 +3658,67 @@ function convertCellsToSheetRows(cells) {
3577
3658
  const rowCells = rowMap.get(rowIdx);
3578
3659
  const sheetRow = {};
3579
3660
  for (const [col, headerName] of colToHeaderName.entries()) sheetRow[headerName] = rowCells.get(col) ?? "";
3661
+ if (sheetRow.var !== void 0 && sheetRow.key === void 0) sheetRow.key = sheetRow.var;
3580
3662
  if (Object.values(sheetRow).some((v) => typeof v === "string" && v.trim().length > 0)) resultRows.push(sheetRow);
3581
3663
  }
3582
3664
  return resultRows;
3583
3665
  }
3666
+ /**
3667
+ * Groups a flat grid of cell coordinates by sheet tab name.
3668
+ * Cells without an explicit sheet prefix are assigned to `defaultSheet` (defaults to 'Sheet1').
3669
+ */
3670
+ function groupCellsBySheet(cells, defaultSheet = "Sheet1") {
3671
+ const result = {};
3672
+ for (const [cellRef, val] of Object.entries(cells)) {
3673
+ const parsed = parseCellRef(cellRef);
3674
+ if (!parsed) continue;
3675
+ const sheetName = parsed.sheet && parsed.sheet.trim().length > 0 ? parsed.sheet.trim() : defaultSheet;
3676
+ if (!result[sheetName]) result[sheetName] = {};
3677
+ const flatRef = `${parsed.col}${parsed.row}`;
3678
+ result[sheetName][flatRef] = val;
3679
+ }
3680
+ if (Object.keys(result).length === 0 && Object.keys(cells).length > 0) result[defaultSheet] = { ...cells };
3681
+ return result;
3682
+ }
3683
+ /**
3684
+ * Converts a grid of cell references into a dictionary of SheetRow arrays keyed by sheet tab name.
3685
+ */
3686
+ function convertCellsToMultiSheetRows(cells, defaultSheet = "Sheet1") {
3687
+ const grouped = groupCellsBySheet(cells, defaultSheet);
3688
+ const result = {};
3689
+ for (const [sheetName, sheetGrid] of Object.entries(grouped)) result[sheetName] = convertCellsToSheetRows(sheetGrid);
3690
+ return result;
3691
+ }
3692
+ /**
3693
+ * Encodes a single cell update record into OnlyOffice binary format (0x08 prefix + UTF-16LE string).
3694
+ */
3695
+ function encodeOnlyOfficeCellRecord(cellRef, value) {
3696
+ const refBuf = Buffer.from(cellRef, "utf16le");
3697
+ const valBuf = Buffer.from(value, "utf16le");
3698
+ const refHeader = Buffer.alloc(5);
3699
+ refHeader[0] = 8;
3700
+ refHeader.writeUInt32LE(refBuf.length, 1);
3701
+ const valHeader = Buffer.alloc(5);
3702
+ valHeader[0] = 8;
3703
+ valHeader.writeUInt32LE(valBuf.length, 1);
3704
+ return Buffer.concat([
3705
+ refHeader,
3706
+ refBuf,
3707
+ valHeader,
3708
+ valBuf
3709
+ ]);
3710
+ }
3711
+ /**
3712
+ * Formats a list of cell updates into an OnlyOffice change transaction JSON string.
3713
+ */
3714
+ function buildOnlyOfficeChangePayload(updates) {
3715
+ const records = updates.map((u) => {
3716
+ const colStr = typeof u.col === "number" ? colIndexToLetter(u.col) : u.col.toUpperCase();
3717
+ return encodeOnlyOfficeCellRecord(`${u.sheet && u.sheet.trim().length > 0 ? `${u.sheet.trim()}!` : ""}${colStr}${u.row}`, u.value);
3718
+ });
3719
+ const changeEntry = `asc_1;${Buffer.concat(records).toString("base64")}`;
3720
+ return JSON.stringify({ changes: [{ change: changeEntry }] });
3721
+ }
3584
3722
  //#endregion
3585
3723
  //#region src/providers/cryptpad/client.ts
3586
3724
  /**
@@ -3607,8 +3745,8 @@ var CryptPadClient = class {
3607
3745
  return resolveCryptPadWebsocketUrl(this.parsedUrl.origin, signal);
3608
3746
  }
3609
3747
  /**
3610
- * Fetches the complete sheet data, including raw cell coordinate mappings
3611
- * and structured rows formatted for translation ingestion.
3748
+ * Fetches the complete sheet data, including raw cell coordinate mappings,
3749
+ * multi-sheet tabs, and structured rows formatted for translation ingestion.
3612
3750
  */
3613
3751
  async fetchSheetData(signal) {
3614
3752
  const wsUrl = await this.getWebsocketUrl(signal);
@@ -3622,11 +3760,22 @@ var CryptPadClient = class {
3622
3760
  timeoutMs: this.timeoutMs,
3623
3761
  signal
3624
3762
  }));
3625
- const rows = convertCellsToSheetRows(cells);
3763
+ const multiSheets = convertCellsToMultiSheetRows(cells);
3764
+ const sheetNames = Object.keys(multiSheets);
3765
+ const defaultSheet = multiSheets["Sheet1"] ? "Sheet1" : sheetNames[0];
3766
+ const defaultRows = defaultSheet && multiSheets[defaultSheet] ? multiSheets[defaultSheet] : convertCellsToSheetRows(cells);
3767
+ const groupedCells = groupCellsBySheet(cells);
3768
+ const sheetsResult = {};
3769
+ for (const name of sheetNames) sheetsResult[name] = {
3770
+ cells: groupedCells[name] ?? {},
3771
+ rows: multiSheets[name] ?? []
3772
+ };
3626
3773
  return {
3627
3774
  url: this.parsedUrl.cleanUrl,
3628
3775
  cells,
3629
- rows,
3776
+ rows: defaultRows,
3777
+ sheets: sheetsResult,
3778
+ sheetNames,
3630
3779
  metadata: {
3631
3780
  app: this.parsedUrl.app,
3632
3781
  mode: this.parsedUrl.mode,
@@ -3637,9 +3786,83 @@ var CryptPadClient = class {
3637
3786
  }
3638
3787
  /**
3639
3788
  * Directly fetches tabular translation rows `[ { key: '...', en: '...', de: '...' } ]`.
3789
+ * If `sheetName` is provided, returns rows for that specific tab.
3790
+ */
3791
+ async fetchSheetRows(sheetName, signal) {
3792
+ const result = await this.fetchSheetData(signal);
3793
+ if (sheetName && result.sheets[sheetName]) return result.sheets[sheetName].rows;
3794
+ return result.rows;
3795
+ }
3796
+ /**
3797
+ * Broadcasts cell updates to the OnlyOffice real-time collaboration channel.
3798
+ */
3799
+ async sendCellUpdates(updates, signal) {
3800
+ if (updates.length === 0) return;
3801
+ const rtChannel = (await this.fetchSheetData(signal)).metadata.rtChannelId;
3802
+ if (!rtChannel) throw new Error(`CryptPad sheet "${this.parsedUrl.cleanUrl}" does not have an active OnlyOffice RT channel.`);
3803
+ const wsUrl = await this.getWebsocketUrl(signal);
3804
+ const { cryptKey } = this.getKeys();
3805
+ await broadcastChannelMessage(wsUrl, rtChannel, cryptKey, buildOnlyOfficeChangePayload(updates), {
3806
+ timeoutMs: this.timeoutMs,
3807
+ signal
3808
+ });
3809
+ }
3810
+ /**
3811
+ * Updates or appends rows in a target sheet tab, creating new cells as needed.
3640
3812
  */
3641
- async fetchSheetRows(signal) {
3642
- return (await this.fetchSheetData(signal)).rows;
3813
+ async writeSheetRows(sheetName, rows, options = {}) {
3814
+ if (rows.length === 0) return 0;
3815
+ const existingRows = (await this.fetchSheetData(options.signal)).sheets[sheetName]?.rows ?? [];
3816
+ let keyColName = "var";
3817
+ const firstExisting = existingRows[0];
3818
+ const firstIncoming = rows[0];
3819
+ if (firstExisting) {
3820
+ if ("var" in firstExisting) keyColName = "var";
3821
+ else if ("key" in firstExisting) keyColName = "key";
3822
+ } else if (firstIncoming) {
3823
+ if ("var" in firstIncoming) keyColName = "var";
3824
+ else if ("key" in firstIncoming) keyColName = "key";
3825
+ }
3826
+ const colNamesSet = /* @__PURE__ */ new Set([keyColName]);
3827
+ for (const r of existingRows) for (const k of Object.keys(r)) if (k !== "key" && k !== "var") colNamesSet.add(k);
3828
+ for (const r of rows) for (const k of Object.keys(r)) if (k !== "key" && k !== "var") colNamesSet.add(k);
3829
+ const colNames = Array.from(colNamesSet);
3830
+ const updates = [];
3831
+ colNames.forEach((name, idx) => {
3832
+ updates.push({
3833
+ sheet: sheetName,
3834
+ col: colIndexToLetter(idx),
3835
+ row: 1,
3836
+ value: name
3837
+ });
3838
+ });
3839
+ const keyToRowIdx = /* @__PURE__ */ new Map();
3840
+ existingRows.forEach((r, idx) => {
3841
+ const rowKey = r.var ?? r.key ?? r[keyColName];
3842
+ if (rowKey) keyToRowIdx.set(rowKey, idx + 2);
3843
+ });
3844
+ let nextAvailableRow = existingRows.length + 2;
3845
+ for (const row of rows) {
3846
+ const rowKey = row.var ?? row.key ?? row[keyColName];
3847
+ if (!rowKey) continue;
3848
+ const targetRow = keyToRowIdx.get(rowKey) ?? nextAvailableRow++;
3849
+ keyToRowIdx.set(rowKey, targetRow);
3850
+ for (const [colName, val] of Object.entries(row)) {
3851
+ const mappedColName = colName === "key" || colName === "var" ? keyColName : colName;
3852
+ const colIdx = colNames.indexOf(mappedColName);
3853
+ if (colIdx >= 0 && val !== void 0) {
3854
+ const existingVal = existingRows[targetRow - 2]?.[mappedColName];
3855
+ if (options.override || !existingVal || existingVal.trim().length === 0) updates.push({
3856
+ sheet: sheetName,
3857
+ col: colIndexToLetter(colIdx),
3858
+ row: targetRow,
3859
+ value: String(val)
3860
+ });
3861
+ }
3862
+ }
3863
+ }
3864
+ await this.sendCellUpdates(updates, options.signal);
3865
+ return updates.length;
3643
3866
  }
3644
3867
  };
3645
3868
  //#endregion
@@ -3673,7 +3896,7 @@ function createCryptPadSheetInputProvider(options) {
3673
3896
  const requested = new Set((request.tableNames ?? []).filter(Boolean));
3674
3897
  const selectedSources = requested.size === 0 ? sources : sources.filter((source) => requested.has(source.tableName));
3675
3898
  return {
3676
- tables: await Promise.all(selectedSources.map(async (source) => {
3899
+ tables: (await Promise.all(selectedSources.map(async (source) => {
3677
3900
  const url = source.url ?? options.url ?? process.env.CRYPTPAD_URL;
3678
3901
  if (!url) throw new Error(`CryptPad sheet source "${source.tableName}" does not define a URL.`);
3679
3902
  const sheetData = await new CryptPadClient({
@@ -3681,7 +3904,22 @@ function createCryptPadSheetInputProvider(options) {
3681
3904
  password: source.password ?? options.password ?? process.env.CRYPTPAD_PASSWORD,
3682
3905
  timeoutMs: options.timeoutMs
3683
3906
  }).fetchSheetData(request.signal);
3684
- return {
3907
+ const sourceTables = [];
3908
+ if (Array.isArray(sheetData.sheetNames) && sheetData.sheetNames.length > 1) {
3909
+ for (const tabName of sheetData.sheetNames) if (requested.size === 0 || requested.has(tabName) || requested.has(source.tableName)) sourceTables.push({
3910
+ tableId: `${source.tableId ?? url}#${tabName}`,
3911
+ tableName: tabName,
3912
+ rows: sheetData.sheets[tabName]?.rows ?? [],
3913
+ sourcePath: url,
3914
+ metadata: {
3915
+ provider: "cryptpad-sheet",
3916
+ channelId: sheetData.metadata.channelId,
3917
+ rtChannelId: sheetData.metadata.rtChannelId,
3918
+ sheetTab: tabName
3919
+ }
3920
+ });
3921
+ }
3922
+ if (sourceTables.length === 0) sourceTables.push({
3685
3923
  tableId: source.tableId ?? url,
3686
3924
  tableName: source.tableName,
3687
3925
  rows: sheetData.rows,
@@ -3692,8 +3930,9 @@ function createCryptPadSheetInputProvider(options) {
3692
3930
  rtChannelId: sheetData.metadata.rtChannelId,
3693
3931
  cellCount: Object.keys(sheetData.cells).length
3694
3932
  }
3695
- };
3696
- })),
3933
+ });
3934
+ return sourceTables;
3935
+ }))).flat(),
3697
3936
  metadata: {
3698
3937
  provider: "cryptpad-sheet",
3699
3938
  sourceCount: selectedSources.length
@@ -3703,6 +3942,72 @@ function createCryptPadSheetInputProvider(options) {
3703
3942
  };
3704
3943
  }
3705
3944
  //#endregion
3945
+ //#region src/providers/cryptpad/sheetOutputProvider.ts
3946
+ const CRYPTPAD_SHEET_OUTPUT_CAPABILITIES = createCapabilitySet({ writeTables: true });
3947
+ /**
3948
+ * Converts nested TranslationData `[locale][sheet][key] = value` into
3949
+ * tabular rows `Record<sheetName, SheetRow[]>`.
3950
+ */
3951
+ function convertTranslationsToSheetRows(translations, localeMapping = {}, keyColumnName = "key") {
3952
+ const reverseMapping = {};
3953
+ for (const [header, norm] of Object.entries(localeMapping)) reverseMapping[norm] = header;
3954
+ const sheetRowsMap = {};
3955
+ for (const [locale, sheets] of Object.entries(translations)) {
3956
+ const colHeader = reverseMapping[locale] ?? locale;
3957
+ for (const [sheetName, keys] of Object.entries(sheets)) {
3958
+ if (!sheetRowsMap[sheetName]) sheetRowsMap[sheetName] = /* @__PURE__ */ new Map();
3959
+ for (const [key, value] of Object.entries(keys)) {
3960
+ if (!sheetRowsMap[sheetName].has(key)) sheetRowsMap[sheetName].set(key, { [keyColumnName]: key });
3961
+ sheetRowsMap[sheetName].get(key)[colHeader] = String(value);
3962
+ }
3963
+ }
3964
+ }
3965
+ const result = {};
3966
+ for (const [sheetName, keyMap] of Object.entries(sheetRowsMap)) result[sheetName] = Array.from(keyMap.values());
3967
+ return result;
3968
+ }
3969
+ /**
3970
+ * Creates a {@link TranslationOutputProvider} that writes translation data directly
3971
+ * into password-protected or public CryptPad spreadsheets (multi-tab OnlyOffice workbooks)
3972
+ * using end-to-end encrypted Netflux WebSockets.
3973
+ */
3974
+ function createCryptPadSheetOutputProvider(options = {}) {
3975
+ const url = options.url ?? process.env.CRYPTPAD_URL;
3976
+ if (!url) throw new Error("CryptPad Sheet output provider requires a \"url\" option or CRYPTPAD_URL environment variable.");
3977
+ const password = options.password ?? process.env.CRYPTPAD_PASSWORD;
3978
+ return {
3979
+ kind: "output",
3980
+ providerId: options.providerId ?? "cryptpad-sheet",
3981
+ displayName: options.displayName ?? "CryptPad Sheet Output (E2EE)",
3982
+ capabilities: CRYPTPAD_SHEET_OUTPUT_CAPABILITIES,
3983
+ async writeTranslations(payload) {
3984
+ const client = new CryptPadClient({
3985
+ url,
3986
+ password,
3987
+ timeoutMs: options.timeoutMs
3988
+ });
3989
+ const effectiveMapping = options.localeMapping ?? payload.localeMapping ?? {};
3990
+ const sheetRowsMap = convertTranslationsToSheetRows(payload.translations, effectiveMapping, options.keyColumnName ?? "key");
3991
+ const updatedSheets = [];
3992
+ let totalUpdatedCells = 0;
3993
+ for (const [sheetName, rows] of Object.entries(sheetRowsMap)) {
3994
+ const count = await client.writeSheetRows(sheetName, rows, { override: options.override ?? false });
3995
+ updatedSheets.push(sheetName);
3996
+ totalUpdatedCells += count;
3997
+ }
3998
+ return {
3999
+ wroteFiles: [url],
4000
+ metadata: {
4001
+ provider: "cryptpad-sheet",
4002
+ url,
4003
+ updatedSheets,
4004
+ totalUpdatedCells
4005
+ }
4006
+ };
4007
+ }
4008
+ };
4009
+ }
4010
+ //#endregion
3706
4011
  //#region src/providers/syncEngine.ts
3707
4012
  function pathKey(locale, sheet, key) {
3708
4013
  return `${locale}::${sheet}::${key}`;
@@ -3849,6 +4154,148 @@ function resolveSyncPlan(input, policy) {
3849
4154
  };
3850
4155
  }
3851
4156
  //#endregion
4157
+ //#region src/providers/cryptpad/sheetSyncProvider.ts
4158
+ const CRYPTPAD_SHEET_SYNC_CAPABILITIES = createCapabilitySet({
4159
+ syncBack: true,
4160
+ writeTables: true
4161
+ });
4162
+ /**
4163
+ * Creates a {@link TranslationSyncProvider} that reconciles local translation changes
4164
+ * against a live CryptPad spreadsheet (multi-tab OnlyOffice workbook) using three-way
4165
+ * diffing and conflict policies, pushing only the resolved diffs back over Netflux.
4166
+ */
4167
+ function createCryptPadSheetSyncProvider(options = {}) {
4168
+ const url = options.url ?? process.env.CRYPTPAD_URL;
4169
+ if (!url) throw new Error("CryptPad Sheet sync provider requires a \"url\" option or CRYPTPAD_URL environment variable.");
4170
+ const password = options.password ?? process.env.CRYPTPAD_PASSWORD;
4171
+ return {
4172
+ kind: "sync",
4173
+ providerId: options.providerId ?? "cryptpad-sheet",
4174
+ displayName: options.displayName ?? "CryptPad Sheet Sync (E2EE)",
4175
+ capabilities: CRYPTPAD_SHEET_SYNC_CAPABILITIES,
4176
+ async syncTranslations(payload) {
4177
+ const resolution = resolveSyncPlan({
4178
+ baseTranslations: payload.metadata?.baseTranslations ?? payload.remoteTranslations,
4179
+ localTranslations: payload.localTranslations,
4180
+ remoteTranslations: payload.remoteTranslations
4181
+ }, options.conflictPolicy ?? "manual");
4182
+ if (resolution.appliedLocalChanges === 0) return {
4183
+ changedKeys: 0,
4184
+ skippedKeys: resolution.skippedConflicts,
4185
+ metadata: {
4186
+ reason: "no-local-diff",
4187
+ url,
4188
+ policy: resolution.policy
4189
+ }
4190
+ };
4191
+ const client = new CryptPadClient({
4192
+ url,
4193
+ password,
4194
+ timeoutMs: options.timeoutMs
4195
+ });
4196
+ const effectiveMapping = options.localeMapping ?? {};
4197
+ const sheetRowsMap = convertTranslationsToSheetRows(resolution.mergedTranslations, effectiveMapping);
4198
+ for (const [sheetName, rows] of Object.entries(sheetRowsMap)) await client.writeSheetRows(sheetName, rows, { override: options.override ?? options.conflictPolicy === "local-wins" });
4199
+ return {
4200
+ changedKeys: resolution.appliedLocalChanges,
4201
+ skippedKeys: resolution.skippedConflicts,
4202
+ metadata: {
4203
+ provider: "cryptpad-sheet",
4204
+ url,
4205
+ policy: resolution.policy
4206
+ }
4207
+ };
4208
+ }
4209
+ };
4210
+ }
4211
+ //#endregion
4212
+ //#region src/providers/cryptpad/driveClient.ts
4213
+ /**
4214
+ * Headless client for inspecting and traversing encrypted CryptPad Drive folders.
4215
+ */
4216
+ var CryptPadDriveClient = class {
4217
+ constructor(options) {
4218
+ if (!options.url || typeof options.url !== "string") throw new Error("CryptPadDriveClient requires a valid \"url\" option.");
4219
+ this.parsedUrl = parsePadUrl(options.url);
4220
+ this.password = options.password ?? process.env.CRYPTPAD_PASSWORD;
4221
+ this.passwords = options.passwords ?? {};
4222
+ this.timeoutMs = options.timeoutMs ?? 1e4;
4223
+ }
4224
+ /**
4225
+ * Fetches the drive pad's Netflux history, decrypts all changes, and parses
4226
+ * the hierarchical filesystem tree into a flat list of items.
4227
+ */
4228
+ async listDriveItems(signal) {
4229
+ const wsUrl = await resolveCryptPadWebsocketUrl(this.parsedUrl.origin, signal);
4230
+ const { channelHex, cryptKey } = deriveCryptPadKeys(this.parsedUrl.seed, this.password);
4231
+ const messages = await fetchChannelHistory(wsUrl, channelHex, cryptKey, {
4232
+ timeoutMs: this.timeoutMs,
4233
+ signal
4234
+ });
4235
+ const items = [];
4236
+ for (const raw of messages) try {
4237
+ const parsed = JSON.parse(raw);
4238
+ if (parsed && typeof parsed === "object") {
4239
+ const fileDict = parsed.files || parsed.data?.files || parsed;
4240
+ if (fileDict && typeof fileDict === "object" && !Array.isArray(fileDict)) for (const [id, val] of Object.entries(fileDict)) {
4241
+ if (!val || typeof val !== "object") continue;
4242
+ const fileObj = val;
4243
+ const title = String(fileObj.title || fileObj.name || id);
4244
+ const href = String(fileObj.href || fileObj.url || "");
4245
+ const typeStr = String(fileObj.type || fileObj.app || (href.includes("/sheet/") ? "sheet" : "file"));
4246
+ const type = typeStr.includes("sheet") ? "sheet" : typeStr.includes("folder") ? "folder" : typeStr.includes("pad") ? "pad" : "file";
4247
+ const itemUrl = href.startsWith("http") ? href : `${this.parsedUrl.origin}${href.startsWith("/") ? "" : "/"}${href}`;
4248
+ const password = fileObj.password ?? this.passwords[itemUrl] ?? this.passwords[title] ?? this.password;
4249
+ items.push({
4250
+ id,
4251
+ title,
4252
+ type,
4253
+ url: itemUrl,
4254
+ password,
4255
+ channel: typeof fileObj.channel === "string" ? fileObj.channel : void 0,
4256
+ path: title,
4257
+ ctime: typeof fileObj.ctime === "number" ? fileObj.ctime : void 0,
4258
+ mtime: typeof fileObj.mtime === "number" ? fileObj.mtime : void 0
4259
+ });
4260
+ }
4261
+ if (Array.isArray(parsed)) for (const entry of parsed) {
4262
+ if (!entry || typeof entry !== "object") continue;
4263
+ const fileObj = entry;
4264
+ const title = String(fileObj.title || fileObj.name || "untitled");
4265
+ const href = String(fileObj.href || fileObj.url || "");
4266
+ const typeStr = String(fileObj.type || fileObj.app || (href.includes("/sheet/") ? "sheet" : "file"));
4267
+ const type = typeStr.includes("sheet") ? "sheet" : typeStr.includes("folder") ? "folder" : typeStr.includes("pad") ? "pad" : "file";
4268
+ const itemUrl = href.startsWith("http") ? href : `${this.parsedUrl.origin}${href.startsWith("/") ? "" : "/"}${href}`;
4269
+ const password = fileObj.password ?? this.passwords[itemUrl] ?? this.passwords[title] ?? this.password;
4270
+ items.push({
4271
+ id: String(fileObj.id || title),
4272
+ title,
4273
+ type,
4274
+ url: itemUrl,
4275
+ password,
4276
+ channel: typeof fileObj.channel === "string" ? fileObj.channel : void 0,
4277
+ path: title
4278
+ });
4279
+ }
4280
+ }
4281
+ } catch {}
4282
+ const seen = /* @__PURE__ */ new Set();
4283
+ const deduplicated = [];
4284
+ for (const item of items) if (item.url && !seen.has(item.url)) {
4285
+ seen.add(item.url);
4286
+ deduplicated.push(item);
4287
+ }
4288
+ return deduplicated;
4289
+ }
4290
+ /**
4291
+ * Filters and returns only spreadsheets found within the drive folder.
4292
+ */
4293
+ async listSheets(signal) {
4294
+ return (await this.listDriveItems(signal)).filter((item) => item.type === "sheet" || item.url.includes("/sheet/"));
4295
+ }
4296
+ };
4297
+ createCapabilitySet({ discoverByFolder: true });
4298
+ //#endregion
3852
4299
  //#region src/providers/cryptpad/fullProvider.ts
3853
4300
  const CRYPTPAD_WORKSPACE_OUTPUT_CAPABILITIES = createCapabilitySet({ writeTables: true });
3854
4301
  const CRYPTPAD_WORKSPACE_SYNC_CAPABILITIES = createCapabilitySet({
@@ -3995,6 +4442,9 @@ function createCryptPadWorkspaceSyncProvider(options, depsOverrides = {}) {
3995
4442
  }
3996
4443
  //#endregion
3997
4444
  //#region src/providers/cryptpad/assetProvider.ts
4445
+ /**
4446
+ * Options for configuring a CryptPad asset synchronization provider.
4447
+ */
3998
4448
  const CRYPTPAD_ASSET_SYNC_CAPABILITIES = createCapabilitySet({
3999
4449
  assetSync: true,
4000
4450
  discoverByFolder: true
@@ -4080,7 +4530,16 @@ function createCryptPadAssetSyncProvider(options, depsOverrides = {}) {
4080
4530
  displayName: options.displayName ?? "CryptPad Asset Sync",
4081
4531
  capabilities: CRYPTPAD_ASSET_SYNC_CAPABILITIES,
4082
4532
  async syncAssets(request) {
4083
- const manifest = await deps.readManifest(options.manifestPath);
4533
+ let manifest = [];
4534
+ if (options.manifestPath) manifest = await deps.readManifest(options.manifestPath);
4535
+ else if (options.driveUrl) manifest = (await new CryptPadDriveClient({
4536
+ url: options.driveUrl,
4537
+ password: options.password
4538
+ }).listDriveItems(request.signal)).filter((item) => item.type !== "folder" && !item.url.includes("/sheet/")).map((item) => ({
4539
+ assetId: item.id || item.title,
4540
+ relativePath: item.path || item.title,
4541
+ sourceUrl: item.url
4542
+ }));
4084
4543
  const downloaded = [];
4085
4544
  const updated = [];
4086
4545
  const skipped = [];
@@ -4417,6 +4876,16 @@ function createInputProvider(providerId, options) {
4417
4876
  function createOutputProvider(providerId, options) {
4418
4877
  switch (providerId) {
4419
4878
  case "google-sheets": return createGoogleSheetsOutputProvider(options);
4879
+ case "cryptpad-sheet":
4880
+ case "cryptpad": return createCryptPadSheetOutputProvider({
4881
+ url: typeof options.url === "string" ? options.url : void 0,
4882
+ password: typeof options.password === "string" ? options.password : void 0,
4883
+ override: options.override === true,
4884
+ localeMapping: typeof options.localeMapping === "object" && options.localeMapping !== null ? options.localeMapping : void 0,
4885
+ providerId: typeof options.providerId === "string" ? options.providerId : void 0,
4886
+ displayName: typeof options.displayName === "string" ? options.displayName : void 0,
4887
+ timeoutMs: typeof options.timeoutMs === "number" ? options.timeoutMs : void 0
4888
+ });
4420
4889
  case "cryptpad-workspace":
4421
4890
  if (typeof options.filePath !== "string" || options.filePath.trim().length === 0) throw new Error("cryptpad-workspace output provider requires a non-empty \"filePath\" option.");
4422
4891
  return createCryptPadWorkspaceOutputProvider({
@@ -4432,6 +4901,17 @@ function createOutputProvider(providerId, options) {
4432
4901
  function createSyncProvider(providerId, options) {
4433
4902
  switch (providerId) {
4434
4903
  case "google-sheets": return createGoogleSheetsSyncProvider(options);
4904
+ case "cryptpad-sheet":
4905
+ case "cryptpad": return createCryptPadSheetSyncProvider({
4906
+ url: typeof options.url === "string" ? options.url : void 0,
4907
+ password: typeof options.password === "string" ? options.password : void 0,
4908
+ override: options.override === true,
4909
+ conflictPolicy: options.conflictPolicy === "remote-wins" || options.conflictPolicy === "local-wins" || options.conflictPolicy === "manual" ? options.conflictPolicy : void 0,
4910
+ localeMapping: typeof options.localeMapping === "object" && options.localeMapping !== null ? options.localeMapping : void 0,
4911
+ providerId: typeof options.providerId === "string" ? options.providerId : void 0,
4912
+ displayName: typeof options.displayName === "string" ? options.displayName : void 0,
4913
+ timeoutMs: typeof options.timeoutMs === "number" ? options.timeoutMs : void 0
4914
+ });
4435
4915
  case "cryptpad-workspace":
4436
4916
  if (typeof options.filePath !== "string" || options.filePath.trim().length === 0) throw new Error("cryptpad-workspace sync provider requires a non-empty \"filePath\" option.");
4437
4917
  return createCryptPadWorkspaceSyncProvider({
@@ -4447,13 +4927,18 @@ function createSyncProvider(providerId, options) {
4447
4927
  }
4448
4928
  function createAssetSyncProvider(providerId, options) {
4449
4929
  switch (providerId) {
4450
- case "cryptpad-assets":
4451
- if (typeof options.manifestPath !== "string" || options.manifestPath.trim().length === 0) throw new Error("cryptpad-assets sync provider requires a non-empty \"manifestPath\" option.");
4930
+ case "cryptpad-assets": {
4931
+ const hasManifest = typeof options.manifestPath === "string" && options.manifestPath.trim().length > 0;
4932
+ const hasDrive = typeof options.driveUrl === "string" && options.driveUrl.trim().length > 0;
4933
+ if (!hasManifest && !hasDrive) throw new Error("cryptpad-assets sync provider requires a non-empty \"manifestPath\" option.");
4452
4934
  return createCryptPadAssetSyncProvider({
4453
- manifestPath: options.manifestPath,
4935
+ manifestPath: typeof options.manifestPath === "string" ? options.manifestPath : void 0,
4936
+ driveUrl: typeof options.driveUrl === "string" ? options.driveUrl : void 0,
4937
+ password: typeof options.password === "string" ? options.password : void 0,
4454
4938
  providerId: typeof options.providerId === "string" ? options.providerId : void 0,
4455
4939
  displayName: typeof options.displayName === "string" ? options.displayName : void 0
4456
4940
  });
4941
+ }
4457
4942
  default: throw new Error(`Unsupported asset sync provider: "${providerId}"`);
4458
4943
  }
4459
4944
  }