@agent-native/core 0.160.0 → 0.161.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.
Files changed (97) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/actions/bigquery.ts +57 -0
  3. package/corpus/templates/analytics/actions/restore-analysis-revision.ts +5 -2
  4. package/corpus/templates/analytics/actions/restore-dashboard-revision.ts +5 -2
  5. package/corpus/templates/analytics/app/components/analysis/AnalysisHistoryPanel.tsx +14 -3
  6. package/corpus/templates/analytics/app/components/dashboard/DashboardHistoryPanel.tsx +14 -3
  7. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +3 -0
  8. package/corpus/templates/analytics/app/hooks/use-analysis-revisions.ts +1 -0
  9. package/corpus/templates/analytics/app/hooks/use-dashboard-revisions.ts +16 -1
  10. package/corpus/templates/analytics/app/lib/use-auto-focus-select.ts +19 -0
  11. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/PanelEditorDialog.tsx +0 -1
  12. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/dashboard-sync.ts +14 -0
  13. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +19 -0
  14. package/corpus/templates/analytics/changelog/2026-08-17-fixed-dashboard-editing-focus-history-recovery-panel-setup-f.md +6 -0
  15. package/corpus/templates/calendar/actions/create-event.ts +7 -3
  16. package/corpus/templates/calendar/actions/manage-event-draft.ts +8 -0
  17. package/corpus/templates/calendar/app/components/calendar/CreateEventDialog.tsx +64 -0
  18. package/corpus/templates/calendar/app/components/calendar/EventDetailPopover.tsx +5 -1
  19. package/corpus/templates/calendar/app/hooks/use-events.ts +1 -0
  20. package/corpus/templates/calendar/changelog/2026-08-17-booking-links-now-respect-each-required-host-s-saved-availab.md +6 -0
  21. package/corpus/templates/calendar/server/lib/booking-host-availability.ts +76 -0
  22. package/corpus/templates/calendar/server/lib/google-calendar.ts +2 -0
  23. package/corpus/templates/calendar/shared/api.ts +1 -1
  24. package/corpus/templates/clips/app/components/player/signed-out-share-actions.tsx +47 -0
  25. package/corpus/templates/clips/app/components/player/transcript-panel.tsx +3 -1
  26. package/corpus/templates/clips/app/routes/share.$shareId.tsx +27 -19
  27. package/corpus/templates/clips/changelog/2026-08-17-shared-clips-sign-in-comment-no-audio.md +6 -0
  28. package/corpus/templates/clips/server/routes/api/public-recording.get.ts +6 -5
  29. package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +13 -0
  30. package/corpus/templates/content/AGENTS.md +2 -0
  31. package/corpus/templates/content/actions/_blocks-field-identity.ts +17 -0
  32. package/corpus/templates/content/actions/_database-block-actions.ts +958 -0
  33. package/corpus/templates/content/actions/_database-row-mutation.ts +7 -7
  34. package/corpus/templates/content/actions/list-content-database-blocks.ts +31 -0
  35. package/corpus/templates/content/actions/mutate-content-database-block.ts +49 -0
  36. package/corpus/templates/content/changelog/2026-08-10-agents-can-safely-insert-update-upsert-delete-and-reorder-on.md +6 -0
  37. package/corpus/templates/content/docs/product/capabilities/content.object.block.md +6 -2
  38. package/corpus/templates/content/docs/product/capabilities/content.object.blocks-field.md +2 -1
  39. package/corpus/templates/content/parity/matrix.md +30 -30
  40. package/corpus/templates/content/parity/matrix.ts +4 -1
  41. package/corpus/templates/content/shared/blocks-field-identity.ts +114 -37
  42. package/corpus/templates/content/shared/database-block-actions.ts +82 -0
  43. package/corpus/templates/content/shared/database-block-mutations.ts +366 -0
  44. package/corpus/templates/content/shared/nfm.ts +3 -2
  45. package/corpus/templates/mail/.agents/skills/email-drafts/SKILL.md +24 -0
  46. package/corpus/templates/mail/actions/get-mail-settings.ts +1 -1
  47. package/corpus/templates/mail/actions/update-mail-settings.ts +7 -3
  48. package/corpus/templates/mail/app/i18n/en-US.ts +3 -1
  49. package/corpus/templates/mail/app/pages/SettingsPage.tsx +63 -0
  50. package/corpus/templates/mail/changelog/2026-08-17-gmail-signatures-now-preserve-images-and-support-paste-or-up.md +6 -0
  51. package/corpus/templates/mail/changelog/2026-08-17-mail-now-updates-durable-drafting-preferences-without-openin.md +6 -0
  52. package/corpus/templates/mail/server/plugins/agent-chat.ts +19 -0
  53. package/corpus/templates/mail/shared/gmail-signature.ts +14 -2
  54. package/corpus/templates/mail/shared/signature.ts +1 -10
  55. package/corpus/templates/slides/actions/generate-slides-ai.ts +2 -0
  56. package/corpus/templates/slides/actions/patch-deck.ts +1 -1
  57. package/corpus/templates/slides/app/components/ThemeToggle.tsx +1 -0
  58. package/corpus/templates/slides/app/components/editor/SlideThumbnailContextMenu.tsx +59 -0
  59. package/corpus/templates/slides/app/global.css +1 -0
  60. package/corpus/templates/slides/app/lib/normalize-slide-padding.ts +40 -11
  61. package/corpus/templates/slides/changelog/2026-08-17-stable-layout-and-slide-editing.md +6 -0
  62. package/corpus/templates/slides/shared/deck-title.ts +12 -4
  63. package/dist/agent/production-agent.js +27 -15
  64. package/dist/agent/tool-approval-migrations.d.ts +2 -0
  65. package/dist/agent/tool-approval-migrations.js +20 -0
  66. package/dist/agent/tool-approval-store.d.ts +17 -3
  67. package/dist/agent/tool-approval-store.js +56 -6
  68. package/dist/agent/types.d.ts +3 -1
  69. package/dist/cli/code-agent-connector.js +79 -7
  70. package/dist/cli/portal-transfer.d.ts +43 -0
  71. package/dist/cli/portal-transfer.js +201 -0
  72. package/dist/client/AssistantChat.js +3 -1
  73. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  74. package/dist/localization/core-messages/ar-SA.js +1 -0
  75. package/dist/localization/core-messages/de-DE.js +1 -0
  76. package/dist/localization/core-messages/en-US.d.ts +1 -0
  77. package/dist/localization/core-messages/en-US.js +1 -0
  78. package/dist/localization/core-messages/es-ES.js +1 -0
  79. package/dist/localization/core-messages/fr-FR.js +1 -0
  80. package/dist/localization/core-messages/hi-IN.js +1 -0
  81. package/dist/localization/core-messages/ja-JP.js +1 -0
  82. package/dist/localization/core-messages/ko-KR.js +1 -0
  83. package/dist/localization/core-messages/pt-BR.js +1 -0
  84. package/dist/localization/core-messages/zh-CN.js +1 -0
  85. package/dist/localization/core-messages/zh-TW.js +1 -0
  86. package/dist/localization/core-messages.d.ts +1 -0
  87. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  88. package/dist/resources/handlers.d.ts +1 -1
  89. package/dist/scripts/resources/read.js +4 -4
  90. package/dist/scripts/resources/save-memory.js +15 -2
  91. package/dist/scripts/resources/write.js +3 -3
  92. package/dist/server/agent-chat/script-entries.d.ts +1 -0
  93. package/dist/server/agent-chat/script-entries.js +8 -0
  94. package/dist/server/realtime-token.d.ts +1 -1
  95. package/dist/server/transcribe-voice.d.ts +1 -1
  96. package/docs/content/onboarding.mdx +13 -2
  97. package/package.json +1 -1
@@ -4,6 +4,83 @@ export const BLOCKS_FIELD_IDENTITY_VERSION = 1;
4
4
  export const BLOCK_TOMBSTONE_REVISION_WINDOW = 20;
5
5
  export const MAX_BLOCK_TOMBSTONES_PER_FIELD = 500;
6
6
 
7
+ export const BLOCKS_FIELD_BLOCK_KINDS = [
8
+ "paragraph",
9
+ "heading",
10
+ "horizontalRule",
11
+ "codeBlock",
12
+ "blockquote",
13
+ "bulletList",
14
+ "orderedList",
15
+ "listItem",
16
+ "taskList",
17
+ "taskItem",
18
+ "notionToggle",
19
+ "notionCallout",
20
+ "notionColumns",
21
+ "notionColumn",
22
+ "notionSyncedBlock",
23
+ "table",
24
+ "tableRow",
25
+ "tableHeader",
26
+ "tableCell",
27
+ "image",
28
+ "video",
29
+ "audio",
30
+ "notionBlockAtom",
31
+ "registryBlock",
32
+ "contentReference",
33
+ "localMdxComponent",
34
+ ] as const;
35
+
36
+ export type BlocksFieldBlockKind = (typeof BLOCKS_FIELD_BLOCK_KINDS)[number];
37
+ export type BlocksFieldBlockOperation =
38
+ | "insert"
39
+ | "update"
40
+ | "upsert"
41
+ | "delete"
42
+ | "reorder";
43
+
44
+ const FULL_BLOCK_OPERATIONS = [
45
+ "insert",
46
+ "update",
47
+ "upsert",
48
+ "delete",
49
+ "reorder",
50
+ ] as const;
51
+ const ORDER_ONLY_BLOCK_OPERATIONS = ["delete", "reorder"] as const;
52
+
53
+ export const BLOCKS_FIELD_OPERATION_CAPABILITIES: Readonly<
54
+ Record<BlocksFieldBlockKind, readonly BlocksFieldBlockOperation[]>
55
+ > = {
56
+ paragraph: FULL_BLOCK_OPERATIONS,
57
+ heading: FULL_BLOCK_OPERATIONS,
58
+ horizontalRule: FULL_BLOCK_OPERATIONS,
59
+ codeBlock: FULL_BLOCK_OPERATIONS,
60
+ blockquote: FULL_BLOCK_OPERATIONS,
61
+ bulletList: [],
62
+ orderedList: [],
63
+ listItem: ORDER_ONLY_BLOCK_OPERATIONS,
64
+ taskList: [],
65
+ taskItem: ORDER_ONLY_BLOCK_OPERATIONS,
66
+ notionToggle: FULL_BLOCK_OPERATIONS,
67
+ notionCallout: FULL_BLOCK_OPERATIONS,
68
+ notionColumns: FULL_BLOCK_OPERATIONS,
69
+ notionColumn: FULL_BLOCK_OPERATIONS,
70
+ notionSyncedBlock: FULL_BLOCK_OPERATIONS,
71
+ table: FULL_BLOCK_OPERATIONS,
72
+ tableRow: [],
73
+ tableHeader: [],
74
+ tableCell: [],
75
+ image: FULL_BLOCK_OPERATIONS,
76
+ video: FULL_BLOCK_OPERATIONS,
77
+ audio: FULL_BLOCK_OPERATIONS,
78
+ notionBlockAtom: FULL_BLOCK_OPERATIONS,
79
+ registryBlock: FULL_BLOCK_OPERATIONS,
80
+ contentReference: FULL_BLOCK_OPERATIONS,
81
+ localMdxComponent: FULL_BLOCK_OPERATIONS,
82
+ };
83
+
7
84
  export type BlocksFieldIdentityStatus = "legacy" | "materialized" | "stale";
8
85
 
9
86
  export interface BlocksFieldBlock {
@@ -46,7 +123,7 @@ export interface StoredBlocksFieldIdentity {
46
123
  blocks: StoredBlocksFieldBlock[];
47
124
  }
48
125
 
49
- interface BlockSnapshot {
126
+ export interface BlocksFieldBlockSnapshot {
50
127
  path: string;
51
128
  parentPath: string | null;
52
129
  kind: string;
@@ -57,34 +134,7 @@ interface BlockSnapshot {
57
134
  preferredId: string | null;
58
135
  }
59
136
 
60
- const BLOCK_NODE_TYPES = new Set([
61
- "paragraph",
62
- "heading",
63
- "horizontalRule",
64
- "codeBlock",
65
- "blockquote",
66
- "bulletList",
67
- "orderedList",
68
- "listItem",
69
- "taskList",
70
- "taskItem",
71
- "notionToggle",
72
- "notionCallout",
73
- "notionColumns",
74
- "notionColumn",
75
- "notionSyncedBlock",
76
- "table",
77
- "tableRow",
78
- "tableHeader",
79
- "tableCell",
80
- "image",
81
- "video",
82
- "audio",
83
- "notionBlockAtom",
84
- "registryBlock",
85
- "contentReference",
86
- "localMdxComponent",
87
- ]);
137
+ const BLOCK_NODE_TYPES = new Set<string>(BLOCKS_FIELD_BLOCK_KINDS);
88
138
 
89
139
  const NON_ADDRESSABLE_NODE_TYPES = new Set([
90
140
  "bulletList",
@@ -133,8 +183,10 @@ function nodeMarkdown(node: PMNode): string {
133
183
  return docToNfm({ type: "doc", content: [node] });
134
184
  }
135
185
 
136
- function snapshotMarkdown(markdown: string): BlockSnapshot[] {
137
- const snapshots: BlockSnapshot[] = [];
186
+ export function snapshotBlocksFieldMarkdown(
187
+ markdown: string,
188
+ ): BlocksFieldBlockSnapshot[] {
189
+ const snapshots: BlocksFieldBlockSnapshot[] = [];
138
190
  const doc = nfmToDoc(markdown);
139
191
 
140
192
  function visit(nodes: PMNode[] | undefined, parentPath: string | null) {
@@ -172,7 +224,7 @@ function snapshotMarkdown(markdown: string): BlockSnapshot[] {
172
224
 
173
225
  function deterministicBlockId(
174
226
  fieldId: string,
175
- snapshot: Pick<BlockSnapshot, "path" | "kind" | "contentHash">,
227
+ snapshot: Pick<BlocksFieldBlockSnapshot, "path" | "kind" | "contentHash">,
176
228
  ): string {
177
229
  return `block_${hashString(
178
230
  `${fieldId}\0${snapshot.path}\0${snapshot.kind}\0${snapshot.contentHash}`,
@@ -225,7 +277,7 @@ export function legacyBlocksFieldIdentity(args: {
225
277
  markdown: string;
226
278
  }): BlocksFieldIdentity {
227
279
  const fieldId = blocksFieldId(args.documentId, args.propertyId);
228
- const snapshots = snapshotMarkdown(args.markdown);
280
+ const snapshots = snapshotBlocksFieldMarkdown(args.markdown);
229
281
  const idByPath = new Map<string, string>();
230
282
  const usedIds = new Set<string>();
231
283
  const blocks: StoredBlocksFieldBlock[] = snapshots.map((snapshot) => {
@@ -308,9 +360,10 @@ export function reconcileBlocksFieldIdentity(args: {
308
360
  previous: StoredBlocksFieldIdentity;
309
361
  markdown: string;
310
362
  createId: () => string;
363
+ preferredIdsByPath?: Readonly<Record<string, string>>;
311
364
  }): StoredBlocksFieldIdentity {
312
365
  const nextRevision = args.previous.revision + 1;
313
- const snapshots = snapshotMarkdown(args.markdown);
366
+ const snapshots = snapshotBlocksFieldMarkdown(args.markdown);
314
367
  const previousLive = args.previous.blocks.filter(
315
368
  (block) => block.state === "live",
316
369
  );
@@ -320,6 +373,21 @@ export function reconcileBlocksFieldIdentity(args: {
320
373
  const matchedPrevious = new Set<number>();
321
374
  const assigned = new Map<number, StoredBlocksFieldBlock>();
322
375
  const usedIds = new Set(args.previous.blocks.map((block) => block.id));
376
+ const assignedNextIds = new Set<string>();
377
+
378
+ const previousLiveById = new Map(
379
+ previousLive.map((block, index) => [block.id, index]),
380
+ );
381
+ for (let nextIndex = 0; nextIndex < snapshots.length; nextIndex++) {
382
+ const explicitId = args.preferredIdsByPath?.[snapshots[nextIndex]!.path];
383
+ if (!explicitId) continue;
384
+ const previousIndex = previousLiveById.get(explicitId);
385
+ if (previousIndex === undefined || matchedPrevious.has(previousIndex)) {
386
+ continue;
387
+ }
388
+ matchedPrevious.add(previousIndex);
389
+ assigned.set(nextIndex, previousLive[previousIndex]!);
390
+ }
323
391
 
324
392
  const previousExact = uniqueIndexByKey(
325
393
  previousLive,
@@ -440,8 +508,9 @@ export function reconcileBlocksFieldIdentity(args: {
440
508
  const recoveredIds = new Set<string>();
441
509
  const idByPath = new Map<string, string>();
442
510
  const nextBlocks = snapshots.map((snapshot, nextIndex) => {
511
+ const explicitId = args.preferredIdsByPath?.[snapshot.path];
443
512
  let previous = assigned.get(nextIndex);
444
- if (!previous) {
513
+ if (!previous && !explicitId) {
445
514
  const recoveredIndex = recoverable.get(
446
515
  `${snapshot.kind}\0${snapshot.contentHash}`,
447
516
  );
@@ -453,16 +522,24 @@ export function reconcileBlocksFieldIdentity(args: {
453
522
  }
454
523
  }
455
524
  }
525
+ if (explicitId && usedIds.has(explicitId) && explicitId !== previous?.id) {
526
+ throw new Error(`Preferred Block ID is already reserved: ${explicitId}`);
527
+ }
456
528
  let id =
529
+ explicitId ??
457
530
  previous?.id ??
458
531
  fieldScopedBlockId(
459
532
  args.previous.fieldId,
460
533
  snapshot.preferredId ?? args.createId(),
461
534
  );
462
- while (usedIds.has(id) && id !== previous?.id) {
535
+ while (
536
+ assignedNextIds.has(id) ||
537
+ (!explicitId && usedIds.has(id) && id !== previous?.id)
538
+ ) {
463
539
  id = fieldScopedBlockId(args.previous.fieldId, args.createId());
464
540
  }
465
541
  usedIds.add(id);
542
+ assignedNextIds.add(id);
466
543
  idByPath.set(snapshot.path, id);
467
544
  return {
468
545
  id,
@@ -537,7 +614,7 @@ export function materializeLegacyBlocksFieldIdentity(args: {
537
614
  markdown: string;
538
615
  }): StoredBlocksFieldIdentity {
539
616
  const publicState = legacyBlocksFieldIdentity(args);
540
- const snapshots = snapshotMarkdown(args.markdown);
617
+ const snapshots = snapshotBlocksFieldMarkdown(args.markdown);
541
618
  return {
542
619
  fieldId: publicState.fieldId,
543
620
  revision: 0,
@@ -0,0 +1,82 @@
1
+ import type { ContentDatabaseMutationTarget } from "./api.js";
2
+ import type {
3
+ BlocksFieldBlockKind,
4
+ BlocksFieldBlockOperation,
5
+ BlocksFieldIdentityStatus,
6
+ } from "./blocks-field-identity.js";
7
+
8
+ export interface ContentDatabaseBlockTarget extends ContentDatabaseMutationTarget {
9
+ itemId: string;
10
+ rowDocumentId: string;
11
+ propertyId: string;
12
+ }
13
+
14
+ export interface ContentDatabaseBlockValue {
15
+ format: "nfm";
16
+ nfm: string;
17
+ }
18
+
19
+ export interface ContentDatabaseBlock {
20
+ id: string;
21
+ parentId: string | null;
22
+ kind: BlocksFieldBlockKind;
23
+ index: number;
24
+ addressable: boolean;
25
+ value: ContentDatabaseBlockValue;
26
+ supportedOperations: readonly BlocksFieldBlockOperation[];
27
+ degraded: boolean;
28
+ }
29
+
30
+ export interface ContentDatabaseBlocksReadResult {
31
+ target: ContentDatabaseBlockTarget;
32
+ rowLink: { urlPath: string; label: string };
33
+ schemaRevision: string;
34
+ rowRevision: string;
35
+ fieldRevision: number;
36
+ identityStatus: BlocksFieldIdentityStatus;
37
+ total: number;
38
+ order: string[];
39
+ blocks: ContentDatabaseBlock[];
40
+ page: { offset: number; limit: number; nextCursor: string | null };
41
+ }
42
+
43
+ export type ContentDatabaseBlockMutationOperation =
44
+ | "insert"
45
+ | "update"
46
+ | "upsert"
47
+ | "delete"
48
+ | "reorder";
49
+
50
+ export interface ContentDatabaseBlockMutationReceipt {
51
+ receiptId: string;
52
+ operation: ContentDatabaseBlockMutationOperation;
53
+ outcome: "inserted" | "updated" | "deleted" | "reordered" | "unchanged";
54
+ target: ContentDatabaseBlockTarget;
55
+ rowLink: { urlPath: string; label: string };
56
+ schemaRevision: string;
57
+ idempotency: {
58
+ key: string;
59
+ result: "applied" | "replayed";
60
+ payloadDigest: string;
61
+ };
62
+ revisions: {
63
+ row: { before: string; after: string };
64
+ field: { before: number; after: number };
65
+ };
66
+ affected: {
67
+ blockIds: string[];
68
+ deletedBlockIds: string[];
69
+ order: string[];
70
+ };
71
+ readback: {
72
+ verified: true;
73
+ fieldRevision: number;
74
+ contentHash: string;
75
+ order: string[];
76
+ blocks: ContentDatabaseBlock[];
77
+ };
78
+ }
79
+
80
+ export interface ContentDatabaseBlockMutationResult {
81
+ receipt: ContentDatabaseBlockMutationReceipt;
82
+ }
@@ -0,0 +1,366 @@
1
+ import {
2
+ BLOCKS_FIELD_BLOCK_KINDS,
3
+ BLOCKS_FIELD_OPERATION_CAPABILITIES,
4
+ type BlocksFieldBlockKind,
5
+ type BlocksFieldBlockOperation,
6
+ type BlocksFieldIdentity,
7
+ } from "./blocks-field-identity.js";
8
+ import { docToNfm, nfmToDoc, type PMDoc, type PMNode } from "./nfm.js";
9
+
10
+ type Placement =
11
+ | { placement: "start" | "end"; parentBlockId?: string | null }
12
+ | { placement: "before" | "after"; anchorBlockId: string };
13
+
14
+ export type BlockDocumentMutation =
15
+ | {
16
+ operation: "insert";
17
+ block: { kind: BlocksFieldBlockKind; nfm: string };
18
+ position: Placement;
19
+ }
20
+ | {
21
+ operation: "update" | "upsert";
22
+ blockId: string;
23
+ block: { kind: BlocksFieldBlockKind; nfm: string };
24
+ position?: Placement;
25
+ }
26
+ | { operation: "delete"; blockId: string }
27
+ | { operation: "reorder"; blockId: string; position: Placement };
28
+
29
+ interface NodeRef {
30
+ node: PMNode;
31
+ nodes: PMNode[];
32
+ nodeIndex: number;
33
+ path: string;
34
+ parentPath: string | null;
35
+ }
36
+
37
+ export interface BlockDocumentMutationResult {
38
+ markdown: string;
39
+ preferredIdsByPath: Record<string, string>;
40
+ requestedBlockId: string | null;
41
+ deletedCandidateIds: string[];
42
+ changed: boolean;
43
+ }
44
+
45
+ const BLOCK_KIND_SET = new Set<string>(BLOCKS_FIELD_BLOCK_KINDS);
46
+
47
+ function collectNodeRefs(doc: PMDoc): NodeRef[] {
48
+ const refs: NodeRef[] = [];
49
+ const visit = (nodes: PMNode[] | undefined, parentPath: string | null) => {
50
+ let blockPosition = 0;
51
+ for (let nodeIndex = 0; nodeIndex < (nodes?.length ?? 0); nodeIndex++) {
52
+ const node = nodes![nodeIndex]!;
53
+ if (!BLOCK_KIND_SET.has(node.type)) continue;
54
+ const path =
55
+ parentPath === null
56
+ ? `${blockPosition}`
57
+ : `${parentPath}.${blockPosition}`;
58
+ refs.push({ node, nodes: nodes!, nodeIndex, path, parentPath });
59
+ visit(node.content, path);
60
+ blockPosition++;
61
+ }
62
+ };
63
+ visit(doc.content, null);
64
+ return refs;
65
+ }
66
+
67
+ function indexIdentity(markdown: string, identity: BlocksFieldIdentity) {
68
+ const doc = nfmToDoc(markdown);
69
+ const refs = collectNodeRefs(doc);
70
+ if (refs.length !== identity.blocks.length) {
71
+ throw new Error("Blocks identity does not match the current field body.");
72
+ }
73
+ const byId = new Map<string, NodeRef>();
74
+ const idByNode = new Map<PMNode, string>();
75
+ refs.forEach((ref, index) => {
76
+ const block = identity.blocks[index]!;
77
+ if (block.kind !== ref.node.type) {
78
+ throw new Error(
79
+ "Blocks identity kind does not match the current field body.",
80
+ );
81
+ }
82
+ byId.set(block.id, ref);
83
+ idByNode.set(ref.node, block.id);
84
+ });
85
+ return { doc, byId, idByNode };
86
+ }
87
+
88
+ function parsedBlock(kind: BlocksFieldBlockKind, nfm: string): PMNode {
89
+ const doc = nfmToDoc(nfm);
90
+ if (doc.content.length !== 1 || doc.content[0]?.type !== kind) {
91
+ throw new Error(
92
+ `Block value must be canonical NFM containing exactly one top-level ${kind} block.`,
93
+ );
94
+ }
95
+ return doc.content[0];
96
+ }
97
+
98
+ function requireOperation(
99
+ kind: BlocksFieldBlockKind,
100
+ operation: BlocksFieldBlockOperation,
101
+ ) {
102
+ const supported = BLOCKS_FIELD_OPERATION_CAPABILITIES[
103
+ kind
104
+ ] as readonly string[];
105
+ if (!supported.includes(operation as string)) {
106
+ throw new Error(`Block kind "${kind}" does not support ${operation}.`);
107
+ }
108
+ }
109
+
110
+ function destination(
111
+ position: Placement,
112
+ byId: Map<string, NodeRef>,
113
+ doc: PMDoc,
114
+ ): { nodes: PMNode[]; index: number; parentPath: string | null } {
115
+ if ("anchorBlockId" in position) {
116
+ const anchor = byId.get(position.anchorBlockId);
117
+ if (!anchor) throw new Error("Anchor block not found.");
118
+ return {
119
+ nodes: anchor.nodes,
120
+ index: anchor.nodeIndex + (position.placement === "after" ? 1 : 0),
121
+ parentPath: anchor.parentPath,
122
+ };
123
+ }
124
+ if (!position.parentBlockId) {
125
+ return {
126
+ nodes: doc.content,
127
+ index: position.placement === "start" ? 0 : doc.content.length,
128
+ parentPath: null,
129
+ };
130
+ }
131
+ const parent = byId.get(position.parentBlockId);
132
+ if (!parent) throw new Error("Parent block not found.");
133
+ const nodes = (parent.node.content ??= []);
134
+ return {
135
+ nodes,
136
+ index: position.placement === "start" ? 0 : nodes.length,
137
+ parentPath: parent.path,
138
+ };
139
+ }
140
+
141
+ function assertCompatibleParent(
142
+ parentPath: string | null,
143
+ kind: BlocksFieldBlockKind,
144
+ byId: Map<string, NodeRef>,
145
+ ) {
146
+ const parent = [...byId.values()].find(
147
+ (ref) => ref.path === parentPath,
148
+ )?.node;
149
+ const parentKind = parent?.type;
150
+ const generalContainerKinds = new Set([
151
+ "blockquote",
152
+ "listItem",
153
+ "taskItem",
154
+ "notionToggle",
155
+ "notionCallout",
156
+ "notionColumn",
157
+ "notionSyncedBlock",
158
+ "tableHeader",
159
+ "tableCell",
160
+ ]);
161
+ const valid =
162
+ parentKind === undefined
163
+ ? ![
164
+ "listItem",
165
+ "taskItem",
166
+ "notionColumn",
167
+ "tableRow",
168
+ "tableHeader",
169
+ "tableCell",
170
+ ].includes(kind)
171
+ : parentKind === "bulletList" || parentKind === "orderedList"
172
+ ? kind === "listItem"
173
+ : parentKind === "taskList"
174
+ ? kind === "taskItem"
175
+ : parentKind === "notionColumns"
176
+ ? kind === "notionColumn"
177
+ : parentKind === "table"
178
+ ? kind === "tableRow"
179
+ : parentKind === "tableRow"
180
+ ? kind === "tableHeader" || kind === "tableCell"
181
+ : generalContainerKinds.has(parentKind) &&
182
+ ![
183
+ "listItem",
184
+ "taskItem",
185
+ "notionColumn",
186
+ "tableRow",
187
+ "tableHeader",
188
+ "tableCell",
189
+ ].includes(kind);
190
+ if (!valid) {
191
+ throw new Error(
192
+ `Block kind "${kind}" is not valid inside ${parentKind ?? "the field root"}.`,
193
+ );
194
+ }
195
+ }
196
+
197
+ function repositionNode(args: {
198
+ blockId: string;
199
+ current: NodeRef;
200
+ node: PMNode;
201
+ position: Placement;
202
+ byId: Map<string, NodeRef>;
203
+ doc: PMDoc;
204
+ }) {
205
+ if (
206
+ "anchorBlockId" in args.position &&
207
+ args.position.anchorBlockId === args.blockId
208
+ ) {
209
+ throw new Error("A block cannot be reordered relative to itself.");
210
+ }
211
+ const target = destination(args.position, args.byId, args.doc);
212
+ if (target.parentPath !== args.current.parentPath) {
213
+ throw new Error("Cross-parent block reorder is not supported.");
214
+ }
215
+ args.current.nodes.splice(args.current.nodeIndex, 1);
216
+ const targetIndex =
217
+ "anchorBlockId" in args.position
218
+ ? (() => {
219
+ const anchor = args.byId.get(args.position.anchorBlockId);
220
+ if (!anchor || anchor.nodes !== target.nodes) {
221
+ throw new Error("Reorder anchor is outside the current parent.");
222
+ }
223
+ const anchorIndex = target.nodes.indexOf(anchor.node);
224
+ return anchorIndex + (args.position.placement === "after" ? 1 : 0);
225
+ })()
226
+ : args.position.placement === "start"
227
+ ? 0
228
+ : target.nodes.length;
229
+ target.nodes.splice(targetIndex, 0, args.node);
230
+ }
231
+
232
+ function preferredIds(
233
+ doc: PMDoc,
234
+ idByNode: Map<PMNode, string>,
235
+ requestedNode?: PMNode,
236
+ requestedId?: string,
237
+ createInsertedDescendantId?: () => string,
238
+ ) {
239
+ const requestedDescendants = new Set<PMNode>();
240
+ const collectRequestedDescendants = (nodes: PMNode[] | undefined) => {
241
+ for (const node of nodes ?? []) {
242
+ requestedDescendants.add(node);
243
+ collectRequestedDescendants(node.content);
244
+ }
245
+ };
246
+ if (requestedId && requestedNode) {
247
+ collectRequestedDescendants(requestedNode.content);
248
+ }
249
+ return Object.fromEntries(
250
+ collectNodeRefs(doc).flatMap((ref) => {
251
+ const id =
252
+ idByNode.get(ref.node) ??
253
+ (ref.node === requestedNode
254
+ ? requestedId
255
+ : requestedDescendants.has(ref.node)
256
+ ? createInsertedDescendantId?.()
257
+ : undefined);
258
+ if (
259
+ requestedDescendants.has(ref.node) &&
260
+ !id &&
261
+ !createInsertedDescendantId
262
+ ) {
263
+ throw new Error(
264
+ "Inserting a nested block requires fresh descendant Block IDs.",
265
+ );
266
+ }
267
+ return id ? [[ref.path, id]] : [];
268
+ }),
269
+ );
270
+ }
271
+
272
+ export function mutateBlocksFieldDocument(args: {
273
+ markdown: string;
274
+ identity: BlocksFieldIdentity;
275
+ mutation: BlockDocumentMutation;
276
+ insertedBlockId?: string;
277
+ createInsertedDescendantId?: () => string;
278
+ }): BlockDocumentMutationResult {
279
+ const indexed = indexIdentity(args.markdown, args.identity);
280
+ const { doc, byId, idByNode } = indexed;
281
+ const before = docToNfm(doc);
282
+ let requestedNode: PMNode | undefined;
283
+ let requestedBlockId: string | null = null;
284
+ let deletedCandidateIds: string[] = [];
285
+
286
+ if (args.mutation.operation === "insert") {
287
+ requireOperation(args.mutation.block.kind, "insert");
288
+ requestedNode = parsedBlock(
289
+ args.mutation.block.kind,
290
+ args.mutation.block.nfm,
291
+ );
292
+ const target = destination(args.mutation.position, byId, doc);
293
+ assertCompatibleParent(target.parentPath, args.mutation.block.kind, byId);
294
+ target.nodes.splice(target.index, 0, requestedNode);
295
+ } else {
296
+ const current = byId.get(args.mutation.blockId);
297
+ if (!current) throw new Error("Block not found.");
298
+ const currentKind = current.node.type as BlocksFieldBlockKind;
299
+ requireOperation(currentKind, args.mutation.operation);
300
+ requestedBlockId = args.mutation.blockId;
301
+
302
+ if (
303
+ args.mutation.operation === "update" ||
304
+ args.mutation.operation === "upsert"
305
+ ) {
306
+ if (args.mutation.block.kind !== currentKind) {
307
+ throw new Error("Individual block mutations cannot change block kind.");
308
+ }
309
+ requestedNode = parsedBlock(currentKind, args.mutation.block.nfm);
310
+ current.nodes[current.nodeIndex] = requestedNode;
311
+ current.node = requestedNode;
312
+ idByNode.set(requestedNode, args.mutation.blockId);
313
+ if (args.mutation.operation === "upsert" && args.mutation.position) {
314
+ repositionNode({
315
+ blockId: args.mutation.blockId,
316
+ current,
317
+ node: requestedNode,
318
+ position: args.mutation.position,
319
+ byId,
320
+ doc,
321
+ });
322
+ }
323
+ } else if (args.mutation.operation === "delete") {
324
+ deletedCandidateIds = args.identity.blocks
325
+ .filter((block) => {
326
+ const ref = byId.get(block.id);
327
+ return (
328
+ ref?.path === current.path ||
329
+ ref?.path.startsWith(`${current.path}.`)
330
+ );
331
+ })
332
+ .map((block) => block.id);
333
+ current.nodes.splice(current.nodeIndex, 1);
334
+ } else if (args.mutation.operation === "reorder") {
335
+ repositionNode({
336
+ blockId: args.mutation.blockId,
337
+ current,
338
+ node: current.node,
339
+ position: args.mutation.position,
340
+ byId,
341
+ doc,
342
+ });
343
+ }
344
+ }
345
+
346
+ const markdown = docToNfm(doc);
347
+ const preferredIdsByPath = preferredIds(
348
+ doc,
349
+ idByNode,
350
+ requestedNode,
351
+ args.insertedBlockId,
352
+ args.createInsertedDescendantId,
353
+ );
354
+ const identityOrderChanged = collectNodeRefs(doc).some(
355
+ (ref, index) =>
356
+ preferredIdsByPath[ref.path] !== undefined &&
357
+ preferredIdsByPath[ref.path] !== args.identity.blocks[index]?.id,
358
+ );
359
+ return {
360
+ markdown,
361
+ preferredIdsByPath,
362
+ requestedBlockId,
363
+ deletedCandidateIds,
364
+ changed: markdown !== before || identityOrderChanged,
365
+ };
366
+ }
@@ -986,7 +986,8 @@ function serializeToggle(node: PMNode, ind: number): string[] {
986
986
  return out;
987
987
  }
988
988
  const attrStr = serializeAttrs([["color", isColor(color) ? color : null]]);
989
- out.push(indentStr(ind) + `<details${attrStr}>`);
989
+ const openAttr = node.attrs?.open === true ? " open" : "";
990
+ out.push(indentStr(ind) + `<details${attrStr}${openAttr}>`);
990
991
  out.push(indentStr(ind) + `<summary>${summary}</summary>`);
991
992
  out.push(...serializeBlocks(node.content || [], ind + 1));
992
993
  out.push(indentStr(ind) + "</details>");
@@ -2006,7 +2007,7 @@ function parseContainer(
2006
2007
  attrs: {
2007
2008
  summary,
2008
2009
  headingLevel: null,
2009
- open: false,
2010
+ open: attrs.open !== undefined || /(?:^|\s)open(?:\s|>)/.test(openLine),
2010
2011
  color: isColor(attrs.color) ? attrs.color : null,
2011
2012
  indent: 0,
2012
2013
  },