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

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
@@ -3403,30 +3403,45 @@ function fetchChannelHistory(wsUrl, channelHex, cryptKey, options = {}) {
3403
3403
  let quietTimer = null;
3404
3404
  let overallTimeout = null;
3405
3405
  let requestedHistory = false;
3406
+ let abortListener = null;
3407
+ let settled = false;
3406
3408
  const cleanup = () => {
3407
3409
  if (quietTimer) clearTimeout(quietTimer);
3408
3410
  if (overallTimeout) clearTimeout(overallTimeout);
3411
+ if (signal && abortListener) signal.removeEventListener("abort", abortListener);
3409
3412
  try {
3410
3413
  ws.close();
3411
3414
  } catch {}
3412
3415
  };
3413
- const finish = () => {
3416
+ const finish = (result) => {
3417
+ if (settled) return;
3418
+ settled = true;
3414
3419
  cleanup();
3415
- resolve(decryptedMessages);
3420
+ resolve(result);
3421
+ };
3422
+ const rejectOnce = (error) => {
3423
+ if (settled) return;
3424
+ settled = true;
3425
+ cleanup();
3426
+ reject(error);
3416
3427
  };
3417
3428
  const resetQuietTimer = () => {
3418
3429
  if (quietTimer) clearTimeout(quietTimer);
3419
- quietTimer = setTimeout(finish, quietPeriodMs);
3430
+ quietTimer = setTimeout(() => {
3431
+ if (decryptedMessages.length > 0) finish(decryptedMessages);
3432
+ else rejectOnce(/* @__PURE__ */ new Error(`Timeout after ${timeoutMs}ms waiting for CryptPad channel "${channelHex}" history.`));
3433
+ }, quietPeriodMs);
3420
3434
  };
3421
3435
  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.`));
3436
+ if (decryptedMessages.length > 0) finish(decryptedMessages);
3437
+ else rejectOnce(/* @__PURE__ */ new Error(`Timeout after ${timeoutMs}ms waiting for CryptPad channel "${channelHex}" history.`));
3425
3438
  }, timeoutMs);
3426
- if (signal) signal.addEventListener("abort", () => {
3427
- cleanup();
3428
- reject(/* @__PURE__ */ new Error("Operation aborted"));
3429
- });
3439
+ if (signal) {
3440
+ abortListener = () => {
3441
+ rejectOnce(/* @__PURE__ */ new Error("Operation aborted"));
3442
+ };
3443
+ signal.addEventListener("abort", abortListener);
3444
+ }
3430
3445
  ws.onopen = () => {
3431
3446
  ws.send(JSON.stringify([
3432
3447
  seq++,
@@ -3492,21 +3507,37 @@ function broadcastChannelMessage(wsUrl, channelHex, cryptKey, message, options =
3492
3507
  let seq = 1;
3493
3508
  let timeout = null;
3494
3509
  let sent = false;
3510
+ let abortListener = null;
3511
+ let settled = false;
3495
3512
  const cleanup = () => {
3496
3513
  if (timeout) clearTimeout(timeout);
3514
+ if (signal && abortListener) signal.removeEventListener("abort", abortListener);
3497
3515
  try {
3498
3516
  ws.close();
3499
3517
  } catch {}
3500
3518
  };
3501
- timeout = setTimeout(() => {
3519
+ const resolveOnce = () => {
3520
+ if (settled) return;
3521
+ settled = true;
3502
3522
  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", () => {
3523
+ resolve();
3524
+ };
3525
+ const rejectOnce = (error) => {
3526
+ if (settled) return;
3527
+ settled = true;
3507
3528
  cleanup();
3508
- reject(/* @__PURE__ */ new Error("Operation aborted"));
3509
- });
3529
+ reject(error);
3530
+ };
3531
+ timeout = setTimeout(() => {
3532
+ if (sent) resolveOnce();
3533
+ else rejectOnce(/* @__PURE__ */ new Error(`Timeout after ${timeoutMs}ms broadcasting message to CryptPad channel "${channelHex}".`));
3534
+ }, timeoutMs);
3535
+ if (signal) {
3536
+ abortListener = () => {
3537
+ rejectOnce(/* @__PURE__ */ new Error("Operation aborted"));
3538
+ };
3539
+ signal.addEventListener("abort", abortListener);
3540
+ }
3510
3541
  ws.onopen = () => {
3511
3542
  ws.send(JSON.stringify([
3512
3543
  seq++,
@@ -3530,15 +3561,13 @@ function broadcastChannelMessage(wsUrl, channelHex, cryptKey, message, options =
3530
3561
  encrypted
3531
3562
  ]));
3532
3563
  setTimeout(() => {
3533
- cleanup();
3534
- resolve();
3564
+ resolveOnce();
3535
3565
  }, 350);
3536
3566
  }
3537
3567
  } catch {}
3538
3568
  };
3539
3569
  ws.onerror = (err) => {
3540
- cleanup();
3541
- reject(/* @__PURE__ */ new Error(`CryptPad WebSocket broadcast error: ${String(err)}`));
3570
+ rejectOnce(/* @__PURE__ */ new Error(`CryptPad WebSocket broadcast error: ${String(err)}`));
3542
3571
  };
3543
3572
  });
3544
3573
  }
@@ -3584,6 +3613,27 @@ function extractOnlyOfficeChannelId(metadataMessages) {
3584
3613
  }
3585
3614
  /**
3586
3615
  * Parses OnlyOffice incremental binary change records into cell coordinates and text values.
3616
+ *
3617
+ * ### OnlyOffice Document Server / CryptPad Binary Protocol Specification
3618
+ * OnlyOffice collaborative document changes are broadcast over Netflux real-time channels.
3619
+ * Each message contains a `changes` array of transaction objects.
3620
+ *
3621
+ * Inside each transaction, `change` is formatted with a command prefix followed by base64 binary:
3622
+ * `asc_<version>;<base64_binary_payload>` (e.g., `asc_1;<base64>`).
3623
+ *
3624
+ * The decoded binary stream represents OnlyOffice document AST changes:
3625
+ * - Text and identifiers are serialized as length-prefixed UTF-16LE strings.
3626
+ * - The marker byte `0x08` indicates the start of a UTF-16LE string entry, followed by a 4-byte
3627
+ * little-endian unsigned integer (`UInt32LE`) specifying byte length, followed by the UTF-16LE payload.
3628
+ *
3629
+ * Two layout patterns are supported:
3630
+ * - **Case A (Explicit coordinate reference)**: A string matching coordinate syntax (e.g. `A1` or `sheet!B2`)
3631
+ * followed closely (within 30 bytes) by another `0x08` marker holding the cell's UTF-16LE text value.
3632
+ * - **Case B (Binary header coordinates)**: Header packets store 0-based column index `c1` at byte 14
3633
+ * and row index `r1` at byte 18 as `UInt32LE`, followed by string values starting at byte 40+.
3634
+ *
3635
+ * @param rtMessages - Decrypted raw change messages retrieved from the OnlyOffice RT Netflux channel.
3636
+ * @returns A mapping of cell coordinates (`A1` or `sheet!A1`) to cell text values.
3587
3637
  */
3588
3638
  function parseOnlyOfficeChanges(rtMessages) {
3589
3639
  const cells = {};
@@ -3732,25 +3782,48 @@ var CryptPadClient = class {
3732
3782
  this.password = options.password ?? process.env.CRYPTPAD_PASSWORD;
3733
3783
  this.websocketUrl = options.websocketUrl;
3734
3784
  this.timeoutMs = options.timeoutMs ?? 1e4;
3785
+ this.onProgress = options.onProgress;
3735
3786
  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
3787
  }
3737
- /** Gets or derives the symmetric key and primary Netflux channel ID. */
3788
+ /**
3789
+ * Gets or derives the symmetric key and primary Netflux channel ID.
3790
+ *
3791
+ * @returns The derived CryptPad channel hex ID and 32-byte TweetNaCl secretbox key.
3792
+ */
3738
3793
  getKeys() {
3739
3794
  if (!this.derivedKeys) this.derivedKeys = deriveCryptPadKeys(this.parsedUrl.seed, this.password);
3740
3795
  return this.derivedKeys;
3741
3796
  }
3742
- /** Resolves the Netflux WebSocket endpoint to connect to. */
3797
+ /**
3798
+ * Resolves the Netflux WebSocket endpoint to connect to.
3799
+ *
3800
+ * @param signal - Optional AbortSignal to cancel connection discovery.
3801
+ * @returns The WebSocket endpoint URL (wss:// or ws://).
3802
+ */
3743
3803
  async getWebsocketUrl(signal) {
3744
3804
  if (this.websocketUrl) return this.websocketUrl;
3745
3805
  return resolveCryptPadWebsocketUrl(this.parsedUrl.origin, signal);
3746
3806
  }
3747
3807
  /**
3748
3808
  * 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.
3809
+ * been opened in a browser.
3810
+ *
3811
+ * ### CryptPad Protocol Details
3812
+ * CryptPad pads use Netflux channels for real-time collaboration. The main pad channel
3813
+ * stores document metadata. When a spreadsheet pad is opened in OnlyOffice, the OnlyOffice
3814
+ * web wrapper creates a secondary Netflux channel for real-time document change frames
3815
+ * and registers it in the pad's metadata channel.
3816
+ *
3817
+ * The metadata update is formatted as an edit sequence:
3818
+ * `[sequenceNumber, [[offset, length, innerJsonString]]]`
3819
+ * where `sequenceNumber` is 1, and `innerJsonString` is:
3820
+ * `{"content":{"channel":"<32-hex-channel-id>"}}`.
3752
3821
  *
3753
- * This removes the requirement to open the sheet in a browser before the CLI can write to it.
3822
+ * Broadcasting this envelope over the main channel headlessly replicates OnlyOffice's
3823
+ * first-open handshake without requiring a browser or headless browser session.
3824
+ *
3825
+ * @param signal - Optional AbortSignal to cancel the initialization.
3826
+ * @returns The newly allocated 32-character hexadecimal RT channel ID.
3754
3827
  */
3755
3828
  async initializeRtChannel(signal) {
3756
3829
  const wsUrl = await this.getWebsocketUrl(signal);
@@ -3770,6 +3843,9 @@ var CryptPadClient = class {
3770
3843
  /**
3771
3844
  * Fetches the complete sheet data, including raw cell coordinate mappings,
3772
3845
  * multi-sheet tabs, and structured rows formatted for translation ingestion.
3846
+ *
3847
+ * @param signal - Optional AbortSignal to cancel the fetch.
3848
+ * @returns The structured {@link CryptPadSheetResult} containing grids, rows, and metadata.
3773
3849
  */
3774
3850
  async fetchSheetData(signal) {
3775
3851
  const wsUrl = await this.getWebsocketUrl(signal);
@@ -3810,6 +3886,10 @@ var CryptPadClient = class {
3810
3886
  /**
3811
3887
  * Directly fetches tabular translation rows `[ { key: '...', en: '...', de: '...' } ]`.
3812
3888
  * If `sheetName` is provided, returns rows for that specific tab.
3889
+ *
3890
+ * @param sheetName - Optional tab/sheet name to extract rows from.
3891
+ * @param signal - Optional AbortSignal to cancel the operation.
3892
+ * @returns An array of {@link SheetRow} objects.
3813
3893
  */
3814
3894
  async fetchSheetRows(sheetName, signal) {
3815
3895
  const result = await this.fetchSheetData(signal);
@@ -3820,14 +3900,17 @@ var CryptPadClient = class {
3820
3900
  * Broadcasts cell updates to the OnlyOffice real-time collaboration channel.
3821
3901
  * If the sheet has never been opened in a browser (RT channel is None), it is
3822
3902
  * automatically initialized headlessly — no browser required.
3903
+ *
3904
+ * @param updates - Array of cell updates containing coordinates and text values.
3905
+ * @param signal - Optional AbortSignal to cancel the broadcast.
3823
3906
  */
3824
3907
  async sendCellUpdates(updates, signal) {
3825
3908
  if (updates.length === 0) return;
3826
3909
  let rtChannel = (await this.fetchSheetData(signal)).metadata.rtChannelId;
3827
3910
  if (!rtChannel) {
3828
- console.log("No OnlyOffice RT channel found. Initializing headlessly (no browser required)...");
3911
+ this.onProgress?.("No OnlyOffice RT channel found. Initializing headlessly (no browser required)...");
3829
3912
  rtChannel = await this.initializeRtChannel(signal);
3830
- console.log(`RT channel initialized: ${rtChannel}`);
3913
+ this.onProgress?.(`RT channel initialized: ${rtChannel}`);
3831
3914
  await new Promise((r) => setTimeout(r, 800));
3832
3915
  }
3833
3916
  const wsUrl = await this.getWebsocketUrl(signal);
@@ -3840,6 +3923,11 @@ var CryptPadClient = class {
3840
3923
  }
3841
3924
  /**
3842
3925
  * Updates or appends rows in a target sheet tab, creating new cells as needed.
3926
+ *
3927
+ * @param sheetName - Target sheet tab name (e.g. 'i18n' or 'common').
3928
+ * @param rows - Array of translation rows to write.
3929
+ * @param options - Write options: override existing non-empty values and optional signal.
3930
+ * @returns The total number of cell update records sent.
3843
3931
  */
3844
3932
  async writeSheetRows(sheetName, rows, options = {}) {
3845
3933
  if (rows.length === 0) return 0;
@@ -3979,7 +4067,7 @@ const CRYPTPAD_SHEET_OUTPUT_CAPABILITIES = createCapabilitySet({ writeTables: tr
3979
4067
  * Converts nested TranslationData `[locale][sheet][key] = value` into
3980
4068
  * tabular rows `Record<sheetName, SheetRow[]>`.
3981
4069
  */
3982
- function convertTranslationsToSheetRows(translations, localeMapping = {}, keyColumnName = "key") {
4070
+ function convertTranslationsToSheetRows(translations, localeMapping = {}, keyColumnName = "var") {
3983
4071
  const reverseMapping = {};
3984
4072
  for (const [header, norm] of Object.entries(localeMapping)) reverseMapping[norm] = header;
3985
4073
  const sheetRowsMap = {};
@@ -4018,7 +4106,7 @@ function createCryptPadSheetOutputProvider(options = {}) {
4018
4106
  timeoutMs: options.timeoutMs
4019
4107
  });
4020
4108
  const effectiveMapping = options.localeMapping ?? payload.localeMapping ?? {};
4021
- const sheetRowsMap = convertTranslationsToSheetRows(payload.translations, effectiveMapping, options.keyColumnName ?? "key");
4109
+ const sheetRowsMap = convertTranslationsToSheetRows(payload.translations, effectiveMapping, options.keyColumnName ?? "var");
4022
4110
  const updatedSheets = [];
4023
4111
  let totalUpdatedCells = 0;
4024
4112
  for (const [sheetName, rows] of Object.entries(sheetRowsMap)) {
@@ -4400,7 +4488,7 @@ function createCryptPadWorkspaceOutputProvider(options, depsOverrides = {}) {
4400
4488
  displayName: options.displayName ?? "CryptPad Workspace Output",
4401
4489
  capabilities: CRYPTPAD_WORKSPACE_OUTPUT_CAPABILITIES,
4402
4490
  async writeTranslations(payload) {
4403
- const snapshot = await deps.readSnapshot(options.filePath, options.authToken);
4491
+ const snapshot = await deps.readSnapshot(options.filePath);
4404
4492
  assertRevision(snapshot, options.expectedRevision);
4405
4493
  const merged = mergeTranslations$1(snapshot.translations, payload.translations);
4406
4494
  const nextRevision = snapshot.revision + 1;
@@ -4411,7 +4499,7 @@ function createCryptPadWorkspaceOutputProvider(options, depsOverrides = {}) {
4411
4499
  ...snapshot.metadata,
4412
4500
  lastWriteProvider: "cryptpad-workspace-output"
4413
4501
  }
4414
- }, options.authToken);
4502
+ });
4415
4503
  return {
4416
4504
  wroteFiles: [options.filePath],
4417
4505
  metadata: {
@@ -4422,6 +4510,14 @@ function createCryptPadWorkspaceOutputProvider(options, depsOverrides = {}) {
4422
4510
  }
4423
4511
  };
4424
4512
  }
4513
+ /**
4514
+ * Builds the {@link BuildSyncPlanInput} from a {@link TranslationSyncPayload}.
4515
+ * Falls back to using `payload.remoteTranslations` as the base if `payload.metadata.baseTranslations`
4516
+ * is not supplied.
4517
+ *
4518
+ * @param payload - The incoming sync payload containing local, remote, and optional base translations.
4519
+ * @returns The structured three-way sync plan input.
4520
+ */
4425
4521
  function buildSyncInput(payload) {
4426
4522
  return {
4427
4523
  baseTranslations: payload.metadata?.baseTranslations ?? payload.remoteTranslations,
@@ -4447,7 +4543,7 @@ function createCryptPadWorkspaceSyncProvider(options, depsOverrides = {}) {
4447
4543
  displayName: options.displayName ?? "CryptPad Workspace Sync",
4448
4544
  capabilities: CRYPTPAD_WORKSPACE_SYNC_CAPABILITIES,
4449
4545
  async syncTranslations(payload) {
4450
- const snapshot = await deps.readSnapshot(options.filePath, options.authToken);
4546
+ const snapshot = await deps.readSnapshot(options.filePath);
4451
4547
  assertRevision(snapshot, options.expectedRevision ?? (Number.isFinite(payload.metadata?.expectedRevision) ? Number(payload.metadata?.expectedRevision) : void 0));
4452
4548
  const resolution = resolveSyncPlan(buildSyncInput(payload), options.conflictPolicy ?? "manual");
4453
4549
  const nextRevision = snapshot.revision + 1;
@@ -4459,7 +4555,7 @@ function createCryptPadWorkspaceSyncProvider(options, depsOverrides = {}) {
4459
4555
  lastSyncProvider: "cryptpad-workspace-sync",
4460
4556
  policy: resolution.policy
4461
4557
  }
4462
- }, options.authToken);
4558
+ });
4463
4559
  return {
4464
4560
  changedKeys: resolution.appliedLocalChanges,
4465
4561
  skippedKeys: resolution.skippedConflicts,
@@ -4921,7 +5017,6 @@ function createOutputProvider(providerId, options) {
4921
5017
  if (typeof options.filePath !== "string" || options.filePath.trim().length === 0) throw new Error("cryptpad-workspace output provider requires a non-empty \"filePath\" option.");
4922
5018
  return createCryptPadWorkspaceOutputProvider({
4923
5019
  filePath: options.filePath,
4924
- authToken: typeof options.authToken === "string" ? options.authToken : void 0,
4925
5020
  expectedRevision: typeof options.expectedRevision === "number" ? options.expectedRevision : void 0,
4926
5021
  providerId: typeof options.providerId === "string" ? options.providerId : void 0,
4927
5022
  displayName: typeof options.displayName === "string" ? options.displayName : void 0
@@ -4947,7 +5042,6 @@ function createSyncProvider(providerId, options) {
4947
5042
  if (typeof options.filePath !== "string" || options.filePath.trim().length === 0) throw new Error("cryptpad-workspace sync provider requires a non-empty \"filePath\" option.");
4948
5043
  return createCryptPadWorkspaceSyncProvider({
4949
5044
  filePath: options.filePath,
4950
- authToken: typeof options.authToken === "string" ? options.authToken : void 0,
4951
5045
  expectedRevision: typeof options.expectedRevision === "number" ? options.expectedRevision : void 0,
4952
5046
  conflictPolicy: options.conflictPolicy === "remote-wins" || options.conflictPolicy === "local-wins" || options.conflictPolicy === "manual" ? options.conflictPolicy : void 0,
4953
5047
  providerId: typeof options.providerId === "string" ? options.providerId : void 0,
package/dist/index.js CHANGED
@@ -3436,30 +3436,45 @@ function fetchChannelHistory(wsUrl, channelHex, cryptKey, options = {}) {
3436
3436
  let quietTimer = null;
3437
3437
  let overallTimeout = null;
3438
3438
  let requestedHistory = false;
3439
+ let abortListener = null;
3440
+ let settled = false;
3439
3441
  const cleanup = () => {
3440
3442
  if (quietTimer) clearTimeout(quietTimer);
3441
3443
  if (overallTimeout) clearTimeout(overallTimeout);
3444
+ if (signal && abortListener) signal.removeEventListener("abort", abortListener);
3442
3445
  try {
3443
3446
  ws.close();
3444
3447
  } catch {}
3445
3448
  };
3446
- const finish = () => {
3449
+ const finish = (result) => {
3450
+ if (settled) return;
3451
+ settled = true;
3447
3452
  cleanup();
3448
- resolve(decryptedMessages);
3453
+ resolve(result);
3454
+ };
3455
+ const rejectOnce = (error) => {
3456
+ if (settled) return;
3457
+ settled = true;
3458
+ cleanup();
3459
+ reject(error);
3449
3460
  };
3450
3461
  const resetQuietTimer = () => {
3451
3462
  if (quietTimer) clearTimeout(quietTimer);
3452
- quietTimer = setTimeout(finish, quietPeriodMs);
3463
+ quietTimer = setTimeout(() => {
3464
+ if (decryptedMessages.length > 0) finish(decryptedMessages);
3465
+ else rejectOnce(/* @__PURE__ */ new Error(`Timeout after ${timeoutMs}ms waiting for CryptPad channel "${channelHex}" history.`));
3466
+ }, quietPeriodMs);
3453
3467
  };
3454
3468
  overallTimeout = setTimeout(() => {
3455
- cleanup();
3456
- if (decryptedMessages.length > 0) resolve(decryptedMessages);
3457
- else reject(/* @__PURE__ */ new Error(`Timeout after ${timeoutMs}ms waiting for CryptPad channel "${channelHex}" history.`));
3469
+ if (decryptedMessages.length > 0) finish(decryptedMessages);
3470
+ else rejectOnce(/* @__PURE__ */ new Error(`Timeout after ${timeoutMs}ms waiting for CryptPad channel "${channelHex}" history.`));
3458
3471
  }, timeoutMs);
3459
- if (signal) signal.addEventListener("abort", () => {
3460
- cleanup();
3461
- reject(/* @__PURE__ */ new Error("Operation aborted"));
3462
- });
3472
+ if (signal) {
3473
+ abortListener = () => {
3474
+ rejectOnce(/* @__PURE__ */ new Error("Operation aborted"));
3475
+ };
3476
+ signal.addEventListener("abort", abortListener);
3477
+ }
3463
3478
  ws.onopen = () => {
3464
3479
  ws.send(JSON.stringify([
3465
3480
  seq++,
@@ -3525,21 +3540,37 @@ function broadcastChannelMessage(wsUrl, channelHex, cryptKey, message, options =
3525
3540
  let seq = 1;
3526
3541
  let timeout = null;
3527
3542
  let sent = false;
3543
+ let abortListener = null;
3544
+ let settled = false;
3528
3545
  const cleanup = () => {
3529
3546
  if (timeout) clearTimeout(timeout);
3547
+ if (signal && abortListener) signal.removeEventListener("abort", abortListener);
3530
3548
  try {
3531
3549
  ws.close();
3532
3550
  } catch {}
3533
3551
  };
3534
- timeout = setTimeout(() => {
3552
+ const resolveOnce = () => {
3553
+ if (settled) return;
3554
+ settled = true;
3535
3555
  cleanup();
3536
- if (sent) resolve();
3537
- else reject(/* @__PURE__ */ new Error(`Timeout after ${timeoutMs}ms broadcasting message to CryptPad channel "${channelHex}".`));
3538
- }, timeoutMs);
3539
- if (signal) signal.addEventListener("abort", () => {
3556
+ resolve();
3557
+ };
3558
+ const rejectOnce = (error) => {
3559
+ if (settled) return;
3560
+ settled = true;
3540
3561
  cleanup();
3541
- reject(/* @__PURE__ */ new Error("Operation aborted"));
3542
- });
3562
+ reject(error);
3563
+ };
3564
+ timeout = setTimeout(() => {
3565
+ if (sent) resolveOnce();
3566
+ else rejectOnce(/* @__PURE__ */ new Error(`Timeout after ${timeoutMs}ms broadcasting message to CryptPad channel "${channelHex}".`));
3567
+ }, timeoutMs);
3568
+ if (signal) {
3569
+ abortListener = () => {
3570
+ rejectOnce(/* @__PURE__ */ new Error("Operation aborted"));
3571
+ };
3572
+ signal.addEventListener("abort", abortListener);
3573
+ }
3543
3574
  ws.onopen = () => {
3544
3575
  ws.send(JSON.stringify([
3545
3576
  seq++,
@@ -3563,15 +3594,13 @@ function broadcastChannelMessage(wsUrl, channelHex, cryptKey, message, options =
3563
3594
  encrypted
3564
3595
  ]));
3565
3596
  setTimeout(() => {
3566
- cleanup();
3567
- resolve();
3597
+ resolveOnce();
3568
3598
  }, 350);
3569
3599
  }
3570
3600
  } catch {}
3571
3601
  };
3572
3602
  ws.onerror = (err) => {
3573
- cleanup();
3574
- reject(/* @__PURE__ */ new Error(`CryptPad WebSocket broadcast error: ${String(err)}`));
3603
+ rejectOnce(/* @__PURE__ */ new Error(`CryptPad WebSocket broadcast error: ${String(err)}`));
3575
3604
  };
3576
3605
  });
3577
3606
  }
@@ -3617,6 +3646,27 @@ function extractOnlyOfficeChannelId(metadataMessages) {
3617
3646
  }
3618
3647
  /**
3619
3648
  * Parses OnlyOffice incremental binary change records into cell coordinates and text values.
3649
+ *
3650
+ * ### OnlyOffice Document Server / CryptPad Binary Protocol Specification
3651
+ * OnlyOffice collaborative document changes are broadcast over Netflux real-time channels.
3652
+ * Each message contains a `changes` array of transaction objects.
3653
+ *
3654
+ * Inside each transaction, `change` is formatted with a command prefix followed by base64 binary:
3655
+ * `asc_<version>;<base64_binary_payload>` (e.g., `asc_1;<base64>`).
3656
+ *
3657
+ * The decoded binary stream represents OnlyOffice document AST changes:
3658
+ * - Text and identifiers are serialized as length-prefixed UTF-16LE strings.
3659
+ * - The marker byte `0x08` indicates the start of a UTF-16LE string entry, followed by a 4-byte
3660
+ * little-endian unsigned integer (`UInt32LE`) specifying byte length, followed by the UTF-16LE payload.
3661
+ *
3662
+ * Two layout patterns are supported:
3663
+ * - **Case A (Explicit coordinate reference)**: A string matching coordinate syntax (e.g. `A1` or `sheet!B2`)
3664
+ * followed closely (within 30 bytes) by another `0x08` marker holding the cell's UTF-16LE text value.
3665
+ * - **Case B (Binary header coordinates)**: Header packets store 0-based column index `c1` at byte 14
3666
+ * and row index `r1` at byte 18 as `UInt32LE`, followed by string values starting at byte 40+.
3667
+ *
3668
+ * @param rtMessages - Decrypted raw change messages retrieved from the OnlyOffice RT Netflux channel.
3669
+ * @returns A mapping of cell coordinates (`A1` or `sheet!A1`) to cell text values.
3620
3670
  */
3621
3671
  function parseOnlyOfficeChanges(rtMessages) {
3622
3672
  const cells = {};
@@ -3765,25 +3815,48 @@ var CryptPadClient = class {
3765
3815
  this.password = options.password ?? process.env.CRYPTPAD_PASSWORD;
3766
3816
  this.websocketUrl = options.websocketUrl;
3767
3817
  this.timeoutMs = options.timeoutMs ?? 1e4;
3818
+ this.onProgress = options.onProgress;
3768
3819
  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.`);
3769
3820
  }
3770
- /** Gets or derives the symmetric key and primary Netflux channel ID. */
3821
+ /**
3822
+ * Gets or derives the symmetric key and primary Netflux channel ID.
3823
+ *
3824
+ * @returns The derived CryptPad channel hex ID and 32-byte TweetNaCl secretbox key.
3825
+ */
3771
3826
  getKeys() {
3772
3827
  if (!this.derivedKeys) this.derivedKeys = deriveCryptPadKeys(this.parsedUrl.seed, this.password);
3773
3828
  return this.derivedKeys;
3774
3829
  }
3775
- /** Resolves the Netflux WebSocket endpoint to connect to. */
3830
+ /**
3831
+ * Resolves the Netflux WebSocket endpoint to connect to.
3832
+ *
3833
+ * @param signal - Optional AbortSignal to cancel connection discovery.
3834
+ * @returns The WebSocket endpoint URL (wss:// or ws://).
3835
+ */
3776
3836
  async getWebsocketUrl(signal) {
3777
3837
  if (this.websocketUrl) return this.websocketUrl;
3778
3838
  return resolveCryptPadWebsocketUrl(this.parsedUrl.origin, signal);
3779
3839
  }
3780
3840
  /**
3781
3841
  * Initializes the OnlyOffice RT channel for a brand-new CryptPad sheet that has never
3782
- * been opened in a browser. Generates a random 32-hex channel ID, broadcasts it as an
3783
- * encrypted metadata patch on the Netflux channel (the same thing the OnlyOffice browser
3784
- * client does on first open), and returns the new RT channel ID.
3842
+ * been opened in a browser.
3843
+ *
3844
+ * ### CryptPad Protocol Details
3845
+ * CryptPad pads use Netflux channels for real-time collaboration. The main pad channel
3846
+ * stores document metadata. When a spreadsheet pad is opened in OnlyOffice, the OnlyOffice
3847
+ * web wrapper creates a secondary Netflux channel for real-time document change frames
3848
+ * and registers it in the pad's metadata channel.
3849
+ *
3850
+ * The metadata update is formatted as an edit sequence:
3851
+ * `[sequenceNumber, [[offset, length, innerJsonString]]]`
3852
+ * where `sequenceNumber` is 1, and `innerJsonString` is:
3853
+ * `{"content":{"channel":"<32-hex-channel-id>"}}`.
3785
3854
  *
3786
- * This removes the requirement to open the sheet in a browser before the CLI can write to it.
3855
+ * Broadcasting this envelope over the main channel headlessly replicates OnlyOffice's
3856
+ * first-open handshake without requiring a browser or headless browser session.
3857
+ *
3858
+ * @param signal - Optional AbortSignal to cancel the initialization.
3859
+ * @returns The newly allocated 32-character hexadecimal RT channel ID.
3787
3860
  */
3788
3861
  async initializeRtChannel(signal) {
3789
3862
  const wsUrl = await this.getWebsocketUrl(signal);
@@ -3803,6 +3876,9 @@ var CryptPadClient = class {
3803
3876
  /**
3804
3877
  * Fetches the complete sheet data, including raw cell coordinate mappings,
3805
3878
  * multi-sheet tabs, and structured rows formatted for translation ingestion.
3879
+ *
3880
+ * @param signal - Optional AbortSignal to cancel the fetch.
3881
+ * @returns The structured {@link CryptPadSheetResult} containing grids, rows, and metadata.
3806
3882
  */
3807
3883
  async fetchSheetData(signal) {
3808
3884
  const wsUrl = await this.getWebsocketUrl(signal);
@@ -3843,6 +3919,10 @@ var CryptPadClient = class {
3843
3919
  /**
3844
3920
  * Directly fetches tabular translation rows `[ { key: '...', en: '...', de: '...' } ]`.
3845
3921
  * If `sheetName` is provided, returns rows for that specific tab.
3922
+ *
3923
+ * @param sheetName - Optional tab/sheet name to extract rows from.
3924
+ * @param signal - Optional AbortSignal to cancel the operation.
3925
+ * @returns An array of {@link SheetRow} objects.
3846
3926
  */
3847
3927
  async fetchSheetRows(sheetName, signal) {
3848
3928
  const result = await this.fetchSheetData(signal);
@@ -3853,14 +3933,17 @@ var CryptPadClient = class {
3853
3933
  * Broadcasts cell updates to the OnlyOffice real-time collaboration channel.
3854
3934
  * If the sheet has never been opened in a browser (RT channel is None), it is
3855
3935
  * automatically initialized headlessly — no browser required.
3936
+ *
3937
+ * @param updates - Array of cell updates containing coordinates and text values.
3938
+ * @param signal - Optional AbortSignal to cancel the broadcast.
3856
3939
  */
3857
3940
  async sendCellUpdates(updates, signal) {
3858
3941
  if (updates.length === 0) return;
3859
3942
  let rtChannel = (await this.fetchSheetData(signal)).metadata.rtChannelId;
3860
3943
  if (!rtChannel) {
3861
- console.log("No OnlyOffice RT channel found. Initializing headlessly (no browser required)...");
3944
+ this.onProgress?.("No OnlyOffice RT channel found. Initializing headlessly (no browser required)...");
3862
3945
  rtChannel = await this.initializeRtChannel(signal);
3863
- console.log(`RT channel initialized: ${rtChannel}`);
3946
+ this.onProgress?.(`RT channel initialized: ${rtChannel}`);
3864
3947
  await new Promise((r) => setTimeout(r, 800));
3865
3948
  }
3866
3949
  const wsUrl = await this.getWebsocketUrl(signal);
@@ -3873,6 +3956,11 @@ var CryptPadClient = class {
3873
3956
  }
3874
3957
  /**
3875
3958
  * Updates or appends rows in a target sheet tab, creating new cells as needed.
3959
+ *
3960
+ * @param sheetName - Target sheet tab name (e.g. 'i18n' or 'common').
3961
+ * @param rows - Array of translation rows to write.
3962
+ * @param options - Write options: override existing non-empty values and optional signal.
3963
+ * @returns The total number of cell update records sent.
3876
3964
  */
3877
3965
  async writeSheetRows(sheetName, rows, options = {}) {
3878
3966
  if (rows.length === 0) return 0;
@@ -4012,7 +4100,7 @@ const CRYPTPAD_SHEET_OUTPUT_CAPABILITIES = createCapabilitySet({ writeTables: tr
4012
4100
  * Converts nested TranslationData `[locale][sheet][key] = value` into
4013
4101
  * tabular rows `Record<sheetName, SheetRow[]>`.
4014
4102
  */
4015
- function convertTranslationsToSheetRows(translations, localeMapping = {}, keyColumnName = "key") {
4103
+ function convertTranslationsToSheetRows(translations, localeMapping = {}, keyColumnName = "var") {
4016
4104
  const reverseMapping = {};
4017
4105
  for (const [header, norm] of Object.entries(localeMapping)) reverseMapping[norm] = header;
4018
4106
  const sheetRowsMap = {};
@@ -4051,7 +4139,7 @@ function createCryptPadSheetOutputProvider(options = {}) {
4051
4139
  timeoutMs: options.timeoutMs
4052
4140
  });
4053
4141
  const effectiveMapping = options.localeMapping ?? payload.localeMapping ?? {};
4054
- const sheetRowsMap = convertTranslationsToSheetRows(payload.translations, effectiveMapping, options.keyColumnName ?? "key");
4142
+ const sheetRowsMap = convertTranslationsToSheetRows(payload.translations, effectiveMapping, options.keyColumnName ?? "var");
4055
4143
  const updatedSheets = [];
4056
4144
  let totalUpdatedCells = 0;
4057
4145
  for (const [sheetName, rows] of Object.entries(sheetRowsMap)) {
@@ -4433,7 +4521,7 @@ function createCryptPadWorkspaceOutputProvider(options, depsOverrides = {}) {
4433
4521
  displayName: options.displayName ?? "CryptPad Workspace Output",
4434
4522
  capabilities: CRYPTPAD_WORKSPACE_OUTPUT_CAPABILITIES,
4435
4523
  async writeTranslations(payload) {
4436
- const snapshot = await deps.readSnapshot(options.filePath, options.authToken);
4524
+ const snapshot = await deps.readSnapshot(options.filePath);
4437
4525
  assertRevision(snapshot, options.expectedRevision);
4438
4526
  const merged = mergeTranslations$1(snapshot.translations, payload.translations);
4439
4527
  const nextRevision = snapshot.revision + 1;
@@ -4444,7 +4532,7 @@ function createCryptPadWorkspaceOutputProvider(options, depsOverrides = {}) {
4444
4532
  ...snapshot.metadata,
4445
4533
  lastWriteProvider: "cryptpad-workspace-output"
4446
4534
  }
4447
- }, options.authToken);
4535
+ });
4448
4536
  return {
4449
4537
  wroteFiles: [options.filePath],
4450
4538
  metadata: {
@@ -4455,6 +4543,14 @@ function createCryptPadWorkspaceOutputProvider(options, depsOverrides = {}) {
4455
4543
  }
4456
4544
  };
4457
4545
  }
4546
+ /**
4547
+ * Builds the {@link BuildSyncPlanInput} from a {@link TranslationSyncPayload}.
4548
+ * Falls back to using `payload.remoteTranslations` as the base if `payload.metadata.baseTranslations`
4549
+ * is not supplied.
4550
+ *
4551
+ * @param payload - The incoming sync payload containing local, remote, and optional base translations.
4552
+ * @returns The structured three-way sync plan input.
4553
+ */
4458
4554
  function buildSyncInput(payload) {
4459
4555
  return {
4460
4556
  baseTranslations: payload.metadata?.baseTranslations ?? payload.remoteTranslations,
@@ -4480,7 +4576,7 @@ function createCryptPadWorkspaceSyncProvider(options, depsOverrides = {}) {
4480
4576
  displayName: options.displayName ?? "CryptPad Workspace Sync",
4481
4577
  capabilities: CRYPTPAD_WORKSPACE_SYNC_CAPABILITIES,
4482
4578
  async syncTranslations(payload) {
4483
- const snapshot = await deps.readSnapshot(options.filePath, options.authToken);
4579
+ const snapshot = await deps.readSnapshot(options.filePath);
4484
4580
  assertRevision(snapshot, options.expectedRevision ?? (Number.isFinite(payload.metadata?.expectedRevision) ? Number(payload.metadata?.expectedRevision) : void 0));
4485
4581
  const resolution = resolveSyncPlan(buildSyncInput(payload), options.conflictPolicy ?? "manual");
4486
4582
  const nextRevision = snapshot.revision + 1;
@@ -4492,7 +4588,7 @@ function createCryptPadWorkspaceSyncProvider(options, depsOverrides = {}) {
4492
4588
  lastSyncProvider: "cryptpad-workspace-sync",
4493
4589
  policy: resolution.policy
4494
4590
  }
4495
- }, options.authToken);
4591
+ });
4496
4592
  return {
4497
4593
  changedKeys: resolution.appliedLocalChanges,
4498
4594
  skippedKeys: resolution.skippedConflicts,
@@ -4954,7 +5050,6 @@ function createOutputProvider(providerId, options) {
4954
5050
  if (typeof options.filePath !== "string" || options.filePath.trim().length === 0) throw new Error("cryptpad-workspace output provider requires a non-empty \"filePath\" option.");
4955
5051
  return createCryptPadWorkspaceOutputProvider({
4956
5052
  filePath: options.filePath,
4957
- authToken: typeof options.authToken === "string" ? options.authToken : void 0,
4958
5053
  expectedRevision: typeof options.expectedRevision === "number" ? options.expectedRevision : void 0,
4959
5054
  providerId: typeof options.providerId === "string" ? options.providerId : void 0,
4960
5055
  displayName: typeof options.displayName === "string" ? options.displayName : void 0
@@ -4980,7 +5075,6 @@ function createSyncProvider(providerId, options) {
4980
5075
  if (typeof options.filePath !== "string" || options.filePath.trim().length === 0) throw new Error("cryptpad-workspace sync provider requires a non-empty \"filePath\" option.");
4981
5076
  return createCryptPadWorkspaceSyncProvider({
4982
5077
  filePath: options.filePath,
4983
- authToken: typeof options.authToken === "string" ? options.authToken : void 0,
4984
5078
  expectedRevision: typeof options.expectedRevision === "number" ? options.expectedRevision : void 0,
4985
5079
  conflictPolicy: options.conflictPolicy === "remote-wins" || options.conflictPolicy === "local-wins" || options.conflictPolicy === "manual" ? options.conflictPolicy : void 0,
4986
5080
  providerId: typeof options.providerId === "string" ? options.providerId : void 0,