@el-j/google-sheet-translations 3.0.0-beta.7 → 3.0.0-beta.9

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/dist/esm/index.js CHANGED
@@ -3299,11 +3299,13 @@ function parsePadUrl(url) {
3299
3299
  const match = urlObj.hash.match(/#\/(\d+)\/([^/]+)\/([^/]+)\/([^/]+)(\/p\/)?/);
3300
3300
  if (!match) throw new Error(`Unsupported CryptPad URL format: "${url}". Expected "#/<version>/<app>/<mode>/<seed>[/p/]"`);
3301
3301
  const [, versionStr, app, modeRaw, seed, passwordFlag] = match;
3302
+ const version = parseInt(versionStr, 10);
3303
+ if (version === 3) throw new Error(`CryptPad URL "${url}" is a version 3 "safe link" (hidden hash) which omits the encryption key to protect it in browser address bars. Please use the original pad share URL (version 2 format: "https://.../#/2/${app}/${modeRaw}/<secret>/..."), which can be copied via CryptPad's "Share" -> "Link" menu.`);
3302
3304
  const mode = modeRaw === "edit" ? "edit" : "view";
3303
3305
  const isPasswordProtected = Boolean(passwordFlag);
3304
3306
  return {
3305
3307
  origin: urlObj.origin,
3306
- version: parseInt(versionStr, 10),
3308
+ version,
3307
3309
  app,
3308
3310
  mode,
3309
3311
  seed,
@@ -3312,27 +3314,36 @@ function parsePadUrl(url) {
3312
3314
  };
3313
3315
  }
3314
3316
  /**
3315
- * Derives the Netflux channel ID (hex string) and symmetric encryption key (32 bytes)
3316
- * from a CryptPad pad seed and optional password according to CryptPad's createEditCryptor2 / createViewCryptor2 protocol.
3317
+ * Derives the Netflux channel ID (hex string), symmetric encryption key (32 bytes),
3318
+ * and Ed25519 signing keypair from a CryptPad pad seed and optional password
3319
+ * according to CryptPad's createEditCryptor2 / createViewCryptor2 protocol.
3317
3320
  */
3318
3321
  function deriveCryptPadKeys(seedStr, password) {
3319
3322
  const seed = b64Decode(seedStr);
3320
3323
  if (!password) {
3321
- const seed2 = crypto.createHash("sha512").update(seed).digest().subarray(32, 64);
3324
+ const hash = crypto.createHash("sha512").update(seed).digest();
3325
+ const signKp = nacl.sign.keyPair.fromSeed(hash.subarray(0, 32));
3326
+ const seed2 = hash.subarray(32, 64);
3322
3327
  const hash2 = crypto.createHash("sha512").update(seed2).digest();
3323
3328
  return {
3324
3329
  channelHex: hash2.subarray(0, 16).toString("hex"),
3325
- cryptKey: new Uint8Array(hash2.subarray(16, 48))
3330
+ cryptKey: new Uint8Array(hash2.subarray(16, 48)),
3331
+ signKey: signKp.secretKey,
3332
+ validateKey: signKp.publicKey
3326
3333
  };
3327
3334
  }
3328
3335
  const pwBytes = decodeUTF8(password);
3329
3336
  const superSeed1 = Buffer.concat([Buffer.from(pwBytes), Buffer.from(seed)]);
3330
- const seed2 = crypto.createHash("sha512").update(superSeed1).digest().subarray(32, 64);
3337
+ const hash1 = crypto.createHash("sha512").update(superSeed1).digest();
3338
+ const signKp = nacl.sign.keyPair.fromSeed(hash1.subarray(0, 32));
3339
+ const seed2 = hash1.subarray(32, 64);
3331
3340
  const superSeed2 = Buffer.concat([Buffer.from(pwBytes), Buffer.from(seed2)]);
3332
3341
  const hash2 = crypto.createHash("sha512").update(superSeed2).digest();
3333
3342
  return {
3334
3343
  channelHex: hash2.subarray(0, 16).toString("hex"),
3335
- cryptKey: new Uint8Array(hash2.subarray(16, 48))
3344
+ cryptKey: new Uint8Array(hash2.subarray(16, 48)),
3345
+ signKey: signKp.secretKey,
3346
+ validateKey: signKp.publicKey
3336
3347
  };
3337
3348
  }
3338
3349
  /**
@@ -3363,13 +3374,16 @@ function decryptCryptPadPayload(payload, cryptKey) {
3363
3374
  }
3364
3375
  /**
3365
3376
  * Encrypts a plaintext message payload using TweetNaCl secretbox (XSalsa20-Poly1305).
3366
- * Formats output as `base64(nonce)|base64(ciphertext)`.
3377
+ * If signKey is provided, signs the `nonce|ciphertext` string using TweetNaCl Ed25519
3378
+ * matching OnlyOffice / CryptPad real-time channel requirements.
3367
3379
  */
3368
- function encryptCryptPadPayload(plaintext, cryptKey) {
3380
+ function encryptCryptPadPayload(plaintext, cryptKey, signKey) {
3369
3381
  const nonce = nacl.randomBytes(24);
3370
3382
  const msgBytes = decodeUTF8(plaintext);
3371
3383
  const cipher = nacl.secretbox(msgBytes, nonce, cryptKey);
3372
- return `${b64Encode(nonce)}|${b64Encode(cipher)}`;
3384
+ const encStr = `${b64Encode(nonce)}|${b64Encode(cipher)}`;
3385
+ if (signKey) return b64Encode(nacl.sign(decodeUTF8(encStr), signKey));
3386
+ return encStr;
3373
3387
  }
3374
3388
  //#endregion
3375
3389
  //#region src/providers/cryptpad/netflux.ts
@@ -3403,30 +3417,45 @@ function fetchChannelHistory(wsUrl, channelHex, cryptKey, options = {}) {
3403
3417
  let quietTimer = null;
3404
3418
  let overallTimeout = null;
3405
3419
  let requestedHistory = false;
3420
+ let abortListener = null;
3421
+ let settled = false;
3406
3422
  const cleanup = () => {
3407
3423
  if (quietTimer) clearTimeout(quietTimer);
3408
3424
  if (overallTimeout) clearTimeout(overallTimeout);
3425
+ if (signal && abortListener) signal.removeEventListener("abort", abortListener);
3409
3426
  try {
3410
3427
  ws.close();
3411
3428
  } catch {}
3412
3429
  };
3413
- const finish = () => {
3430
+ const finish = (result) => {
3431
+ if (settled) return;
3432
+ settled = true;
3414
3433
  cleanup();
3415
- resolve(decryptedMessages);
3434
+ resolve(result);
3435
+ };
3436
+ const rejectOnce = (error) => {
3437
+ if (settled) return;
3438
+ settled = true;
3439
+ cleanup();
3440
+ reject(error);
3416
3441
  };
3417
3442
  const resetQuietTimer = () => {
3418
3443
  if (quietTimer) clearTimeout(quietTimer);
3419
- quietTimer = setTimeout(finish, quietPeriodMs);
3444
+ quietTimer = setTimeout(() => {
3445
+ if (decryptedMessages.length > 0) finish(decryptedMessages);
3446
+ else rejectOnce(/* @__PURE__ */ new Error(`Timeout after ${timeoutMs}ms waiting for CryptPad channel "${channelHex}" history.`));
3447
+ }, quietPeriodMs);
3420
3448
  };
3421
3449
  overallTimeout = setTimeout(() => {
3422
- cleanup();
3423
- if (decryptedMessages.length > 0) resolve(decryptedMessages);
3424
- else reject(/* @__PURE__ */ new Error(`Timeout after ${timeoutMs}ms waiting for CryptPad channel "${channelHex}" history.`));
3450
+ if (decryptedMessages.length > 0) finish(decryptedMessages);
3451
+ else rejectOnce(/* @__PURE__ */ new Error(`Timeout after ${timeoutMs}ms waiting for CryptPad channel "${channelHex}" history.`));
3425
3452
  }, timeoutMs);
3426
- if (signal) signal.addEventListener("abort", () => {
3427
- cleanup();
3428
- reject(/* @__PURE__ */ new Error("Operation aborted"));
3429
- });
3453
+ if (signal) {
3454
+ abortListener = () => {
3455
+ rejectOnce(/* @__PURE__ */ new Error("Operation aborted"));
3456
+ };
3457
+ signal.addEventListener("abort", abortListener);
3458
+ }
3430
3459
  ws.onopen = () => {
3431
3460
  ws.send(JSON.stringify([
3432
3461
  seq++,
@@ -3447,11 +3476,7 @@ function fetchChannelHistory(wsUrl, channelHex, cryptKey, options = {}) {
3447
3476
  seq++,
3448
3477
  "MSG",
3449
3478
  peerId,
3450
- JSON.stringify([
3451
- "GET_HISTORY",
3452
- channelHex,
3453
- {}
3454
- ])
3479
+ JSON.stringify(["GET_FULL_HISTORY", channelHex])
3455
3480
  ]));
3456
3481
  resetQuietTimer();
3457
3482
  }
@@ -3461,7 +3486,11 @@ function fetchChannelHistory(wsUrl, channelHex, cryptKey, options = {}) {
3461
3486
  if (!encStr && typeof msg[3] === "string" && msg[3].length > 50) encStr = msg[3];
3462
3487
  try {
3463
3488
  const parsed = JSON.parse(encStr);
3464
- if (Array.isArray(parsed) && parsed[2] === "MSG" && typeof parsed[4] === "string") encStr = parsed[4];
3489
+ if (Array.isArray(parsed) && parsed[0] === "FULL_HISTORY" && Array.isArray(parsed[1])) encStr = parsed[1][4];
3490
+ else if (Array.isArray(parsed) && parsed[0] === "FULL_HISTORY_END") {
3491
+ finish(decryptedMessages);
3492
+ return;
3493
+ } else if (Array.isArray(parsed) && parsed[2] === "MSG" && typeof parsed[4] === "string") encStr = parsed[4];
3465
3494
  } catch {}
3466
3495
  if (typeof encStr === "string" && encStr.length > 30) {
3467
3496
  const decrypted = decryptCryptPadPayload(encStr, cryptKey);
@@ -3475,7 +3504,9 @@ function fetchChannelHistory(wsUrl, channelHex, cryptKey, options = {}) {
3475
3504
  };
3476
3505
  ws.onerror = (err) => {
3477
3506
  cleanup();
3478
- reject(/* @__PURE__ */ new Error(`CryptPad WebSocket error: ${String(err)}`));
3507
+ const errObj = err;
3508
+ const detail = errObj?.message || errObj?.error?.message || (errObj?.error ? String(errObj.error) : String(err));
3509
+ reject(/* @__PURE__ */ new Error(`CryptPad WebSocket error: ${detail}`));
3479
3510
  };
3480
3511
  });
3481
3512
  }
@@ -3492,21 +3523,37 @@ function broadcastChannelMessage(wsUrl, channelHex, cryptKey, message, options =
3492
3523
  let seq = 1;
3493
3524
  let timeout = null;
3494
3525
  let sent = false;
3526
+ let abortListener = null;
3527
+ let settled = false;
3495
3528
  const cleanup = () => {
3496
3529
  if (timeout) clearTimeout(timeout);
3530
+ if (signal && abortListener) signal.removeEventListener("abort", abortListener);
3497
3531
  try {
3498
3532
  ws.close();
3499
3533
  } catch {}
3500
3534
  };
3501
- timeout = setTimeout(() => {
3535
+ const resolveOnce = () => {
3536
+ if (settled) return;
3537
+ settled = true;
3502
3538
  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", () => {
3539
+ resolve();
3540
+ };
3541
+ const rejectOnce = (error) => {
3542
+ if (settled) return;
3543
+ settled = true;
3507
3544
  cleanup();
3508
- reject(/* @__PURE__ */ new Error("Operation aborted"));
3509
- });
3545
+ reject(error);
3546
+ };
3547
+ timeout = setTimeout(() => {
3548
+ if (sent) resolveOnce();
3549
+ else rejectOnce(/* @__PURE__ */ new Error(`Timeout after ${timeoutMs}ms broadcasting message to CryptPad channel "${channelHex}".`));
3550
+ }, timeoutMs);
3551
+ if (signal) {
3552
+ abortListener = () => {
3553
+ rejectOnce(/* @__PURE__ */ new Error("Operation aborted"));
3554
+ };
3555
+ signal.addEventListener("abort", abortListener);
3556
+ }
3510
3557
  ws.onopen = () => {
3511
3558
  ws.send(JSON.stringify([
3512
3559
  seq++,
@@ -3522,7 +3569,7 @@ function broadcastChannelMessage(wsUrl, channelHex, cryptKey, message, options =
3522
3569
  const [, peerId, cmd] = msg;
3523
3570
  if (cmd === "JOIN" && typeof peerId === "string" && !sent) {
3524
3571
  sent = true;
3525
- const encrypted = encryptCryptPadPayload(message, cryptKey);
3572
+ const encrypted = encryptCryptPadPayload(message, cryptKey, options.signKey);
3526
3573
  ws.send(JSON.stringify([
3527
3574
  seq++,
3528
3575
  "MSG",
@@ -3530,15 +3577,15 @@ function broadcastChannelMessage(wsUrl, channelHex, cryptKey, message, options =
3530
3577
  encrypted
3531
3578
  ]));
3532
3579
  setTimeout(() => {
3533
- cleanup();
3534
- resolve();
3580
+ resolveOnce();
3535
3581
  }, 350);
3536
3582
  }
3537
3583
  } catch {}
3538
3584
  };
3539
3585
  ws.onerror = (err) => {
3540
- cleanup();
3541
- reject(/* @__PURE__ */ new Error(`CryptPad WebSocket broadcast error: ${String(err)}`));
3586
+ const errObj = err;
3587
+ const detail = errObj?.message || errObj?.error?.message || (errObj?.error ? String(errObj.error) : String(err));
3588
+ rejectOnce(/* @__PURE__ */ new Error(`CryptPad WebSocket broadcast error: ${detail}`));
3542
3589
  };
3543
3590
  });
3544
3591
  }
@@ -3554,6 +3601,12 @@ function colIndexToLetter(colIndex) {
3554
3601
  }
3555
3602
  return letter;
3556
3603
  }
3604
+ /** Converts column letter to 0-based index: 'A' -> 0, 'Z' -> 25, 'AA' -> 26. */
3605
+ function letterToColIndex(letter) {
3606
+ let index = 0;
3607
+ for (let i = 0; i < letter.length; i++) index = index * 26 + (letter.charCodeAt(i) - 64);
3608
+ return index - 1;
3609
+ }
3557
3610
  /**
3558
3611
  * Parses cell coordinates from a reference like "A1", "Sheet1!B2", or "$C$5".
3559
3612
  */
@@ -3572,18 +3625,51 @@ function parseCellRef(ref) {
3572
3625
  function extractOnlyOfficeChannelId(metadataMessages) {
3573
3626
  for (const raw of metadataMessages) try {
3574
3627
  const parsed = JSON.parse(raw);
3628
+ if (parsed?.content?.channel && typeof parsed.content.channel === "string") return parsed.content.channel;
3575
3629
  if (Array.isArray(parsed) && Array.isArray(parsed[1])) {
3576
- for (const edit of parsed[1]) if (Array.isArray(edit) && typeof edit[2] === "string") try {
3577
- const inner = JSON.parse(edit[2]);
3578
- if (inner?.content?.channel && typeof inner.content.channel === "string") return inner.content.channel;
3579
- } catch {}
3630
+ const candidateContainers = [parsed[1], Array.isArray(parsed[1][0]) ? parsed[1][0] : null].filter(Boolean);
3631
+ for (const container of candidateContainers) for (const item of container) if (Array.isArray(item)) {
3632
+ for (const elem of item) if (typeof elem === "string" && elem.includes("channel")) try {
3633
+ const inner = JSON.parse(elem);
3634
+ if (inner?.content?.channel && typeof inner.content.channel === "string") return inner.content.channel;
3635
+ } catch {}
3636
+ }
3580
3637
  }
3581
- if (parsed?.content?.channel && typeof parsed.content.channel === "string") return parsed.content.channel;
3582
- } catch {}
3638
+ if (typeof raw === "string") {
3639
+ const match = raw.match(/\\?"channel\\?"\s*:\s*\\?"([a-f0-9]{32})\\?"/i);
3640
+ if (match) return match[1];
3641
+ }
3642
+ } catch {
3643
+ if (typeof raw === "string") {
3644
+ const match = raw.match(/\\?"channel\\?"\s*:\s*\\?"([a-f0-9]{32})\\?"/i);
3645
+ if (match) return match[1];
3646
+ }
3647
+ }
3583
3648
  return null;
3584
3649
  }
3585
3650
  /**
3586
3651
  * Parses OnlyOffice incremental binary change records into cell coordinates and text values.
3652
+ *
3653
+ * ### OnlyOffice Document Server / CryptPad Binary Protocol Specification
3654
+ * OnlyOffice collaborative document changes are broadcast over Netflux real-time channels.
3655
+ * Each message contains a `changes` array of transaction objects.
3656
+ *
3657
+ * Inside each transaction, `change` is formatted with a command prefix followed by base64 binary:
3658
+ * `asc_<version>;<base64_binary_payload>` (e.g., `asc_1;<base64>`).
3659
+ *
3660
+ * The decoded binary stream represents OnlyOffice document AST changes:
3661
+ * - Text and identifiers are serialized as length-prefixed UTF-16LE strings.
3662
+ * - The marker byte `0x08` indicates the start of a UTF-16LE string entry, followed by a 4-byte
3663
+ * little-endian unsigned integer (`UInt32LE`) specifying byte length, followed by the UTF-16LE payload.
3664
+ *
3665
+ * Two layout patterns are supported:
3666
+ * - **Case A (Explicit coordinate reference)**: A string matching coordinate syntax (e.g. `A1` or `sheet!B2`)
3667
+ * followed closely (within 30 bytes) by another `0x08` marker holding the cell's UTF-16LE text value.
3668
+ * - **Case B (Binary header coordinates)**: Header packets store 0-based column index `c1` at byte 14
3669
+ * and row index `r1` at byte 18 as `UInt32LE`, followed by string values starting at byte 40+.
3670
+ *
3671
+ * @param rtMessages - Decrypted raw change messages retrieved from the OnlyOffice RT Netflux channel.
3672
+ * @returns A mapping of cell coordinates (`A1` or `sheet!A1`) to cell text values.
3587
3673
  */
3588
3674
  function parseOnlyOfficeChanges(rtMessages) {
3589
3675
  const cells = {};
@@ -3616,12 +3702,20 @@ function parseOnlyOfficeChanges(rtMessages) {
3616
3702
  break;
3617
3703
  }
3618
3704
  }
3619
- if (buf.length >= 30 && i >= 40) try {
3705
+ if (buf.length >= 31 && buf.readUInt32BE(4) === 19468289 && buf[14] === 1) try {
3706
+ const c1 = buf.readUInt32LE(15);
3707
+ const r1 = buf.readUInt32LE(19);
3708
+ if (r1 < 1e5 && c1 < 200) {
3709
+ const cellRef = `${colIndexToLetter(c1)}${r1 + 1}`;
3710
+ if (str.trim().length > 0 && !str.includes("!")) cells[cellRef] = str;
3711
+ }
3712
+ } catch {}
3713
+ else if (buf.length >= 30 && i >= 40 && buf.readUInt32BE(4) === 0) try {
3620
3714
  const c1 = buf.readUInt32LE(14);
3621
3715
  const r1 = buf.readUInt32LE(18);
3622
3716
  if (r1 < 1e5 && c1 < 200) {
3623
3717
  const cellRef = `${colIndexToLetter(c1)}${r1 + 1}`;
3624
- if (!cells[cellRef] && str.trim().length > 0 && !str.includes("!")) cells[cellRef] = str;
3718
+ if (str.trim().length > 0 && !str.includes("!")) cells[cellRef] = str;
3625
3719
  }
3626
3720
  } catch {}
3627
3721
  }
@@ -3690,11 +3784,70 @@ function convertCellsToMultiSheetRows(cells, defaultSheet = "Sheet1") {
3690
3784
  return result;
3691
3785
  }
3692
3786
  /**
3693
- * Encodes a single cell update record into OnlyOffice binary format (0x08 prefix + UTF-16LE string).
3787
+ * Encodes a single cell update record into OnlyOffice binary format:
3788
+ * - Native AscCH.historyitem_Cell_ChangeValue (0x01291001) with 32-bit LE coordinates c1, r1.
3789
+ * - Explicit UTF-16LE cell reference tags for backward-compatibility and multi-sheet routing.
3694
3790
  */
3695
- function encodeOnlyOfficeCellRecord(cellRef, value) {
3696
- const refBuf = Buffer.from(cellRef, "utf16le");
3791
+ function encodeOnlyOfficeCellRecord(cellRef, value, sheetId = 6) {
3792
+ let c1 = 0;
3793
+ let r1 = 0;
3794
+ const parsed = parseCellRef(cellRef);
3795
+ if (parsed) {
3796
+ c1 = letterToColIndex(parsed.col);
3797
+ r1 = Math.max(0, parsed.row - 1);
3798
+ }
3697
3799
  const valBuf = Buffer.from(value, "utf16le");
3800
+ const L = valBuf.length;
3801
+ const tail = Buffer.from([
3802
+ 1,
3803
+ 0,
3804
+ 2,
3805
+ 0,
3806
+ 3,
3807
+ 2,
3808
+ 1,
3809
+ 2,
3810
+ 0,
3811
+ 3,
3812
+ 1
3813
+ ]);
3814
+ const body = Buffer.alloc(60 + L + tail.length);
3815
+ body.writeUInt32BE(19468289, 0);
3816
+ body.writeUInt32LE(2, 4);
3817
+ body[8] = 48 + sheetId % 10;
3818
+ body[9] = 0;
3819
+ body[10] = 1;
3820
+ body.writeUInt32LE(c1, 11);
3821
+ body.writeUInt32LE(r1, 15);
3822
+ body.writeUInt32LE(c1, 19);
3823
+ body.writeUInt32LE(r1, 23);
3824
+ body[27] = 0;
3825
+ body.writeUInt32LE(39 + L, 28);
3826
+ body[32] = 0;
3827
+ body[33] = 2;
3828
+ body[34] = r1 & 255;
3829
+ body[35] = 1;
3830
+ body[36] = 2;
3831
+ body[37] = c1 & 255;
3832
+ body[38] = 2;
3833
+ body[39] = 9;
3834
+ body[40] = 3;
3835
+ body.writeUInt32LE(26 + L, 41);
3836
+ body[45] = 0;
3837
+ body[46] = 0;
3838
+ body[47] = 1;
3839
+ body[48] = 9;
3840
+ body[49] = 1;
3841
+ body.writeUInt32LE(13 + L, 50);
3842
+ body[54] = 0;
3843
+ body[55] = 8;
3844
+ body.writeUInt32LE(L, 56);
3845
+ valBuf.copy(body, 60);
3846
+ tail.copy(body, 60 + L);
3847
+ const header = Buffer.alloc(4);
3848
+ header.writeUInt32LE(body.length, 0);
3849
+ const nativeBuf = Buffer.concat([header, body]);
3850
+ const refBuf = Buffer.from(cellRef, "utf16le");
3698
3851
  const refHeader = Buffer.alloc(5);
3699
3852
  refHeader[0] = 8;
3700
3853
  refHeader.writeUInt32LE(refBuf.length, 1);
@@ -3702,6 +3855,7 @@ function encodeOnlyOfficeCellRecord(cellRef, value) {
3702
3855
  valHeader[0] = 8;
3703
3856
  valHeader.writeUInt32LE(valBuf.length, 1);
3704
3857
  return Buffer.concat([
3858
+ nativeBuf,
3705
3859
  refHeader,
3706
3860
  refBuf,
3707
3861
  valHeader,
@@ -3712,12 +3866,19 @@ function encodeOnlyOfficeCellRecord(cellRef, value) {
3712
3866
  * Formats a list of cell updates into an OnlyOffice change transaction JSON string.
3713
3867
  */
3714
3868
  function buildOnlyOfficeChangePayload(updates) {
3869
+ const txOpen = Buffer.from("0a0000000129000000ff00000000", "hex");
3715
3870
  const records = updates.map((u) => {
3716
3871
  const colStr = typeof u.col === "number" ? colIndexToLetter(u.col) : u.col.toUpperCase();
3717
3872
  return encodeOnlyOfficeCellRecord(`${u.sheet && u.sheet.trim().length > 0 ? `${u.sheet.trim()}!` : ""}${colStr}${u.row}`, u.value);
3718
3873
  });
3719
- const changeEntry = `asc_1;${Buffer.concat(records).toString("base64")}`;
3720
- return JSON.stringify({ changes: [{ change: changeEntry }] });
3874
+ const changeEntry = `asc_1;${Buffer.concat([txOpen, ...records]).toString("base64")}`;
3875
+ return JSON.stringify({
3876
+ type: "saveChanges",
3877
+ changes: [{ change: changeEntry }],
3878
+ startSaveChanges: true,
3879
+ endSaveChanges: true,
3880
+ isExcel: true
3881
+ });
3721
3882
  }
3722
3883
  //#endregion
3723
3884
  //#region src/providers/cryptpad/client.ts
@@ -3732,36 +3893,73 @@ var CryptPadClient = class {
3732
3893
  this.password = options.password ?? process.env.CRYPTPAD_PASSWORD;
3733
3894
  this.websocketUrl = options.websocketUrl;
3734
3895
  this.timeoutMs = options.timeoutMs ?? 1e4;
3896
+ this.onProgress = options.onProgress;
3735
3897
  if (this.parsedUrl.isPasswordProtected && !this.password) throw new Error(`CryptPad pad "${this.parsedUrl.cleanUrl}" is password protected, but no password was provided. Set "password" option or CRYPTPAD_PASSWORD environment variable.`);
3736
3898
  }
3737
- /** Gets or derives the symmetric key and primary Netflux channel ID. */
3899
+ /**
3900
+ * Gets or derives the symmetric key and primary Netflux channel ID.
3901
+ *
3902
+ * @returns The derived CryptPad channel hex ID and 32-byte TweetNaCl secretbox key.
3903
+ */
3738
3904
  getKeys() {
3739
3905
  if (!this.derivedKeys) this.derivedKeys = deriveCryptPadKeys(this.parsedUrl.seed, this.password);
3740
3906
  return this.derivedKeys;
3741
3907
  }
3742
- /** Resolves the Netflux WebSocket endpoint to connect to. */
3908
+ /**
3909
+ * Resolves the Netflux WebSocket endpoint to connect to.
3910
+ *
3911
+ * @param signal - Optional AbortSignal to cancel connection discovery.
3912
+ * @returns The WebSocket endpoint URL (wss:// or ws://).
3913
+ */
3743
3914
  async getWebsocketUrl(signal) {
3744
3915
  if (this.websocketUrl) return this.websocketUrl;
3745
3916
  return resolveCryptPadWebsocketUrl(this.parsedUrl.origin, signal);
3746
3917
  }
3747
3918
  /**
3748
3919
  * 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.
3920
+ * been opened in a browser.
3752
3921
  *
3753
- * This removes the requirement to open the sheet in a browser before the CLI can write to it.
3922
+ * ### CryptPad Protocol Details
3923
+ * CryptPad pads use Netflux channels for real-time collaboration. The main pad channel
3924
+ * stores document metadata. When a spreadsheet pad is opened in OnlyOffice, the OnlyOffice
3925
+ * web wrapper creates a secondary Netflux channel for real-time document change frames
3926
+ * and registers it in the pad's metadata channel.
3927
+ *
3928
+ * The metadata update is formatted as an edit sequence:
3929
+ * `[sequenceNumber, [[offset, length, innerJsonString]]]`
3930
+ * where `sequenceNumber` is 1, and `innerJsonString` is:
3931
+ * `{"content":{"channel":"<32-hex-channel-id>"}}`.
3932
+ *
3933
+ * Broadcasting this envelope over the main channel headlessly replicates OnlyOffice's
3934
+ * first-open handshake without requiring a browser or headless browser session.
3935
+ *
3936
+ * @param signal - Optional AbortSignal to cancel the initialization.
3937
+ * @returns The newly allocated 32-character hexadecimal RT channel ID.
3754
3938
  */
3755
3939
  async initializeRtChannel(signal) {
3756
3940
  const wsUrl = await this.getWebsocketUrl(signal);
3757
3941
  const { channelHex, cryptKey } = this.getKeys();
3758
3942
  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
- ]]]), {
3943
+ const innerJson = JSON.stringify({
3944
+ content: {
3945
+ hashes: {},
3946
+ ids: {},
3947
+ mediasSources: {},
3948
+ originalVersion: 9,
3949
+ version: 9,
3950
+ channel: newRtChannel
3951
+ },
3952
+ metadata: { type: "oo" }
3953
+ });
3954
+ await broadcastChannelMessage(wsUrl, channelHex, cryptKey, JSON.stringify([
3955
+ 2,
3956
+ [[[
3957
+ 0,
3958
+ 0,
3959
+ innerJson
3960
+ ]], "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"],
3961
+ "a4b411975be1d48a91f0ebfcd967add000f69564b2dc5ee644b87bf2bbfd786f"
3962
+ ]), {
3765
3963
  timeoutMs: this.timeoutMs,
3766
3964
  signal
3767
3965
  });
@@ -3770,6 +3968,9 @@ var CryptPadClient = class {
3770
3968
  /**
3771
3969
  * Fetches the complete sheet data, including raw cell coordinate mappings,
3772
3970
  * multi-sheet tabs, and structured rows formatted for translation ingestion.
3971
+ *
3972
+ * @param signal - Optional AbortSignal to cancel the fetch.
3973
+ * @returns The structured {@link CryptPadSheetResult} containing grids, rows, and metadata.
3773
3974
  */
3774
3975
  async fetchSheetData(signal) {
3775
3976
  const wsUrl = await this.getWebsocketUrl(signal);
@@ -3810,6 +4011,10 @@ var CryptPadClient = class {
3810
4011
  /**
3811
4012
  * Directly fetches tabular translation rows `[ { key: '...', en: '...', de: '...' } ]`.
3812
4013
  * If `sheetName` is provided, returns rows for that specific tab.
4014
+ *
4015
+ * @param sheetName - Optional tab/sheet name to extract rows from.
4016
+ * @param signal - Optional AbortSignal to cancel the operation.
4017
+ * @returns An array of {@link SheetRow} objects.
3813
4018
  */
3814
4019
  async fetchSheetRows(sheetName, signal) {
3815
4020
  const result = await this.fetchSheetData(signal);
@@ -3820,30 +4025,42 @@ var CryptPadClient = class {
3820
4025
  * Broadcasts cell updates to the OnlyOffice real-time collaboration channel.
3821
4026
  * If the sheet has never been opened in a browser (RT channel is None), it is
3822
4027
  * automatically initialized headlessly — no browser required.
4028
+ *
4029
+ * @param updates - Array of cell updates containing coordinates and text values.
4030
+ * @param signal - Optional AbortSignal to cancel the broadcast.
4031
+ * @param existingRtChannel - Optional pre-fetched RT channel ID to avoid refetching sheet data.
3823
4032
  */
3824
- async sendCellUpdates(updates, signal) {
4033
+ async sendCellUpdates(updates, signal, existingRtChannel) {
3825
4034
  if (updates.length === 0) return;
3826
- let rtChannel = (await this.fetchSheetData(signal)).metadata.rtChannelId;
4035
+ let rtChannel = existingRtChannel;
4036
+ if (!rtChannel) rtChannel = (await this.fetchSheetData(signal)).metadata.rtChannelId;
3827
4037
  if (!rtChannel) {
3828
- console.log("No OnlyOffice RT channel found. Initializing headlessly (no browser required)...");
4038
+ this.onProgress?.("No OnlyOffice RT channel found. Initializing headlessly (no browser required)...");
3829
4039
  rtChannel = await this.initializeRtChannel(signal);
3830
- console.log(`RT channel initialized: ${rtChannel}`);
4040
+ this.onProgress?.(`RT channel initialized: ${rtChannel}`);
3831
4041
  await new Promise((r) => setTimeout(r, 800));
3832
4042
  }
3833
4043
  const wsUrl = await this.getWebsocketUrl(signal);
3834
- const { cryptKey } = this.getKeys();
4044
+ const { cryptKey, signKey } = this.getKeys();
3835
4045
  const payload = buildOnlyOfficeChangePayload(updates);
3836
4046
  await broadcastChannelMessage(wsUrl, rtChannel, cryptKey, payload, {
3837
4047
  timeoutMs: this.timeoutMs,
3838
- signal
4048
+ signal,
4049
+ signKey
3839
4050
  });
3840
4051
  }
3841
4052
  /**
3842
4053
  * Updates or appends rows in a target sheet tab, creating new cells as needed.
4054
+ *
4055
+ * @param sheetName - Target sheet tab name (e.g. 'i18n' or 'common').
4056
+ * @param rows - Array of translation rows to write.
4057
+ * @param options - Write options: override existing non-empty values and optional signal.
4058
+ * @returns The total number of cell update records sent.
3843
4059
  */
3844
4060
  async writeSheetRows(sheetName, rows, options = {}) {
3845
4061
  if (rows.length === 0) return 0;
3846
- const existingRows = (await this.fetchSheetData(options.signal)).sheets[sheetName]?.rows ?? [];
4062
+ const data = await this.fetchSheetData(options.signal);
4063
+ const existingRows = data.sheets[sheetName]?.rows ?? [];
3847
4064
  let keyColName = "var";
3848
4065
  const firstExisting = existingRows[0];
3849
4066
  const firstIncoming = rows[0];
@@ -3892,7 +4109,7 @@ var CryptPadClient = class {
3892
4109
  }
3893
4110
  }
3894
4111
  }
3895
- await this.sendCellUpdates(updates, options.signal);
4112
+ await this.sendCellUpdates(updates, options.signal, data.metadata.rtChannelId);
3896
4113
  return updates.length;
3897
4114
  }
3898
4115
  };
@@ -3979,7 +4196,7 @@ const CRYPTPAD_SHEET_OUTPUT_CAPABILITIES = createCapabilitySet({ writeTables: tr
3979
4196
  * Converts nested TranslationData `[locale][sheet][key] = value` into
3980
4197
  * tabular rows `Record<sheetName, SheetRow[]>`.
3981
4198
  */
3982
- function convertTranslationsToSheetRows(translations, localeMapping = {}, keyColumnName = "key") {
4199
+ function convertTranslationsToSheetRows(translations, localeMapping = {}, keyColumnName = "var") {
3983
4200
  const reverseMapping = {};
3984
4201
  for (const [header, norm] of Object.entries(localeMapping)) reverseMapping[norm] = header;
3985
4202
  const sheetRowsMap = {};
@@ -4018,13 +4235,14 @@ function createCryptPadSheetOutputProvider(options = {}) {
4018
4235
  timeoutMs: options.timeoutMs
4019
4236
  });
4020
4237
  const effectiveMapping = options.localeMapping ?? payload.localeMapping ?? {};
4021
- const sheetRowsMap = convertTranslationsToSheetRows(payload.translations, effectiveMapping, options.keyColumnName ?? "key");
4238
+ const sheetRowsMap = convertTranslationsToSheetRows(payload.translations, effectiveMapping, options.keyColumnName ?? "var");
4022
4239
  const updatedSheets = [];
4023
4240
  let totalUpdatedCells = 0;
4024
4241
  for (const [sheetName, rows] of Object.entries(sheetRowsMap)) {
4025
4242
  const count = await client.writeSheetRows(sheetName, rows, { override: options.override ?? false });
4026
4243
  updatedSheets.push(sheetName);
4027
4244
  totalUpdatedCells += count;
4245
+ await new Promise((r) => setTimeout(r, 400));
4028
4246
  }
4029
4247
  return {
4030
4248
  wroteFiles: [url],
@@ -4400,7 +4618,7 @@ function createCryptPadWorkspaceOutputProvider(options, depsOverrides = {}) {
4400
4618
  displayName: options.displayName ?? "CryptPad Workspace Output",
4401
4619
  capabilities: CRYPTPAD_WORKSPACE_OUTPUT_CAPABILITIES,
4402
4620
  async writeTranslations(payload) {
4403
- const snapshot = await deps.readSnapshot(options.filePath, options.authToken);
4621
+ const snapshot = await deps.readSnapshot(options.filePath);
4404
4622
  assertRevision(snapshot, options.expectedRevision);
4405
4623
  const merged = mergeTranslations$1(snapshot.translations, payload.translations);
4406
4624
  const nextRevision = snapshot.revision + 1;
@@ -4411,7 +4629,7 @@ function createCryptPadWorkspaceOutputProvider(options, depsOverrides = {}) {
4411
4629
  ...snapshot.metadata,
4412
4630
  lastWriteProvider: "cryptpad-workspace-output"
4413
4631
  }
4414
- }, options.authToken);
4632
+ });
4415
4633
  return {
4416
4634
  wroteFiles: [options.filePath],
4417
4635
  metadata: {
@@ -4422,6 +4640,14 @@ function createCryptPadWorkspaceOutputProvider(options, depsOverrides = {}) {
4422
4640
  }
4423
4641
  };
4424
4642
  }
4643
+ /**
4644
+ * Builds the {@link BuildSyncPlanInput} from a {@link TranslationSyncPayload}.
4645
+ * Falls back to using `payload.remoteTranslations` as the base if `payload.metadata.baseTranslations`
4646
+ * is not supplied.
4647
+ *
4648
+ * @param payload - The incoming sync payload containing local, remote, and optional base translations.
4649
+ * @returns The structured three-way sync plan input.
4650
+ */
4425
4651
  function buildSyncInput(payload) {
4426
4652
  return {
4427
4653
  baseTranslations: payload.metadata?.baseTranslations ?? payload.remoteTranslations,
@@ -4447,7 +4673,7 @@ function createCryptPadWorkspaceSyncProvider(options, depsOverrides = {}) {
4447
4673
  displayName: options.displayName ?? "CryptPad Workspace Sync",
4448
4674
  capabilities: CRYPTPAD_WORKSPACE_SYNC_CAPABILITIES,
4449
4675
  async syncTranslations(payload) {
4450
- const snapshot = await deps.readSnapshot(options.filePath, options.authToken);
4676
+ const snapshot = await deps.readSnapshot(options.filePath);
4451
4677
  assertRevision(snapshot, options.expectedRevision ?? (Number.isFinite(payload.metadata?.expectedRevision) ? Number(payload.metadata?.expectedRevision) : void 0));
4452
4678
  const resolution = resolveSyncPlan(buildSyncInput(payload), options.conflictPolicy ?? "manual");
4453
4679
  const nextRevision = snapshot.revision + 1;
@@ -4459,7 +4685,7 @@ function createCryptPadWorkspaceSyncProvider(options, depsOverrides = {}) {
4459
4685
  lastSyncProvider: "cryptpad-workspace-sync",
4460
4686
  policy: resolution.policy
4461
4687
  }
4462
- }, options.authToken);
4688
+ });
4463
4689
  return {
4464
4690
  changedKeys: resolution.appliedLocalChanges,
4465
4691
  skippedKeys: resolution.skippedConflicts,
@@ -4921,7 +5147,6 @@ function createOutputProvider(providerId, options) {
4921
5147
  if (typeof options.filePath !== "string" || options.filePath.trim().length === 0) throw new Error("cryptpad-workspace output provider requires a non-empty \"filePath\" option.");
4922
5148
  return createCryptPadWorkspaceOutputProvider({
4923
5149
  filePath: options.filePath,
4924
- authToken: typeof options.authToken === "string" ? options.authToken : void 0,
4925
5150
  expectedRevision: typeof options.expectedRevision === "number" ? options.expectedRevision : void 0,
4926
5151
  providerId: typeof options.providerId === "string" ? options.providerId : void 0,
4927
5152
  displayName: typeof options.displayName === "string" ? options.displayName : void 0
@@ -4947,7 +5172,6 @@ function createSyncProvider(providerId, options) {
4947
5172
  if (typeof options.filePath !== "string" || options.filePath.trim().length === 0) throw new Error("cryptpad-workspace sync provider requires a non-empty \"filePath\" option.");
4948
5173
  return createCryptPadWorkspaceSyncProvider({
4949
5174
  filePath: options.filePath,
4950
- authToken: typeof options.authToken === "string" ? options.authToken : void 0,
4951
5175
  expectedRevision: typeof options.expectedRevision === "number" ? options.expectedRevision : void 0,
4952
5176
  conflictPolicy: options.conflictPolicy === "remote-wins" || options.conflictPolicy === "local-wins" || options.conflictPolicy === "manual" ? options.conflictPolicy : void 0,
4953
5177
  providerId: typeof options.providerId === "string" ? options.providerId : void 0,