@abraca/dabra 2.16.0 → 2.17.0

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.
@@ -1470,6 +1470,32 @@ var AbracadabraWS = class extends EventEmitter {
1470
1470
  console.error(e);
1471
1471
  }
1472
1472
  }
1473
+ /**
1474
+ * Force a fresh socket on the SAME WS manager: drop the current connection
1475
+ * (if any) and reconnect immediately.
1476
+ *
1477
+ * Unlike {@link disconnect} (which clears `shouldConnect` and gives up) this
1478
+ * re-arms `shouldConnect`, so it also revives a socket that a permanent
1479
+ * permission-denial turned off (see `AbracadabraBaseProvider.permissionDeniedHandler`).
1480
+ * The WS manager and its `providerMap` are preserved, so every attached
1481
+ * provider — and every Y.Doc + observer riding this socket — survives
1482
+ * untouched; only the transport is recycled.
1483
+ *
1484
+ * The existing socket is torn down silently (handlers removed first), so no
1485
+ * `close` event fires and no second reconnect is scheduled — `connect()`
1486
+ * below is the single path that re-opens. Returns the `connect()` promise.
1487
+ */
1488
+ reconnect() {
1489
+ this.shouldConnect = true;
1490
+ if (this.cancelWebsocketRetry) {
1491
+ this.cancelWebsocketRetry();
1492
+ this.cancelWebsocketRetry = void 0;
1493
+ }
1494
+ if (this.webSocket) this.cleanupWebSocket();
1495
+ this.status = WebSocketStatus.Disconnected;
1496
+ this.emit("status", { status: WebSocketStatus.Disconnected });
1497
+ return this.connect();
1498
+ }
1473
1499
  send(message) {
1474
1500
  if (this.webSocket?.readyState === WsReadyStates.Open) this.webSocket.send(message);
1475
1501
  else this.messageQueue.push(message);
@@ -2460,6 +2486,7 @@ var AbracadabraBaseProvider = class extends EventEmitter {
2460
2486
  onAuthenticationFailed: () => null,
2461
2487
  onRateLimited: () => null,
2462
2488
  onOpen: () => null,
2489
+ onReconnected: () => null,
2463
2490
  onConnect: () => null,
2464
2491
  onMessage: () => null,
2465
2492
  onOutgoingMessage: () => null,
@@ -2478,6 +2505,7 @@ var AbracadabraBaseProvider = class extends EventEmitter {
2478
2505
  this.unsyncedChanges = 0;
2479
2506
  this.isAuthenticated = false;
2480
2507
  this._hasEverAuthenticated = false;
2508
+ this._hasOpenedBefore = false;
2481
2509
  this.authorizedScope = void 0;
2482
2510
  this.manageSocket = false;
2483
2511
  this._isAttached = false;
@@ -2498,6 +2526,7 @@ var AbracadabraBaseProvider = class extends EventEmitter {
2498
2526
  this.configuration.awareness = configuration.awareness !== void 0 ? configuration.awareness : new Awareness(this.document);
2499
2527
  if (this.awareness && this.awareness.getLocalState() === null) this.awareness.setLocalState({});
2500
2528
  this.on("open", this.configuration.onOpen);
2529
+ this.on("reconnected", this.configuration.onReconnected);
2501
2530
  this.on("message", this.configuration.onMessage);
2502
2531
  this.on("outgoingMessage", this.configuration.onOutgoingMessage);
2503
2532
  this.on("synced", this.configuration.onSynced);
@@ -2647,9 +2676,41 @@ var AbracadabraBaseProvider = class extends EventEmitter {
2647
2676
  if (this.manageSocket) return this.configuration.websocketProvider.disconnect();
2648
2677
  console.warn("AbracadabraBaseProvider::disconnect() is deprecated and does not do anything. Please connect/disconnect on the websocketProvider, or attach/deattach providers.");
2649
2678
  }
2679
+ /**
2680
+ * Force the underlying socket to drop and reconnect on the SAME Y.Doc.
2681
+ *
2682
+ * Unlike destroying and re-creating the provider, the document is never
2683
+ * replaced — so every `observe`/`observeDeep` listener attached to it (and
2684
+ * its subdocs) keeps firing once the socket comes back. This also revives a
2685
+ * socket that a permanent permission-denial gave up on (see
2686
+ * {@link permissionDeniedHandler}). Pair it with a freshly-minted token (the
2687
+ * `token` callback is re-invoked on every reconnect) when healing a drop that
2688
+ * was caused by an expired JWT.
2689
+ *
2690
+ * Every provider multiplexed onto this socket is marked unsynced so a
2691
+ * subsequent `waitForSync` actually waits for the post-reconnect handshake
2692
+ * instead of short-circuiting on the stale `isSynced` flag.
2693
+ *
2694
+ * No-op (with a warning) for a provider on a shared, externally-managed
2695
+ * socket — reconnect the owning `websocketProvider` directly in that case.
2696
+ */
2697
+ reconnect() {
2698
+ if (!this.manageSocket) {
2699
+ console.warn("AbracadabraBaseProvider::reconnect() — this provider shares an externally-managed socket; call reconnect() on the websocketProvider instead.");
2700
+ return;
2701
+ }
2702
+ for (const provider of this.configuration.websocketProvider.configuration.providerMap.values()) {
2703
+ provider.synced = false;
2704
+ provider.isAuthenticated = false;
2705
+ }
2706
+ return this.configuration.websocketProvider.reconnect();
2707
+ }
2650
2708
  async onOpen(event) {
2709
+ const isReopen = this._hasOpenedBefore;
2710
+ this._hasOpenedBefore = true;
2651
2711
  this.isAuthenticated = false;
2652
2712
  this.emit("open", { event });
2713
+ if (isReopen) this.emit("reconnected", { event });
2653
2714
  await this.sendToken();
2654
2715
  this.startSync();
2655
2716
  }
@@ -19177,6 +19238,93 @@ var TreeManager = class {
19177
19238
  }
19178
19239
  };
19179
19240
 
19241
+ //#endregion
19242
+ //#region packages/provider/src/CoverReconcile.ts
19243
+ /**
19244
+ * CoverReconcile — keep a document's cover-image metadata in sync with the
19245
+ * image/video file blocks that actually live in its content.
19246
+ *
19247
+ * The cover (`meta.coverUploadId` / `coverDocId` / `coverMimeType` on the
19248
+ * doc's own tree entry) is *persisted* metadata, not derived live — the
19249
+ * kanban/gallery/table views read it without loading each card's content.
19250
+ * Historically the only thing that reconciled it was cou-sh's TipTap editor
19251
+ * (`reconcileCover` in DocRenderer.vue), so any write that did NOT go through
19252
+ * that editor — MCP `write_document`, `@abraca/convert`, the CLI, another SDK
19253
+ * — could leave an orphaned cover pointing at an image the body no longer
19254
+ * contains. This is the shared, write-path-level reconciliation so every
19255
+ * content write through the SDK keeps the cover honest.
19256
+ *
19257
+ * Semantics mirror cou-sh's editor reconciler exactly:
19258
+ * - cover set + still references a present image/video → leave it (a
19259
+ * user-chosen cover is preserved as long as its image is in the doc);
19260
+ * - cover set + its image is gone, others remain → swap to the first;
19261
+ * - cover set + no image/video remains → clear the three cover keys;
19262
+ * - no cover + at least one image/video → adopt the first as the cover.
19263
+ */
19264
+ /**
19265
+ * Recursively collect image/video `fileBlock` nodes from a Y.XmlFragment (or
19266
+ * element subtree), in document order. Mirrors the TipTap
19267
+ * `doc.descendants()` walk used by cou-sh — fileBlocks may be nested inside
19268
+ * other block nodes, so this recurses rather than scanning only top level.
19269
+ */
19270
+ function collectMediaFileBlocks(root) {
19271
+ const out = [];
19272
+ const visit = (node) => {
19273
+ if (node instanceof yjs.XmlElement && node.nodeName === "fileBlock") {
19274
+ const mimeType = node.getAttribute("mimeType");
19275
+ if (typeof mimeType === "string" && (mimeType.startsWith("image/") || mimeType.startsWith("video/"))) {
19276
+ const uploadId = node.getAttribute("uploadId");
19277
+ if (typeof uploadId === "string" && uploadId) {
19278
+ const docId = node.getAttribute("docId");
19279
+ out.push({
19280
+ uploadId,
19281
+ docId: typeof docId === "string" ? docId : "",
19282
+ mimeType
19283
+ });
19284
+ }
19285
+ }
19286
+ }
19287
+ if (node instanceof yjs.XmlElement || node instanceof yjs.XmlFragment) for (let i = 0; i < node.length; i++) visit(node.get(i));
19288
+ };
19289
+ visit(root);
19290
+ return out;
19291
+ }
19292
+ /**
19293
+ * Reconcile the cover metadata of `docId`'s tree entry against the media file
19294
+ * blocks currently present in `fragment`. No-op when the entry is missing or
19295
+ * the cover is already valid, so it is safe (and cheap) to call after every
19296
+ * content write. The `treeMap` is the root doc-tree Y.Map; the cover lives on
19297
+ * the doc's OWN self-entry (`treeMap.get(docId).meta`).
19298
+ */
19299
+ function reconcileDocCover(treeMap, docId, fragment) {
19300
+ const raw = treeMap.get(docId);
19301
+ if (!raw) return;
19302
+ const meta = toPlain(raw).meta ?? {};
19303
+ const currentCoverId = meta.coverUploadId;
19304
+ const media = collectMediaFileBlocks(fragment);
19305
+ if (currentCoverId && media.some((b) => b.uploadId === currentCoverId)) return;
19306
+ if (media.length > 0) {
19307
+ const best = media[0];
19308
+ if (currentCoverId === best.uploadId && meta.coverDocId === (best.docId || docId) && meta.coverMimeType === best.mimeType) return;
19309
+ patchEntry(treeMap, docId, {
19310
+ meta: {
19311
+ ...meta,
19312
+ coverUploadId: best.uploadId,
19313
+ coverDocId: best.docId || docId,
19314
+ coverMimeType: best.mimeType
19315
+ },
19316
+ updatedAt: Date.now()
19317
+ });
19318
+ return;
19319
+ }
19320
+ if (!currentCoverId && !meta.coverDocId && !meta.coverMimeType) return;
19321
+ const { coverUploadId: _cu, coverDocId: _cd, coverMimeType: _cm, ...rest } = meta;
19322
+ patchEntry(treeMap, docId, {
19323
+ meta: rest,
19324
+ updatedAt: Date.now()
19325
+ });
19326
+ }
19327
+
19180
19328
  //#endregion
19181
19329
  //#region packages/provider/src/DocConverters.ts
19182
19330
  /**
@@ -20583,6 +20731,8 @@ var ContentManager = class {
20583
20731
  while (fragment.length > 0) fragment.delete(0);
20584
20732
  });
20585
20733
  populateYDocFromMarkdown(fragment, body || markdown, title || "Untitled");
20734
+ const reconcileTree = this.dm.getTreeMap();
20735
+ if (reconcileTree) reconcileDocCover(reconcileTree, docId, fragment);
20586
20736
  }
20587
20737
  async _appendElements(docId, els) {
20588
20738
  const doc = (await this.dm.getChildProvider(docId)).document;
@@ -21054,6 +21204,7 @@ exports.buildHorizontalRuleElement = buildHorizontalRuleElement;
21054
21204
  exports.buildOrderedListElement = buildOrderedListElement;
21055
21205
  exports.buildParagraphElement = buildParagraphElement;
21056
21206
  exports.buildTaskListElement = buildTaskListElement;
21207
+ exports.collectMediaFileBlocks = collectMediaFileBlocks;
21057
21208
  exports.decryptChatContent = decryptChatContent;
21058
21209
  exports.decryptField = decryptField;
21059
21210
  exports.deriveIdentityDocId = deriveIdentityDocId;
@@ -21076,6 +21227,7 @@ exports.patchEntry = patchEntry;
21076
21227
  exports.populateYDocFromMarkdown = populateYDocFromMarkdown;
21077
21228
  exports.readAuthMessage = readAuthMessage;
21078
21229
  exports.readBlocksFromFragment = readBlocksFromFragment;
21230
+ exports.reconcileDocCover = reconcileDocCover;
21079
21231
  exports.recordFromYAny = recordFromYAny;
21080
21232
  exports.resolvePageType = resolvePageType;
21081
21233
  exports.toPlain = toPlain;