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

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 +532 -16
  2. package/dist/index.js +532 -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 +32 -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 +1786 -0
  31. package/dist-cli/run-provider.mjs +532 -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,31 @@ 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
+ * Initializes the OnlyOffice RT channel for a brand-new CryptPad sheet that has never
3749
+ * been opened in a browser. Generates a random 32-hex channel ID, broadcasts it as an
3750
+ * encrypted metadata patch on the Netflux channel (the same thing the OnlyOffice browser
3751
+ * client does on first open), and returns the new RT channel ID.
3752
+ *
3753
+ * This removes the requirement to open the sheet in a browser before the CLI can write to it.
3754
+ */
3755
+ async initializeRtChannel(signal) {
3756
+ const wsUrl = await this.getWebsocketUrl(signal);
3757
+ const { channelHex, cryptKey } = this.getKeys();
3758
+ const newRtChannel = crypto.randomBytes(16).toString("hex");
3759
+ const innerJson = JSON.stringify({ content: { channel: newRtChannel } });
3760
+ await broadcastChannelMessage(wsUrl, channelHex, cryptKey, JSON.stringify([1, [[
3761
+ 0,
3762
+ 0,
3763
+ innerJson
3764
+ ]]]), {
3765
+ timeoutMs: this.timeoutMs,
3766
+ signal
3767
+ });
3768
+ return newRtChannel;
3769
+ }
3770
+ /**
3771
+ * Fetches the complete sheet data, including raw cell coordinate mappings,
3772
+ * multi-sheet tabs, and structured rows formatted for translation ingestion.
3612
3773
  */
3613
3774
  async fetchSheetData(signal) {
3614
3775
  const wsUrl = await this.getWebsocketUrl(signal);
@@ -3622,11 +3783,22 @@ var CryptPadClient = class {
3622
3783
  timeoutMs: this.timeoutMs,
3623
3784
  signal
3624
3785
  }));
3625
- const rows = convertCellsToSheetRows(cells);
3786
+ const multiSheets = convertCellsToMultiSheetRows(cells);
3787
+ const sheetNames = Object.keys(multiSheets);
3788
+ const defaultSheet = multiSheets["Sheet1"] ? "Sheet1" : sheetNames[0];
3789
+ const defaultRows = defaultSheet && multiSheets[defaultSheet] ? multiSheets[defaultSheet] : convertCellsToSheetRows(cells);
3790
+ const groupedCells = groupCellsBySheet(cells);
3791
+ const sheetsResult = {};
3792
+ for (const name of sheetNames) sheetsResult[name] = {
3793
+ cells: groupedCells[name] ?? {},
3794
+ rows: multiSheets[name] ?? []
3795
+ };
3626
3796
  return {
3627
3797
  url: this.parsedUrl.cleanUrl,
3628
3798
  cells,
3629
- rows,
3799
+ rows: defaultRows,
3800
+ sheets: sheetsResult,
3801
+ sheetNames,
3630
3802
  metadata: {
3631
3803
  app: this.parsedUrl.app,
3632
3804
  mode: this.parsedUrl.mode,
@@ -3637,9 +3809,91 @@ var CryptPadClient = class {
3637
3809
  }
3638
3810
  /**
3639
3811
  * Directly fetches tabular translation rows `[ { key: '...', en: '...', de: '...' } ]`.
3812
+ * If `sheetName` is provided, returns rows for that specific tab.
3640
3813
  */
3641
- async fetchSheetRows(signal) {
3642
- return (await this.fetchSheetData(signal)).rows;
3814
+ async fetchSheetRows(sheetName, signal) {
3815
+ const result = await this.fetchSheetData(signal);
3816
+ if (sheetName && result.sheets[sheetName]) return result.sheets[sheetName].rows;
3817
+ return result.rows;
3818
+ }
3819
+ /**
3820
+ * Broadcasts cell updates to the OnlyOffice real-time collaboration channel.
3821
+ * If the sheet has never been opened in a browser (RT channel is None), it is
3822
+ * automatically initialized headlessly — no browser required.
3823
+ */
3824
+ async sendCellUpdates(updates, signal) {
3825
+ if (updates.length === 0) return;
3826
+ let rtChannel = (await this.fetchSheetData(signal)).metadata.rtChannelId;
3827
+ if (!rtChannel) {
3828
+ console.log("No OnlyOffice RT channel found. Initializing headlessly (no browser required)...");
3829
+ rtChannel = await this.initializeRtChannel(signal);
3830
+ console.log(`RT channel initialized: ${rtChannel}`);
3831
+ await new Promise((r) => setTimeout(r, 800));
3832
+ }
3833
+ const wsUrl = await this.getWebsocketUrl(signal);
3834
+ const { cryptKey } = this.getKeys();
3835
+ const payload = buildOnlyOfficeChangePayload(updates);
3836
+ await broadcastChannelMessage(wsUrl, rtChannel, cryptKey, payload, {
3837
+ timeoutMs: this.timeoutMs,
3838
+ signal
3839
+ });
3840
+ }
3841
+ /**
3842
+ * Updates or appends rows in a target sheet tab, creating new cells as needed.
3843
+ */
3844
+ async writeSheetRows(sheetName, rows, options = {}) {
3845
+ if (rows.length === 0) return 0;
3846
+ const existingRows = (await this.fetchSheetData(options.signal)).sheets[sheetName]?.rows ?? [];
3847
+ let keyColName = "var";
3848
+ const firstExisting = existingRows[0];
3849
+ const firstIncoming = rows[0];
3850
+ if (firstExisting) {
3851
+ if ("var" in firstExisting) keyColName = "var";
3852
+ else if ("key" in firstExisting) keyColName = "key";
3853
+ } else if (firstIncoming) {
3854
+ if ("var" in firstIncoming) keyColName = "var";
3855
+ else if ("key" in firstIncoming) keyColName = "key";
3856
+ }
3857
+ const colNamesSet = /* @__PURE__ */ new Set([keyColName]);
3858
+ for (const r of existingRows) for (const k of Object.keys(r)) if (k !== "key" && k !== "var") colNamesSet.add(k);
3859
+ for (const r of rows) for (const k of Object.keys(r)) if (k !== "key" && k !== "var") colNamesSet.add(k);
3860
+ const colNames = Array.from(colNamesSet);
3861
+ const updates = [];
3862
+ colNames.forEach((name, idx) => {
3863
+ updates.push({
3864
+ sheet: sheetName,
3865
+ col: colIndexToLetter(idx),
3866
+ row: 1,
3867
+ value: name
3868
+ });
3869
+ });
3870
+ const keyToRowIdx = /* @__PURE__ */ new Map();
3871
+ existingRows.forEach((r, idx) => {
3872
+ const rowKey = r.var ?? r.key ?? r[keyColName];
3873
+ if (rowKey) keyToRowIdx.set(rowKey, idx + 2);
3874
+ });
3875
+ let nextAvailableRow = existingRows.length + 2;
3876
+ for (const row of rows) {
3877
+ const rowKey = row.var ?? row.key ?? row[keyColName];
3878
+ if (!rowKey) continue;
3879
+ const targetRow = keyToRowIdx.get(rowKey) ?? nextAvailableRow++;
3880
+ keyToRowIdx.set(rowKey, targetRow);
3881
+ for (const [colName, val] of Object.entries(row)) {
3882
+ const mappedColName = colName === "key" || colName === "var" ? keyColName : colName;
3883
+ const colIdx = colNames.indexOf(mappedColName);
3884
+ if (colIdx >= 0 && val !== void 0) {
3885
+ const existingVal = existingRows[targetRow - 2]?.[mappedColName];
3886
+ if (options.override || !existingVal || existingVal.trim().length === 0) updates.push({
3887
+ sheet: sheetName,
3888
+ col: colIndexToLetter(colIdx),
3889
+ row: targetRow,
3890
+ value: String(val)
3891
+ });
3892
+ }
3893
+ }
3894
+ }
3895
+ await this.sendCellUpdates(updates, options.signal);
3896
+ return updates.length;
3643
3897
  }
3644
3898
  };
3645
3899
  //#endregion
@@ -3673,7 +3927,7 @@ function createCryptPadSheetInputProvider(options) {
3673
3927
  const requested = new Set((request.tableNames ?? []).filter(Boolean));
3674
3928
  const selectedSources = requested.size === 0 ? sources : sources.filter((source) => requested.has(source.tableName));
3675
3929
  return {
3676
- tables: await Promise.all(selectedSources.map(async (source) => {
3930
+ tables: (await Promise.all(selectedSources.map(async (source) => {
3677
3931
  const url = source.url ?? options.url ?? process.env.CRYPTPAD_URL;
3678
3932
  if (!url) throw new Error(`CryptPad sheet source "${source.tableName}" does not define a URL.`);
3679
3933
  const sheetData = await new CryptPadClient({
@@ -3681,7 +3935,22 @@ function createCryptPadSheetInputProvider(options) {
3681
3935
  password: source.password ?? options.password ?? process.env.CRYPTPAD_PASSWORD,
3682
3936
  timeoutMs: options.timeoutMs
3683
3937
  }).fetchSheetData(request.signal);
3684
- return {
3938
+ const sourceTables = [];
3939
+ if (Array.isArray(sheetData.sheetNames) && sheetData.sheetNames.length > 1) {
3940
+ for (const tabName of sheetData.sheetNames) if (requested.size === 0 || requested.has(tabName) || requested.has(source.tableName)) sourceTables.push({
3941
+ tableId: `${source.tableId ?? url}#${tabName}`,
3942
+ tableName: tabName,
3943
+ rows: sheetData.sheets[tabName]?.rows ?? [],
3944
+ sourcePath: url,
3945
+ metadata: {
3946
+ provider: "cryptpad-sheet",
3947
+ channelId: sheetData.metadata.channelId,
3948
+ rtChannelId: sheetData.metadata.rtChannelId,
3949
+ sheetTab: tabName
3950
+ }
3951
+ });
3952
+ }
3953
+ if (sourceTables.length === 0) sourceTables.push({
3685
3954
  tableId: source.tableId ?? url,
3686
3955
  tableName: source.tableName,
3687
3956
  rows: sheetData.rows,
@@ -3692,8 +3961,9 @@ function createCryptPadSheetInputProvider(options) {
3692
3961
  rtChannelId: sheetData.metadata.rtChannelId,
3693
3962
  cellCount: Object.keys(sheetData.cells).length
3694
3963
  }
3695
- };
3696
- })),
3964
+ });
3965
+ return sourceTables;
3966
+ }))).flat(),
3697
3967
  metadata: {
3698
3968
  provider: "cryptpad-sheet",
3699
3969
  sourceCount: selectedSources.length
@@ -3703,6 +3973,72 @@ function createCryptPadSheetInputProvider(options) {
3703
3973
  };
3704
3974
  }
3705
3975
  //#endregion
3976
+ //#region src/providers/cryptpad/sheetOutputProvider.ts
3977
+ const CRYPTPAD_SHEET_OUTPUT_CAPABILITIES = createCapabilitySet({ writeTables: true });
3978
+ /**
3979
+ * Converts nested TranslationData `[locale][sheet][key] = value` into
3980
+ * tabular rows `Record<sheetName, SheetRow[]>`.
3981
+ */
3982
+ function convertTranslationsToSheetRows(translations, localeMapping = {}, keyColumnName = "key") {
3983
+ const reverseMapping = {};
3984
+ for (const [header, norm] of Object.entries(localeMapping)) reverseMapping[norm] = header;
3985
+ const sheetRowsMap = {};
3986
+ for (const [locale, sheets] of Object.entries(translations)) {
3987
+ const colHeader = reverseMapping[locale] ?? locale;
3988
+ for (const [sheetName, keys] of Object.entries(sheets)) {
3989
+ if (!sheetRowsMap[sheetName]) sheetRowsMap[sheetName] = /* @__PURE__ */ new Map();
3990
+ for (const [key, value] of Object.entries(keys)) {
3991
+ if (!sheetRowsMap[sheetName].has(key)) sheetRowsMap[sheetName].set(key, { [keyColumnName]: key });
3992
+ sheetRowsMap[sheetName].get(key)[colHeader] = String(value);
3993
+ }
3994
+ }
3995
+ }
3996
+ const result = {};
3997
+ for (const [sheetName, keyMap] of Object.entries(sheetRowsMap)) result[sheetName] = Array.from(keyMap.values());
3998
+ return result;
3999
+ }
4000
+ /**
4001
+ * Creates a {@link TranslationOutputProvider} that writes translation data directly
4002
+ * into password-protected or public CryptPad spreadsheets (multi-tab OnlyOffice workbooks)
4003
+ * using end-to-end encrypted Netflux WebSockets.
4004
+ */
4005
+ function createCryptPadSheetOutputProvider(options = {}) {
4006
+ const url = options.url ?? process.env.CRYPTPAD_URL;
4007
+ if (!url) throw new Error("CryptPad Sheet output provider requires a \"url\" option or CRYPTPAD_URL environment variable.");
4008
+ const password = options.password ?? process.env.CRYPTPAD_PASSWORD;
4009
+ return {
4010
+ kind: "output",
4011
+ providerId: options.providerId ?? "cryptpad-sheet",
4012
+ displayName: options.displayName ?? "CryptPad Sheet Output (E2EE)",
4013
+ capabilities: CRYPTPAD_SHEET_OUTPUT_CAPABILITIES,
4014
+ async writeTranslations(payload) {
4015
+ const client = new CryptPadClient({
4016
+ url,
4017
+ password,
4018
+ timeoutMs: options.timeoutMs
4019
+ });
4020
+ const effectiveMapping = options.localeMapping ?? payload.localeMapping ?? {};
4021
+ const sheetRowsMap = convertTranslationsToSheetRows(payload.translations, effectiveMapping, options.keyColumnName ?? "key");
4022
+ const updatedSheets = [];
4023
+ let totalUpdatedCells = 0;
4024
+ for (const [sheetName, rows] of Object.entries(sheetRowsMap)) {
4025
+ const count = await client.writeSheetRows(sheetName, rows, { override: options.override ?? false });
4026
+ updatedSheets.push(sheetName);
4027
+ totalUpdatedCells += count;
4028
+ }
4029
+ return {
4030
+ wroteFiles: [url],
4031
+ metadata: {
4032
+ provider: "cryptpad-sheet",
4033
+ url,
4034
+ updatedSheets,
4035
+ totalUpdatedCells
4036
+ }
4037
+ };
4038
+ }
4039
+ };
4040
+ }
4041
+ //#endregion
3706
4042
  //#region src/providers/syncEngine.ts
3707
4043
  function pathKey(locale, sheet, key) {
3708
4044
  return `${locale}::${sheet}::${key}`;
@@ -3849,6 +4185,148 @@ function resolveSyncPlan(input, policy) {
3849
4185
  };
3850
4186
  }
3851
4187
  //#endregion
4188
+ //#region src/providers/cryptpad/sheetSyncProvider.ts
4189
+ const CRYPTPAD_SHEET_SYNC_CAPABILITIES = createCapabilitySet({
4190
+ syncBack: true,
4191
+ writeTables: true
4192
+ });
4193
+ /**
4194
+ * Creates a {@link TranslationSyncProvider} that reconciles local translation changes
4195
+ * against a live CryptPad spreadsheet (multi-tab OnlyOffice workbook) using three-way
4196
+ * diffing and conflict policies, pushing only the resolved diffs back over Netflux.
4197
+ */
4198
+ function createCryptPadSheetSyncProvider(options = {}) {
4199
+ const url = options.url ?? process.env.CRYPTPAD_URL;
4200
+ if (!url) throw new Error("CryptPad Sheet sync provider requires a \"url\" option or CRYPTPAD_URL environment variable.");
4201
+ const password = options.password ?? process.env.CRYPTPAD_PASSWORD;
4202
+ return {
4203
+ kind: "sync",
4204
+ providerId: options.providerId ?? "cryptpad-sheet",
4205
+ displayName: options.displayName ?? "CryptPad Sheet Sync (E2EE)",
4206
+ capabilities: CRYPTPAD_SHEET_SYNC_CAPABILITIES,
4207
+ async syncTranslations(payload) {
4208
+ const resolution = resolveSyncPlan({
4209
+ baseTranslations: payload.metadata?.baseTranslations ?? payload.remoteTranslations,
4210
+ localTranslations: payload.localTranslations,
4211
+ remoteTranslations: payload.remoteTranslations
4212
+ }, options.conflictPolicy ?? "manual");
4213
+ if (resolution.appliedLocalChanges === 0) return {
4214
+ changedKeys: 0,
4215
+ skippedKeys: resolution.skippedConflicts,
4216
+ metadata: {
4217
+ reason: "no-local-diff",
4218
+ url,
4219
+ policy: resolution.policy
4220
+ }
4221
+ };
4222
+ const client = new CryptPadClient({
4223
+ url,
4224
+ password,
4225
+ timeoutMs: options.timeoutMs
4226
+ });
4227
+ const effectiveMapping = options.localeMapping ?? {};
4228
+ const sheetRowsMap = convertTranslationsToSheetRows(resolution.mergedTranslations, effectiveMapping);
4229
+ for (const [sheetName, rows] of Object.entries(sheetRowsMap)) await client.writeSheetRows(sheetName, rows, { override: options.override ?? options.conflictPolicy === "local-wins" });
4230
+ return {
4231
+ changedKeys: resolution.appliedLocalChanges,
4232
+ skippedKeys: resolution.skippedConflicts,
4233
+ metadata: {
4234
+ provider: "cryptpad-sheet",
4235
+ url,
4236
+ policy: resolution.policy
4237
+ }
4238
+ };
4239
+ }
4240
+ };
4241
+ }
4242
+ //#endregion
4243
+ //#region src/providers/cryptpad/driveClient.ts
4244
+ /**
4245
+ * Headless client for inspecting and traversing encrypted CryptPad Drive folders.
4246
+ */
4247
+ var CryptPadDriveClient = class {
4248
+ constructor(options) {
4249
+ if (!options.url || typeof options.url !== "string") throw new Error("CryptPadDriveClient requires a valid \"url\" option.");
4250
+ this.parsedUrl = parsePadUrl(options.url);
4251
+ this.password = options.password ?? process.env.CRYPTPAD_PASSWORD;
4252
+ this.passwords = options.passwords ?? {};
4253
+ this.timeoutMs = options.timeoutMs ?? 1e4;
4254
+ }
4255
+ /**
4256
+ * Fetches the drive pad's Netflux history, decrypts all changes, and parses
4257
+ * the hierarchical filesystem tree into a flat list of items.
4258
+ */
4259
+ async listDriveItems(signal) {
4260
+ const wsUrl = await resolveCryptPadWebsocketUrl(this.parsedUrl.origin, signal);
4261
+ const { channelHex, cryptKey } = deriveCryptPadKeys(this.parsedUrl.seed, this.password);
4262
+ const messages = await fetchChannelHistory(wsUrl, channelHex, cryptKey, {
4263
+ timeoutMs: this.timeoutMs,
4264
+ signal
4265
+ });
4266
+ const items = [];
4267
+ for (const raw of messages) try {
4268
+ const parsed = JSON.parse(raw);
4269
+ if (parsed && typeof parsed === "object") {
4270
+ const fileDict = parsed.files || parsed.data?.files || parsed;
4271
+ if (fileDict && typeof fileDict === "object" && !Array.isArray(fileDict)) for (const [id, val] of Object.entries(fileDict)) {
4272
+ if (!val || typeof val !== "object") continue;
4273
+ const fileObj = val;
4274
+ const title = String(fileObj.title || fileObj.name || id);
4275
+ const href = String(fileObj.href || fileObj.url || "");
4276
+ const typeStr = String(fileObj.type || fileObj.app || (href.includes("/sheet/") ? "sheet" : "file"));
4277
+ const type = typeStr.includes("sheet") ? "sheet" : typeStr.includes("folder") ? "folder" : typeStr.includes("pad") ? "pad" : "file";
4278
+ const itemUrl = href.startsWith("http") ? href : `${this.parsedUrl.origin}${href.startsWith("/") ? "" : "/"}${href}`;
4279
+ const password = fileObj.password ?? this.passwords[itemUrl] ?? this.passwords[title] ?? this.password;
4280
+ items.push({
4281
+ id,
4282
+ title,
4283
+ type,
4284
+ url: itemUrl,
4285
+ password,
4286
+ channel: typeof fileObj.channel === "string" ? fileObj.channel : void 0,
4287
+ path: title,
4288
+ ctime: typeof fileObj.ctime === "number" ? fileObj.ctime : void 0,
4289
+ mtime: typeof fileObj.mtime === "number" ? fileObj.mtime : void 0
4290
+ });
4291
+ }
4292
+ if (Array.isArray(parsed)) for (const entry of parsed) {
4293
+ if (!entry || typeof entry !== "object") continue;
4294
+ const fileObj = entry;
4295
+ const title = String(fileObj.title || fileObj.name || "untitled");
4296
+ const href = String(fileObj.href || fileObj.url || "");
4297
+ const typeStr = String(fileObj.type || fileObj.app || (href.includes("/sheet/") ? "sheet" : "file"));
4298
+ const type = typeStr.includes("sheet") ? "sheet" : typeStr.includes("folder") ? "folder" : typeStr.includes("pad") ? "pad" : "file";
4299
+ const itemUrl = href.startsWith("http") ? href : `${this.parsedUrl.origin}${href.startsWith("/") ? "" : "/"}${href}`;
4300
+ const password = fileObj.password ?? this.passwords[itemUrl] ?? this.passwords[title] ?? this.password;
4301
+ items.push({
4302
+ id: String(fileObj.id || title),
4303
+ title,
4304
+ type,
4305
+ url: itemUrl,
4306
+ password,
4307
+ channel: typeof fileObj.channel === "string" ? fileObj.channel : void 0,
4308
+ path: title
4309
+ });
4310
+ }
4311
+ }
4312
+ } catch {}
4313
+ const seen = /* @__PURE__ */ new Set();
4314
+ const deduplicated = [];
4315
+ for (const item of items) if (item.url && !seen.has(item.url)) {
4316
+ seen.add(item.url);
4317
+ deduplicated.push(item);
4318
+ }
4319
+ return deduplicated;
4320
+ }
4321
+ /**
4322
+ * Filters and returns only spreadsheets found within the drive folder.
4323
+ */
4324
+ async listSheets(signal) {
4325
+ return (await this.listDriveItems(signal)).filter((item) => item.type === "sheet" || item.url.includes("/sheet/"));
4326
+ }
4327
+ };
4328
+ createCapabilitySet({ discoverByFolder: true });
4329
+ //#endregion
3852
4330
  //#region src/providers/cryptpad/fullProvider.ts
3853
4331
  const CRYPTPAD_WORKSPACE_OUTPUT_CAPABILITIES = createCapabilitySet({ writeTables: true });
3854
4332
  const CRYPTPAD_WORKSPACE_SYNC_CAPABILITIES = createCapabilitySet({
@@ -3995,6 +4473,9 @@ function createCryptPadWorkspaceSyncProvider(options, depsOverrides = {}) {
3995
4473
  }
3996
4474
  //#endregion
3997
4475
  //#region src/providers/cryptpad/assetProvider.ts
4476
+ /**
4477
+ * Options for configuring a CryptPad asset synchronization provider.
4478
+ */
3998
4479
  const CRYPTPAD_ASSET_SYNC_CAPABILITIES = createCapabilitySet({
3999
4480
  assetSync: true,
4000
4481
  discoverByFolder: true
@@ -4080,7 +4561,16 @@ function createCryptPadAssetSyncProvider(options, depsOverrides = {}) {
4080
4561
  displayName: options.displayName ?? "CryptPad Asset Sync",
4081
4562
  capabilities: CRYPTPAD_ASSET_SYNC_CAPABILITIES,
4082
4563
  async syncAssets(request) {
4083
- const manifest = await deps.readManifest(options.manifestPath);
4564
+ let manifest = [];
4565
+ if (options.manifestPath) manifest = await deps.readManifest(options.manifestPath);
4566
+ else if (options.driveUrl) manifest = (await new CryptPadDriveClient({
4567
+ url: options.driveUrl,
4568
+ password: options.password
4569
+ }).listDriveItems(request.signal)).filter((item) => item.type !== "folder" && !item.url.includes("/sheet/")).map((item) => ({
4570
+ assetId: item.id || item.title,
4571
+ relativePath: item.path || item.title,
4572
+ sourceUrl: item.url
4573
+ }));
4084
4574
  const downloaded = [];
4085
4575
  const updated = [];
4086
4576
  const skipped = [];
@@ -4417,6 +4907,16 @@ function createInputProvider(providerId, options) {
4417
4907
  function createOutputProvider(providerId, options) {
4418
4908
  switch (providerId) {
4419
4909
  case "google-sheets": return createGoogleSheetsOutputProvider(options);
4910
+ case "cryptpad-sheet":
4911
+ case "cryptpad": return createCryptPadSheetOutputProvider({
4912
+ url: typeof options.url === "string" ? options.url : void 0,
4913
+ password: typeof options.password === "string" ? options.password : void 0,
4914
+ override: options.override === true,
4915
+ localeMapping: typeof options.localeMapping === "object" && options.localeMapping !== null ? options.localeMapping : void 0,
4916
+ providerId: typeof options.providerId === "string" ? options.providerId : void 0,
4917
+ displayName: typeof options.displayName === "string" ? options.displayName : void 0,
4918
+ timeoutMs: typeof options.timeoutMs === "number" ? options.timeoutMs : void 0
4919
+ });
4420
4920
  case "cryptpad-workspace":
4421
4921
  if (typeof options.filePath !== "string" || options.filePath.trim().length === 0) throw new Error("cryptpad-workspace output provider requires a non-empty \"filePath\" option.");
4422
4922
  return createCryptPadWorkspaceOutputProvider({
@@ -4432,6 +4932,17 @@ function createOutputProvider(providerId, options) {
4432
4932
  function createSyncProvider(providerId, options) {
4433
4933
  switch (providerId) {
4434
4934
  case "google-sheets": return createGoogleSheetsSyncProvider(options);
4935
+ case "cryptpad-sheet":
4936
+ case "cryptpad": return createCryptPadSheetSyncProvider({
4937
+ url: typeof options.url === "string" ? options.url : void 0,
4938
+ password: typeof options.password === "string" ? options.password : void 0,
4939
+ override: options.override === true,
4940
+ conflictPolicy: options.conflictPolicy === "remote-wins" || options.conflictPolicy === "local-wins" || options.conflictPolicy === "manual" ? options.conflictPolicy : void 0,
4941
+ localeMapping: typeof options.localeMapping === "object" && options.localeMapping !== null ? options.localeMapping : void 0,
4942
+ providerId: typeof options.providerId === "string" ? options.providerId : void 0,
4943
+ displayName: typeof options.displayName === "string" ? options.displayName : void 0,
4944
+ timeoutMs: typeof options.timeoutMs === "number" ? options.timeoutMs : void 0
4945
+ });
4435
4946
  case "cryptpad-workspace":
4436
4947
  if (typeof options.filePath !== "string" || options.filePath.trim().length === 0) throw new Error("cryptpad-workspace sync provider requires a non-empty \"filePath\" option.");
4437
4948
  return createCryptPadWorkspaceSyncProvider({
@@ -4447,13 +4958,18 @@ function createSyncProvider(providerId, options) {
4447
4958
  }
4448
4959
  function createAssetSyncProvider(providerId, options) {
4449
4960
  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.");
4961
+ case "cryptpad-assets": {
4962
+ const hasManifest = typeof options.manifestPath === "string" && options.manifestPath.trim().length > 0;
4963
+ const hasDrive = typeof options.driveUrl === "string" && options.driveUrl.trim().length > 0;
4964
+ if (!hasManifest && !hasDrive) throw new Error("cryptpad-assets sync provider requires a non-empty \"manifestPath\" option.");
4452
4965
  return createCryptPadAssetSyncProvider({
4453
- manifestPath: options.manifestPath,
4966
+ manifestPath: typeof options.manifestPath === "string" ? options.manifestPath : void 0,
4967
+ driveUrl: typeof options.driveUrl === "string" ? options.driveUrl : void 0,
4968
+ password: typeof options.password === "string" ? options.password : void 0,
4454
4969
  providerId: typeof options.providerId === "string" ? options.providerId : void 0,
4455
4970
  displayName: typeof options.displayName === "string" ? options.displayName : void 0
4456
4971
  });
4972
+ }
4457
4973
  default: throw new Error(`Unsupported asset sync provider: "${providerId}"`);
4458
4974
  }
4459
4975
  }