affine-mcp-server 1.12.0 → 2.0.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.
- package/README.md +155 -502
- package/dist/edgeless/layout.js +222 -0
- package/dist/index.js +34 -62
- package/dist/markdown/parse.js +51 -10
- package/dist/toolSurface.js +322 -0
- package/dist/tools/comments.js +25 -5
- package/dist/tools/docs.js +3220 -583
- package/dist/tools/organize.js +419 -42
- package/dist/tools/workspaces.js +25 -6
- package/dist/util/mcp.js +26 -2
- package/docs/assets/edgeless-canvas-demo-advanced-dark.png +0 -0
- package/docs/assets/edgeless-canvas-demo-advanced-light.png +0 -0
- package/docs/client-setup.md +174 -0
- package/docs/configuration-and-deployment.md +265 -0
- package/docs/edgeless-canvas-cookbook.md +226 -0
- package/docs/getting-started.md +229 -0
- package/docs/tool-reference.md +186 -0
- package/docs/workflow-recipes.md +147 -0
- package/package.json +11 -2
- package/tool-manifest.json +89 -0
package/dist/tools/docs.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { generateKeyBetween } from "fractional-indexing";
|
|
3
|
+
import { receipt, text } from "../util/mcp.js";
|
|
3
4
|
import { wsUrlFromGraphQLEndpoint, connectWorkspaceSocket, joinWorkspace, loadDoc, pushDocUpdate, deleteDoc as wsDeleteDoc } from "../ws.js";
|
|
4
5
|
import * as Y from "yjs";
|
|
5
6
|
import { parseMarkdownToOperations } from "../markdown/parse.js";
|
|
6
7
|
import { renderBlocksToMarkdown } from "../markdown/render.js";
|
|
8
|
+
import { DEFAULT_NOTE_XYWH, DEFAULT_STACK_GAP_HORIZONTAL, DEFAULT_STACK_GAP_VERTICAL, SIDE_TO_NORMALIZED_POSITION, encloseBounds, estimateConnectorLabelXYWH, estimateNoteHeightForMarkdown, formatXywhString, parseXywhString, pickConnectorSides, pickFurthestInDirection, sortByFractionalIndex, stackRelativeTo, } from "../edgeless/layout.js";
|
|
7
9
|
const WorkspaceId = z.string().min(1, "workspaceId required");
|
|
8
10
|
const DocId = z.string().min(1, "docId required");
|
|
9
11
|
const MarkdownContent = z.string().min(1, "markdown required");
|
|
@@ -55,6 +57,54 @@ const APPEND_BLOCK_BOOKMARK_STYLE_VALUES = [
|
|
|
55
57
|
const AppendBlockBookmarkStyle = z.enum(APPEND_BLOCK_BOOKMARK_STYLE_VALUES);
|
|
56
58
|
const APPEND_BLOCK_DATA_VIEW_MODE_VALUES = ["table", "kanban"];
|
|
57
59
|
const AppendBlockDataViewMode = z.enum(APPEND_BLOCK_DATA_VIEW_MODE_VALUES);
|
|
60
|
+
const DATABASE_INTENT_VALUES = ["task_board", "issue_tracker"];
|
|
61
|
+
const DatabaseIntent = z.enum(DATABASE_INTENT_VALUES);
|
|
62
|
+
const DATABASE_COLUMN_TYPE_VALUES = ["rich-text", "select", "multi-select", "number", "checkbox", "link", "date"];
|
|
63
|
+
const MARKDOWN_EXPORT_SUPPORTED_FLAVOURS = new Set([
|
|
64
|
+
"affine:paragraph",
|
|
65
|
+
"affine:list",
|
|
66
|
+
"affine:code",
|
|
67
|
+
"affine:divider",
|
|
68
|
+
"affine:bookmark",
|
|
69
|
+
"affine:embed-youtube",
|
|
70
|
+
"affine:embed-github",
|
|
71
|
+
"affine:embed-figma",
|
|
72
|
+
"affine:embed-loom",
|
|
73
|
+
"affine:embed-iframe",
|
|
74
|
+
"affine:image",
|
|
75
|
+
"affine:table",
|
|
76
|
+
"affine:callout",
|
|
77
|
+
"affine:note",
|
|
78
|
+
"affine:page",
|
|
79
|
+
"affine:surface",
|
|
80
|
+
]);
|
|
81
|
+
const KNOWN_BLOCK_FLAVOURS = new Set([
|
|
82
|
+
"affine:page",
|
|
83
|
+
"affine:surface",
|
|
84
|
+
"affine:paragraph",
|
|
85
|
+
"affine:list",
|
|
86
|
+
"affine:code",
|
|
87
|
+
"affine:divider",
|
|
88
|
+
"affine:callout",
|
|
89
|
+
"affine:latex",
|
|
90
|
+
"affine:table",
|
|
91
|
+
"affine:bookmark",
|
|
92
|
+
"affine:image",
|
|
93
|
+
"affine:attachment",
|
|
94
|
+
"affine:embed-youtube",
|
|
95
|
+
"affine:embed-github",
|
|
96
|
+
"affine:embed-figma",
|
|
97
|
+
"affine:embed-loom",
|
|
98
|
+
"affine:embed-html",
|
|
99
|
+
"affine:embed-linked-doc",
|
|
100
|
+
"affine:embed-synced-doc",
|
|
101
|
+
"affine:embed-iframe",
|
|
102
|
+
"affine:database",
|
|
103
|
+
"affine:surface-ref",
|
|
104
|
+
"affine:frame",
|
|
105
|
+
"affine:edgeless-text",
|
|
106
|
+
"affine:note",
|
|
107
|
+
]);
|
|
58
108
|
function blockVersion(flavour) {
|
|
59
109
|
switch (flavour) {
|
|
60
110
|
case "affine:page":
|
|
@@ -471,6 +521,24 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
471
521
|
}
|
|
472
522
|
return null;
|
|
473
523
|
}
|
|
524
|
+
function pruneFromFrameChildElementIds(blocks, deletedIds) {
|
|
525
|
+
if (deletedIds.length === 0)
|
|
526
|
+
return;
|
|
527
|
+
const idSet = new Set(deletedIds);
|
|
528
|
+
for (const [, value] of blocks) {
|
|
529
|
+
if (!(value instanceof Y.Map))
|
|
530
|
+
continue;
|
|
531
|
+
if (value.get("sys:flavour") !== "affine:frame")
|
|
532
|
+
continue;
|
|
533
|
+
const owned = value.get("prop:childElementIds");
|
|
534
|
+
if (!(owned instanceof Y.Map))
|
|
535
|
+
continue;
|
|
536
|
+
for (const id of idSet) {
|
|
537
|
+
if (owned.has(id))
|
|
538
|
+
owned.delete(id);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
474
542
|
function ensureNoteBlock(blocks) {
|
|
475
543
|
const existingNoteId = findBlockIdByFlavour(blocks, "affine:note");
|
|
476
544
|
if (existingNoteId) {
|
|
@@ -480,20 +548,29 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
480
548
|
if (!pageId) {
|
|
481
549
|
throw new Error("Document has no page block; unable to insert content.");
|
|
482
550
|
}
|
|
551
|
+
// Mirror BlockSuite's createDefaultDoc shape so the editor doesn't re-seed
|
|
552
|
+
// its own default note alongside ours.
|
|
483
553
|
const noteId = generateId();
|
|
484
554
|
const note = new Y.Map();
|
|
485
555
|
setSysFields(note, noteId, "affine:note");
|
|
486
556
|
note.set("sys:parent", null);
|
|
487
|
-
|
|
488
|
-
note.set("
|
|
557
|
+
const noteChildren = new Y.Array();
|
|
558
|
+
note.set("sys:children", noteChildren);
|
|
559
|
+
note.set("prop:xywh", DEFAULT_NOTE_XYWH);
|
|
489
560
|
note.set("prop:index", "a0");
|
|
490
561
|
note.set("prop:hidden", false);
|
|
491
562
|
note.set("prop:displayMode", "both");
|
|
492
|
-
|
|
493
|
-
background.set("light", "#ffffff");
|
|
494
|
-
background.set("dark", "#252525");
|
|
495
|
-
note.set("prop:background", background);
|
|
563
|
+
note.set("prop:background", buildDefaultNoteBackground());
|
|
496
564
|
blocks.set(noteId, note);
|
|
565
|
+
const paragraphId = generateId();
|
|
566
|
+
const paragraph = new Y.Map();
|
|
567
|
+
setSysFields(paragraph, paragraphId, "affine:paragraph");
|
|
568
|
+
paragraph.set("sys:parent", null);
|
|
569
|
+
paragraph.set("sys:children", new Y.Array());
|
|
570
|
+
paragraph.set("prop:type", "text");
|
|
571
|
+
paragraph.set("prop:text", makeText(""));
|
|
572
|
+
blocks.set(paragraphId, paragraph);
|
|
573
|
+
noteChildren.push([paragraphId]);
|
|
497
574
|
const page = blocks.get(pageId);
|
|
498
575
|
let pageChildren = page.get("sys:children");
|
|
499
576
|
if (!(pageChildren instanceof Y.Array)) {
|
|
@@ -503,6 +580,12 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
503
580
|
pageChildren.push([noteId]);
|
|
504
581
|
return noteId;
|
|
505
582
|
}
|
|
583
|
+
function buildDefaultNoteBackground() {
|
|
584
|
+
const map = new Y.Map();
|
|
585
|
+
map.set("light", "#ffffff");
|
|
586
|
+
map.set("dark", "#252525");
|
|
587
|
+
return map;
|
|
588
|
+
}
|
|
506
589
|
function ensureSurfaceBlock(blocks) {
|
|
507
590
|
const existingSurfaceId = findBlockIdByFlavour(blocks, "affine:surface");
|
|
508
591
|
if (existingSurfaceId) {
|
|
@@ -786,9 +869,20 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
786
869
|
const design = parsed.design ?? "";
|
|
787
870
|
const reference = (parsed.reference ?? "").trim();
|
|
788
871
|
const refFlavour = (parsed.refFlavour ?? "").trim();
|
|
789
|
-
const
|
|
790
|
-
const
|
|
791
|
-
const
|
|
872
|
+
const x = Number.isFinite(parsed.x) ? Math.floor(parsed.x) : 0;
|
|
873
|
+
const y = Number.isFinite(parsed.y) ? Math.floor(parsed.y) : 0;
|
|
874
|
+
const widthProvided = Number.isFinite(parsed.width);
|
|
875
|
+
const heightProvided = Number.isFinite(parsed.height);
|
|
876
|
+
const width = widthProvided ? Math.max(1, Math.floor(parsed.width)) : 100;
|
|
877
|
+
let height = heightProvided ? Math.max(1, Math.floor(parsed.height)) : 100;
|
|
878
|
+
// Pre-inflate the stored height so stackAfter'd siblings don't overlap the
|
|
879
|
+
// note before the editor's ResizeObserver corrects it on first render.
|
|
880
|
+
if (typeInfo.type === "note" && !heightProvided && typeof parsed.markdown === "string" && parsed.markdown.length > 0) {
|
|
881
|
+
height = Math.max(height, estimateNoteHeightForMarkdown(parsed.markdown, widthProvided ? width : 400));
|
|
882
|
+
}
|
|
883
|
+
const background = typeof parsed.background === "string"
|
|
884
|
+
? (parsed.background.trim() || "transparent")
|
|
885
|
+
: (parsed.background && typeof parsed.background === "object" ? parsed.background : "transparent");
|
|
792
886
|
const sourceId = (parsed.sourceId ?? "").trim();
|
|
793
887
|
const name = (parsed.name ?? "attachment").trim() || "attachment";
|
|
794
888
|
const mimeType = (parsed.mimeType ?? "application/octet-stream").trim() || "application/octet-stream";
|
|
@@ -812,6 +906,8 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
812
906
|
design,
|
|
813
907
|
reference,
|
|
814
908
|
refFlavour,
|
|
909
|
+
x,
|
|
910
|
+
y,
|
|
815
911
|
width,
|
|
816
912
|
height,
|
|
817
913
|
background,
|
|
@@ -834,6 +930,14 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
834
930
|
tableData,
|
|
835
931
|
deltas: parsed.deltas,
|
|
836
932
|
tableCellDeltas,
|
|
933
|
+
childElementIds: Array.isArray(parsed.childElementIds) ? parsed.childElementIds : undefined,
|
|
934
|
+
stackAfter: parsed.stackAfter,
|
|
935
|
+
padding: Number.isFinite(parsed.padding) ? Math.max(0, Math.floor(parsed.padding)) : undefined,
|
|
936
|
+
xProvided: Number.isFinite(parsed.x),
|
|
937
|
+
yProvided: Number.isFinite(parsed.y),
|
|
938
|
+
widthProvided,
|
|
939
|
+
heightProvided,
|
|
940
|
+
markdown: typeof parsed.markdown === "string" ? parsed.markdown : undefined,
|
|
837
941
|
};
|
|
838
942
|
validateNormalizedAppendBlockInput(normalized, parsed);
|
|
839
943
|
return normalized;
|
|
@@ -991,6 +1095,28 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
991
1095
|
column.set("hide", hide);
|
|
992
1096
|
return column;
|
|
993
1097
|
}
|
|
1098
|
+
function replaceSelectColumnOptions(column, options) {
|
|
1099
|
+
let data = column.get("data");
|
|
1100
|
+
if (!(data instanceof Y.Map)) {
|
|
1101
|
+
data = new Y.Map();
|
|
1102
|
+
column.set("data", data);
|
|
1103
|
+
}
|
|
1104
|
+
let rawOptions = data.get("options");
|
|
1105
|
+
if (!(rawOptions instanceof Y.Array)) {
|
|
1106
|
+
rawOptions = new Y.Array();
|
|
1107
|
+
data.set("options", rawOptions);
|
|
1108
|
+
}
|
|
1109
|
+
else if (rawOptions.length > 0) {
|
|
1110
|
+
rawOptions.delete(0, rawOptions.length);
|
|
1111
|
+
}
|
|
1112
|
+
options.forEach((value, index) => {
|
|
1113
|
+
const option = new Y.Map();
|
|
1114
|
+
option.set("id", generateId());
|
|
1115
|
+
option.set("value", value);
|
|
1116
|
+
option.set("color", SELECT_COLORS[index % SELECT_COLORS.length]);
|
|
1117
|
+
rawOptions.push([option]);
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
994
1120
|
function createDatabaseColumnDefinition(input) {
|
|
995
1121
|
const column = new Y.Map();
|
|
996
1122
|
column.set("id", input.id);
|
|
@@ -1012,6 +1138,46 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
1012
1138
|
}
|
|
1013
1139
|
return column;
|
|
1014
1140
|
}
|
|
1141
|
+
function addDatabaseColumnToBlock(dbBlock, spec) {
|
|
1142
|
+
const columns = dbBlock.get("prop:columns");
|
|
1143
|
+
if (!(columns instanceof Y.Array)) {
|
|
1144
|
+
throw new Error("Database has no columns array");
|
|
1145
|
+
}
|
|
1146
|
+
const currentDefs = readColumnDefs(dbBlock);
|
|
1147
|
+
const existing = currentDefs.find(column => column.name === spec.name);
|
|
1148
|
+
if (existing) {
|
|
1149
|
+
if (existing.type !== spec.type) {
|
|
1150
|
+
throw new Error(`Column '${spec.name}' already exists with type '${existing.type}'`);
|
|
1151
|
+
}
|
|
1152
|
+
return existing.id;
|
|
1153
|
+
}
|
|
1154
|
+
const columnId = generateId();
|
|
1155
|
+
columns.push([createDatabaseColumnDefinition({
|
|
1156
|
+
id: columnId,
|
|
1157
|
+
name: spec.name,
|
|
1158
|
+
type: spec.type,
|
|
1159
|
+
width: spec.width,
|
|
1160
|
+
options: spec.options,
|
|
1161
|
+
})]);
|
|
1162
|
+
const views = dbBlock.get("prop:views");
|
|
1163
|
+
if (views instanceof Y.Array) {
|
|
1164
|
+
views.forEach((view) => {
|
|
1165
|
+
if (!(view instanceof Y.Map)) {
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
const viewColumns = view.get("columns");
|
|
1169
|
+
if (!(viewColumns instanceof Y.Array)) {
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
const viewColumn = new Y.Map();
|
|
1173
|
+
viewColumn.set("id", columnId);
|
|
1174
|
+
viewColumn.set("hide", false);
|
|
1175
|
+
viewColumn.set("width", spec.width ?? 200);
|
|
1176
|
+
viewColumns.push([viewColumn]);
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
return columnId;
|
|
1180
|
+
}
|
|
1015
1181
|
function createPresetBackedDataViewBlock(blockId, titleText, viewMode, blockType) {
|
|
1016
1182
|
const block = new Y.Map();
|
|
1017
1183
|
setSysFields(block, blockId, "affine:database");
|
|
@@ -1098,7 +1264,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
1098
1264
|
? "quote"
|
|
1099
1265
|
: "text";
|
|
1100
1266
|
block.set("prop:type", blockType);
|
|
1101
|
-
block.set("prop:text", makeText(content));
|
|
1267
|
+
block.set("prop:text", makeText(normalized.deltas ?? content));
|
|
1102
1268
|
return { blockId, block, flavour: "affine:paragraph", blockType };
|
|
1103
1269
|
}
|
|
1104
1270
|
case "list": {
|
|
@@ -1137,7 +1303,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
1137
1303
|
textBlock.set("sys:parent", null);
|
|
1138
1304
|
textBlock.set("sys:children", new Y.Array());
|
|
1139
1305
|
textBlock.set("prop:type", "text");
|
|
1140
|
-
textBlock.set("prop:text", makeText(content));
|
|
1306
|
+
textBlock.set("prop:text", makeText(normalized.deltas ?? content));
|
|
1141
1307
|
calloutChildren.push([textBlockId]);
|
|
1142
1308
|
block.set("sys:children", calloutChildren);
|
|
1143
1309
|
block.set("prop:icon", { type: "emoji", unicode: "💡" });
|
|
@@ -1455,10 +1621,15 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
1455
1621
|
block.set("sys:parent", null);
|
|
1456
1622
|
block.set("sys:children", new Y.Array());
|
|
1457
1623
|
block.set("prop:title", makeText(content || "Frame"));
|
|
1458
|
-
|
|
1459
|
-
|
|
1624
|
+
// 'transparent' matches FrameBlockSchema; any other value renders as a
|
|
1625
|
+
// solid fill (the border is separate).
|
|
1626
|
+
block.set("prop:background", normalized.background ?? "transparent");
|
|
1627
|
+
block.set("prop:xywh", `[${normalized.x},${normalized.y},${normalized.width},${normalized.height}]`);
|
|
1460
1628
|
block.set("prop:index", "a0");
|
|
1461
|
-
|
|
1629
|
+
const childIds = new Y.Map();
|
|
1630
|
+
for (const id of normalized._frameOwnedIds ?? [])
|
|
1631
|
+
childIds.set(id, true);
|
|
1632
|
+
block.set("prop:childElementIds", childIds);
|
|
1462
1633
|
block.set("prop:presentationIndex", "a0");
|
|
1463
1634
|
block.set("prop:lockedBySelf", false);
|
|
1464
1635
|
return { blockId, block, flavour: "affine:frame" };
|
|
@@ -1466,27 +1637,54 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
1466
1637
|
case "edgeless_text": {
|
|
1467
1638
|
setSysFields(block, blockId, "affine:edgeless-text");
|
|
1468
1639
|
block.set("sys:parent", null);
|
|
1469
|
-
|
|
1470
|
-
block.set("prop:xywh", `[
|
|
1640
|
+
const edgelessTextChildren = new Y.Array();
|
|
1641
|
+
block.set("prop:xywh", `[${normalized.x},${normalized.y},${normalized.width},${normalized.height}]`);
|
|
1471
1642
|
block.set("prop:index", "a0");
|
|
1472
1643
|
block.set("prop:lockedBySelf", false);
|
|
1473
1644
|
block.set("prop:scale", 1);
|
|
1474
1645
|
block.set("prop:rotate", 0);
|
|
1475
1646
|
block.set("prop:hasMaxWidth", false);
|
|
1476
1647
|
block.set("prop:comments", undefined);
|
|
1477
|
-
|
|
1648
|
+
// Theme-adaptive token so canvas text stays legible in dark mode.
|
|
1649
|
+
block.set("prop:color", "--affine-text-primary-color");
|
|
1478
1650
|
block.set("prop:fontFamily", "Inter");
|
|
1479
1651
|
block.set("prop:fontStyle", "normal");
|
|
1480
1652
|
block.set("prop:fontWeight", "regular");
|
|
1481
1653
|
block.set("prop:textAlign", "left");
|
|
1482
|
-
|
|
1654
|
+
const edgelessTextExtraBlocks = [];
|
|
1655
|
+
if (content) {
|
|
1656
|
+
const paraId = generateId();
|
|
1657
|
+
const para = new Y.Map();
|
|
1658
|
+
setSysFields(para, paraId, "affine:paragraph");
|
|
1659
|
+
para.set("sys:parent", null);
|
|
1660
|
+
para.set("sys:children", new Y.Array());
|
|
1661
|
+
para.set("prop:type", "text");
|
|
1662
|
+
para.set("prop:text", makeText(normalized.deltas ?? content));
|
|
1663
|
+
edgelessTextChildren.push([paraId]);
|
|
1664
|
+
edgelessTextExtraBlocks.push({ blockId: paraId, block: para });
|
|
1665
|
+
}
|
|
1666
|
+
block.set("sys:children", edgelessTextChildren);
|
|
1667
|
+
return { blockId, block, flavour: "affine:edgeless-text", extraBlocks: edgelessTextExtraBlocks };
|
|
1483
1668
|
}
|
|
1484
1669
|
case "note": {
|
|
1485
1670
|
setSysFields(block, blockId, "affine:note");
|
|
1486
1671
|
block.set("sys:parent", null);
|
|
1487
|
-
|
|
1488
|
-
block.set("prop:xywh", `[
|
|
1489
|
-
|
|
1672
|
+
const noteChildren = new Y.Array();
|
|
1673
|
+
block.set("prop:xywh", `[${normalized.x},${normalized.y},${normalized.width},${normalized.height}]`);
|
|
1674
|
+
// BlockSuite reads the adaptive-bg case as a Y.Map; a plain JS object
|
|
1675
|
+
// would serialize to a JSON string and break theme switching.
|
|
1676
|
+
const bg = normalized.background;
|
|
1677
|
+
if (bg && typeof bg === "object" && !Array.isArray(bg) && ("light" in bg || "dark" in bg)) {
|
|
1678
|
+
const bgMap = new Y.Map();
|
|
1679
|
+
if (typeof bg.light === "string")
|
|
1680
|
+
bgMap.set("light", bg.light);
|
|
1681
|
+
if (typeof bg.dark === "string")
|
|
1682
|
+
bgMap.set("dark", bg.dark);
|
|
1683
|
+
block.set("prop:background", bgMap);
|
|
1684
|
+
}
|
|
1685
|
+
else {
|
|
1686
|
+
block.set("prop:background", bg);
|
|
1687
|
+
}
|
|
1490
1688
|
block.set("prop:index", "a0");
|
|
1491
1689
|
block.set("prop:lockedBySelf", false);
|
|
1492
1690
|
block.set("prop:hidden", false);
|
|
@@ -1500,7 +1698,152 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
1500
1698
|
edgeless.set("style", style);
|
|
1501
1699
|
block.set("prop:edgeless", edgeless);
|
|
1502
1700
|
block.set("prop:comments", undefined);
|
|
1503
|
-
|
|
1701
|
+
const noteExtraBlocks = [];
|
|
1702
|
+
if (content) {
|
|
1703
|
+
const paraId = generateId();
|
|
1704
|
+
const para = new Y.Map();
|
|
1705
|
+
setSysFields(para, paraId, "affine:paragraph");
|
|
1706
|
+
para.set("sys:parent", null);
|
|
1707
|
+
para.set("sys:children", new Y.Array());
|
|
1708
|
+
para.set("prop:type", "text");
|
|
1709
|
+
para.set("prop:text", makeText(normalized.deltas ?? content));
|
|
1710
|
+
noteChildren.push([paraId]);
|
|
1711
|
+
noteExtraBlocks.push({ blockId: paraId, block: para });
|
|
1712
|
+
}
|
|
1713
|
+
block.set("sys:children", noteChildren);
|
|
1714
|
+
return { blockId, block, flavour: "affine:note", extraBlocks: noteExtraBlocks };
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
function resolveBlockBoundAsBound(blocks, blockId) {
|
|
1719
|
+
const b = blocks.get(blockId);
|
|
1720
|
+
if (b instanceof Y.Map) {
|
|
1721
|
+
const xywh = parseXywhString(b.get("prop:xywh"));
|
|
1722
|
+
if (xywh)
|
|
1723
|
+
return { x: xywh.x, y: xywh.y, w: xywh.width, h: xywh.height };
|
|
1724
|
+
}
|
|
1725
|
+
return null;
|
|
1726
|
+
}
|
|
1727
|
+
function resolveEdgelessLayoutHints(blocks, normalized) {
|
|
1728
|
+
const defaultPadding = normalized.padding ?? 40;
|
|
1729
|
+
let placed = false;
|
|
1730
|
+
if (normalized.stackAfter) {
|
|
1731
|
+
const idList = Array.isArray(normalized.stackAfter.blockId)
|
|
1732
|
+
? normalized.stackAfter.blockId
|
|
1733
|
+
: [normalized.stackAfter.blockId];
|
|
1734
|
+
const direction = normalized.stackAfter.direction ?? "down";
|
|
1735
|
+
const missing = [];
|
|
1736
|
+
const bounds = [];
|
|
1737
|
+
for (const id of idList) {
|
|
1738
|
+
const b = resolveBlockBoundAsBound(blocks, id);
|
|
1739
|
+
if (!b)
|
|
1740
|
+
missing.push(id);
|
|
1741
|
+
else
|
|
1742
|
+
bounds.push(b);
|
|
1743
|
+
}
|
|
1744
|
+
const ref = pickFurthestInDirection(bounds, direction);
|
|
1745
|
+
if (!ref) {
|
|
1746
|
+
throw new Error(`stackAfter: no blockIds resolved to xywh. Missing: ${JSON.stringify(missing)}`);
|
|
1747
|
+
}
|
|
1748
|
+
// Gap precedence: explicit `gap` > explicit `padding` > direction default
|
|
1749
|
+
// (horizontal larger because notes are wide-short and tight sideways).
|
|
1750
|
+
const isHorizontal = direction === "left" || direction === "right";
|
|
1751
|
+
const directionDefaultGap = isHorizontal ? DEFAULT_STACK_GAP_HORIZONTAL : DEFAULT_STACK_GAP_VERTICAL;
|
|
1752
|
+
const gap = normalized.stackAfter.gap
|
|
1753
|
+
?? (normalized.padding !== undefined ? normalized.padding : directionDefaultGap);
|
|
1754
|
+
// Center on the anchor group's orthogonal-axis union; caller x/y wins.
|
|
1755
|
+
const isVertical = direction === "down" || direction === "up";
|
|
1756
|
+
let preserveX;
|
|
1757
|
+
let preserveY;
|
|
1758
|
+
if (normalized.xProvided === true) {
|
|
1759
|
+
preserveX = normalized.x;
|
|
1760
|
+
}
|
|
1761
|
+
else if (isVertical) {
|
|
1762
|
+
const minX = bounds.reduce((m, b) => Math.min(m, b.x), Infinity);
|
|
1763
|
+
const maxX = bounds.reduce((m, b) => Math.max(m, b.x + b.w), -Infinity);
|
|
1764
|
+
preserveX = Math.round((minX + maxX) / 2 - normalized.width / 2);
|
|
1765
|
+
}
|
|
1766
|
+
if (normalized.yProvided === true) {
|
|
1767
|
+
preserveY = normalized.y;
|
|
1768
|
+
}
|
|
1769
|
+
else if (!isVertical) {
|
|
1770
|
+
const minY = bounds.reduce((m, b) => Math.min(m, b.y), Infinity);
|
|
1771
|
+
const maxY = bounds.reduce((m, b) => Math.max(m, b.y + b.h), -Infinity);
|
|
1772
|
+
preserveY = Math.round((minY + maxY) / 2 - normalized.height / 2);
|
|
1773
|
+
}
|
|
1774
|
+
const { x, y } = stackRelativeTo(ref, { w: normalized.width, h: normalized.height }, { direction, gap, preserveX, preserveY });
|
|
1775
|
+
normalized.x = x;
|
|
1776
|
+
normalized.y = y;
|
|
1777
|
+
placed = true;
|
|
1778
|
+
}
|
|
1779
|
+
// prop:childElementIds accepts both surface-element ids and block ids —
|
|
1780
|
+
// that's what the editor writes when you drag a note into a frame.
|
|
1781
|
+
if (normalized.type === "frame" && normalized.childElementIds && normalized.childElementIds.length > 0) {
|
|
1782
|
+
const surfaceCtx = getSurfaceElementsValueMap(blocks, { create: false });
|
|
1783
|
+
const surfaceValueMap = surfaceCtx?.value ?? new Y.Map();
|
|
1784
|
+
const ownedIds = [];
|
|
1785
|
+
const missing = [];
|
|
1786
|
+
const kids = [];
|
|
1787
|
+
for (const id of normalized.childElementIds) {
|
|
1788
|
+
const resolved = resolveChildBound(surfaceValueMap, blocks, id);
|
|
1789
|
+
if (resolved.kind === "missing") {
|
|
1790
|
+
missing.push(id);
|
|
1791
|
+
}
|
|
1792
|
+
else {
|
|
1793
|
+
ownedIds.push(id);
|
|
1794
|
+
if (resolved.bound)
|
|
1795
|
+
kids.push(resolved.bound);
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
if (ownedIds.length === 0) {
|
|
1799
|
+
throw new Error(`None of the ids in childElementIds were found: ${JSON.stringify(missing)}.`);
|
|
1800
|
+
}
|
|
1801
|
+
normalized._frameOwnedIds = ownedIds;
|
|
1802
|
+
normalized._frameMissing = missing;
|
|
1803
|
+
if (!normalized.widthProvided || !normalized.heightProvided || !normalized.xProvided || !normalized.yProvided) {
|
|
1804
|
+
const wrapped = encloseBounds(kids, { padding: defaultPadding, titleBand: 60 });
|
|
1805
|
+
if (wrapped) {
|
|
1806
|
+
if (!normalized.xProvided)
|
|
1807
|
+
normalized.x = wrapped.x;
|
|
1808
|
+
if (!normalized.yProvided)
|
|
1809
|
+
normalized.y = wrapped.y;
|
|
1810
|
+
if (!normalized.widthProvided)
|
|
1811
|
+
normalized.width = wrapped.w;
|
|
1812
|
+
if (!normalized.heightProvided)
|
|
1813
|
+
normalized.height = wrapped.h;
|
|
1814
|
+
placed = true;
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
// Auto-stack-below fallback: avoids dropping new edgeless blocks on top of
|
|
1819
|
+
// the seeded default note at [0,0,…] when the caller gave no placement.
|
|
1820
|
+
const isEdgelessBlock = normalized.type === "frame" ||
|
|
1821
|
+
normalized.type === "note" ||
|
|
1822
|
+
normalized.type === "edgeless_text";
|
|
1823
|
+
if (!placed && isEdgelessBlock && !normalized.yProvided) {
|
|
1824
|
+
const existing = [];
|
|
1825
|
+
for (const [, b] of blocks.entries()) {
|
|
1826
|
+
if (!(b instanceof Y.Map))
|
|
1827
|
+
continue;
|
|
1828
|
+
const flavour = b.get("sys:flavour");
|
|
1829
|
+
if (flavour !== "affine:note" &&
|
|
1830
|
+
flavour !== "affine:frame" &&
|
|
1831
|
+
flavour !== "affine:edgeless-text")
|
|
1832
|
+
continue;
|
|
1833
|
+
const xywh = parseXywhString(b.get("prop:xywh"));
|
|
1834
|
+
if (!xywh)
|
|
1835
|
+
continue;
|
|
1836
|
+
existing.push({ x: xywh.x, y: xywh.y, w: xywh.width, h: xywh.height });
|
|
1837
|
+
}
|
|
1838
|
+
const ref = pickFurthestInDirection(existing, "down");
|
|
1839
|
+
if (ref) {
|
|
1840
|
+
const { x, y } = stackRelativeTo(ref, { w: normalized.width, h: normalized.height }, {
|
|
1841
|
+
direction: "down",
|
|
1842
|
+
gap: defaultPadding,
|
|
1843
|
+
preserveX: normalized.xProvided === true ? normalized.x : undefined,
|
|
1844
|
+
});
|
|
1845
|
+
normalized.x = x;
|
|
1846
|
+
normalized.y = y;
|
|
1504
1847
|
}
|
|
1505
1848
|
}
|
|
1506
1849
|
}
|
|
@@ -1521,6 +1864,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
1521
1864
|
}
|
|
1522
1865
|
const prevSV = Y.encodeStateVector(doc);
|
|
1523
1866
|
const blocks = doc.getMap("blocks");
|
|
1867
|
+
resolveEdgelessLayoutHints(blocks, normalized);
|
|
1524
1868
|
const context = resolveInsertContext(blocks, normalized);
|
|
1525
1869
|
const { blockId, block, flavour, blockType, extraBlocks } = createBlock(normalized);
|
|
1526
1870
|
blocks.set(blockId, block);
|
|
@@ -1537,7 +1881,16 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
1537
1881
|
}
|
|
1538
1882
|
const delta = Y.encodeStateAsUpdate(doc, prevSV);
|
|
1539
1883
|
await pushDocUpdate(socket, workspaceId, normalized.docId, Buffer.from(delta).toString("base64"));
|
|
1540
|
-
return {
|
|
1884
|
+
return {
|
|
1885
|
+
appended: true,
|
|
1886
|
+
blockId,
|
|
1887
|
+
flavour,
|
|
1888
|
+
blockType,
|
|
1889
|
+
normalizedType: normalized.type,
|
|
1890
|
+
legacyType: normalized.legacyType || null,
|
|
1891
|
+
ownedIds: normalized._frameOwnedIds,
|
|
1892
|
+
missing: normalized._frameMissing,
|
|
1893
|
+
};
|
|
1541
1894
|
}
|
|
1542
1895
|
finally {
|
|
1543
1896
|
socket.disconnect();
|
|
@@ -1561,6 +1914,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
1561
1914
|
type: "heading",
|
|
1562
1915
|
text: operation.text,
|
|
1563
1916
|
level: operation.level,
|
|
1917
|
+
deltas: operation.deltas,
|
|
1564
1918
|
strict,
|
|
1565
1919
|
placement,
|
|
1566
1920
|
};
|
|
@@ -1570,6 +1924,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
1570
1924
|
docId,
|
|
1571
1925
|
type: "paragraph",
|
|
1572
1926
|
text: operation.text,
|
|
1927
|
+
deltas: operation.deltas,
|
|
1573
1928
|
strict,
|
|
1574
1929
|
placement,
|
|
1575
1930
|
};
|
|
@@ -1579,6 +1934,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
1579
1934
|
docId,
|
|
1580
1935
|
type: "quote",
|
|
1581
1936
|
text: operation.text,
|
|
1937
|
+
deltas: operation.deltas,
|
|
1582
1938
|
strict,
|
|
1583
1939
|
placement,
|
|
1584
1940
|
};
|
|
@@ -1588,6 +1944,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
1588
1944
|
docId,
|
|
1589
1945
|
type: "callout",
|
|
1590
1946
|
text: operation.text,
|
|
1947
|
+
deltas: operation.deltas,
|
|
1591
1948
|
strict,
|
|
1592
1949
|
placement,
|
|
1593
1950
|
};
|
|
@@ -1862,6 +2219,330 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
1862
2219
|
blocksById,
|
|
1863
2220
|
};
|
|
1864
2221
|
}
|
|
2222
|
+
function collectDocBlockRows(doc) {
|
|
2223
|
+
const blocks = doc.getMap("blocks");
|
|
2224
|
+
const rows = [];
|
|
2225
|
+
for (const [id, raw] of blocks) {
|
|
2226
|
+
if (!(raw instanceof Y.Map)) {
|
|
2227
|
+
continue;
|
|
2228
|
+
}
|
|
2229
|
+
const textValue = asText(raw.get("prop:text"));
|
|
2230
|
+
rows.push({
|
|
2231
|
+
id: String(id),
|
|
2232
|
+
flavour: asStringOrNull(raw.get("sys:flavour")),
|
|
2233
|
+
type: asStringOrNull(raw.get("prop:type")),
|
|
2234
|
+
hasText: textValue.length > 0,
|
|
2235
|
+
hasUrl: asText(raw.get("prop:url")).trim().length > 0,
|
|
2236
|
+
hasSourceId: asText(raw.get("prop:sourceId")).trim().length > 0,
|
|
2237
|
+
childCount: childIdsFrom(raw.get("sys:children")).length,
|
|
2238
|
+
});
|
|
2239
|
+
}
|
|
2240
|
+
return rows;
|
|
2241
|
+
}
|
|
2242
|
+
function summarizeDocFidelity(doc, tagOptionsById = new Map()) {
|
|
2243
|
+
const collected = collectDocForMarkdown(doc, tagOptionsById);
|
|
2244
|
+
const rendered = renderBlocksToMarkdown({
|
|
2245
|
+
rootBlockIds: collected.rootBlockIds,
|
|
2246
|
+
blocksById: collected.blocksById,
|
|
2247
|
+
});
|
|
2248
|
+
const blockRows = collectDocBlockRows(doc);
|
|
2249
|
+
const flavourCounts = {};
|
|
2250
|
+
const unsupportedBlocks = [];
|
|
2251
|
+
const conditionallyRiskyBlocks = [];
|
|
2252
|
+
for (const block of blockRows) {
|
|
2253
|
+
const flavourKey = block.flavour || "unknown";
|
|
2254
|
+
flavourCounts[flavourKey] = (flavourCounts[flavourKey] ?? 0) + 1;
|
|
2255
|
+
if (!block.flavour) {
|
|
2256
|
+
unsupportedBlocks.push({ id: block.id, flavour: null, reason: "Block flavour is missing." });
|
|
2257
|
+
continue;
|
|
2258
|
+
}
|
|
2259
|
+
if (!KNOWN_BLOCK_FLAVOURS.has(block.flavour)) {
|
|
2260
|
+
unsupportedBlocks.push({
|
|
2261
|
+
id: block.id,
|
|
2262
|
+
flavour: block.flavour,
|
|
2263
|
+
reason: "Block flavour is unknown to this MCP build.",
|
|
2264
|
+
});
|
|
2265
|
+
continue;
|
|
2266
|
+
}
|
|
2267
|
+
if (!MARKDOWN_EXPORT_SUPPORTED_FLAVOURS.has(block.flavour)) {
|
|
2268
|
+
unsupportedBlocks.push({
|
|
2269
|
+
id: block.id,
|
|
2270
|
+
flavour: block.flavour,
|
|
2271
|
+
reason: "Markdown export does not have a native renderer for this flavour.",
|
|
2272
|
+
});
|
|
2273
|
+
continue;
|
|
2274
|
+
}
|
|
2275
|
+
if (block.flavour === "affine:image" && !block.hasSourceId) {
|
|
2276
|
+
conditionallyRiskyBlocks.push({
|
|
2277
|
+
id: block.id,
|
|
2278
|
+
flavour: block.flavour,
|
|
2279
|
+
reason: "Image block has no sourceId; markdown export will skip it.",
|
|
2280
|
+
});
|
|
2281
|
+
}
|
|
2282
|
+
if ((block.flavour === "affine:bookmark" ||
|
|
2283
|
+
block.flavour === "affine:embed-youtube" ||
|
|
2284
|
+
block.flavour === "affine:embed-github" ||
|
|
2285
|
+
block.flavour === "affine:embed-figma" ||
|
|
2286
|
+
block.flavour === "affine:embed-loom" ||
|
|
2287
|
+
block.flavour === "affine:embed-iframe") &&
|
|
2288
|
+
!block.hasUrl) {
|
|
2289
|
+
conditionallyRiskyBlocks.push({
|
|
2290
|
+
id: block.id,
|
|
2291
|
+
flavour: block.flavour,
|
|
2292
|
+
reason: "Embed/bookmark block has no URL; markdown export will skip it.",
|
|
2293
|
+
});
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
const overallRisk = unsupportedBlocks.length > 0
|
|
2297
|
+
? "high"
|
|
2298
|
+
: conditionallyRiskyBlocks.length > 0 || rendered.lossy
|
|
2299
|
+
? "medium"
|
|
2300
|
+
: "low";
|
|
2301
|
+
return {
|
|
2302
|
+
title: collected.title || null,
|
|
2303
|
+
tags: collected.tags,
|
|
2304
|
+
rootBlockIds: collected.rootBlockIds,
|
|
2305
|
+
markdown: rendered.markdown,
|
|
2306
|
+
markdownWarnings: rendered.warnings,
|
|
2307
|
+
markdownLossy: rendered.lossy,
|
|
2308
|
+
flavourCounts,
|
|
2309
|
+
unsupportedBlocks,
|
|
2310
|
+
conditionallyRiskyBlocks,
|
|
2311
|
+
overallRisk,
|
|
2312
|
+
recommendedPath: overallRisk === "low"
|
|
2313
|
+
? "markdown_export_ok"
|
|
2314
|
+
: overallRisk === "medium"
|
|
2315
|
+
? "markdown_export_with_review"
|
|
2316
|
+
: "prefer_native_read_or_clone",
|
|
2317
|
+
stats: {
|
|
2318
|
+
blockCount: blockRows.length,
|
|
2319
|
+
markdownUnsupportedCount: rendered.stats.unsupportedCount,
|
|
2320
|
+
unsupportedBlockCount: unsupportedBlocks.length,
|
|
2321
|
+
conditionallyRiskyBlockCount: conditionallyRiskyBlocks.length,
|
|
2322
|
+
},
|
|
2323
|
+
};
|
|
2324
|
+
}
|
|
2325
|
+
function truncateTemplatePreview(text, maxLength = 140) {
|
|
2326
|
+
if (text.length <= maxLength) {
|
|
2327
|
+
return text;
|
|
2328
|
+
}
|
|
2329
|
+
return `${text.slice(0, maxLength - 1)}…`;
|
|
2330
|
+
}
|
|
2331
|
+
function substituteTemplateVariables(input, ctx) {
|
|
2332
|
+
return input.replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (match, key) => {
|
|
2333
|
+
const value = ctx.variables[key];
|
|
2334
|
+
if (value === undefined) {
|
|
2335
|
+
ctx.unresolvedVariables.add(match);
|
|
2336
|
+
return match;
|
|
2337
|
+
}
|
|
2338
|
+
ctx.replacedVariableCount += 1;
|
|
2339
|
+
return value;
|
|
2340
|
+
});
|
|
2341
|
+
}
|
|
2342
|
+
function remapTemplateString(value, ctx) {
|
|
2343
|
+
const remapped = ctx.blockIdMap.get(value) ?? (value === ctx.sourceDocId ? ctx.targetDocId : value);
|
|
2344
|
+
return substituteTemplateVariables(remapped, ctx);
|
|
2345
|
+
}
|
|
2346
|
+
function cloneNativeTemplateValue(value, ctx, path) {
|
|
2347
|
+
if (typeof value === "string") {
|
|
2348
|
+
return remapTemplateString(value, ctx);
|
|
2349
|
+
}
|
|
2350
|
+
if (typeof value === "number" || typeof value === "boolean" || value === null || value === undefined) {
|
|
2351
|
+
return value;
|
|
2352
|
+
}
|
|
2353
|
+
if (value instanceof Y.Text) {
|
|
2354
|
+
const next = new Y.Text();
|
|
2355
|
+
let offset = 0;
|
|
2356
|
+
value.toDelta().forEach((delta) => {
|
|
2357
|
+
const insert = typeof delta?.insert === "string"
|
|
2358
|
+
? substituteTemplateVariables(delta.insert, ctx)
|
|
2359
|
+
: delta?.insert;
|
|
2360
|
+
const attributes = delta?.attributes ? cloneNativeTemplateValue(delta.attributes, ctx, `${path}.attributes`) : undefined;
|
|
2361
|
+
if (typeof insert === "string") {
|
|
2362
|
+
next.insert(offset, insert, (attributes && typeof attributes === "object") ? attributes : {});
|
|
2363
|
+
offset += insert.length;
|
|
2364
|
+
}
|
|
2365
|
+
else if (insert !== undefined) {
|
|
2366
|
+
const text = String(insert);
|
|
2367
|
+
next.insert(offset, text, (attributes && typeof attributes === "object") ? attributes : {});
|
|
2368
|
+
offset += text.length;
|
|
2369
|
+
}
|
|
2370
|
+
});
|
|
2371
|
+
return next;
|
|
2372
|
+
}
|
|
2373
|
+
if (value instanceof Y.Array) {
|
|
2374
|
+
const next = new Y.Array();
|
|
2375
|
+
value.forEach((entry) => {
|
|
2376
|
+
next.push([cloneNativeTemplateValue(entry, ctx, `${path}[]`)]);
|
|
2377
|
+
});
|
|
2378
|
+
return next;
|
|
2379
|
+
}
|
|
2380
|
+
if (value instanceof Y.Map) {
|
|
2381
|
+
const next = new Y.Map();
|
|
2382
|
+
for (const [key, entry] of value) {
|
|
2383
|
+
next.set(String(key), cloneNativeTemplateValue(entry, ctx, `${path}.${String(key)}`));
|
|
2384
|
+
}
|
|
2385
|
+
return next;
|
|
2386
|
+
}
|
|
2387
|
+
if (Array.isArray(value)) {
|
|
2388
|
+
return value.map((entry, index) => cloneNativeTemplateValue(entry, ctx, `${path}[${index}]`));
|
|
2389
|
+
}
|
|
2390
|
+
if (value instanceof Date) {
|
|
2391
|
+
return new Date(value.getTime());
|
|
2392
|
+
}
|
|
2393
|
+
if (value instanceof RegExp) {
|
|
2394
|
+
return new RegExp(value.source, value.flags);
|
|
2395
|
+
}
|
|
2396
|
+
if (typeof value === "object") {
|
|
2397
|
+
const proto = Object.getPrototypeOf(value);
|
|
2398
|
+
if (proto === Object.prototype || proto === null) {
|
|
2399
|
+
const next = {};
|
|
2400
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
2401
|
+
next[key] = cloneNativeTemplateValue(entry, ctx, `${path}.${key}`);
|
|
2402
|
+
}
|
|
2403
|
+
return next;
|
|
2404
|
+
}
|
|
2405
|
+
throw new Error(`Unsupported native template value at ${path}: ${value.constructor?.name || "unknown"}.`);
|
|
2406
|
+
}
|
|
2407
|
+
return value;
|
|
2408
|
+
}
|
|
2409
|
+
function scanNativeTemplateValue(value, path, issues, seen) {
|
|
2410
|
+
if (value === null || value === undefined) {
|
|
2411
|
+
return;
|
|
2412
|
+
}
|
|
2413
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
2414
|
+
return;
|
|
2415
|
+
}
|
|
2416
|
+
if (value instanceof Y.Text) {
|
|
2417
|
+
for (const delta of value.toDelta()) {
|
|
2418
|
+
if (delta?.attributes && typeof delta.attributes === "object") {
|
|
2419
|
+
scanNativeTemplateValue(delta.attributes, `${path}.attributes`, issues, seen);
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
return;
|
|
2423
|
+
}
|
|
2424
|
+
if (value instanceof Y.Array) {
|
|
2425
|
+
if (seen.has(value)) {
|
|
2426
|
+
return;
|
|
2427
|
+
}
|
|
2428
|
+
seen.add(value);
|
|
2429
|
+
let index = 0;
|
|
2430
|
+
value.forEach((entry) => {
|
|
2431
|
+
scanNativeTemplateValue(entry, `${path}[${index}]`, issues, seen);
|
|
2432
|
+
index += 1;
|
|
2433
|
+
});
|
|
2434
|
+
return;
|
|
2435
|
+
}
|
|
2436
|
+
if (value instanceof Y.Map) {
|
|
2437
|
+
if (seen.has(value)) {
|
|
2438
|
+
return;
|
|
2439
|
+
}
|
|
2440
|
+
seen.add(value);
|
|
2441
|
+
for (const [key, entry] of value) {
|
|
2442
|
+
scanNativeTemplateValue(entry, `${path}.${String(key)}`, issues, seen);
|
|
2443
|
+
}
|
|
2444
|
+
return;
|
|
2445
|
+
}
|
|
2446
|
+
if (Array.isArray(value)) {
|
|
2447
|
+
if (seen.has(value)) {
|
|
2448
|
+
return;
|
|
2449
|
+
}
|
|
2450
|
+
seen.add(value);
|
|
2451
|
+
value.forEach((entry, index) => {
|
|
2452
|
+
scanNativeTemplateValue(entry, `${path}[${index}]`, issues, seen);
|
|
2453
|
+
});
|
|
2454
|
+
return;
|
|
2455
|
+
}
|
|
2456
|
+
if (value instanceof Date || value instanceof RegExp) {
|
|
2457
|
+
return;
|
|
2458
|
+
}
|
|
2459
|
+
if (typeof value === "object") {
|
|
2460
|
+
const proto = Object.getPrototypeOf(value);
|
|
2461
|
+
if (proto === Object.prototype || proto === null) {
|
|
2462
|
+
if (seen.has(value)) {
|
|
2463
|
+
return;
|
|
2464
|
+
}
|
|
2465
|
+
seen.add(value);
|
|
2466
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
2467
|
+
scanNativeTemplateValue(entry, `${path}.${key}`, issues, seen);
|
|
2468
|
+
}
|
|
2469
|
+
return;
|
|
2470
|
+
}
|
|
2471
|
+
issues.push({
|
|
2472
|
+
path,
|
|
2473
|
+
reason: `Unsupported native template value type: ${value.constructor?.name || "unknown"}.`,
|
|
2474
|
+
});
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
function summarizeNativeTemplateStructure(doc, workspaceId, templateDocId, tagLabels, supportIssues) {
|
|
2478
|
+
const meta = doc.getMap("meta");
|
|
2479
|
+
const blocks = doc.getMap("blocks");
|
|
2480
|
+
const pageId = findBlockIdByFlavour(blocks, "affine:page");
|
|
2481
|
+
const surfaceId = findBlockIdByFlavour(blocks, "affine:surface");
|
|
2482
|
+
const noteId = findBlockIdByFlavour(blocks, "affine:note");
|
|
2483
|
+
const visited = new Set();
|
|
2484
|
+
const summaries = [];
|
|
2485
|
+
const rootBlockIds = [];
|
|
2486
|
+
if (pageId) {
|
|
2487
|
+
const pageBlock = findBlockById(blocks, pageId);
|
|
2488
|
+
if (pageBlock) {
|
|
2489
|
+
rootBlockIds.push(...childIdsFrom(pageBlock.get("sys:children")));
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
else if (noteId) {
|
|
2493
|
+
rootBlockIds.push(noteId);
|
|
2494
|
+
}
|
|
2495
|
+
if (rootBlockIds.length === 0) {
|
|
2496
|
+
for (const [id] of blocks) {
|
|
2497
|
+
rootBlockIds.push(String(id));
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
const visit = (blockId) => {
|
|
2501
|
+
if (visited.has(blockId)) {
|
|
2502
|
+
return;
|
|
2503
|
+
}
|
|
2504
|
+
visited.add(blockId);
|
|
2505
|
+
const block = findBlockById(blocks, blockId);
|
|
2506
|
+
if (!block) {
|
|
2507
|
+
return;
|
|
2508
|
+
}
|
|
2509
|
+
const childIds = childIdsFrom(block.get("sys:children"));
|
|
2510
|
+
const textValue = asText(block.get("prop:text"));
|
|
2511
|
+
summaries.push({
|
|
2512
|
+
id: blockId,
|
|
2513
|
+
parentId: resolveBlockParentId(blocks, blockId),
|
|
2514
|
+
flavour: asStringOrNull(block.get("sys:flavour")),
|
|
2515
|
+
type: asStringOrNull(block.get("prop:type")),
|
|
2516
|
+
textPreview: textValue.length > 0 ? truncateTemplatePreview(textValue) : null,
|
|
2517
|
+
textLength: textValue.length,
|
|
2518
|
+
childIds,
|
|
2519
|
+
});
|
|
2520
|
+
for (const childId of childIds) {
|
|
2521
|
+
visit(childId);
|
|
2522
|
+
}
|
|
2523
|
+
};
|
|
2524
|
+
for (const rootId of rootBlockIds) {
|
|
2525
|
+
visit(rootId);
|
|
2526
|
+
}
|
|
2527
|
+
for (const [id] of blocks) {
|
|
2528
|
+
visit(String(id));
|
|
2529
|
+
}
|
|
2530
|
+
const title = asText(meta.get("title")) || "Untitled";
|
|
2531
|
+
return {
|
|
2532
|
+
workspaceId,
|
|
2533
|
+
templateDocId,
|
|
2534
|
+
title,
|
|
2535
|
+
tags: tagLabels,
|
|
2536
|
+
pageId,
|
|
2537
|
+
surfaceId,
|
|
2538
|
+
noteId,
|
|
2539
|
+
rootBlockIds,
|
|
2540
|
+
blockCount: summaries.length,
|
|
2541
|
+
blocks: summaries,
|
|
2542
|
+
nativeCloneSupported: supportIssues.length === 0,
|
|
2543
|
+
fallbackReasons: supportIssues.map(issue => `${issue.path}: ${issue.reason}`),
|
|
2544
|
+
};
|
|
2545
|
+
}
|
|
1865
2546
|
async function applyMarkdownOperationsInternal(parsed) {
|
|
1866
2547
|
const strict = parsed.strict !== false;
|
|
1867
2548
|
const replaceExisting = parsed.replaceExisting === true;
|
|
@@ -1982,30 +2663,26 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
1982
2663
|
setSysFields(note, noteId, "affine:note");
|
|
1983
2664
|
note.set("sys:parent", null);
|
|
1984
2665
|
note.set("prop:displayMode", "both");
|
|
1985
|
-
note.set("prop:xywh",
|
|
2666
|
+
note.set("prop:xywh", DEFAULT_NOTE_XYWH);
|
|
1986
2667
|
note.set("prop:index", "a0");
|
|
1987
2668
|
note.set("prop:hidden", false);
|
|
1988
|
-
|
|
1989
|
-
background.set("light", "#ffffff");
|
|
1990
|
-
background.set("dark", "#252525");
|
|
1991
|
-
note.set("prop:background", background);
|
|
2669
|
+
note.set("prop:background", buildDefaultNoteBackground());
|
|
1992
2670
|
const noteChildren = new Y.Array();
|
|
1993
2671
|
note.set("sys:children", noteChildren);
|
|
1994
2672
|
blocks.set(noteId, note);
|
|
1995
2673
|
children.push([noteId]);
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2674
|
+
const paraId = generateId();
|
|
2675
|
+
const para = new Y.Map();
|
|
2676
|
+
setSysFields(para, paraId, "affine:paragraph");
|
|
2677
|
+
para.set("sys:parent", null);
|
|
2678
|
+
para.set("sys:children", new Y.Array());
|
|
2679
|
+
para.set("prop:type", "text");
|
|
2680
|
+
const paragraphText = new Y.Text();
|
|
2681
|
+
if (parsed.content)
|
|
2004
2682
|
paragraphText.insert(0, parsed.content);
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
}
|
|
2683
|
+
para.set("prop:text", paragraphText);
|
|
2684
|
+
blocks.set(paraId, para);
|
|
2685
|
+
noteChildren.push([paraId]);
|
|
2009
2686
|
const meta = ydoc.getMap("meta");
|
|
2010
2687
|
meta.set("id", docId);
|
|
2011
2688
|
meta.set("title", title);
|
|
@@ -2035,62 +2712,443 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
2035
2712
|
const wsDelta = Y.encodeStateAsUpdate(wsDoc, prevSV);
|
|
2036
2713
|
const wsDeltaBase64 = Buffer.from(wsDelta).toString("base64");
|
|
2037
2714
|
await pushDocUpdate(socket, workspaceId, workspaceId, wsDeltaBase64);
|
|
2038
|
-
return {
|
|
2715
|
+
return {
|
|
2716
|
+
workspaceId,
|
|
2717
|
+
docId,
|
|
2718
|
+
title,
|
|
2719
|
+
parentDocId: null,
|
|
2720
|
+
linkedToParent: false,
|
|
2721
|
+
warnings: [],
|
|
2722
|
+
};
|
|
2039
2723
|
}
|
|
2040
2724
|
finally {
|
|
2041
2725
|
socket.disconnect();
|
|
2042
2726
|
}
|
|
2043
2727
|
}
|
|
2044
|
-
|
|
2045
|
-
const
|
|
2046
|
-
if (!
|
|
2047
|
-
|
|
2728
|
+
async function finalizeDocPlacement(parsed) {
|
|
2729
|
+
const parentDocId = parsed.parentDocId?.trim();
|
|
2730
|
+
if (!parentDocId) {
|
|
2731
|
+
return { parentDocId: null, linkedToParent: false, warnings: [] };
|
|
2048
2732
|
}
|
|
2049
|
-
const
|
|
2050
|
-
const
|
|
2051
|
-
const
|
|
2052
|
-
const tagsByDocId = new Map();
|
|
2053
|
-
const titlesByDocId = new Map();
|
|
2054
|
-
let workspacePageCount = null;
|
|
2055
|
-
let workspacePageIds = null;
|
|
2056
|
-
const deletedDocIds = new Set();
|
|
2733
|
+
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
2734
|
+
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
2735
|
+
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
2057
2736
|
try {
|
|
2058
|
-
|
|
2059
|
-
const
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2737
|
+
await joinWorkspace(socket, parsed.workspaceId);
|
|
2738
|
+
const workspaceSnapshot = await loadDoc(socket, parsed.workspaceId, parsed.workspaceId);
|
|
2739
|
+
if (!workspaceSnapshot.missing) {
|
|
2740
|
+
return {
|
|
2741
|
+
parentDocId,
|
|
2742
|
+
linkedToParent: false,
|
|
2743
|
+
warnings: [`${parsed.context}: workspace metadata could not be loaded to verify parent doc "${parentDocId}". Link it manually.`],
|
|
2744
|
+
};
|
|
2745
|
+
}
|
|
2746
|
+
const workspaceDoc = new Y.Doc();
|
|
2747
|
+
Y.applyUpdate(workspaceDoc, Buffer.from(workspaceSnapshot.missing, "base64"));
|
|
2748
|
+
const parentExists = getWorkspacePageEntries(workspaceDoc.getMap("meta")).some(page => page.id === parentDocId);
|
|
2749
|
+
if (!parentExists) {
|
|
2750
|
+
return {
|
|
2751
|
+
parentDocId,
|
|
2752
|
+
linkedToParent: false,
|
|
2753
|
+
warnings: [`${parsed.context}: parent doc "${parentDocId}" was not found in workspace "${parsed.workspaceId}". Doc was left at the workspace root.`],
|
|
2754
|
+
};
|
|
2755
|
+
}
|
|
2756
|
+
try {
|
|
2757
|
+
await appendBlockInternal({
|
|
2758
|
+
workspaceId: parsed.workspaceId,
|
|
2759
|
+
docId: parentDocId,
|
|
2760
|
+
type: "embed_linked_doc",
|
|
2761
|
+
pageId: parsed.docId,
|
|
2762
|
+
});
|
|
2763
|
+
return { parentDocId, linkedToParent: true, warnings: [] };
|
|
2764
|
+
}
|
|
2765
|
+
catch {
|
|
2766
|
+
return {
|
|
2767
|
+
parentDocId,
|
|
2768
|
+
linkedToParent: false,
|
|
2769
|
+
warnings: [`${parsed.context}: doc created but could not be linked to parent doc "${parentDocId}". Link it manually.`],
|
|
2770
|
+
};
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
finally {
|
|
2774
|
+
socket.disconnect();
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
function createDocSkeleton(title, docId) {
|
|
2778
|
+
const doc = new Y.Doc();
|
|
2779
|
+
const blocks = doc.getMap("blocks");
|
|
2780
|
+
const pageId = generateId();
|
|
2781
|
+
const page = new Y.Map();
|
|
2782
|
+
setSysFields(page, pageId, "affine:page");
|
|
2783
|
+
const titleText = new Y.Text();
|
|
2784
|
+
titleText.insert(0, title);
|
|
2785
|
+
page.set("prop:title", titleText);
|
|
2786
|
+
page.set("sys:children", new Y.Array());
|
|
2787
|
+
blocks.set(pageId, page);
|
|
2788
|
+
const surfaceId = generateId();
|
|
2789
|
+
const surface = new Y.Map();
|
|
2790
|
+
setSysFields(surface, surfaceId, "affine:surface");
|
|
2791
|
+
surface.set("sys:parent", null);
|
|
2792
|
+
surface.set("sys:children", new Y.Array());
|
|
2793
|
+
const elements = new Y.Map();
|
|
2794
|
+
elements.set("type", "$blocksuite:internal:native$");
|
|
2795
|
+
elements.set("value", new Y.Map());
|
|
2796
|
+
surface.set("prop:elements", elements);
|
|
2797
|
+
blocks.set(surfaceId, surface);
|
|
2798
|
+
page.get("sys:children").push([surfaceId]);
|
|
2799
|
+
const noteId = generateId();
|
|
2800
|
+
const note = new Y.Map();
|
|
2801
|
+
setSysFields(note, noteId, "affine:note");
|
|
2802
|
+
note.set("sys:parent", null);
|
|
2803
|
+
note.set("prop:displayMode", "both");
|
|
2804
|
+
note.set("prop:xywh", DEFAULT_NOTE_XYWH);
|
|
2805
|
+
note.set("prop:index", "a0");
|
|
2806
|
+
note.set("prop:hidden", false);
|
|
2807
|
+
note.set("prop:background", buildDefaultNoteBackground());
|
|
2808
|
+
const skeletonNoteChildren = new Y.Array();
|
|
2809
|
+
note.set("sys:children", skeletonNoteChildren);
|
|
2810
|
+
blocks.set(noteId, note);
|
|
2811
|
+
page.get("sys:children").push([noteId]);
|
|
2812
|
+
const skeletonParaId = generateId();
|
|
2813
|
+
const skeletonPara = new Y.Map();
|
|
2814
|
+
setSysFields(skeletonPara, skeletonParaId, "affine:paragraph");
|
|
2815
|
+
skeletonPara.set("sys:parent", null);
|
|
2816
|
+
skeletonPara.set("sys:children", new Y.Array());
|
|
2817
|
+
skeletonPara.set("prop:type", "text");
|
|
2818
|
+
skeletonPara.set("prop:text", new Y.Text());
|
|
2819
|
+
blocks.set(skeletonParaId, skeletonPara);
|
|
2820
|
+
skeletonNoteChildren.push([skeletonParaId]);
|
|
2821
|
+
const meta = doc.getMap("meta");
|
|
2822
|
+
meta.set("id", docId);
|
|
2823
|
+
meta.set("title", title);
|
|
2824
|
+
meta.set("createDate", Date.now());
|
|
2825
|
+
meta.set("tags", new Y.Array());
|
|
2826
|
+
return { doc, blocks, pageId, surfaceId, noteId };
|
|
2827
|
+
}
|
|
2828
|
+
function makeWorkspacePageEntry(docId, title) {
|
|
2829
|
+
const entry = new Y.Map();
|
|
2830
|
+
entry.set("id", docId);
|
|
2831
|
+
entry.set("title", title);
|
|
2832
|
+
entry.set("createDate", Date.now());
|
|
2833
|
+
entry.set("tags", new Y.Array());
|
|
2834
|
+
return entry;
|
|
2835
|
+
}
|
|
2836
|
+
function defaultSemanticSections(pageType) {
|
|
2837
|
+
switch (pageType) {
|
|
2838
|
+
case "meeting_notes":
|
|
2839
|
+
return [
|
|
2840
|
+
{ title: "Attendees" },
|
|
2841
|
+
{ title: "Agenda" },
|
|
2842
|
+
{ title: "Notes" },
|
|
2843
|
+
{ title: "Action Items" },
|
|
2844
|
+
];
|
|
2845
|
+
case "project_hub":
|
|
2846
|
+
return [
|
|
2847
|
+
{ title: "Overview" },
|
|
2848
|
+
{ title: "Milestones" },
|
|
2849
|
+
{ title: "Risks" },
|
|
2850
|
+
{ title: "References" },
|
|
2851
|
+
];
|
|
2852
|
+
case "spec_page":
|
|
2853
|
+
return [
|
|
2854
|
+
{ title: "Context" },
|
|
2855
|
+
{ title: "Goals" },
|
|
2856
|
+
{ title: "Non-Goals" },
|
|
2857
|
+
{ title: "Proposal" },
|
|
2858
|
+
{ title: "Open Questions" },
|
|
2859
|
+
];
|
|
2860
|
+
case "wiki_page":
|
|
2861
|
+
default:
|
|
2862
|
+
return [
|
|
2863
|
+
{ title: "Summary" },
|
|
2864
|
+
{ title: "Details" },
|
|
2865
|
+
{ title: "Related Resources" },
|
|
2866
|
+
];
|
|
2867
|
+
}
|
|
2868
|
+
}
|
|
2869
|
+
function normalizeSemanticSections(pageType, sections) {
|
|
2870
|
+
const source = sections?.length ? sections : defaultSemanticSections(pageType ?? "wiki_page");
|
|
2871
|
+
return source.map((section) => ({
|
|
2872
|
+
title: section.title.trim(),
|
|
2873
|
+
paragraphs: section.paragraphs?.map((paragraph) => paragraph.trim()).filter(Boolean),
|
|
2874
|
+
bullets: section.bullets?.map((bullet) => bullet.trim()).filter(Boolean),
|
|
2875
|
+
callouts: section.callouts?.map((callout) => callout.trim()).filter(Boolean),
|
|
2876
|
+
}));
|
|
2877
|
+
}
|
|
2878
|
+
function semanticSectionToAppendInputs(section) {
|
|
2879
|
+
const inputs = [
|
|
2880
|
+
{
|
|
2881
|
+
type: "heading",
|
|
2882
|
+
text: section.title,
|
|
2883
|
+
level: 2,
|
|
2884
|
+
},
|
|
2885
|
+
];
|
|
2886
|
+
for (const paragraph of section.paragraphs ?? []) {
|
|
2887
|
+
inputs.push({
|
|
2888
|
+
type: "paragraph",
|
|
2889
|
+
text: paragraph,
|
|
2890
|
+
});
|
|
2891
|
+
}
|
|
2892
|
+
for (const bullet of section.bullets ?? []) {
|
|
2893
|
+
inputs.push({
|
|
2894
|
+
type: "list",
|
|
2895
|
+
text: bullet,
|
|
2896
|
+
style: "bulleted",
|
|
2897
|
+
});
|
|
2898
|
+
}
|
|
2899
|
+
for (const callout of section.callouts ?? []) {
|
|
2900
|
+
inputs.push({
|
|
2901
|
+
type: "callout",
|
|
2902
|
+
text: callout,
|
|
2903
|
+
});
|
|
2904
|
+
}
|
|
2905
|
+
return inputs;
|
|
2906
|
+
}
|
|
2907
|
+
function appendNativeBlocks(blocks, parentId, inputs, workspaceId, docId, strict = true, initialPlacement) {
|
|
2908
|
+
const parentBlock = findBlockById(blocks, parentId);
|
|
2909
|
+
if (!parentBlock) {
|
|
2910
|
+
throw new Error(`Target parent block '${parentId}' was not found.`);
|
|
2911
|
+
}
|
|
2912
|
+
let anchorPlacement = initialPlacement ?? { parentId };
|
|
2913
|
+
const blockIds = [];
|
|
2914
|
+
const headingIds = [];
|
|
2915
|
+
for (const input of inputs) {
|
|
2916
|
+
const normalized = normalizeAppendBlockInput({
|
|
2917
|
+
workspaceId,
|
|
2918
|
+
docId,
|
|
2919
|
+
strict,
|
|
2920
|
+
placement: anchorPlacement,
|
|
2921
|
+
...input,
|
|
2922
|
+
});
|
|
2923
|
+
const context = resolveInsertContext(blocks, normalized);
|
|
2924
|
+
const { blockId, block, extraBlocks } = createBlock(normalized);
|
|
2925
|
+
blocks.set(blockId, block);
|
|
2926
|
+
if (Array.isArray(extraBlocks)) {
|
|
2927
|
+
for (const extra of extraBlocks) {
|
|
2928
|
+
blocks.set(extra.blockId, extra.block);
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
if (context.insertIndex >= context.children.length) {
|
|
2932
|
+
context.children.push([blockId]);
|
|
2933
|
+
}
|
|
2934
|
+
else {
|
|
2935
|
+
context.children.insert(context.insertIndex, [blockId]);
|
|
2936
|
+
}
|
|
2937
|
+
blockIds.push(blockId);
|
|
2938
|
+
if (normalized.type === "heading") {
|
|
2939
|
+
headingIds.push(blockId);
|
|
2940
|
+
}
|
|
2941
|
+
anchorPlacement = { afterBlockId: blockId };
|
|
2942
|
+
}
|
|
2943
|
+
return { blockIds, headingIds };
|
|
2944
|
+
}
|
|
2945
|
+
function isHeadingBlock(block) {
|
|
2946
|
+
return block.get("sys:flavour") === "affine:paragraph" && /^h[1-6]$/.test(String(block.get("prop:type") || ""));
|
|
2947
|
+
}
|
|
2948
|
+
function getHeadingLevel(block) {
|
|
2949
|
+
const type = String(block.get("prop:type") || "");
|
|
2950
|
+
const match = type.match(/^h([1-6])$/);
|
|
2951
|
+
return match ? Number(match[1]) : null;
|
|
2952
|
+
}
|
|
2953
|
+
function normalizedText(value) {
|
|
2954
|
+
return richTextValueToString(value).trim().toLocaleLowerCase();
|
|
2955
|
+
}
|
|
2956
|
+
function findSectionInsertionIndex(blocks, noteId, sectionTitle) {
|
|
2957
|
+
const noteBlock = findBlockById(blocks, noteId);
|
|
2958
|
+
if (!noteBlock) {
|
|
2959
|
+
throw new Error(`Note block '${noteId}' was not found.`);
|
|
2960
|
+
}
|
|
2961
|
+
const children = childIdsFrom(noteBlock.get("sys:children"));
|
|
2962
|
+
const target = normalizedText(sectionTitle);
|
|
2963
|
+
for (let i = 0; i < children.length; i += 1) {
|
|
2964
|
+
const childBlock = findBlockById(blocks, children[i]);
|
|
2965
|
+
if (!childBlock || !isHeadingBlock(childBlock)) {
|
|
2966
|
+
continue;
|
|
2967
|
+
}
|
|
2968
|
+
if (normalizedText(childBlock.get("prop:text")) !== target) {
|
|
2969
|
+
continue;
|
|
2970
|
+
}
|
|
2971
|
+
const targetLevel = getHeadingLevel(childBlock) ?? 2;
|
|
2972
|
+
let endIndex = i + 1;
|
|
2973
|
+
while (endIndex < children.length) {
|
|
2974
|
+
const nextBlock = findBlockById(blocks, children[endIndex]);
|
|
2975
|
+
if (nextBlock && isHeadingBlock(nextBlock)) {
|
|
2976
|
+
const nextLevel = getHeadingLevel(nextBlock) ?? 2;
|
|
2977
|
+
if (nextLevel <= targetLevel) {
|
|
2978
|
+
break;
|
|
2979
|
+
}
|
|
2980
|
+
}
|
|
2981
|
+
endIndex += 1;
|
|
2982
|
+
}
|
|
2983
|
+
return endIndex;
|
|
2984
|
+
}
|
|
2985
|
+
throw new Error(`Section heading '${sectionTitle}' was not found.`);
|
|
2986
|
+
}
|
|
2987
|
+
async function commitNewDocument(socket, workspaceId, docId, title, doc) {
|
|
2988
|
+
const updateFull = Y.encodeStateAsUpdate(doc);
|
|
2989
|
+
await pushDocUpdate(socket, workspaceId, docId, Buffer.from(updateFull).toString("base64"));
|
|
2990
|
+
const wsDoc = new Y.Doc();
|
|
2991
|
+
const snapshot = await loadDoc(socket, workspaceId, workspaceId);
|
|
2992
|
+
if (snapshot.missing) {
|
|
2993
|
+
Y.applyUpdate(wsDoc, Buffer.from(snapshot.missing, "base64"));
|
|
2994
|
+
}
|
|
2995
|
+
const prevSV = Y.encodeStateVector(wsDoc);
|
|
2996
|
+
const wsMeta = wsDoc.getMap("meta");
|
|
2997
|
+
let pages = wsMeta.get("pages");
|
|
2998
|
+
if (!pages) {
|
|
2999
|
+
pages = new Y.Array();
|
|
3000
|
+
wsMeta.set("pages", pages);
|
|
3001
|
+
}
|
|
3002
|
+
pages.push([makeWorkspacePageEntry(docId, title)]);
|
|
3003
|
+
const wsDelta = Y.encodeStateAsUpdate(wsDoc, prevSV);
|
|
3004
|
+
await pushDocUpdate(socket, workspaceId, workspaceId, Buffer.from(wsDelta).toString("base64"));
|
|
3005
|
+
}
|
|
3006
|
+
async function createSemanticPageInternal(parsed) {
|
|
3007
|
+
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
3008
|
+
if (!workspaceId) {
|
|
3009
|
+
throw new Error("workspaceId is required. Provide it or set AFFINE_WORKSPACE_ID.");
|
|
3010
|
+
}
|
|
3011
|
+
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
3012
|
+
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
3013
|
+
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
3014
|
+
try {
|
|
3015
|
+
await joinWorkspace(socket, workspaceId);
|
|
3016
|
+
const docId = generateId();
|
|
3017
|
+
const title = parsed.title || "Untitled";
|
|
3018
|
+
const pageType = parsed.pageType ?? "wiki_page";
|
|
3019
|
+
const sections = normalizeSemanticSections(pageType, parsed.sections);
|
|
3020
|
+
const docShell = createDocSkeleton(title, docId);
|
|
3021
|
+
const { blockIds, headingIds } = appendNativeBlocks(docShell.blocks, docShell.noteId, sections.flatMap(semanticSectionToAppendInputs), workspaceId, docId);
|
|
3022
|
+
await commitNewDocument(socket, workspaceId, docId, title, docShell.doc);
|
|
3023
|
+
let parentLinked = false;
|
|
3024
|
+
const warnings = [];
|
|
3025
|
+
if (parsed.parentDocId) {
|
|
3026
|
+
try {
|
|
3027
|
+
await appendBlockInternal({
|
|
3028
|
+
workspaceId,
|
|
3029
|
+
docId: parsed.parentDocId,
|
|
3030
|
+
type: "embed_linked_doc",
|
|
3031
|
+
pageId: docId,
|
|
3032
|
+
});
|
|
3033
|
+
parentLinked = true;
|
|
3034
|
+
}
|
|
3035
|
+
catch {
|
|
3036
|
+
warnings.push(`Semantic page created but could not be linked to parent doc "${parsed.parentDocId}". Link it manually.`);
|
|
3037
|
+
}
|
|
3038
|
+
}
|
|
3039
|
+
return {
|
|
3040
|
+
workspaceId,
|
|
3041
|
+
docId,
|
|
3042
|
+
title,
|
|
3043
|
+
pageType,
|
|
3044
|
+
noteId: docShell.noteId,
|
|
3045
|
+
pageId: docShell.pageId,
|
|
3046
|
+
sectionHeadingIds: headingIds,
|
|
3047
|
+
blockIds,
|
|
3048
|
+
parentLinked,
|
|
3049
|
+
warnings,
|
|
3050
|
+
};
|
|
3051
|
+
}
|
|
3052
|
+
finally {
|
|
3053
|
+
socket.disconnect();
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
async function appendSemanticSectionInternal(parsed) {
|
|
3057
|
+
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
3058
|
+
if (!workspaceId) {
|
|
3059
|
+
throw new Error("workspaceId is required. Provide it or set AFFINE_WORKSPACE_ID.");
|
|
3060
|
+
}
|
|
3061
|
+
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
3062
|
+
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
3063
|
+
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
3064
|
+
try {
|
|
3065
|
+
await joinWorkspace(socket, workspaceId);
|
|
3066
|
+
const doc = new Y.Doc();
|
|
3067
|
+
const snapshot = await loadDoc(socket, workspaceId, parsed.docId);
|
|
3068
|
+
if (!snapshot.missing) {
|
|
3069
|
+
throw new Error(`Document ${parsed.docId} was not found in workspace ${workspaceId}.`);
|
|
3070
|
+
}
|
|
3071
|
+
Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
|
|
3072
|
+
const prevSV = Y.encodeStateVector(doc);
|
|
3073
|
+
const blocks = doc.getMap("blocks");
|
|
3074
|
+
const noteId = ensureNoteBlock(blocks);
|
|
3075
|
+
const insertionIndex = parsed.afterSectionTitle
|
|
3076
|
+
? findSectionInsertionIndex(blocks, noteId, parsed.afterSectionTitle)
|
|
3077
|
+
: childIdsFrom(findBlockById(blocks, noteId)?.get("sys:children")).length;
|
|
3078
|
+
const { blockIds, headingIds } = appendNativeBlocks(blocks, noteId, semanticSectionToAppendInputs({
|
|
3079
|
+
title: parsed.sectionTitle,
|
|
3080
|
+
paragraphs: parsed.paragraphs,
|
|
3081
|
+
bullets: parsed.bullets,
|
|
3082
|
+
callouts: parsed.callouts,
|
|
3083
|
+
}), workspaceId, parsed.docId, true, { parentId: noteId, index: insertionIndex });
|
|
3084
|
+
const delta = Y.encodeStateAsUpdate(doc, prevSV);
|
|
3085
|
+
await pushDocUpdate(socket, workspaceId, parsed.docId, Buffer.from(delta).toString("base64"));
|
|
3086
|
+
return {
|
|
3087
|
+
workspaceId,
|
|
3088
|
+
docId: parsed.docId,
|
|
3089
|
+
noteId,
|
|
3090
|
+
sectionTitle: parsed.sectionTitle,
|
|
3091
|
+
sectionHeadingId: headingIds[0] || blockIds[0],
|
|
3092
|
+
afterSectionTitle: parsed.afterSectionTitle ?? null,
|
|
3093
|
+
blockIds,
|
|
3094
|
+
};
|
|
3095
|
+
}
|
|
3096
|
+
finally {
|
|
3097
|
+
socket.disconnect();
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
const listDocsHandler = async (parsed) => {
|
|
3101
|
+
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
3102
|
+
if (!workspaceId) {
|
|
3103
|
+
throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
|
|
3104
|
+
}
|
|
3105
|
+
const query = `query ListDocs($workspaceId: String!, $first: Int, $offset: Int, $after: String){ workspace(id:$workspaceId){ docs(pagination:{first:$first, offset:$offset, after:$after}){ totalCount pageInfo{ hasNextPage endCursor } edges{ cursor node{ id workspaceId title summary public defaultRole createdAt updatedAt } } } } }`;
|
|
3106
|
+
const data = await gql.request(query, { workspaceId, first: parsed.first, offset: parsed.offset, after: parsed.after });
|
|
3107
|
+
const docs = data.workspace.docs;
|
|
3108
|
+
const tagsByDocId = new Map();
|
|
3109
|
+
const titlesByDocId = new Map();
|
|
3110
|
+
let workspacePageCount = null;
|
|
3111
|
+
let workspacePageIds = null;
|
|
3112
|
+
const deletedDocIds = new Set();
|
|
3113
|
+
try {
|
|
3114
|
+
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
3115
|
+
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
3116
|
+
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
3117
|
+
try {
|
|
3118
|
+
await joinWorkspace(socket, workspaceId);
|
|
3119
|
+
const snapshot = await loadDoc(socket, workspaceId, workspaceId);
|
|
3120
|
+
if (snapshot.missing) {
|
|
3121
|
+
const wsDoc = new Y.Doc();
|
|
3122
|
+
Y.applyUpdate(wsDoc, Buffer.from(snapshot.missing, "base64"));
|
|
3123
|
+
const meta = wsDoc.getMap("meta");
|
|
3124
|
+
const pages = getWorkspacePageEntries(meta);
|
|
3125
|
+
workspacePageCount = pages.length;
|
|
3126
|
+
workspacePageIds = new Set(pages.map(page => page.id));
|
|
3127
|
+
const { byId } = getWorkspaceTagOptionMaps(meta);
|
|
3128
|
+
for (const page of pages) {
|
|
3129
|
+
if (page.title) {
|
|
3130
|
+
titlesByDocId.set(page.id, page.title);
|
|
3131
|
+
}
|
|
3132
|
+
const tagEntries = getStringArray(page.tagsArray);
|
|
3133
|
+
tagsByDocId.set(page.id, resolveTagLabels(tagEntries, byId));
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
3136
|
+
const graphEdges = Array.isArray(docs?.edges) ? docs.edges : [];
|
|
3137
|
+
if (workspacePageIds && graphEdges.length > workspacePageIds.size) {
|
|
3138
|
+
for (const edge of graphEdges) {
|
|
3139
|
+
const nodeId = edge?.node?.id;
|
|
3140
|
+
if (typeof nodeId !== "string" || workspacePageIds.has(nodeId)) {
|
|
3141
|
+
continue;
|
|
3142
|
+
}
|
|
3143
|
+
const edgeSnapshot = await loadDoc(socket, workspaceId, nodeId);
|
|
3144
|
+
// Treat timestamp-only responses as deleted tombstones so list_docs can
|
|
3145
|
+
// hide stale GraphQL edges after delete_doc eventually converges.
|
|
3146
|
+
const edgeExists = Boolean(edgeSnapshot.missing || edgeSnapshot.state);
|
|
3147
|
+
if (!edgeExists) {
|
|
3148
|
+
deletedDocIds.add(nodeId);
|
|
3149
|
+
}
|
|
3150
|
+
}
|
|
3151
|
+
}
|
|
2094
3152
|
}
|
|
2095
3153
|
finally {
|
|
2096
3154
|
socket.disconnect();
|
|
@@ -2717,6 +3775,123 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
2717
3775
|
includeMarkdown: z.boolean().optional().describe("If true, includes rendered markdown in the response. Equivalent to also calling export_doc_markdown."),
|
|
2718
3776
|
},
|
|
2719
3777
|
}, readDocHandler);
|
|
3778
|
+
const getCapabilitiesHandler = async () => {
|
|
3779
|
+
return text({
|
|
3780
|
+
server: {
|
|
3781
|
+
name: "affine-mcp",
|
|
3782
|
+
capabilityVersion: 1,
|
|
3783
|
+
},
|
|
3784
|
+
docs: {
|
|
3785
|
+
canonicalBlockTypes: [...APPEND_BLOCK_CANONICAL_TYPE_VALUES],
|
|
3786
|
+
legacyBlockAliases: Object.keys(APPEND_BLOCK_LEGACY_ALIAS_MAP),
|
|
3787
|
+
markdownImport: {
|
|
3788
|
+
supported: true,
|
|
3789
|
+
lossy: true,
|
|
3790
|
+
knownLosses: [
|
|
3791
|
+
"Nested markdown lists are flattened during import.",
|
|
3792
|
+
"Markdown images are converted into bookmark blocks unless blobs are uploaded separately.",
|
|
3793
|
+
"HTML blocks are imported as plain paragraph text.",
|
|
3794
|
+
],
|
|
3795
|
+
},
|
|
3796
|
+
markdownExport: {
|
|
3797
|
+
supportedFlavours: [...MARKDOWN_EXPORT_SUPPORTED_FLAVOURS].sort(),
|
|
3798
|
+
lossyForUnsupportedFlavours: true,
|
|
3799
|
+
knownUnsupportedFlavours: [...KNOWN_BLOCK_FLAVOURS].filter(flavour => !MARKDOWN_EXPORT_SUPPORTED_FLAVOURS.has(flavour)).sort(),
|
|
3800
|
+
},
|
|
3801
|
+
highLevelAuthoring: {
|
|
3802
|
+
semanticPageComposer: true,
|
|
3803
|
+
nativeTemplateInstantiation: true,
|
|
3804
|
+
createDocWithPlacement: true,
|
|
3805
|
+
semanticSectionEditing: true,
|
|
3806
|
+
},
|
|
3807
|
+
edgelessCanvas: {
|
|
3808
|
+
blockMutation: true,
|
|
3809
|
+
surfaceElementMutation: true,
|
|
3810
|
+
canvasReadback: true,
|
|
3811
|
+
frameOwnershipMutation: true,
|
|
3812
|
+
},
|
|
3813
|
+
},
|
|
3814
|
+
database: {
|
|
3815
|
+
supported: true,
|
|
3816
|
+
columnTypes: [...DATABASE_COLUMN_TYPE_VALUES],
|
|
3817
|
+
initialViewModes: [...APPEND_BLOCK_DATA_VIEW_MODE_VALUES],
|
|
3818
|
+
advancedViewMutation: true,
|
|
3819
|
+
intentDrivenComposition: true,
|
|
3820
|
+
linkedDocRows: true,
|
|
3821
|
+
},
|
|
3822
|
+
workspace: {
|
|
3823
|
+
organizeToolsExperimental: true,
|
|
3824
|
+
ruleBackedCollections: true,
|
|
3825
|
+
workspaceBlueprints: true,
|
|
3826
|
+
},
|
|
3827
|
+
collaboration: {
|
|
3828
|
+
docComments: true,
|
|
3829
|
+
repliesListed: true,
|
|
3830
|
+
replyCreation: false,
|
|
3831
|
+
anchoredComments: false,
|
|
3832
|
+
selectionRangeComments: false,
|
|
3833
|
+
sharePolicyManagement: false,
|
|
3834
|
+
},
|
|
3835
|
+
export: {
|
|
3836
|
+
markdown: true,
|
|
3837
|
+
html: false,
|
|
3838
|
+
fidelityReport: true,
|
|
3839
|
+
snapshotBundle: false,
|
|
3840
|
+
},
|
|
3841
|
+
});
|
|
3842
|
+
};
|
|
3843
|
+
server.registerTool("get_capabilities", {
|
|
3844
|
+
title: "Get Capabilities",
|
|
3845
|
+
description: "Return machine-readable capability flags for this MCP server, including block, database, collaboration, and export support.",
|
|
3846
|
+
inputSchema: {},
|
|
3847
|
+
}, getCapabilitiesHandler);
|
|
3848
|
+
const analyzeDocFidelityHandler = async (parsed) => {
|
|
3849
|
+
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
3850
|
+
if (!workspaceId) {
|
|
3851
|
+
throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
|
|
3852
|
+
}
|
|
3853
|
+
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
3854
|
+
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
3855
|
+
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
3856
|
+
try {
|
|
3857
|
+
await joinWorkspace(socket, workspaceId);
|
|
3858
|
+
let tagOptionsById = new Map();
|
|
3859
|
+
const workspaceSnapshot = await loadDoc(socket, workspaceId, workspaceId);
|
|
3860
|
+
if (workspaceSnapshot.missing) {
|
|
3861
|
+
const workspaceDoc = new Y.Doc();
|
|
3862
|
+
Y.applyUpdate(workspaceDoc, Buffer.from(workspaceSnapshot.missing, "base64"));
|
|
3863
|
+
tagOptionsById = getWorkspaceTagOptionMaps(workspaceDoc.getMap("meta")).byId;
|
|
3864
|
+
}
|
|
3865
|
+
const snapshot = await loadDoc(socket, workspaceId, parsed.docId);
|
|
3866
|
+
if (!snapshot.missing) {
|
|
3867
|
+
return text({
|
|
3868
|
+
docId: parsed.docId,
|
|
3869
|
+
exists: false,
|
|
3870
|
+
unsupportedBlocks: [],
|
|
3871
|
+
conditionallyRiskyBlocks: [],
|
|
3872
|
+
});
|
|
3873
|
+
}
|
|
3874
|
+
const doc = new Y.Doc();
|
|
3875
|
+
Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
|
|
3876
|
+
const summary = summarizeDocFidelity(doc, tagOptionsById);
|
|
3877
|
+
return text({
|
|
3878
|
+
docId: parsed.docId,
|
|
3879
|
+
exists: true,
|
|
3880
|
+
...summary,
|
|
3881
|
+
});
|
|
3882
|
+
}
|
|
3883
|
+
finally {
|
|
3884
|
+
socket.disconnect();
|
|
3885
|
+
}
|
|
3886
|
+
};
|
|
3887
|
+
server.registerTool("analyze_doc_fidelity", {
|
|
3888
|
+
title: "Analyze Document Fidelity",
|
|
3889
|
+
description: "Inspect a document for markdown export fidelity risk, including unsupported AFFiNE block flavours and risky content paths.",
|
|
3890
|
+
inputSchema: {
|
|
3891
|
+
workspaceId: WorkspaceId.optional(),
|
|
3892
|
+
docId: DocId,
|
|
3893
|
+
},
|
|
3894
|
+
}, analyzeDocFidelityHandler);
|
|
2720
3895
|
// move_doc: move a doc in the sidebar by removing its embed_linked_doc from the old parent
|
|
2721
3896
|
// and adding it to the new parent. fromParentDocId is optional — if omitted, only adds to new parent.
|
|
2722
3897
|
const moveDocHandler = async (parsed) => {
|
|
@@ -2784,7 +3959,13 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
2784
3959
|
type: "embed_linked_doc",
|
|
2785
3960
|
pageId: parsed.docId,
|
|
2786
3961
|
});
|
|
2787
|
-
return
|
|
3962
|
+
return receipt("doc.move", {
|
|
3963
|
+
workspaceId,
|
|
3964
|
+
moved: true,
|
|
3965
|
+
docId: parsed.docId,
|
|
3966
|
+
toParentDocId: parsed.toParentDocId,
|
|
3967
|
+
removedFromParent,
|
|
3968
|
+
});
|
|
2788
3969
|
}
|
|
2789
3970
|
finally {
|
|
2790
3971
|
socket.disconnect();
|
|
@@ -2807,9 +3988,13 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
2807
3988
|
}
|
|
2808
3989
|
const mutation = `mutation PublishDoc($workspaceId:String!,$docId:String!,$mode:PublicDocMode){ publishDoc(workspaceId:$workspaceId, docId:$docId, mode:$mode){ id workspaceId public mode } }`;
|
|
2809
3990
|
const data = await gql.request(mutation, { workspaceId, docId: parsed.docId, mode: parsed.mode });
|
|
2810
|
-
return
|
|
2811
|
-
|
|
2812
|
-
|
|
3991
|
+
return receipt("doc.publish", {
|
|
3992
|
+
workspaceId,
|
|
3993
|
+
docId: parsed.docId,
|
|
3994
|
+
...data.publishDoc,
|
|
3995
|
+
});
|
|
3996
|
+
};
|
|
3997
|
+
server.registerTool("publish_doc", {
|
|
2813
3998
|
title: "Publish Document",
|
|
2814
3999
|
description: "Publish a doc (make public).",
|
|
2815
4000
|
inputSchema: {
|
|
@@ -2825,7 +4010,11 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
2825
4010
|
}
|
|
2826
4011
|
const mutation = `mutation RevokeDoc($workspaceId:String!,$docId:String!){ revokePublicDoc(workspaceId:$workspaceId, docId:$docId){ id workspaceId public } }`;
|
|
2827
4012
|
const data = await gql.request(mutation, { workspaceId, docId: parsed.docId });
|
|
2828
|
-
return
|
|
4013
|
+
return receipt("doc.revoke_public", {
|
|
4014
|
+
workspaceId,
|
|
4015
|
+
docId: parsed.docId,
|
|
4016
|
+
...data.revokePublicDoc,
|
|
4017
|
+
});
|
|
2829
4018
|
};
|
|
2830
4019
|
server.registerTool("revoke_doc", {
|
|
2831
4020
|
title: "Revoke Document",
|
|
@@ -2838,45 +4027,128 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
2838
4027
|
// CREATE DOC (high-level)
|
|
2839
4028
|
const createDocHandler = async (parsed) => {
|
|
2840
4029
|
const created = await createDocInternal(parsed);
|
|
2841
|
-
|
|
4030
|
+
const placement = await finalizeDocPlacement({
|
|
4031
|
+
workspaceId: created.workspaceId,
|
|
4032
|
+
docId: created.docId,
|
|
4033
|
+
parentDocId: parsed.parentDocId,
|
|
4034
|
+
context: "create_doc",
|
|
4035
|
+
});
|
|
4036
|
+
return receipt("doc.create", {
|
|
4037
|
+
workspaceId: created.workspaceId,
|
|
4038
|
+
docId: created.docId,
|
|
4039
|
+
title: created.title,
|
|
4040
|
+
parentDocId: placement.parentDocId,
|
|
4041
|
+
linkedToParent: placement.linkedToParent,
|
|
4042
|
+
warnings: mergeWarnings(created.warnings ?? [], placement.warnings),
|
|
4043
|
+
});
|
|
2842
4044
|
};
|
|
2843
4045
|
server.registerTool('create_doc', {
|
|
2844
4046
|
title: 'Create Document',
|
|
2845
|
-
description: 'Create a new AFFiNE document with optional content',
|
|
4047
|
+
description: 'Create a new AFFiNE document with optional content. If parentDocId is provided, the new doc is linked into the sidebar tree immediately.',
|
|
2846
4048
|
inputSchema: {
|
|
2847
4049
|
workspaceId: z.string().optional(),
|
|
2848
4050
|
title: z.string().optional(),
|
|
2849
4051
|
content: z.string().optional(),
|
|
4052
|
+
parentDocId: z.string().optional().describe("Optional parent doc to link the new doc under in the sidebar."),
|
|
2850
4053
|
},
|
|
2851
4054
|
}, createDocHandler);
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
4055
|
+
const semanticSectionSchema = z.object({
|
|
4056
|
+
title: z.string().min(1).describe("Semantic section title."),
|
|
4057
|
+
paragraphs: z.array(z.string().min(1)).optional().describe("Paragraphs to append under the section heading."),
|
|
4058
|
+
bullets: z.array(z.string().min(1)).optional().describe("Bulleted items to append under the section heading."),
|
|
4059
|
+
callouts: z.array(z.string().min(1)).optional().describe("Callout blocks to append under the section heading."),
|
|
4060
|
+
});
|
|
4061
|
+
const createSemanticPageHandler = async (parsed) => {
|
|
4062
|
+
const created = await createSemanticPageInternal(parsed);
|
|
4063
|
+
return text({
|
|
4064
|
+
workspaceId: created.workspaceId,
|
|
4065
|
+
docId: created.docId,
|
|
4066
|
+
title: created.title,
|
|
4067
|
+
pageType: created.pageType,
|
|
4068
|
+
pageId: created.pageId,
|
|
4069
|
+
noteId: created.noteId,
|
|
4070
|
+
sectionCount: created.sectionHeadingIds.length,
|
|
4071
|
+
sectionHeadingIds: created.sectionHeadingIds,
|
|
4072
|
+
blockIds: created.blockIds,
|
|
4073
|
+
parentLinked: created.parentLinked,
|
|
4074
|
+
warnings: created.warnings,
|
|
2859
4075
|
});
|
|
2860
|
-
return text({ appended: result.appended, paragraphId: result.blockId });
|
|
2861
4076
|
};
|
|
2862
|
-
server.registerTool(
|
|
2863
|
-
title:
|
|
2864
|
-
description:
|
|
4077
|
+
server.registerTool("create_semantic_page", {
|
|
4078
|
+
title: "Create Semantic Page",
|
|
4079
|
+
description: "Create an AFFiNE-native page with intentional section structure and native block composition.",
|
|
2865
4080
|
inputSchema: {
|
|
2866
|
-
workspaceId:
|
|
2867
|
-
|
|
2868
|
-
|
|
4081
|
+
workspaceId: WorkspaceId.optional(),
|
|
4082
|
+
title: z.string().optional().describe("Page title."),
|
|
4083
|
+
pageType: z.enum(["meeting_notes", "project_hub", "spec_page", "wiki_page"]).optional().describe("Semantic page template to seed default sections."),
|
|
4084
|
+
parentDocId: z.string().optional().describe("Optional parent doc to link the new page under in the sidebar."),
|
|
4085
|
+
sections: z.array(semanticSectionSchema).optional().describe("Optional explicit section structure. If omitted, the page type defaults are used."),
|
|
2869
4086
|
},
|
|
2870
|
-
},
|
|
2871
|
-
const
|
|
2872
|
-
const result = await
|
|
4087
|
+
}, createSemanticPageHandler);
|
|
4088
|
+
const appendSemanticSectionHandler = async (parsed) => {
|
|
4089
|
+
const result = await appendSemanticSectionInternal(parsed);
|
|
2873
4090
|
return text({
|
|
4091
|
+
workspaceId: result.workspaceId,
|
|
4092
|
+
docId: result.docId,
|
|
4093
|
+
noteId: result.noteId,
|
|
4094
|
+
sectionTitle: result.sectionTitle,
|
|
4095
|
+
sectionHeadingId: result.sectionHeadingId,
|
|
4096
|
+
afterSectionTitle: result.afterSectionTitle,
|
|
4097
|
+
blockIds: result.blockIds,
|
|
4098
|
+
appendedCount: result.blockIds.length,
|
|
4099
|
+
});
|
|
4100
|
+
};
|
|
4101
|
+
server.registerTool("append_semantic_section", {
|
|
4102
|
+
title: "Append Semantic Section",
|
|
4103
|
+
description: "Append a semantic section to an existing AFFiNE document by heading title and native block composition.",
|
|
4104
|
+
inputSchema: {
|
|
4105
|
+
workspaceId: WorkspaceId.optional(),
|
|
4106
|
+
docId: DocId,
|
|
4107
|
+
sectionTitle: z.string().min(1).describe("Heading text for the new semantic section."),
|
|
4108
|
+
afterSectionTitle: z.string().optional().describe("Optional existing section heading to append after."),
|
|
4109
|
+
paragraphs: z.array(z.string().min(1)).optional().describe("Paragraphs to append under the new section."),
|
|
4110
|
+
bullets: z.array(z.string().min(1)).optional().describe("Bulleted items to append under the new section."),
|
|
4111
|
+
callouts: z.array(z.string().min(1)).optional().describe("Callout blocks to append under the new section."),
|
|
4112
|
+
},
|
|
4113
|
+
}, appendSemanticSectionHandler);
|
|
4114
|
+
const appendBlockHandler = async (parsed) => {
|
|
4115
|
+
// Drop `text` when `markdown` is set so markdown-parsed children don't
|
|
4116
|
+
// sit next to a stale one-paragraph echo.
|
|
4117
|
+
const shouldApplyMarkdown = parsed.type === "note" && !!parsed.markdown;
|
|
4118
|
+
const coreParsed = shouldApplyMarkdown ? { ...parsed, text: undefined } : parsed;
|
|
4119
|
+
const result = await appendBlockInternal(coreParsed);
|
|
4120
|
+
let markdownApplied;
|
|
4121
|
+
if (shouldApplyMarkdown && result.appended && result.blockId) {
|
|
4122
|
+
const parsedMd = parseMarkdownToOperations(parsed.markdown);
|
|
4123
|
+
if (parsedMd.operations.length > 0) {
|
|
4124
|
+
const applied = await applyMarkdownOperationsInternal({
|
|
4125
|
+
workspaceId: parsed.workspaceId || defaults.workspaceId,
|
|
4126
|
+
docId: parsed.docId,
|
|
4127
|
+
operations: parsedMd.operations,
|
|
4128
|
+
strict: parsed.strict,
|
|
4129
|
+
placement: { parentId: result.blockId },
|
|
4130
|
+
});
|
|
4131
|
+
markdownApplied = {
|
|
4132
|
+
appendedCount: applied.appendedCount,
|
|
4133
|
+
skippedCount: applied.skippedCount,
|
|
4134
|
+
blockIds: applied.blockIds,
|
|
4135
|
+
warnings: parsedMd.warnings,
|
|
4136
|
+
};
|
|
4137
|
+
}
|
|
4138
|
+
}
|
|
4139
|
+
return receipt("doc.append_block", {
|
|
4140
|
+
workspaceId: parsed.workspaceId || defaults.workspaceId || null,
|
|
4141
|
+
docId: parsed.docId,
|
|
2874
4142
|
appended: result.appended,
|
|
2875
4143
|
blockId: result.blockId,
|
|
2876
4144
|
flavour: result.flavour,
|
|
2877
4145
|
type: result.blockType || null,
|
|
4146
|
+
blockType: result.blockType || null,
|
|
2878
4147
|
normalizedType: result.normalizedType,
|
|
2879
4148
|
legacyType: result.legacyType,
|
|
4149
|
+
...(result.ownedIds ? { ownedIds: result.ownedIds } : {}),
|
|
4150
|
+
...(result.missing ? { missing: result.missing } : {}),
|
|
4151
|
+
...(markdownApplied ? { markdown: markdownApplied } : {}),
|
|
2880
4152
|
});
|
|
2881
4153
|
};
|
|
2882
4154
|
server.registerTool("append_block", {
|
|
@@ -2894,9 +4166,19 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
2894
4166
|
design: z.string().optional().describe("Design payload for embed_html"),
|
|
2895
4167
|
reference: z.string().optional().describe("Target id for surface_ref"),
|
|
2896
4168
|
refFlavour: z.string().optional().describe("Target flavour for surface_ref (e.g. affine:frame)"),
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
4169
|
+
x: z.number().int().optional().describe("X position on the edgeless canvas for frame/edgeless_text/note (default 0). Prefer ≥40px between sibling bounds; BlockSuite does not auto-arrange."),
|
|
4170
|
+
y: z.number().int().optional().describe("Y position on the edgeless canvas for frame/edgeless_text/note (default 0)."),
|
|
4171
|
+
width: z.number().int().min(1).max(10000).optional().describe("Width for frame/edgeless_text/note."),
|
|
4172
|
+
height: z.number().int().min(1).max(10000).optional().describe("Height for frame/edgeless_text/note. When `markdown` is set and height is omitted, an over-estimate is computed from the content — AFFiNE's render-time ResizeObserver corrects `prop:xywh` to the true DOM-measured height on first browser open."),
|
|
4173
|
+
background: z.any().optional().describe("Background for frame/note. Frame default 'transparent'. For notes, prefer AFFiNE's adaptive `--affine-note-background-<color>` family — `blue` / `purple` / `yellow` / `green` / `teal` / `red` / `orange` / `magenta` / `grey` / `white` / `black`. For specific per-theme colors, pass a `{light, dark}` hex object like `{light:'#fff', dark:'#252525'}`."),
|
|
4174
|
+
markdown: z.string().optional().describe("When type='note', parse this markdown into heading/paragraph/list/code child blocks inside the note (BlockSuite-native: mirrors what happens when you paste markdown into an edgeless note). Takes precedence over 'text' for note children. Ignored for other block types."),
|
|
4175
|
+
childElementIds: z.array(z.string()).optional().describe("For type='frame' only. The frame's contents. Accepts ids of surface elements (shapes/connectors/groups) AND edgeless blocks (notes/frames/edgeless-text) — BlockSuite's prop:childElementIds holds both, matching what the editor writes when you drag a note into a frame. Dragging the frame drags every owned member. Ids that don't resolve come back under 'missing'. When width/height are omitted the frame is sized to the union of resolvable child bounds + padding + a 30px title band."),
|
|
4176
|
+
stackAfter: z.object({
|
|
4177
|
+
blockId: z.union([z.string(), z.array(z.string())]).describe("Block(s) to stack relative to. String = a single anchor; array = pick whichever is furthest in the stack direction (bottommost for 'down', rightmost for 'right', etc.)."),
|
|
4178
|
+
direction: z.enum(["down", "up", "right", "left"]).optional().describe("Direction (default 'down')"),
|
|
4179
|
+
gap: z.number().int().optional().describe("Gap in px between the anchor and the new block. Default is direction-aware: 80 for left/right, 40 for down/up — mirrors native-flowchart spacing where the flow axis gets more breathing room than the cross axis. Explicit `padding` on the block overrides this default; explicit `gap` wins over both."),
|
|
4180
|
+
}).optional().describe("Layout helper — position this block relative to one or more existing edgeless blocks. Picks the furthest anchor in `direction` for the stack axis, and centers the new block on the anchor group's union on the orthogonal axis (matches how BlockSuite aligns selection-derived blocks; reduces to inherit-anchor-x when widths match). Caller-provided x/y on the orthogonal axis still wins. Works for frame/note/edgeless_text. Example: `stackAfter: { blockId: [f1, f2, f3], gap: 80 }` stacks below whichever column frame ends lowest, centered across all three. Note heights shift at first render (page-root grows with the title, content notes shrink/grow with their children); give extra gap and fix up with `update_edgeless_block` if the down/right chain drifts."),
|
|
4181
|
+
padding: z.number().int().optional().describe("Default padding (px) for `childElementIds` auto-sizing on frames (each side, plus +30px title band) and fallback gap for `stackAfter` (default 40)."),
|
|
2900
4182
|
sourceId: z.string().optional().describe("Blob source id for image/attachment"),
|
|
2901
4183
|
name: z.string().optional().describe("Attachment file name"),
|
|
2902
4184
|
mimeType: z.string().optional().describe("Attachment mime type"),
|
|
@@ -3002,8 +4284,86 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3002
4284
|
includeFrontmatter: z.boolean().optional(),
|
|
3003
4285
|
},
|
|
3004
4286
|
}, exportDocMarkdownHandler);
|
|
4287
|
+
const exportWithFidelityReportHandler = async (parsed) => {
|
|
4288
|
+
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
4289
|
+
if (!workspaceId) {
|
|
4290
|
+
throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
|
|
4291
|
+
}
|
|
4292
|
+
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
4293
|
+
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
4294
|
+
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
4295
|
+
try {
|
|
4296
|
+
await joinWorkspace(socket, workspaceId);
|
|
4297
|
+
let tagOptionsById = new Map();
|
|
4298
|
+
const workspaceSnapshot = await loadDoc(socket, workspaceId, workspaceId);
|
|
4299
|
+
if (workspaceSnapshot.missing) {
|
|
4300
|
+
const wsDoc = new Y.Doc();
|
|
4301
|
+
Y.applyUpdate(wsDoc, Buffer.from(workspaceSnapshot.missing, "base64"));
|
|
4302
|
+
tagOptionsById = getWorkspaceTagOptionMaps(wsDoc.getMap("meta")).byId;
|
|
4303
|
+
}
|
|
4304
|
+
const snapshot = await loadDoc(socket, workspaceId, parsed.docId);
|
|
4305
|
+
if (!snapshot.missing) {
|
|
4306
|
+
return text({
|
|
4307
|
+
docId: parsed.docId,
|
|
4308
|
+
exists: false,
|
|
4309
|
+
markdown: "",
|
|
4310
|
+
fidelity: {
|
|
4311
|
+
overallRisk: "high",
|
|
4312
|
+
recommendedPath: "prefer_native_read_or_clone",
|
|
4313
|
+
unsupportedBlocks: [],
|
|
4314
|
+
conditionallyRiskyBlocks: [],
|
|
4315
|
+
},
|
|
4316
|
+
});
|
|
4317
|
+
}
|
|
4318
|
+
const doc = new Y.Doc();
|
|
4319
|
+
Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
|
|
4320
|
+
const summary = summarizeDocFidelity(doc, tagOptionsById);
|
|
4321
|
+
let markdown = summary.markdown;
|
|
4322
|
+
if (parsed.includeFrontmatter) {
|
|
4323
|
+
const escapedTitle = (summary.title || "Untitled").replace(/\"/g, "\\\"");
|
|
4324
|
+
const frontmatterLines = [
|
|
4325
|
+
"---",
|
|
4326
|
+
`docId: \"${parsed.docId}\"`,
|
|
4327
|
+
`title: \"${escapedTitle}\"`,
|
|
4328
|
+
"tags:",
|
|
4329
|
+
...(summary.tags.length > 0 ? summary.tags.map(tag => ` - \"${tag.replace(/\"/g, "\\\"")}\"`) : [" -"]),
|
|
4330
|
+
`lossy: ${summary.markdownLossy ? "true" : "false"}`,
|
|
4331
|
+
`fidelityRisk: \"${summary.overallRisk}\"`,
|
|
4332
|
+
"---",
|
|
4333
|
+
];
|
|
4334
|
+
markdown = `${frontmatterLines.join("\n")}\n\n${markdown}`;
|
|
4335
|
+
}
|
|
4336
|
+
return text({
|
|
4337
|
+
docId: parsed.docId,
|
|
4338
|
+
exists: true,
|
|
4339
|
+
markdown,
|
|
4340
|
+
fidelity: {
|
|
4341
|
+
overallRisk: summary.overallRisk,
|
|
4342
|
+
recommendedPath: summary.recommendedPath,
|
|
4343
|
+
unsupportedBlocks: summary.unsupportedBlocks,
|
|
4344
|
+
conditionallyRiskyBlocks: summary.conditionallyRiskyBlocks,
|
|
4345
|
+
markdownWarnings: summary.markdownWarnings,
|
|
4346
|
+
markdownLossy: summary.markdownLossy,
|
|
4347
|
+
flavourCounts: summary.flavourCounts,
|
|
4348
|
+
stats: summary.stats,
|
|
4349
|
+
},
|
|
4350
|
+
});
|
|
4351
|
+
}
|
|
4352
|
+
finally {
|
|
4353
|
+
socket.disconnect();
|
|
4354
|
+
}
|
|
4355
|
+
};
|
|
4356
|
+
server.registerTool("export_with_fidelity_report", {
|
|
4357
|
+
title: "Export With Fidelity Report",
|
|
4358
|
+
description: "Export document markdown together with a structured fidelity report that highlights markdown loss risk and unsupported AFFiNE-native content.",
|
|
4359
|
+
inputSchema: {
|
|
4360
|
+
workspaceId: WorkspaceId.optional(),
|
|
4361
|
+
docId: DocId,
|
|
4362
|
+
includeFrontmatter: z.boolean().optional(),
|
|
4363
|
+
},
|
|
4364
|
+
}, exportWithFidelityReportHandler);
|
|
3005
4365
|
// Core logic for creating a doc from markdown — returns structured data, no MCP envelope.
|
|
3006
|
-
// Used by
|
|
4366
|
+
// Used by createDocFromMarkdownHandler and internal markdown-based flows.
|
|
3007
4367
|
const createDocFromMarkdownCore = async (parsed) => {
|
|
3008
4368
|
const parsedMarkdown = parseMarkdownToOperations(parsed.markdown);
|
|
3009
4369
|
let operations = [...parsedMarkdown.operations];
|
|
@@ -3035,36 +4395,23 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3035
4395
|
strict: parsed.strict,
|
|
3036
4396
|
});
|
|
3037
4397
|
}
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
workspaceId: created.workspaceId,
|
|
3045
|
-
docId: parsed.parentDocId,
|
|
3046
|
-
type: "embed_linked_doc",
|
|
3047
|
-
pageId: created.docId,
|
|
3048
|
-
});
|
|
3049
|
-
linkedToParent = true;
|
|
3050
|
-
}
|
|
3051
|
-
catch {
|
|
3052
|
-
// Non-fatal: doc was created, just not linked. Warn below.
|
|
3053
|
-
}
|
|
3054
|
-
}
|
|
4398
|
+
const placement = await finalizeDocPlacement({
|
|
4399
|
+
workspaceId: created.workspaceId,
|
|
4400
|
+
docId: created.docId,
|
|
4401
|
+
parentDocId: parsed.parentDocId,
|
|
4402
|
+
context: "create_doc_from_markdown",
|
|
4403
|
+
});
|
|
3055
4404
|
const applyWarnings = [];
|
|
3056
4405
|
if (applied.skippedCount > 0) {
|
|
3057
4406
|
applyWarnings.push(`${applied.skippedCount} markdown block(s) could not be applied to AFFiNE and were skipped.`);
|
|
3058
4407
|
}
|
|
3059
|
-
if (parsed.parentDocId && !linkedToParent) {
|
|
3060
|
-
applyWarnings.push(`Doc created but could not be linked to parent doc "${parsed.parentDocId}". Link it manually.`);
|
|
3061
|
-
}
|
|
3062
4408
|
return {
|
|
3063
4409
|
workspaceId: created.workspaceId,
|
|
3064
4410
|
docId: created.docId,
|
|
3065
4411
|
title: created.title,
|
|
3066
|
-
|
|
3067
|
-
|
|
4412
|
+
parentDocId: placement.parentDocId,
|
|
4413
|
+
linkedToParent: placement.linkedToParent,
|
|
4414
|
+
warnings: mergeWarnings(parsedMarkdown.warnings, applyWarnings, placement.warnings),
|
|
3068
4415
|
lossy: parsedMarkdown.lossy || applied.skippedCount > 0,
|
|
3069
4416
|
stats: {
|
|
3070
4417
|
parsedBlocks: parsedMarkdown.operations.length,
|
|
@@ -3074,7 +4421,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3074
4421
|
};
|
|
3075
4422
|
};
|
|
3076
4423
|
const createDocFromMarkdownHandler = async (parsed) => {
|
|
3077
|
-
return
|
|
4424
|
+
return receipt("doc.create_from_markdown", await createDocFromMarkdownCore(parsed));
|
|
3078
4425
|
};
|
|
3079
4426
|
server.registerTool("create_doc_from_markdown", {
|
|
3080
4427
|
title: "Create Document From Markdown",
|
|
@@ -3087,51 +4434,53 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3087
4434
|
parentDocId: z.string().optional().describe("If provided, the new doc is automatically embedded into this parent doc as a linked child (visible in sidebar)."),
|
|
3088
4435
|
},
|
|
3089
4436
|
}, createDocFromMarkdownHandler);
|
|
3090
|
-
|
|
3091
|
-
const batchCreateDocsHandler = async (parsed) => {
|
|
4437
|
+
const createDocFromTemplateCore = async (parsed) => {
|
|
3092
4438
|
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
3093
4439
|
if (!workspaceId)
|
|
3094
4440
|
throw new Error("workspaceId is required.");
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
}
|
|
3113
|
-
}
|
|
3114
|
-
results.push({ title: d.title, docId: d.docId, linkedToParent, warnings: d.warnings ?? [] });
|
|
4441
|
+
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
4442
|
+
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
4443
|
+
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
4444
|
+
try {
|
|
4445
|
+
await joinWorkspace(socket, workspaceId);
|
|
4446
|
+
const snap = await loadDoc(socket, workspaceId, parsed.templateDocId);
|
|
4447
|
+
if (!snap.missing)
|
|
4448
|
+
throw new Error(`Template doc ${parsed.templateDocId} not found.`);
|
|
4449
|
+
const doc = new Y.Doc();
|
|
4450
|
+
Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
|
|
4451
|
+
const collected = collectDocForMarkdown(doc, new Map());
|
|
4452
|
+
const rendered = renderBlocksToMarkdown({ rootBlockIds: collected.rootBlockIds, blocksById: collected.blocksById });
|
|
4453
|
+
let markdown = rendered.markdown;
|
|
4454
|
+
const vars = parsed.variables ?? {};
|
|
4455
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
4456
|
+
const pattern = new RegExp(`\\{\\{\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\}}`, "g");
|
|
4457
|
+
markdown = markdown.replace(pattern, value);
|
|
3115
4458
|
}
|
|
3116
|
-
|
|
3117
|
-
|
|
4459
|
+
const unfilled = [...markdown.matchAll(/\{\{\s*[\w.-]+\s*\}\}/g)].map(match => match[0]);
|
|
4460
|
+
socket.disconnect();
|
|
4461
|
+
const created = await createDocFromMarkdownCore({ workspaceId, title: parsed.title, markdown, parentDocId: parsed.parentDocId });
|
|
4462
|
+
return {
|
|
4463
|
+
workspaceId,
|
|
4464
|
+
sourceTemplateDocId: parsed.templateDocId,
|
|
4465
|
+
docId: created.docId,
|
|
4466
|
+
title: created.title,
|
|
4467
|
+
parentDocId: created.parentDocId,
|
|
4468
|
+
linkedToParent: created.linkedToParent,
|
|
4469
|
+
cloneMode: "markdown-roundtrip",
|
|
4470
|
+
lossy: Boolean(created.lossy ?? (created.warnings?.length ?? 0) > 0),
|
|
4471
|
+
warnings: created.warnings ?? [],
|
|
4472
|
+
stats: created.stats ?? null,
|
|
4473
|
+
unfilledVariables: unfilled,
|
|
4474
|
+
};
|
|
4475
|
+
}
|
|
4476
|
+
catch (err) {
|
|
4477
|
+
try {
|
|
4478
|
+
socket.disconnect();
|
|
3118
4479
|
}
|
|
4480
|
+
catch { /* already disconnected */ }
|
|
4481
|
+
throw err;
|
|
3119
4482
|
}
|
|
3120
|
-
const failed = results.filter(r => !r.docId).length;
|
|
3121
|
-
return text({ created: results.length - failed, failed, results });
|
|
3122
4483
|
};
|
|
3123
|
-
server.registerTool("batch_create_docs", {
|
|
3124
|
-
title: "Batch Create Documents",
|
|
3125
|
-
description: "Create multiple AFFiNE documents in a single call. Each doc can optionally be linked to a parent (parentDocId) to appear in the sidebar. Max 20 docs per batch.",
|
|
3126
|
-
inputSchema: {
|
|
3127
|
-
workspaceId: z.string().optional(),
|
|
3128
|
-
docs: z.array(z.object({
|
|
3129
|
-
title: z.string().describe("Document title."),
|
|
3130
|
-
markdown: z.string().describe("Markdown content."),
|
|
3131
|
-
parentDocId: z.string().optional().describe("Parent doc ID — if provided, the new doc is embedded under this parent in the sidebar."),
|
|
3132
|
-
})).min(1).max(20).describe("Array of docs to create (max 20)."),
|
|
3133
|
-
},
|
|
3134
|
-
}, batchCreateDocsHandler);
|
|
3135
4484
|
const appendMarkdownHandler = async (parsed) => {
|
|
3136
4485
|
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
3137
4486
|
if (!workspaceId) {
|
|
@@ -3148,7 +4497,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3148
4497
|
const applyWarnings = applied.skippedCount > 0
|
|
3149
4498
|
? [`${applied.skippedCount} markdown block(s) could not be applied to AFFiNE and were skipped.`]
|
|
3150
4499
|
: [];
|
|
3151
|
-
return
|
|
4500
|
+
return receipt("doc.append_markdown", {
|
|
3152
4501
|
workspaceId,
|
|
3153
4502
|
docId: parsed.docId,
|
|
3154
4503
|
appended: applied.appendedCount > 0,
|
|
@@ -3198,7 +4547,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3198
4547
|
const applyWarnings = applied.skippedCount > 0
|
|
3199
4548
|
? [`${applied.skippedCount} markdown block(s) could not be applied to AFFiNE and were skipped.`]
|
|
3200
4549
|
: [];
|
|
3201
|
-
return
|
|
4550
|
+
return receipt("doc.replace_with_markdown", {
|
|
3202
4551
|
workspaceId,
|
|
3203
4552
|
docId: parsed.docId,
|
|
3204
4553
|
replaced: true,
|
|
@@ -3255,7 +4604,11 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3255
4604
|
await pushDocUpdate(socket, workspaceId, workspaceId, Buffer.from(wsDelta).toString('base64'));
|
|
3256
4605
|
// delete doc content
|
|
3257
4606
|
wsDeleteDoc(socket, workspaceId, parsed.docId);
|
|
3258
|
-
return
|
|
4607
|
+
return receipt("doc.delete", {
|
|
4608
|
+
workspaceId,
|
|
4609
|
+
docId: parsed.docId,
|
|
4610
|
+
deleted: true,
|
|
4611
|
+
});
|
|
3259
4612
|
}
|
|
3260
4613
|
finally {
|
|
3261
4614
|
socket.disconnect();
|
|
@@ -3266,194 +4619,6 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3266
4619
|
description: 'Delete a document and remove from workspace list',
|
|
3267
4620
|
inputSchema: { workspaceId: z.string().optional(), docId: z.string() },
|
|
3268
4621
|
}, deleteDocHandler);
|
|
3269
|
-
// ─── cleanup_orphan_embeds ──────────────────────────────────────────────────
|
|
3270
|
-
const cleanupOrphanEmbedsHandler = async (parsed) => {
|
|
3271
|
-
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
3272
|
-
if (!workspaceId)
|
|
3273
|
-
throw new Error("workspaceId is required.");
|
|
3274
|
-
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
3275
|
-
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
3276
|
-
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
3277
|
-
try {
|
|
3278
|
-
await joinWorkspace(socket, workspaceId);
|
|
3279
|
-
const snap = await loadDoc(socket, workspaceId, parsed.docId);
|
|
3280
|
-
if (!snap.missing)
|
|
3281
|
-
throw new Error(`Doc ${parsed.docId} not found.`);
|
|
3282
|
-
const doc = new Y.Doc();
|
|
3283
|
-
Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
|
|
3284
|
-
const blocks = doc.getMap("blocks");
|
|
3285
|
-
const orphans = [];
|
|
3286
|
-
for (const [blockId, raw] of blocks) {
|
|
3287
|
-
if (!(raw instanceof Y.Map))
|
|
3288
|
-
continue;
|
|
3289
|
-
if (raw.get("sys:flavour") !== "affine:embed-linked-doc")
|
|
3290
|
-
continue;
|
|
3291
|
-
const targetId = raw.get("prop:pageId");
|
|
3292
|
-
if (typeof targetId !== "string" || !targetId) {
|
|
3293
|
-
orphans.push({ blockId, targetDocId: targetId ?? "" });
|
|
3294
|
-
continue;
|
|
3295
|
-
}
|
|
3296
|
-
const targetSnap = await loadDoc(socket, workspaceId, targetId);
|
|
3297
|
-
if (!targetSnap.missing)
|
|
3298
|
-
orphans.push({ blockId, targetDocId: targetId });
|
|
3299
|
-
}
|
|
3300
|
-
if (parsed.dryRun || orphans.length === 0) {
|
|
3301
|
-
return text({ docId: parsed.docId, dryRun: parsed.dryRun ?? false, orphansFound: orphans.length, orphans });
|
|
3302
|
-
}
|
|
3303
|
-
const prevSV = Y.encodeStateVector(doc);
|
|
3304
|
-
for (const { blockId } of orphans) {
|
|
3305
|
-
for (const [, parentRaw] of blocks) {
|
|
3306
|
-
if (!(parentRaw instanceof Y.Map))
|
|
3307
|
-
continue;
|
|
3308
|
-
const children = parentRaw.get("sys:children");
|
|
3309
|
-
if (!(children instanceof Y.Array))
|
|
3310
|
-
continue;
|
|
3311
|
-
const ids = childIdsFrom(children);
|
|
3312
|
-
const idx = ids.indexOf(blockId);
|
|
3313
|
-
if (idx !== -1) {
|
|
3314
|
-
children.delete(idx, 1);
|
|
3315
|
-
break;
|
|
3316
|
-
}
|
|
3317
|
-
}
|
|
3318
|
-
blocks.delete(blockId);
|
|
3319
|
-
}
|
|
3320
|
-
const delta = Y.encodeStateAsUpdate(doc, prevSV);
|
|
3321
|
-
await pushDocUpdate(socket, workspaceId, parsed.docId, Buffer.from(delta).toString("base64"));
|
|
3322
|
-
return text({ docId: parsed.docId, dryRun: false, orphansRemoved: orphans.length, orphans });
|
|
3323
|
-
}
|
|
3324
|
-
finally {
|
|
3325
|
-
socket.disconnect();
|
|
3326
|
-
}
|
|
3327
|
-
};
|
|
3328
|
-
server.registerTool("cleanup_orphan_embeds", {
|
|
3329
|
-
title: "Cleanup Orphan Embed Links",
|
|
3330
|
-
description: "Remove embed_linked_doc blocks that point to deleted/non-existent docs. Use dryRun=true to preview without making changes.",
|
|
3331
|
-
inputSchema: {
|
|
3332
|
-
workspaceId: z.string().optional(),
|
|
3333
|
-
docId: z.string().describe("The doc to clean up orphan embeds from."),
|
|
3334
|
-
dryRun: z.boolean().optional().describe("If true, only report orphans without deleting (default: false)."),
|
|
3335
|
-
},
|
|
3336
|
-
}, cleanupOrphanEmbedsHandler);
|
|
3337
|
-
// ─── find_and_replace ───────────────────────────────────────────────────────
|
|
3338
|
-
const findAndReplaceHandler = async (parsed) => {
|
|
3339
|
-
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
3340
|
-
if (!workspaceId)
|
|
3341
|
-
throw new Error("workspaceId is required.");
|
|
3342
|
-
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
3343
|
-
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
3344
|
-
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
3345
|
-
try {
|
|
3346
|
-
await joinWorkspace(socket, workspaceId);
|
|
3347
|
-
const snap = await loadDoc(socket, workspaceId, parsed.docId);
|
|
3348
|
-
if (!snap.missing)
|
|
3349
|
-
throw new Error(`Doc ${parsed.docId} not found.`);
|
|
3350
|
-
const doc = new Y.Doc();
|
|
3351
|
-
Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
|
|
3352
|
-
const blocks = doc.getMap("blocks");
|
|
3353
|
-
let totalMatches = 0;
|
|
3354
|
-
const matchLog = [];
|
|
3355
|
-
const matchAll = parsed.matchAll !== false;
|
|
3356
|
-
for (const [blockId, raw] of blocks) {
|
|
3357
|
-
if (!(raw instanceof Y.Map))
|
|
3358
|
-
continue;
|
|
3359
|
-
const flavour = raw.get("sys:flavour");
|
|
3360
|
-
for (const [, val] of raw) {
|
|
3361
|
-
if (!(val instanceof Y.Text))
|
|
3362
|
-
continue;
|
|
3363
|
-
const original = val.toString();
|
|
3364
|
-
if (!original.includes(parsed.search))
|
|
3365
|
-
continue;
|
|
3366
|
-
const replaced = matchAll
|
|
3367
|
-
? original.split(parsed.search).join(parsed.replace)
|
|
3368
|
-
: original.replace(parsed.search, parsed.replace);
|
|
3369
|
-
const count = matchAll ? original.split(parsed.search).length - 1 : 1;
|
|
3370
|
-
totalMatches += count;
|
|
3371
|
-
matchLog.push({ blockId, flavour: flavour ?? "unknown", original, replaced });
|
|
3372
|
-
if (!parsed.dryRun) {
|
|
3373
|
-
const prevSV = Y.encodeStateVector(doc);
|
|
3374
|
-
val.delete(0, val.length);
|
|
3375
|
-
val.insert(0, replaced);
|
|
3376
|
-
const delta = Y.encodeStateAsUpdate(doc, prevSV);
|
|
3377
|
-
await pushDocUpdate(socket, workspaceId, parsed.docId, Buffer.from(delta).toString("base64"));
|
|
3378
|
-
}
|
|
3379
|
-
}
|
|
3380
|
-
}
|
|
3381
|
-
return text({
|
|
3382
|
-
docId: parsed.docId, search: parsed.search, replace: parsed.replace,
|
|
3383
|
-
dryRun: parsed.dryRun ?? false, totalMatches, blocksAffected: matchLog.length, matches: matchLog,
|
|
3384
|
-
});
|
|
3385
|
-
}
|
|
3386
|
-
finally {
|
|
3387
|
-
socket.disconnect();
|
|
3388
|
-
}
|
|
3389
|
-
};
|
|
3390
|
-
server.registerTool("find_and_replace", {
|
|
3391
|
-
title: "Find and Replace in Document",
|
|
3392
|
-
description: "Find and replace text across all Y.Text fields in a document (paragraphs, headings, titles). matchAll defaults to true. Use dryRun=true to preview before applying.",
|
|
3393
|
-
inputSchema: {
|
|
3394
|
-
workspaceId: z.string().optional(),
|
|
3395
|
-
docId: z.string().describe("The doc to search in."),
|
|
3396
|
-
search: z.string().min(1).describe("Text to find (must not be empty)."),
|
|
3397
|
-
replace: z.string().describe("Replacement text."),
|
|
3398
|
-
matchAll: z.boolean().optional().describe("Replace all occurrences (default: true)."),
|
|
3399
|
-
dryRun: z.boolean().optional().describe("If true, only report matches without replacing (default: false)."),
|
|
3400
|
-
},
|
|
3401
|
-
}, findAndReplaceHandler);
|
|
3402
|
-
// ─── get_docs_by_tag ────────────────────────────────────────────────────────
|
|
3403
|
-
const getDocsByTagHandler = async (parsed) => {
|
|
3404
|
-
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
3405
|
-
if (!workspaceId)
|
|
3406
|
-
throw new Error("workspaceId is required.");
|
|
3407
|
-
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
3408
|
-
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
3409
|
-
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
3410
|
-
try {
|
|
3411
|
-
await joinWorkspace(socket, workspaceId);
|
|
3412
|
-
const wsSnap = await loadDoc(socket, workspaceId, workspaceId);
|
|
3413
|
-
if (!wsSnap.missing)
|
|
3414
|
-
return text({ tag: parsed.tag, count: 0, docs: [] });
|
|
3415
|
-
const wsDoc = new Y.Doc();
|
|
3416
|
-
Y.applyUpdate(wsDoc, Buffer.from(wsSnap.missing, "base64"));
|
|
3417
|
-
const meta = wsDoc.getMap("meta");
|
|
3418
|
-
const { byId, options } = getWorkspaceTagOptionMaps(meta);
|
|
3419
|
-
const q = parsed.tag.toLowerCase();
|
|
3420
|
-
const matchingTagIds = new Set(options.filter(o => o.value.toLowerCase().includes(q)).map(o => o.id));
|
|
3421
|
-
if (matchingTagIds.size === 0) {
|
|
3422
|
-
return text({
|
|
3423
|
-
tag: parsed.tag,
|
|
3424
|
-
count: 0,
|
|
3425
|
-
docs: [],
|
|
3426
|
-
availableTags: options.map(o => o.value),
|
|
3427
|
-
});
|
|
3428
|
-
}
|
|
3429
|
-
const pages = getWorkspacePageEntries(meta);
|
|
3430
|
-
const baseUrl = (process.env.AFFINE_BASE_URL || endpoint.replace(/\/graphql\/?$/, '')).replace(/\/$/, '');
|
|
3431
|
-
const matched = pages
|
|
3432
|
-
.map(p => {
|
|
3433
|
-
const rawTagIds = getStringArray(p.tagsArray);
|
|
3434
|
-
return { p, rawTagIds };
|
|
3435
|
-
})
|
|
3436
|
-
.filter(({ rawTagIds }) => rawTagIds.some(tid => matchingTagIds.has(tid)))
|
|
3437
|
-
.map(({ p, rawTagIds }) => ({
|
|
3438
|
-
docId: p.id,
|
|
3439
|
-
title: p.title ?? "Untitled",
|
|
3440
|
-
tags: resolveTagLabels(rawTagIds, byId),
|
|
3441
|
-
url: `${baseUrl}/workspace/${workspaceId}/${p.id}`,
|
|
3442
|
-
}));
|
|
3443
|
-
return text({ tag: parsed.tag, count: matched.length, docs: matched });
|
|
3444
|
-
}
|
|
3445
|
-
finally {
|
|
3446
|
-
socket.disconnect();
|
|
3447
|
-
}
|
|
3448
|
-
};
|
|
3449
|
-
server.registerTool("get_docs_by_tag", {
|
|
3450
|
-
title: "Get Documents by Tag",
|
|
3451
|
-
description: "Filter documents by tag name (case-insensitive substring match). Returns matching docs with their full tag list. If no match, also returns availableTags for discoverability.",
|
|
3452
|
-
inputSchema: {
|
|
3453
|
-
workspaceId: z.string().optional(),
|
|
3454
|
-
tag: z.string().describe("Tag name to filter by (substring match, case-insensitive)."),
|
|
3455
|
-
},
|
|
3456
|
-
}, getDocsByTagHandler);
|
|
3457
4622
|
// ─── list_workspace_tree ────────────────────────────────────────────────────
|
|
3458
4623
|
const listWorkspaceTreeHandler = async (parsed) => {
|
|
3459
4624
|
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
@@ -3668,7 +4833,12 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3668
4833
|
const delta = Y.encodeStateAsUpdate(doc, prevSV);
|
|
3669
4834
|
await pushDocUpdate(socket, workspaceId, parsed.docId, Buffer.from(delta).toString("base64"));
|
|
3670
4835
|
}
|
|
3671
|
-
return
|
|
4836
|
+
return receipt("doc.update_title", {
|
|
4837
|
+
workspaceId,
|
|
4838
|
+
updated: true,
|
|
4839
|
+
docId: parsed.docId,
|
|
4840
|
+
title: newTitle,
|
|
4841
|
+
});
|
|
3672
4842
|
}
|
|
3673
4843
|
finally {
|
|
3674
4844
|
socket.disconnect();
|
|
@@ -3683,212 +4853,344 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3683
4853
|
title: z.string().describe("New title."),
|
|
3684
4854
|
},
|
|
3685
4855
|
}, updateDocTitleHandler);
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
throw new Error("workspaceId is required.");
|
|
4856
|
+
async function syncRawTagsToDoc(parsed) {
|
|
4857
|
+
if (parsed.tags.length === 0) {
|
|
4858
|
+
return;
|
|
4859
|
+
}
|
|
3691
4860
|
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
3692
4861
|
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
3693
4862
|
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
3694
4863
|
try {
|
|
3695
|
-
await joinWorkspace(socket, workspaceId);
|
|
3696
|
-
const
|
|
3697
|
-
if (!
|
|
3698
|
-
|
|
4864
|
+
await joinWorkspace(socket, parsed.workspaceId);
|
|
4865
|
+
const workspaceSnapshot = await loadDoc(socket, parsed.workspaceId, parsed.workspaceId);
|
|
4866
|
+
if (!workspaceSnapshot.missing) {
|
|
4867
|
+
throw new Error(`Workspace root document not found for workspace ${parsed.workspaceId}`);
|
|
4868
|
+
}
|
|
3699
4869
|
const wsDoc = new Y.Doc();
|
|
3700
|
-
Y.applyUpdate(wsDoc, Buffer.from(
|
|
3701
|
-
const
|
|
3702
|
-
const
|
|
3703
|
-
const
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
const
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
const rendered = renderBlocksToMarkdown({ rootBlockIds: collected.rootBlockIds, blocksById: collected.blocksById });
|
|
3718
|
-
results.push({ docId: match.id, title: match.title, found: true, markdown: rendered.markdown,
|
|
3719
|
-
url: `${(process.env.AFFINE_BASE_URL || endpoint.replace(/\/graphql\/?$/, '')).replace(/\/$/, '')}/workspace/${workspaceId}/${match.id}` });
|
|
4870
|
+
Y.applyUpdate(wsDoc, Buffer.from(workspaceSnapshot.missing, "base64"));
|
|
4871
|
+
const wsPrevSV = Y.encodeStateVector(wsDoc);
|
|
4872
|
+
const wsMeta = wsDoc.getMap("meta");
|
|
4873
|
+
const page = getWorkspacePageEntries(wsMeta).find(entry => entry.id === parsed.docId);
|
|
4874
|
+
if (!page) {
|
|
4875
|
+
throw new Error(`docId ${parsed.docId} is not present in workspace ${parsed.workspaceId}`);
|
|
4876
|
+
}
|
|
4877
|
+
const pageTags = ensureTagArray(page.entry);
|
|
4878
|
+
pageTags.delete(0, pageTags.length);
|
|
4879
|
+
for (const tag of parsed.tags) {
|
|
4880
|
+
pageTags.push([tag]);
|
|
4881
|
+
}
|
|
4882
|
+
const wsDelta = Y.encodeStateAsUpdate(wsDoc, wsPrevSV);
|
|
4883
|
+
await pushDocUpdate(socket, parsed.workspaceId, parsed.workspaceId, Buffer.from(wsDelta).toString("base64"));
|
|
4884
|
+
const docSnapshot = await loadDoc(socket, parsed.workspaceId, parsed.docId);
|
|
4885
|
+
if (!docSnapshot.missing) {
|
|
4886
|
+
throw new Error(`Document ${parsed.docId} not found in workspace ${parsed.workspaceId}`);
|
|
3720
4887
|
}
|
|
3721
|
-
|
|
4888
|
+
const doc = new Y.Doc();
|
|
4889
|
+
Y.applyUpdate(doc, Buffer.from(docSnapshot.missing, "base64"));
|
|
4890
|
+
const docPrevSV = Y.encodeStateVector(doc);
|
|
4891
|
+
const docMeta = doc.getMap("meta");
|
|
4892
|
+
const docTags = ensureTagArray(docMeta);
|
|
4893
|
+
docTags.delete(0, docTags.length);
|
|
4894
|
+
for (const tag of parsed.tags) {
|
|
4895
|
+
docTags.push([tag]);
|
|
4896
|
+
}
|
|
4897
|
+
const docDelta = Y.encodeStateAsUpdate(doc, docPrevSV);
|
|
4898
|
+
await pushDocUpdate(socket, parsed.workspaceId, parsed.docId, Buffer.from(docDelta).toString("base64"));
|
|
3722
4899
|
}
|
|
3723
4900
|
finally {
|
|
3724
4901
|
socket.disconnect();
|
|
3725
4902
|
}
|
|
3726
|
-
}
|
|
3727
|
-
|
|
3728
|
-
title: "Get Document by Title",
|
|
3729
|
-
description: "Find a document by title and return its content as markdown in a single call. Combines search_docs + export_doc_markdown. Returns the first match by default; use limit for multiple.",
|
|
3730
|
-
inputSchema: {
|
|
3731
|
-
workspaceId: z.string().optional(),
|
|
3732
|
-
query: z.string().describe("Title search query (case-insensitive substring match)."),
|
|
3733
|
-
limit: z.number().optional().describe("Max docs to return with content (default: 1)."),
|
|
3734
|
-
},
|
|
3735
|
-
}, getDocByTitleHandler);
|
|
3736
|
-
// ─── list_backlinks ──────────────────────────────────────────────────────────
|
|
3737
|
-
const listBacklinksHandler = async (parsed) => {
|
|
4903
|
+
}
|
|
4904
|
+
const inspectTemplateStructureHandler = async (parsed) => {
|
|
3738
4905
|
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
3739
|
-
if (!workspaceId)
|
|
3740
|
-
throw new Error("workspaceId is required.");
|
|
4906
|
+
if (!workspaceId) {
|
|
4907
|
+
throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID.");
|
|
4908
|
+
}
|
|
3741
4909
|
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
3742
4910
|
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
3743
4911
|
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
3744
4912
|
try {
|
|
3745
4913
|
await joinWorkspace(socket, workspaceId);
|
|
3746
|
-
const
|
|
3747
|
-
if (!
|
|
3748
|
-
|
|
3749
|
-
const wsDoc = new Y.Doc();
|
|
3750
|
-
Y.applyUpdate(wsDoc, Buffer.from(wsSnap.missing, "base64"));
|
|
3751
|
-
const pages = getWorkspacePageEntries(wsDoc.getMap("meta"));
|
|
3752
|
-
const titleById = new Map(pages.map(p => [p.id, p.title]));
|
|
3753
|
-
const backlinks = [];
|
|
3754
|
-
for (const page of pages) {
|
|
3755
|
-
if (page.id === parsed.docId)
|
|
3756
|
-
continue;
|
|
3757
|
-
const snap = await loadDoc(socket, workspaceId, page.id);
|
|
3758
|
-
if (!snap.missing)
|
|
3759
|
-
continue;
|
|
3760
|
-
const doc = new Y.Doc();
|
|
3761
|
-
Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
|
|
3762
|
-
const blocks = doc.getMap("blocks");
|
|
3763
|
-
for (const [, raw] of blocks) {
|
|
3764
|
-
if (!(raw instanceof Y.Map))
|
|
3765
|
-
continue;
|
|
3766
|
-
if (raw.get("sys:flavour") === "affine:embed-linked-doc" && raw.get("prop:pageId") === parsed.docId) {
|
|
3767
|
-
backlinks.push({ docId: page.id, title: titleById.get(page.id) ?? null,
|
|
3768
|
-
url: `${(process.env.AFFINE_BASE_URL || endpoint.replace(/\/graphql\/?$/, '')).replace(/\/$/, '')}/workspace/${workspaceId}/${page.id}` });
|
|
3769
|
-
break;
|
|
3770
|
-
}
|
|
3771
|
-
}
|
|
4914
|
+
const workspaceSnapshot = await loadDoc(socket, workspaceId, workspaceId);
|
|
4915
|
+
if (!workspaceSnapshot.missing) {
|
|
4916
|
+
throw new Error(`Workspace root document not found for workspace ${workspaceId}`);
|
|
3772
4917
|
}
|
|
3773
|
-
|
|
4918
|
+
const workspaceDoc = new Y.Doc();
|
|
4919
|
+
Y.applyUpdate(workspaceDoc, Buffer.from(workspaceSnapshot.missing, "base64"));
|
|
4920
|
+
const tagOptionsById = getWorkspaceTagOptionMaps(workspaceDoc.getMap("meta")).byId;
|
|
4921
|
+
const templateSnapshot = await loadDoc(socket, workspaceId, parsed.templateDocId);
|
|
4922
|
+
if (!templateSnapshot.missing) {
|
|
4923
|
+
throw new Error(`Template doc ${parsed.templateDocId} not found.`);
|
|
4924
|
+
}
|
|
4925
|
+
const templateDoc = new Y.Doc();
|
|
4926
|
+
Y.applyUpdate(templateDoc, Buffer.from(templateSnapshot.missing, "base64"));
|
|
4927
|
+
const meta = templateDoc.getMap("meta");
|
|
4928
|
+
const rawTags = getStringArray(getTagArray(meta));
|
|
4929
|
+
const resolvedTags = resolveTagLabels(rawTags, tagOptionsById);
|
|
4930
|
+
const supportIssues = [];
|
|
4931
|
+
scanNativeTemplateValue(meta, "meta", supportIssues, new WeakSet());
|
|
4932
|
+
scanNativeTemplateValue(templateDoc.getMap("blocks"), "blocks", supportIssues, new WeakSet());
|
|
4933
|
+
return text(summarizeNativeTemplateStructure(templateDoc, workspaceId, parsed.templateDocId, resolvedTags, supportIssues));
|
|
3774
4934
|
}
|
|
3775
4935
|
finally {
|
|
3776
4936
|
socket.disconnect();
|
|
3777
4937
|
}
|
|
3778
4938
|
};
|
|
3779
|
-
server.registerTool("
|
|
3780
|
-
title: "
|
|
3781
|
-
description: "
|
|
4939
|
+
server.registerTool("inspect_template_structure", {
|
|
4940
|
+
title: "Inspect Template Structure",
|
|
4941
|
+
description: "Inspect a template doc's native structure, tags, and fallback risk before instantiation.",
|
|
3782
4942
|
inputSchema: {
|
|
3783
|
-
workspaceId:
|
|
3784
|
-
|
|
4943
|
+
workspaceId: WorkspaceId.optional(),
|
|
4944
|
+
templateDocId: z.string().describe("The template doc to inspect."),
|
|
3785
4945
|
},
|
|
3786
|
-
},
|
|
3787
|
-
|
|
3788
|
-
const duplicateDocHandler = async (parsed) => {
|
|
4946
|
+
}, inspectTemplateStructureHandler);
|
|
4947
|
+
const instantiateTemplateNativeHandler = async (parsed) => {
|
|
3789
4948
|
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
3790
|
-
if (!workspaceId)
|
|
3791
|
-
throw new Error("workspaceId is required.");
|
|
4949
|
+
if (!workspaceId) {
|
|
4950
|
+
throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID.");
|
|
4951
|
+
}
|
|
4952
|
+
const allowFallback = parsed.allowFallback !== false;
|
|
4953
|
+
const preserveTags = parsed.preserveTags !== false;
|
|
3792
4954
|
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
3793
4955
|
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
3794
4956
|
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
3795
4957
|
try {
|
|
3796
4958
|
await joinWorkspace(socket, workspaceId);
|
|
3797
|
-
const
|
|
3798
|
-
if (!
|
|
3799
|
-
throw new Error(`
|
|
3800
|
-
const doc = new Y.Doc();
|
|
3801
|
-
Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
|
|
3802
|
-
const collected = collectDocForMarkdown(doc, new Map());
|
|
3803
|
-
const rendered = renderBlocksToMarkdown({ rootBlockIds: collected.rootBlockIds, blocksById: collected.blocksById });
|
|
3804
|
-
const newTitle = (parsed.title ?? `${collected.title || "Untitled"} (copy)`).trim();
|
|
3805
|
-
socket.disconnect();
|
|
3806
|
-
const r = await createDocFromMarkdownHandler({ workspaceId, title: newTitle, markdown: rendered.markdown });
|
|
3807
|
-
const created = JSON.parse(r.content[0].text);
|
|
3808
|
-
let linkedToParent = false;
|
|
3809
|
-
if (parsed.parentDocId && created.docId) {
|
|
3810
|
-
try {
|
|
3811
|
-
await appendBlockInternal({ workspaceId, docId: parsed.parentDocId, type: "embed_linked_doc", pageId: created.docId });
|
|
3812
|
-
linkedToParent = true;
|
|
3813
|
-
}
|
|
3814
|
-
catch { /* non-fatal */ }
|
|
4959
|
+
const workspaceSnapshot = await loadDoc(socket, workspaceId, workspaceId);
|
|
4960
|
+
if (!workspaceSnapshot.missing) {
|
|
4961
|
+
throw new Error(`Workspace root document not found for workspace ${workspaceId}`);
|
|
3815
4962
|
}
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
4963
|
+
const workspaceDoc = new Y.Doc();
|
|
4964
|
+
Y.applyUpdate(workspaceDoc, Buffer.from(workspaceSnapshot.missing, "base64"));
|
|
4965
|
+
const workspaceMeta = workspaceDoc.getMap("meta");
|
|
4966
|
+
const tagOptionById = getWorkspaceTagOptionMaps(workspaceMeta).byId;
|
|
4967
|
+
const sourcePage = getWorkspacePageEntries(workspaceMeta).find(entry => entry.id === parsed.templateDocId);
|
|
4968
|
+
if (!sourcePage) {
|
|
4969
|
+
throw new Error(`Template doc ${parsed.templateDocId} was not found in workspace ${workspaceId}.`);
|
|
3821
4970
|
}
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
}
|
|
3825
|
-
};
|
|
3826
|
-
server.registerTool("duplicate_doc", {
|
|
3827
|
-
title: "Duplicate Document",
|
|
3828
|
-
description: "Clone a document by copying its markdown content into a new doc. Optionally set a new title and/or parentDocId to place it in the sidebar.",
|
|
3829
|
-
inputSchema: {
|
|
3830
|
-
workspaceId: z.string().optional(),
|
|
3831
|
-
docId: z.string().describe("The source doc to duplicate."),
|
|
3832
|
-
title: z.string().optional().describe("Title for the new doc. Defaults to '<original title> (copy)'."),
|
|
3833
|
-
parentDocId: z.string().optional().describe("Parent doc to link the new doc under in the sidebar."),
|
|
3834
|
-
},
|
|
3835
|
-
}, duplicateDocHandler);
|
|
3836
|
-
// ─── create_doc_from_template ───────────────────────────────────────────────
|
|
3837
|
-
const createDocFromTemplateHandler = async (parsed) => {
|
|
3838
|
-
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
3839
|
-
if (!workspaceId)
|
|
3840
|
-
throw new Error("workspaceId is required.");
|
|
3841
|
-
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
3842
|
-
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
3843
|
-
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
3844
|
-
try {
|
|
3845
|
-
await joinWorkspace(socket, workspaceId);
|
|
3846
|
-
const snap = await loadDoc(socket, workspaceId, parsed.templateDocId);
|
|
3847
|
-
if (!snap.missing)
|
|
4971
|
+
const templateSnapshot = await loadDoc(socket, workspaceId, parsed.templateDocId);
|
|
4972
|
+
if (!templateSnapshot.missing) {
|
|
3848
4973
|
throw new Error(`Template doc ${parsed.templateDocId} not found.`);
|
|
3849
|
-
const doc = new Y.Doc();
|
|
3850
|
-
Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
|
|
3851
|
-
const collected = collectDocForMarkdown(doc, new Map());
|
|
3852
|
-
const rendered = renderBlocksToMarkdown({ rootBlockIds: collected.rootBlockIds, blocksById: collected.blocksById });
|
|
3853
|
-
let markdown = rendered.markdown;
|
|
3854
|
-
const vars = parsed.variables ?? {};
|
|
3855
|
-
for (const [key, value] of Object.entries(vars)) {
|
|
3856
|
-
const pattern = new RegExp(`\\{\\{\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\}}`, "g");
|
|
3857
|
-
markdown = markdown.replace(pattern, value);
|
|
3858
4974
|
}
|
|
3859
|
-
const
|
|
3860
|
-
|
|
3861
|
-
const
|
|
3862
|
-
const
|
|
4975
|
+
const templateDoc = new Y.Doc();
|
|
4976
|
+
Y.applyUpdate(templateDoc, Buffer.from(templateSnapshot.missing, "base64"));
|
|
4977
|
+
const templateMeta = templateDoc.getMap("meta");
|
|
4978
|
+
const templateBlocks = templateDoc.getMap("blocks");
|
|
4979
|
+
const rawTags = getStringArray(sourcePage.tagsArray);
|
|
4980
|
+
const resolvedTags = resolveTagLabels(rawTags, tagOptionById);
|
|
4981
|
+
const supportIssues = [];
|
|
4982
|
+
scanNativeTemplateValue(templateMeta, "meta", supportIssues, new WeakSet());
|
|
4983
|
+
scanNativeTemplateValue(templateBlocks, "blocks", supportIssues, new WeakSet());
|
|
4984
|
+
const nativeSummary = summarizeNativeTemplateStructure(templateDoc, workspaceId, parsed.templateDocId, resolvedTags, supportIssues);
|
|
4985
|
+
const sourceTitle = nativeSummary.title || sourcePage.title || "Untitled";
|
|
4986
|
+
const targetTitle = (parsed.title ?? sourceTitle).trim() || sourceTitle;
|
|
4987
|
+
if (!nativeSummary.nativeCloneSupported) {
|
|
4988
|
+
if (!allowFallback) {
|
|
4989
|
+
throw new Error(`Native template instantiation is not supported: ${nativeSummary.fallbackReasons.join(" | ")}`);
|
|
4990
|
+
}
|
|
4991
|
+
const created = await createDocFromTemplateCore({
|
|
4992
|
+
workspaceId,
|
|
4993
|
+
templateDocId: parsed.templateDocId,
|
|
4994
|
+
title: targetTitle,
|
|
4995
|
+
variables: parsed.variables,
|
|
4996
|
+
parentDocId: parsed.parentDocId,
|
|
4997
|
+
});
|
|
4998
|
+
return text({
|
|
4999
|
+
...created,
|
|
5000
|
+
mode: "markdown_fallback",
|
|
5001
|
+
nativeCloneSupported: false,
|
|
5002
|
+
warnings: mergeWarnings(created.warnings ?? [], nativeSummary.fallbackReasons, ["Native template instantiation fell back to markdown materialization."]),
|
|
5003
|
+
});
|
|
5004
|
+
}
|
|
5005
|
+
const created = await createDocInternal({
|
|
5006
|
+
workspaceId,
|
|
5007
|
+
title: targetTitle,
|
|
5008
|
+
});
|
|
5009
|
+
const targetSnapshot = await loadDoc(socket, workspaceId, created.docId);
|
|
5010
|
+
if (!targetSnapshot.missing) {
|
|
5011
|
+
throw new Error(`Created doc ${created.docId} was not found for native template instantiation.`);
|
|
5012
|
+
}
|
|
5013
|
+
const targetDoc = new Y.Doc();
|
|
5014
|
+
Y.applyUpdate(targetDoc, Buffer.from(targetSnapshot.missing, "base64"));
|
|
5015
|
+
const prevSV = Y.encodeStateVector(targetDoc);
|
|
5016
|
+
const targetMeta = targetDoc.getMap("meta");
|
|
5017
|
+
const targetBlocks = targetDoc.getMap("blocks");
|
|
5018
|
+
const blockIdMap = new Map();
|
|
5019
|
+
for (const [sourceBlockId] of templateBlocks) {
|
|
5020
|
+
blockIdMap.set(String(sourceBlockId), generateId());
|
|
5021
|
+
}
|
|
5022
|
+
const cloneContext = {
|
|
5023
|
+
sourceDocId: parsed.templateDocId,
|
|
5024
|
+
targetDocId: created.docId,
|
|
5025
|
+
blockIdMap,
|
|
5026
|
+
variables: parsed.variables ?? {},
|
|
5027
|
+
unresolvedVariables: new Set(),
|
|
5028
|
+
replacedVariableCount: 0,
|
|
5029
|
+
};
|
|
5030
|
+
for (const [existingBlockId] of targetBlocks) {
|
|
5031
|
+
targetBlocks.delete(String(existingBlockId));
|
|
5032
|
+
}
|
|
5033
|
+
for (const [sourceBlockId, rawBlock] of templateBlocks) {
|
|
5034
|
+
if (!(rawBlock instanceof Y.Map)) {
|
|
5035
|
+
continue;
|
|
5036
|
+
}
|
|
5037
|
+
const nextBlockId = blockIdMap.get(String(sourceBlockId));
|
|
5038
|
+
const cloned = cloneNativeTemplateValue(rawBlock, cloneContext, `blocks.${String(sourceBlockId)}`);
|
|
5039
|
+
cloned.set("sys:id", nextBlockId);
|
|
5040
|
+
targetBlocks.set(nextBlockId, cloned);
|
|
5041
|
+
}
|
|
5042
|
+
targetMeta.set("id", created.docId);
|
|
5043
|
+
targetMeta.set("title", targetTitle);
|
|
5044
|
+
if (preserveTags) {
|
|
5045
|
+
const targetMetaTags = ensureTagArray(targetMeta);
|
|
5046
|
+
targetMetaTags.delete(0, targetMetaTags.length);
|
|
5047
|
+
for (const tag of rawTags) {
|
|
5048
|
+
targetMetaTags.push([tag]);
|
|
5049
|
+
}
|
|
5050
|
+
}
|
|
5051
|
+
const pageId = findBlockIdByFlavour(targetBlocks, "affine:page");
|
|
5052
|
+
if (pageId) {
|
|
5053
|
+
const pageBlock = findBlockById(targetBlocks, pageId);
|
|
5054
|
+
if (pageBlock) {
|
|
5055
|
+
pageBlock.set("prop:title", makeText(targetTitle));
|
|
5056
|
+
}
|
|
5057
|
+
}
|
|
5058
|
+
const delta = Y.encodeStateAsUpdate(targetDoc, prevSV);
|
|
5059
|
+
await pushDocUpdate(socket, workspaceId, created.docId, Buffer.from(delta).toString("base64"));
|
|
5060
|
+
if (preserveTags && rawTags.length > 0) {
|
|
5061
|
+
await syncRawTagsToDoc({
|
|
5062
|
+
workspaceId,
|
|
5063
|
+
docId: created.docId,
|
|
5064
|
+
tags: rawTags,
|
|
5065
|
+
});
|
|
5066
|
+
}
|
|
3863
5067
|
let linkedToParent = false;
|
|
3864
|
-
|
|
5068
|
+
const warnings = [];
|
|
5069
|
+
if (parsed.parentDocId) {
|
|
3865
5070
|
try {
|
|
3866
|
-
await appendBlockInternal({
|
|
5071
|
+
await appendBlockInternal({
|
|
5072
|
+
workspaceId,
|
|
5073
|
+
docId: parsed.parentDocId,
|
|
5074
|
+
type: "embed_linked_doc",
|
|
5075
|
+
pageId: created.docId,
|
|
5076
|
+
});
|
|
3867
5077
|
linkedToParent = true;
|
|
3868
5078
|
}
|
|
3869
|
-
catch {
|
|
5079
|
+
catch (err) {
|
|
5080
|
+
warnings.push(`Doc created but could not be linked to parent "${parsed.parentDocId}": ${err?.message ?? "unknown error"}`);
|
|
5081
|
+
}
|
|
3870
5082
|
}
|
|
3871
|
-
return text({
|
|
5083
|
+
return text({
|
|
5084
|
+
workspaceId,
|
|
5085
|
+
sourceTemplateDocId: parsed.templateDocId,
|
|
5086
|
+
docId: created.docId,
|
|
5087
|
+
title: targetTitle,
|
|
5088
|
+
mode: "native",
|
|
5089
|
+
nativeCloneSupported: true,
|
|
5090
|
+
linkedToParent,
|
|
5091
|
+
preservedTags: preserveTags ? resolvedTags : [],
|
|
5092
|
+
replacedVariableCount: cloneContext.replacedVariableCount,
|
|
5093
|
+
unresolvedVariables: Array.from(cloneContext.unresolvedVariables),
|
|
5094
|
+
warnings,
|
|
5095
|
+
blockCount: nativeSummary.blockCount,
|
|
5096
|
+
rootBlockIds: nativeSummary.rootBlockIds.map(blockId => blockIdMap.get(blockId) ?? blockId),
|
|
5097
|
+
});
|
|
3872
5098
|
}
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
socket.disconnect();
|
|
3876
|
-
}
|
|
3877
|
-
catch { /* already disconnected */ }
|
|
3878
|
-
throw err;
|
|
5099
|
+
finally {
|
|
5100
|
+
socket.disconnect();
|
|
3879
5101
|
}
|
|
3880
5102
|
};
|
|
3881
|
-
server.registerTool("
|
|
3882
|
-
title: "
|
|
3883
|
-
description: "
|
|
5103
|
+
server.registerTool("instantiate_template_native", {
|
|
5104
|
+
title: "Instantiate Template Natively",
|
|
5105
|
+
description: "Instantiate a template using native AFFiNE block cloning when supported, falling back to markdown materialization only when necessary.",
|
|
3884
5106
|
inputSchema: {
|
|
3885
|
-
workspaceId:
|
|
3886
|
-
templateDocId: z.string().describe("The template doc to
|
|
3887
|
-
title: z.string().describe("
|
|
3888
|
-
variables: z.record(z.string(), z.string()).optional().describe("Key-value map of {{variable}} substitutions."),
|
|
3889
|
-
parentDocId: z.string().optional().describe("
|
|
5107
|
+
workspaceId: WorkspaceId.optional(),
|
|
5108
|
+
templateDocId: z.string().describe("The template doc to instantiate."),
|
|
5109
|
+
title: z.string().optional().describe("Optional title for the new document. Defaults to the template title."),
|
|
5110
|
+
variables: z.record(z.string(), z.string()).optional().describe("Key-value map of {{variable}} substitutions applied during cloning."),
|
|
5111
|
+
parentDocId: z.string().optional().describe("Optional parent doc to link the instantiated doc under in the sidebar."),
|
|
5112
|
+
allowFallback: z.boolean().optional().describe("If false, fail instead of falling back to markdown materialization when native cloning is unsupported."),
|
|
5113
|
+
preserveTags: z.boolean().optional().describe("If true (default), copy the template's tags onto the instantiated doc."),
|
|
3890
5114
|
},
|
|
3891
|
-
},
|
|
5115
|
+
}, instantiateTemplateNativeHandler);
|
|
5116
|
+
function buildDatabaseIntentPreset(intent) {
|
|
5117
|
+
switch (intent) {
|
|
5118
|
+
case "task_board":
|
|
5119
|
+
return {
|
|
5120
|
+
title: "Task Board",
|
|
5121
|
+
viewName: "Task Board",
|
|
5122
|
+
statusOptions: ["Todo", "In Progress", "Blocked", "Done"],
|
|
5123
|
+
extraColumns: [
|
|
5124
|
+
{ name: "Type", type: "select", options: ["Task", "Bug", "Chore"], width: 140 },
|
|
5125
|
+
{ name: "Priority", type: "select", options: ["P0", "P1", "P2", "P3"], width: 120 },
|
|
5126
|
+
{ name: "Owner", type: "rich-text", width: 180 },
|
|
5127
|
+
{ name: "Due Date", type: "date", width: 160 },
|
|
5128
|
+
],
|
|
5129
|
+
starterRows: [
|
|
5130
|
+
{
|
|
5131
|
+
title: "Define the scope",
|
|
5132
|
+
Status: "Todo",
|
|
5133
|
+
Type: "Task",
|
|
5134
|
+
Priority: "P1",
|
|
5135
|
+
Owner: "Product",
|
|
5136
|
+
},
|
|
5137
|
+
{
|
|
5138
|
+
title: "Build the first pass",
|
|
5139
|
+
Status: "In Progress",
|
|
5140
|
+
Type: "Task",
|
|
5141
|
+
Priority: "P1",
|
|
5142
|
+
Owner: "Engineering",
|
|
5143
|
+
},
|
|
5144
|
+
{
|
|
5145
|
+
title: "Review and ship",
|
|
5146
|
+
Status: "Done",
|
|
5147
|
+
Type: "Chore",
|
|
5148
|
+
Priority: "P2",
|
|
5149
|
+
Owner: "Delivery",
|
|
5150
|
+
},
|
|
5151
|
+
],
|
|
5152
|
+
};
|
|
5153
|
+
case "issue_tracker":
|
|
5154
|
+
return {
|
|
5155
|
+
title: "Issue Tracker",
|
|
5156
|
+
viewName: "Issue Tracker",
|
|
5157
|
+
statusOptions: ["Open", "In Progress", "In Review", "Blocked", "Resolved", "Closed"],
|
|
5158
|
+
extraColumns: [
|
|
5159
|
+
{ name: "Type", type: "select", options: ["Bug", "Feature", "Task", "Incident"], width: 140 },
|
|
5160
|
+
{ name: "Priority", type: "select", options: ["P0", "P1", "P2", "P3"], width: 120 },
|
|
5161
|
+
{ name: "Assignee", type: "rich-text", width: 180 },
|
|
5162
|
+
{ name: "Due Date", type: "date", width: 160 },
|
|
5163
|
+
],
|
|
5164
|
+
starterRows: [
|
|
5165
|
+
{
|
|
5166
|
+
title: "Document reproduction steps",
|
|
5167
|
+
Status: "Open",
|
|
5168
|
+
Type: "Bug",
|
|
5169
|
+
Priority: "P0",
|
|
5170
|
+
Assignee: "Unassigned",
|
|
5171
|
+
},
|
|
5172
|
+
{
|
|
5173
|
+
title: "Fix the regression",
|
|
5174
|
+
Status: "In Progress",
|
|
5175
|
+
Type: "Bug",
|
|
5176
|
+
Priority: "P1",
|
|
5177
|
+
Assignee: "Engineering",
|
|
5178
|
+
},
|
|
5179
|
+
{
|
|
5180
|
+
title: "Verify the release candidate",
|
|
5181
|
+
Status: "Resolved",
|
|
5182
|
+
Type: "Task",
|
|
5183
|
+
Priority: "P2",
|
|
5184
|
+
Assignee: "QA",
|
|
5185
|
+
},
|
|
5186
|
+
],
|
|
5187
|
+
};
|
|
5188
|
+
default: {
|
|
5189
|
+
const exhaustiveCheck = intent;
|
|
5190
|
+
throw new Error(`Unsupported database intent '${exhaustiveCheck}'`);
|
|
5191
|
+
}
|
|
5192
|
+
}
|
|
5193
|
+
}
|
|
3892
5194
|
/** Read column definitions including select options from a database block */
|
|
3893
5195
|
function readColumnDefs(dbBlock) {
|
|
3894
5196
|
const columnsRaw = dbBlock.get("prop:columns");
|
|
@@ -4043,6 +5345,36 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4043
5345
|
cellsMap.set(rowBlockId, rowCells);
|
|
4044
5346
|
return rowCells;
|
|
4045
5347
|
}
|
|
5348
|
+
function addDatabaseRowToBlock(parsed) {
|
|
5349
|
+
const rowBlockId = generateId();
|
|
5350
|
+
const rowBlock = new Y.Map();
|
|
5351
|
+
setSysFields(rowBlock, rowBlockId, "affine:paragraph");
|
|
5352
|
+
rowBlock.set("sys:parent", parsed.databaseBlockId);
|
|
5353
|
+
rowBlock.set("sys:children", new Y.Array());
|
|
5354
|
+
rowBlock.set("prop:type", "text");
|
|
5355
|
+
if (parsed.linkedDocId) {
|
|
5356
|
+
rowBlock.set("prop:text", makeLinkedDocText(parsed.linkedDocId));
|
|
5357
|
+
}
|
|
5358
|
+
else {
|
|
5359
|
+
const titleValue = resolveDatabaseTitleValue(parsed.cells, parsed.lookup);
|
|
5360
|
+
rowBlock.set("prop:text", makeText(String(titleValue)));
|
|
5361
|
+
}
|
|
5362
|
+
parsed.blocks.set(rowBlockId, rowBlock);
|
|
5363
|
+
const dbChildren = ensureChildrenArray(parsed.dbBlock);
|
|
5364
|
+
dbChildren.push([rowBlockId]);
|
|
5365
|
+
const rowCells = ensureDatabaseRowCells(parsed.cellsMap, rowBlockId);
|
|
5366
|
+
for (const [key, value] of Object.entries(parsed.cells)) {
|
|
5367
|
+
const col = findDatabaseColumn(key, parsed.lookup);
|
|
5368
|
+
if (!col) {
|
|
5369
|
+
if (isTitleAliasKey(key)) {
|
|
5370
|
+
continue;
|
|
5371
|
+
}
|
|
5372
|
+
throw new Error(`Column '${key}' not found. Available columns: ${availableDatabaseColumns(parsed.lookup)}`);
|
|
5373
|
+
}
|
|
5374
|
+
writeDatabaseCellValue(rowCells, col, value, true);
|
|
5375
|
+
}
|
|
5376
|
+
return rowBlockId;
|
|
5377
|
+
}
|
|
4046
5378
|
function getDatabaseRowBlock(blocks, dbBlock, databaseBlockId, rowBlockId) {
|
|
4047
5379
|
const rowBlock = findBlockById(blocks, rowBlockId);
|
|
4048
5380
|
if (!rowBlock) {
|
|
@@ -4465,56 +5797,6 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4465
5797
|
databaseBlockId: z.string().min(1).describe("Block ID of the affine:database block"),
|
|
4466
5798
|
},
|
|
4467
5799
|
}, readDatabaseColumnsHandler);
|
|
4468
|
-
const updateDatabaseCellHandler = async (parsed) => {
|
|
4469
|
-
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
4470
|
-
if (!workspaceId)
|
|
4471
|
-
throw new Error("workspaceId is required");
|
|
4472
|
-
const ctx = await loadDatabaseDocContext(workspaceId, parsed.docId, parsed.databaseBlockId);
|
|
4473
|
-
try {
|
|
4474
|
-
const rowBlock = getDatabaseRowBlock(ctx.blocks, ctx.dbBlock, parsed.databaseBlockId, parsed.rowBlockId);
|
|
4475
|
-
const rowCells = ensureDatabaseRowCells(ctx.cellsMap, parsed.rowBlockId);
|
|
4476
|
-
const col = findDatabaseColumn(parsed.column, ctx);
|
|
4477
|
-
if (!col) {
|
|
4478
|
-
if (!isTitleAliasKey(parsed.column)) {
|
|
4479
|
-
throw new Error(`Column '${parsed.column}' not found. Available columns: ${availableDatabaseColumns(ctx)}`);
|
|
4480
|
-
}
|
|
4481
|
-
}
|
|
4482
|
-
else {
|
|
4483
|
-
writeDatabaseCellValue(rowCells, col, parsed.value, parsed.createOption ?? true);
|
|
4484
|
-
}
|
|
4485
|
-
if (parsed.linkedDocId) {
|
|
4486
|
-
rowBlock.set("prop:text", makeLinkedDocText(parsed.linkedDocId));
|
|
4487
|
-
}
|
|
4488
|
-
else if (isTitleAliasKey(parsed.column) || (col && (col.type === "title" || isTitleAliasKey(col.name)))) {
|
|
4489
|
-
rowBlock.set("prop:text", makeText(String(parsed.value ?? "")));
|
|
4490
|
-
}
|
|
4491
|
-
const delta = Y.encodeStateAsUpdate(ctx.doc, ctx.prevSV);
|
|
4492
|
-
await pushDocUpdate(ctx.socket, workspaceId, parsed.docId, Buffer.from(delta).toString("base64"));
|
|
4493
|
-
return text({
|
|
4494
|
-
updated: true,
|
|
4495
|
-
rowBlockId: parsed.rowBlockId,
|
|
4496
|
-
column: parsed.column,
|
|
4497
|
-
value: parsed.value ?? null,
|
|
4498
|
-
});
|
|
4499
|
-
}
|
|
4500
|
-
finally {
|
|
4501
|
-
ctx.socket.disconnect();
|
|
4502
|
-
}
|
|
4503
|
-
};
|
|
4504
|
-
server.registerTool("update_database_cell", {
|
|
4505
|
-
title: "Update Database Cell",
|
|
4506
|
-
description: "Update a single cell on an existing AFFiNE database row. Use `title` to update the row title shown in Kanban card headers.",
|
|
4507
|
-
inputSchema: {
|
|
4508
|
-
workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
|
|
4509
|
-
docId: DocId.describe("Document ID containing the database"),
|
|
4510
|
-
databaseBlockId: z.string().min(1).describe("Block ID of the affine:database block"),
|
|
4511
|
-
rowBlockId: z.string().min(1).describe("Row paragraph block ID"),
|
|
4512
|
-
column: z.string().min(1).describe("Column name or ID. Use `title` for the built-in row title."),
|
|
4513
|
-
value: z.unknown().describe("New cell value"),
|
|
4514
|
-
createOption: z.boolean().optional().describe("For select and multi-select columns, create the option label if it does not exist (default true)"),
|
|
4515
|
-
linkedDocId: z.string().optional().describe("Link this row to an existing doc by ID. Replaces any existing title with a linked doc reference."),
|
|
4516
|
-
},
|
|
4517
|
-
}, updateDatabaseCellHandler);
|
|
4518
5800
|
const updateDatabaseRowHandler = async (parsed) => {
|
|
4519
5801
|
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
4520
5802
|
if (!workspaceId)
|
|
@@ -4569,6 +5851,109 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4569
5851
|
linkedDocId: z.string().optional().describe("Link this row to an existing doc by ID. The row will open the linked doc in center peek when clicked."),
|
|
4570
5852
|
},
|
|
4571
5853
|
}, updateDatabaseRowHandler);
|
|
5854
|
+
const composeDatabaseFromIntentCore = async (parsed) => {
|
|
5855
|
+
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
5856
|
+
if (!workspaceId) {
|
|
5857
|
+
throw new Error("workspaceId is required");
|
|
5858
|
+
}
|
|
5859
|
+
const preset = buildDatabaseIntentPreset(parsed.intent);
|
|
5860
|
+
const title = (parsed.title ?? preset.title).trim() || preset.title;
|
|
5861
|
+
const starterRows = Array.isArray(parsed.seedRows) ? parsed.seedRows : preset.starterRows;
|
|
5862
|
+
const creation = await appendBlockInternal({
|
|
5863
|
+
workspaceId,
|
|
5864
|
+
docId: parsed.docId,
|
|
5865
|
+
type: "data_view",
|
|
5866
|
+
viewMode: "kanban",
|
|
5867
|
+
text: title,
|
|
5868
|
+
placement: parsed.placement,
|
|
5869
|
+
});
|
|
5870
|
+
const ctx = await loadDatabaseDocContext(workspaceId, parsed.docId, creation.blockId);
|
|
5871
|
+
try {
|
|
5872
|
+
const warnings = [];
|
|
5873
|
+
const statusLookup = buildDatabaseColumnLookup(readColumnDefs(ctx.dbBlock));
|
|
5874
|
+
const statusColumn = statusLookup.colByNameLower.get("status");
|
|
5875
|
+
if (statusColumn?.raw instanceof Y.Map) {
|
|
5876
|
+
replaceSelectColumnOptions(statusColumn.raw, preset.statusOptions);
|
|
5877
|
+
}
|
|
5878
|
+
else {
|
|
5879
|
+
warnings.push("Status column was not found after database creation.");
|
|
5880
|
+
}
|
|
5881
|
+
const addedColumnIds = [];
|
|
5882
|
+
for (const columnSpec of preset.extraColumns) {
|
|
5883
|
+
addedColumnIds.push(addDatabaseColumnToBlock(ctx.dbBlock, columnSpec));
|
|
5884
|
+
}
|
|
5885
|
+
const viewEntries = ctx.dbBlock.get("prop:views");
|
|
5886
|
+
if (viewEntries instanceof Y.Array) {
|
|
5887
|
+
const primaryView = viewEntries.get(0);
|
|
5888
|
+
if (primaryView instanceof Y.Map) {
|
|
5889
|
+
primaryView.set("name", preset.viewName);
|
|
5890
|
+
}
|
|
5891
|
+
}
|
|
5892
|
+
const rowBlockIds = [];
|
|
5893
|
+
for (const rowInput of starterRows) {
|
|
5894
|
+
rowBlockIds.push(addDatabaseRowToBlock({
|
|
5895
|
+
blocks: ctx.blocks,
|
|
5896
|
+
dbBlock: ctx.dbBlock,
|
|
5897
|
+
cellsMap: ctx.cellsMap,
|
|
5898
|
+
databaseBlockId: creation.blockId,
|
|
5899
|
+
lookup: buildDatabaseColumnLookup(readColumnDefs(ctx.dbBlock)),
|
|
5900
|
+
cells: rowInput,
|
|
5901
|
+
}));
|
|
5902
|
+
}
|
|
5903
|
+
const delta = Y.encodeStateAsUpdate(ctx.doc, ctx.prevSV);
|
|
5904
|
+
await pushDocUpdate(ctx.socket, workspaceId, parsed.docId, Buffer.from(delta).toString("base64"));
|
|
5905
|
+
const finalLookup = buildDatabaseColumnLookup(readColumnDefs(ctx.dbBlock));
|
|
5906
|
+
const finalViews = readDatabaseViewDefs(ctx.dbBlock, finalLookup);
|
|
5907
|
+
const columnSummary = finalLookup.columnDefs.map(column => ({
|
|
5908
|
+
id: column.id,
|
|
5909
|
+
name: column.name || null,
|
|
5910
|
+
type: column.type,
|
|
5911
|
+
options: column.options,
|
|
5912
|
+
}));
|
|
5913
|
+
return {
|
|
5914
|
+
workspaceId,
|
|
5915
|
+
docId: parsed.docId,
|
|
5916
|
+
intent: parsed.intent,
|
|
5917
|
+
title,
|
|
5918
|
+
databaseBlockId: creation.blockId,
|
|
5919
|
+
primaryViewId: finalViews[0]?.id || null,
|
|
5920
|
+
viewIds: finalViews.map(view => view.id),
|
|
5921
|
+
columnIds: finalLookup.columnDefs.map(column => column.id),
|
|
5922
|
+
rowBlockIds: getDatabaseRowIds(ctx.dbBlock),
|
|
5923
|
+
columns: columnSummary,
|
|
5924
|
+
views: finalViews,
|
|
5925
|
+
warnings: mergeWarnings(warnings),
|
|
5926
|
+
lossy: false,
|
|
5927
|
+
stats: {
|
|
5928
|
+
columnCount: columnSummary.length,
|
|
5929
|
+
viewCount: finalViews.length,
|
|
5930
|
+
rowCount: getDatabaseRowIds(ctx.dbBlock).length,
|
|
5931
|
+
addedColumnCount: addedColumnIds.length,
|
|
5932
|
+
seededRowCount: rowBlockIds.length,
|
|
5933
|
+
},
|
|
5934
|
+
};
|
|
5935
|
+
}
|
|
5936
|
+
finally {
|
|
5937
|
+
ctx.socket.disconnect();
|
|
5938
|
+
}
|
|
5939
|
+
};
|
|
5940
|
+
server.registerTool("compose_database_from_intent", {
|
|
5941
|
+
title: "Compose Database From Intent",
|
|
5942
|
+
description: "Create a useful AFFiNE database/data-view from declarative intent. Supports task_board and issue_tracker presets with starter schema, kanban view, and optional starter rows.",
|
|
5943
|
+
inputSchema: {
|
|
5944
|
+
workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
|
|
5945
|
+
docId: DocId.describe("Document ID containing the database"),
|
|
5946
|
+
intent: DatabaseIntent.describe("Declarative database intent to compose."),
|
|
5947
|
+
title: z.string().optional().describe("Optional database title. Defaults to the intent preset title."),
|
|
5948
|
+
seedRows: z.array(z.record(z.unknown())).optional().describe("Optional starter rows. If omitted, the preset starter rows are used."),
|
|
5949
|
+
placement: z.object({
|
|
5950
|
+
parentId: z.string().optional(),
|
|
5951
|
+
afterBlockId: z.string().optional(),
|
|
5952
|
+
beforeBlockId: z.string().optional(),
|
|
5953
|
+
index: z.number().int().min(0).optional(),
|
|
5954
|
+
}).optional().describe("Optional insertion target/position"),
|
|
5955
|
+
},
|
|
5956
|
+
}, async (parsed) => text(await composeDatabaseFromIntentCore(parsed)));
|
|
4572
5957
|
// ADD DATABASE COLUMN
|
|
4573
5958
|
const addDatabaseColumnHandler = async (parsed) => {
|
|
4574
5959
|
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
@@ -4663,4 +6048,1256 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4663
6048
|
width: z.number().optional().describe("Column width in pixels (default 200)"),
|
|
4664
6049
|
},
|
|
4665
6050
|
}, addDatabaseColumnHandler);
|
|
6051
|
+
function resolveSurfaceNewlines(s) {
|
|
6052
|
+
return s.replace(/\\n/g, "\n");
|
|
6053
|
+
}
|
|
6054
|
+
function getSurfaceElementsValueMap(blocks, options) {
|
|
6055
|
+
let surfaceId = findBlockIdByFlavour(blocks, "affine:surface");
|
|
6056
|
+
if (!surfaceId) {
|
|
6057
|
+
if (!options.create)
|
|
6058
|
+
return null;
|
|
6059
|
+
surfaceId = ensureSurfaceBlock(blocks);
|
|
6060
|
+
}
|
|
6061
|
+
const surface = blocks.get(surfaceId);
|
|
6062
|
+
let elements = surface.get("prop:elements");
|
|
6063
|
+
if (!(elements instanceof Y.Map)) {
|
|
6064
|
+
if (!options.create)
|
|
6065
|
+
return null;
|
|
6066
|
+
elements = new Y.Map();
|
|
6067
|
+
elements.set("type", "$blocksuite:internal:native$");
|
|
6068
|
+
elements.set("value", new Y.Map());
|
|
6069
|
+
surface.set("prop:elements", elements);
|
|
6070
|
+
}
|
|
6071
|
+
let value = elements.get("value");
|
|
6072
|
+
if (!(value instanceof Y.Map)) {
|
|
6073
|
+
if (!options.create)
|
|
6074
|
+
return null;
|
|
6075
|
+
value = new Y.Map();
|
|
6076
|
+
elements.set("value", value);
|
|
6077
|
+
}
|
|
6078
|
+
return { surfaceId, value };
|
|
6079
|
+
}
|
|
6080
|
+
function serializeSurfaceElement(elementId, el) {
|
|
6081
|
+
const out = { id: elementId };
|
|
6082
|
+
for (const [k, v] of el.entries()) {
|
|
6083
|
+
if (v instanceof Y.Text) {
|
|
6084
|
+
out[k] = v.toString();
|
|
6085
|
+
}
|
|
6086
|
+
else if (v instanceof Y.Map) {
|
|
6087
|
+
out[k] = v.toJSON();
|
|
6088
|
+
}
|
|
6089
|
+
else if (v instanceof Y.Array) {
|
|
6090
|
+
out[k] = v.toArray();
|
|
6091
|
+
}
|
|
6092
|
+
else {
|
|
6093
|
+
out[k] = v;
|
|
6094
|
+
}
|
|
6095
|
+
}
|
|
6096
|
+
const xywh = parseXywhString(out.xywh);
|
|
6097
|
+
if (xywh)
|
|
6098
|
+
out.bounds = xywh;
|
|
6099
|
+
return out;
|
|
6100
|
+
}
|
|
6101
|
+
function buildShapeElementData(elementId, seed, index, input) {
|
|
6102
|
+
const x = input.x ?? 0;
|
|
6103
|
+
const y = input.y ?? 0;
|
|
6104
|
+
const w = input.width ?? 100;
|
|
6105
|
+
const h = input.height ?? 100;
|
|
6106
|
+
const data = {
|
|
6107
|
+
type: "shape",
|
|
6108
|
+
id: elementId,
|
|
6109
|
+
index,
|
|
6110
|
+
seed,
|
|
6111
|
+
xywh: formatXywhString(x, y, w, h),
|
|
6112
|
+
rotate: 0,
|
|
6113
|
+
shapeType: input.shapeType ?? "rect",
|
|
6114
|
+
shapeStyle: "General",
|
|
6115
|
+
radius: input.radius ?? 0,
|
|
6116
|
+
filled: input.filled ?? true,
|
|
6117
|
+
fillColor: input.fillColor ?? "--affine-palette-shape-yellow",
|
|
6118
|
+
strokeWidth: input.strokeWidth ?? 2,
|
|
6119
|
+
strokeColor: input.strokeColor ?? "--affine-palette-line-yellow",
|
|
6120
|
+
strokeStyle: input.strokeStyle ?? "solid",
|
|
6121
|
+
roughness: 1.4,
|
|
6122
|
+
// Fixed #000000 matches BlockSuite's shape tool: shape fills don't
|
|
6123
|
+
// theme-adapt, so label color stays pinned too. Override for dark fills.
|
|
6124
|
+
color: input.color ?? "#000000",
|
|
6125
|
+
fontFamily: "blocksuite:surface:Inter",
|
|
6126
|
+
fontSize: input.fontSize ?? 20,
|
|
6127
|
+
fontStyle: "normal",
|
|
6128
|
+
fontWeight: input.fontWeight ?? "600",
|
|
6129
|
+
textAlign: "center",
|
|
6130
|
+
textHorizontalAlign: "center",
|
|
6131
|
+
textVerticalAlign: "center",
|
|
6132
|
+
textResizing: 1,
|
|
6133
|
+
maxWidth: false,
|
|
6134
|
+
padding: [10, 20],
|
|
6135
|
+
shadow: null,
|
|
6136
|
+
};
|
|
6137
|
+
if (input.text) {
|
|
6138
|
+
const yText = new Y.Text();
|
|
6139
|
+
yText.insert(0, resolveSurfaceNewlines(input.text));
|
|
6140
|
+
data.text = yText;
|
|
6141
|
+
}
|
|
6142
|
+
return data;
|
|
6143
|
+
}
|
|
6144
|
+
function resolveConnectorEndpointBoundAsBound(surfaceValueMap, blocks, endpointId) {
|
|
6145
|
+
if (!endpointId)
|
|
6146
|
+
return null;
|
|
6147
|
+
const surfaceEl = surfaceValueMap.get(endpointId);
|
|
6148
|
+
if (surfaceEl instanceof Y.Map) {
|
|
6149
|
+
const xywh = parseXywhString(surfaceEl.get("xywh"));
|
|
6150
|
+
if (xywh)
|
|
6151
|
+
return { x: xywh.x, y: xywh.y, w: xywh.width, h: xywh.height };
|
|
6152
|
+
}
|
|
6153
|
+
const block = blocks.get(endpointId);
|
|
6154
|
+
if (block instanceof Y.Map) {
|
|
6155
|
+
const xywh = parseXywhString(block.get("prop:xywh"));
|
|
6156
|
+
if (xywh)
|
|
6157
|
+
return { x: xywh.x, y: xywh.y, w: xywh.width, h: xywh.height };
|
|
6158
|
+
}
|
|
6159
|
+
return null;
|
|
6160
|
+
}
|
|
6161
|
+
function resolveChildBound(surfaceValueMap, blocks, id) {
|
|
6162
|
+
const surfaceEl = surfaceValueMap.get(id);
|
|
6163
|
+
if (surfaceEl instanceof Y.Map) {
|
|
6164
|
+
const xywh = parseXywhString(surfaceEl.get("xywh"));
|
|
6165
|
+
return {
|
|
6166
|
+
kind: "surface",
|
|
6167
|
+
bound: xywh ? { x: xywh.x, y: xywh.y, w: xywh.width, h: xywh.height } : null,
|
|
6168
|
+
};
|
|
6169
|
+
}
|
|
6170
|
+
const block = blocks.get(id);
|
|
6171
|
+
if (block instanceof Y.Map) {
|
|
6172
|
+
const xywh = parseXywhString(block.get("prop:xywh"));
|
|
6173
|
+
return {
|
|
6174
|
+
kind: "block",
|
|
6175
|
+
bound: xywh ? { x: xywh.x, y: xywh.y, w: xywh.width, h: xywh.height } : null,
|
|
6176
|
+
};
|
|
6177
|
+
}
|
|
6178
|
+
return { kind: "missing", bound: null };
|
|
6179
|
+
}
|
|
6180
|
+
function resolveConnectorEndpointCenter(surfaceValueMap, blocks, endpointId, endpointPosition) {
|
|
6181
|
+
if (endpointPosition && Array.isArray(endpointPosition) && endpointPosition.length === 2) {
|
|
6182
|
+
return { x: endpointPosition[0], y: endpointPosition[1] };
|
|
6183
|
+
}
|
|
6184
|
+
if (!endpointId)
|
|
6185
|
+
return null;
|
|
6186
|
+
const surfaceEl = surfaceValueMap.get(endpointId);
|
|
6187
|
+
if (surfaceEl instanceof Y.Map) {
|
|
6188
|
+
const xywh = parseXywhString(surfaceEl.get("xywh"));
|
|
6189
|
+
if (xywh)
|
|
6190
|
+
return { x: xywh.x + xywh.width / 2, y: xywh.y + xywh.height / 2 };
|
|
6191
|
+
}
|
|
6192
|
+
const block = blocks.get(endpointId);
|
|
6193
|
+
if (block instanceof Y.Map) {
|
|
6194
|
+
const xywh = parseXywhString(block.get("prop:xywh"));
|
|
6195
|
+
if (xywh)
|
|
6196
|
+
return { x: xywh.x + xywh.width / 2, y: xywh.y + xywh.height / 2 };
|
|
6197
|
+
}
|
|
6198
|
+
return null;
|
|
6199
|
+
}
|
|
6200
|
+
function buildConnectorElementData(elementId, seed, index, input) {
|
|
6201
|
+
const source = {};
|
|
6202
|
+
if (input.sourceId) {
|
|
6203
|
+
source.id = input.sourceId;
|
|
6204
|
+
if (input.sourcePosition)
|
|
6205
|
+
source.position = input.sourcePosition;
|
|
6206
|
+
}
|
|
6207
|
+
else if (input.sourcePosition) {
|
|
6208
|
+
source.position = input.sourcePosition;
|
|
6209
|
+
}
|
|
6210
|
+
const target = {};
|
|
6211
|
+
if (input.targetId) {
|
|
6212
|
+
target.id = input.targetId;
|
|
6213
|
+
if (input.targetPosition)
|
|
6214
|
+
target.position = input.targetPosition;
|
|
6215
|
+
}
|
|
6216
|
+
else if (input.targetPosition) {
|
|
6217
|
+
target.position = input.targetPosition;
|
|
6218
|
+
}
|
|
6219
|
+
const data = {
|
|
6220
|
+
type: "connector",
|
|
6221
|
+
id: elementId,
|
|
6222
|
+
index,
|
|
6223
|
+
seed,
|
|
6224
|
+
mode: input.mode ?? 2,
|
|
6225
|
+
// Theme-adaptive token so connectors stay legible in dark mode.
|
|
6226
|
+
stroke: input.stroke ?? "--affine-text-primary-color",
|
|
6227
|
+
strokeWidth: input.strokeWidth ?? 2,
|
|
6228
|
+
strokeStyle: input.strokeStyle ?? "solid",
|
|
6229
|
+
roughness: 1.4,
|
|
6230
|
+
frontEndpointStyle: input.frontEndpointStyle ?? "None",
|
|
6231
|
+
rearEndpointStyle: input.rearEndpointStyle ?? "Arrow",
|
|
6232
|
+
source,
|
|
6233
|
+
target,
|
|
6234
|
+
labelDisplay: true,
|
|
6235
|
+
labelOffset: { distance: 0.5, anchor: "center" },
|
|
6236
|
+
labelStyle: {
|
|
6237
|
+
color: "--affine-text-primary-color",
|
|
6238
|
+
fontFamily: "blocksuite:surface:Inter",
|
|
6239
|
+
fontSize: 16,
|
|
6240
|
+
fontStyle: "normal",
|
|
6241
|
+
fontWeight: "400",
|
|
6242
|
+
textAlign: "center",
|
|
6243
|
+
},
|
|
6244
|
+
labelConstraints: { hasMaxWidth: true, maxWidth: 280 },
|
|
6245
|
+
};
|
|
6246
|
+
if (input.label) {
|
|
6247
|
+
const yText = new Y.Text();
|
|
6248
|
+
yText.insert(0, resolveSurfaceNewlines(input.label));
|
|
6249
|
+
data.text = yText;
|
|
6250
|
+
}
|
|
6251
|
+
return data;
|
|
6252
|
+
}
|
|
6253
|
+
function buildTextElementData(elementId, seed, index, input) {
|
|
6254
|
+
const x = input.x ?? 0;
|
|
6255
|
+
const y = input.y ?? 0;
|
|
6256
|
+
const w = input.width ?? 200;
|
|
6257
|
+
const h = input.height ?? 30;
|
|
6258
|
+
const yText = new Y.Text();
|
|
6259
|
+
if (input.text)
|
|
6260
|
+
yText.insert(0, resolveSurfaceNewlines(input.text));
|
|
6261
|
+
return {
|
|
6262
|
+
type: "text",
|
|
6263
|
+
id: elementId,
|
|
6264
|
+
index,
|
|
6265
|
+
seed,
|
|
6266
|
+
xywh: formatXywhString(x, y, w, h),
|
|
6267
|
+
rotate: 0,
|
|
6268
|
+
text: yText,
|
|
6269
|
+
color: input.color ?? "--affine-text-primary-color",
|
|
6270
|
+
fontFamily: "blocksuite:surface:Inter",
|
|
6271
|
+
fontSize: input.fontSize ?? 16,
|
|
6272
|
+
fontStyle: "normal",
|
|
6273
|
+
fontWeight: input.fontWeight ?? "400",
|
|
6274
|
+
textAlign: "center",
|
|
6275
|
+
hasMaxWidth: false,
|
|
6276
|
+
};
|
|
6277
|
+
}
|
|
6278
|
+
function buildGroupElementData(elementId, seed, index, input) {
|
|
6279
|
+
const childMap = new Y.Map();
|
|
6280
|
+
for (const childId of input.children ?? []) {
|
|
6281
|
+
childMap.set(childId, true);
|
|
6282
|
+
}
|
|
6283
|
+
const yTitle = new Y.Text();
|
|
6284
|
+
if (input.title)
|
|
6285
|
+
yTitle.insert(0, input.title);
|
|
6286
|
+
return {
|
|
6287
|
+
type: "group",
|
|
6288
|
+
id: elementId,
|
|
6289
|
+
index,
|
|
6290
|
+
seed,
|
|
6291
|
+
children: childMap,
|
|
6292
|
+
title: yTitle,
|
|
6293
|
+
};
|
|
6294
|
+
}
|
|
6295
|
+
function nextSurfaceElementIndex(valueMap) {
|
|
6296
|
+
let maxIndex = null;
|
|
6297
|
+
for (const [, el] of valueMap.entries()) {
|
|
6298
|
+
if (!(el instanceof Y.Map))
|
|
6299
|
+
continue;
|
|
6300
|
+
const idx = el.get("index");
|
|
6301
|
+
if (typeof idx !== "string")
|
|
6302
|
+
continue;
|
|
6303
|
+
if (maxIndex === null || idx > maxIndex)
|
|
6304
|
+
maxIndex = idx;
|
|
6305
|
+
}
|
|
6306
|
+
return generateKeyBetween(maxIndex, null);
|
|
6307
|
+
}
|
|
6308
|
+
function buildSurfaceElementData(type, index, input) {
|
|
6309
|
+
const elementId = generateId();
|
|
6310
|
+
const seed = Math.floor(Math.random() * 2 ** 31);
|
|
6311
|
+
switch (type) {
|
|
6312
|
+
case "shape":
|
|
6313
|
+
return { elementId, data: buildShapeElementData(elementId, seed, index, input) };
|
|
6314
|
+
case "connector":
|
|
6315
|
+
return { elementId, data: buildConnectorElementData(elementId, seed, index, input) };
|
|
6316
|
+
case "text":
|
|
6317
|
+
return { elementId, data: buildTextElementData(elementId, seed, index, input) };
|
|
6318
|
+
case "group":
|
|
6319
|
+
return { elementId, data: buildGroupElementData(elementId, seed, index, input) };
|
|
6320
|
+
}
|
|
6321
|
+
}
|
|
6322
|
+
function writeSurfaceElement(valueMap, elementId, data) {
|
|
6323
|
+
const el = new Y.Map();
|
|
6324
|
+
for (const [k, v] of Object.entries(data)) {
|
|
6325
|
+
el.set(k, v);
|
|
6326
|
+
}
|
|
6327
|
+
valueMap.set(elementId, el);
|
|
6328
|
+
}
|
|
6329
|
+
const addSurfaceElementHandler = async (params) => {
|
|
6330
|
+
const workspaceId = params.workspaceId || defaults.workspaceId;
|
|
6331
|
+
if (!workspaceId) {
|
|
6332
|
+
throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
|
|
6333
|
+
}
|
|
6334
|
+
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
6335
|
+
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
6336
|
+
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
6337
|
+
try {
|
|
6338
|
+
await joinWorkspace(socket, workspaceId);
|
|
6339
|
+
const doc = new Y.Doc();
|
|
6340
|
+
const snapshot = await loadDoc(socket, workspaceId, params.docId);
|
|
6341
|
+
if (snapshot.missing) {
|
|
6342
|
+
Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
|
|
6343
|
+
}
|
|
6344
|
+
const prevSV = Y.encodeStateVector(doc);
|
|
6345
|
+
const blocks = doc.getMap("blocks");
|
|
6346
|
+
const ctx = getSurfaceElementsValueMap(blocks, { create: true });
|
|
6347
|
+
const index = params.index ?? nextSurfaceElementIndex(ctx.value);
|
|
6348
|
+
// Mirror updateSurfaceElementHandler: report inapplicable gated fields
|
|
6349
|
+
// in `ignored` instead of silently dropping them at the per-type builder.
|
|
6350
|
+
const ignored = [];
|
|
6351
|
+
for (const [field, applicable] of Object.entries(FIELD_APPLICABILITY)) {
|
|
6352
|
+
if (params[field] === undefined)
|
|
6353
|
+
continue;
|
|
6354
|
+
if (!applicable.includes(params.type))
|
|
6355
|
+
ignored.push(field);
|
|
6356
|
+
}
|
|
6357
|
+
const { elementId, data } = buildSurfaceElementData(params.type, index, params);
|
|
6358
|
+
if (params.type === "connector") {
|
|
6359
|
+
const srcBounds = resolveConnectorEndpointBoundAsBound(ctx.value, blocks, params.sourceId);
|
|
6360
|
+
const tgtBounds = resolveConnectorEndpointBoundAsBound(ctx.value, blocks, params.targetId);
|
|
6361
|
+
// Auto-snap to the four tangent-carrying sides when the caller only
|
|
6362
|
+
// supplied ids — the renderer needs tangents to draw arrow heads.
|
|
6363
|
+
if (srcBounds && tgtBounds && !params.sourcePosition && !params.targetPosition) {
|
|
6364
|
+
const natural = pickConnectorSides(srcBounds, tgtBounds);
|
|
6365
|
+
data.source = { ...data.source, position: SIDE_TO_NORMALIZED_POSITION[natural.from] };
|
|
6366
|
+
data.target = { ...data.target, position: SIDE_TO_NORMALIZED_POSITION[natural.to] };
|
|
6367
|
+
}
|
|
6368
|
+
if (params.label) {
|
|
6369
|
+
const srcCenter = srcBounds
|
|
6370
|
+
? { x: srcBounds.x + srcBounds.w / 2, y: srcBounds.y + srcBounds.h / 2 }
|
|
6371
|
+
: resolveConnectorEndpointCenter(ctx.value, blocks, params.sourceId, params.sourcePosition);
|
|
6372
|
+
const tgtCenter = tgtBounds
|
|
6373
|
+
? { x: tgtBounds.x + tgtBounds.w / 2, y: tgtBounds.y + tgtBounds.h / 2 }
|
|
6374
|
+
: resolveConnectorEndpointCenter(ctx.value, blocks, params.targetId, params.targetPosition);
|
|
6375
|
+
const midpoint = srcCenter && tgtCenter
|
|
6376
|
+
? { x: (srcCenter.x + tgtCenter.x) / 2, y: (srcCenter.y + tgtCenter.y) / 2 }
|
|
6377
|
+
: (srcCenter ?? tgtCenter);
|
|
6378
|
+
data.labelXYWH = estimateConnectorLabelXYWH(params.label, 16, midpoint, 280);
|
|
6379
|
+
}
|
|
6380
|
+
}
|
|
6381
|
+
writeSurfaceElement(ctx.value, elementId, data);
|
|
6382
|
+
const delta = Y.encodeStateAsUpdate(doc, prevSV);
|
|
6383
|
+
await pushDocUpdate(socket, workspaceId, params.docId, Buffer.from(delta).toString("base64"));
|
|
6384
|
+
return text({
|
|
6385
|
+
added: true,
|
|
6386
|
+
elementId,
|
|
6387
|
+
type: params.type,
|
|
6388
|
+
surfaceBlockId: ctx.surfaceId,
|
|
6389
|
+
ignored,
|
|
6390
|
+
});
|
|
6391
|
+
}
|
|
6392
|
+
finally {
|
|
6393
|
+
socket.disconnect();
|
|
6394
|
+
}
|
|
6395
|
+
};
|
|
6396
|
+
const listSurfaceElementsHandler = async (params) => {
|
|
6397
|
+
const workspaceId = params.workspaceId || defaults.workspaceId;
|
|
6398
|
+
if (!workspaceId) {
|
|
6399
|
+
throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
|
|
6400
|
+
}
|
|
6401
|
+
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
6402
|
+
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
6403
|
+
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
6404
|
+
try {
|
|
6405
|
+
await joinWorkspace(socket, workspaceId);
|
|
6406
|
+
const doc = new Y.Doc();
|
|
6407
|
+
const snapshot = await loadDoc(socket, workspaceId, params.docId);
|
|
6408
|
+
if (!snapshot.missing) {
|
|
6409
|
+
return text({
|
|
6410
|
+
docId: params.docId,
|
|
6411
|
+
exists: false,
|
|
6412
|
+
surfaceBlockId: null,
|
|
6413
|
+
count: 0,
|
|
6414
|
+
elements: [],
|
|
6415
|
+
});
|
|
6416
|
+
}
|
|
6417
|
+
Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
|
|
6418
|
+
const blocks = doc.getMap("blocks");
|
|
6419
|
+
const ctx = getSurfaceElementsValueMap(blocks, { create: false });
|
|
6420
|
+
if (!ctx) {
|
|
6421
|
+
return text({
|
|
6422
|
+
docId: params.docId,
|
|
6423
|
+
exists: true,
|
|
6424
|
+
surfaceBlockId: null,
|
|
6425
|
+
count: 0,
|
|
6426
|
+
elements: [],
|
|
6427
|
+
});
|
|
6428
|
+
}
|
|
6429
|
+
const elements = [];
|
|
6430
|
+
for (const [id, value] of ctx.value.entries()) {
|
|
6431
|
+
const entryId = String(id);
|
|
6432
|
+
if (params.elementId && entryId !== params.elementId)
|
|
6433
|
+
continue;
|
|
6434
|
+
if (!(value instanceof Y.Map))
|
|
6435
|
+
continue;
|
|
6436
|
+
const serialized = serializeSurfaceElement(entryId, value);
|
|
6437
|
+
if (params.type && serialized.type !== params.type)
|
|
6438
|
+
continue;
|
|
6439
|
+
elements.push(serialized);
|
|
6440
|
+
}
|
|
6441
|
+
const sorted = sortByFractionalIndex(elements);
|
|
6442
|
+
return text({
|
|
6443
|
+
docId: params.docId,
|
|
6444
|
+
exists: true,
|
|
6445
|
+
surfaceBlockId: ctx.surfaceId,
|
|
6446
|
+
count: sorted.length,
|
|
6447
|
+
elements: sorted,
|
|
6448
|
+
});
|
|
6449
|
+
}
|
|
6450
|
+
finally {
|
|
6451
|
+
socket.disconnect();
|
|
6452
|
+
}
|
|
6453
|
+
};
|
|
6454
|
+
// Also parsed by scripts/verify-surface-element-gating.mjs — adding a styling
|
|
6455
|
+
// field to surfaceElementFieldSchemas without a row here fails that gate.
|
|
6456
|
+
// Per-type rows mirror blocksuite's @field decorators (toeverything/blocksuite@5cb5cb6
|
|
6457
|
+
// packages/affine/model/src/elements/{connector,shape}.ts).
|
|
6458
|
+
const FIELD_APPLICABILITY = {
|
|
6459
|
+
shapeType: ["shape"],
|
|
6460
|
+
radius: ["shape"],
|
|
6461
|
+
filled: ["shape"],
|
|
6462
|
+
fillColor: ["shape"],
|
|
6463
|
+
strokeColor: ["shape"], // connectors use top-level `stroke`
|
|
6464
|
+
strokeWidth: ["shape", "connector"],
|
|
6465
|
+
strokeStyle: ["shape", "connector"],
|
|
6466
|
+
color: ["shape", "text"], // connectors use labelStyle.*
|
|
6467
|
+
fontSize: ["shape", "text"],
|
|
6468
|
+
fontWeight: ["shape", "text"],
|
|
6469
|
+
stroke: ["connector"],
|
|
6470
|
+
mode: ["connector"],
|
|
6471
|
+
frontEndpointStyle: ["connector"],
|
|
6472
|
+
rearEndpointStyle: ["connector"],
|
|
6473
|
+
};
|
|
6474
|
+
const updateSurfaceElementHandler = async (params) => {
|
|
6475
|
+
const workspaceId = params.workspaceId || defaults.workspaceId;
|
|
6476
|
+
if (!workspaceId) {
|
|
6477
|
+
throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
|
|
6478
|
+
}
|
|
6479
|
+
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
6480
|
+
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
6481
|
+
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
6482
|
+
try {
|
|
6483
|
+
await joinWorkspace(socket, workspaceId);
|
|
6484
|
+
const doc = new Y.Doc();
|
|
6485
|
+
const snapshot = await loadDoc(socket, workspaceId, params.docId);
|
|
6486
|
+
if (!snapshot.missing) {
|
|
6487
|
+
throw new Error(`Document '${params.docId}' not found or has no content.`);
|
|
6488
|
+
}
|
|
6489
|
+
Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
|
|
6490
|
+
const prevSV = Y.encodeStateVector(doc);
|
|
6491
|
+
const blocks = doc.getMap("blocks");
|
|
6492
|
+
const ctx = getSurfaceElementsValueMap(blocks, { create: false });
|
|
6493
|
+
if (!ctx)
|
|
6494
|
+
throw new Error("Document has no surface elements to update.");
|
|
6495
|
+
const el = ctx.value.get(params.elementId);
|
|
6496
|
+
if (!(el instanceof Y.Map)) {
|
|
6497
|
+
throw new Error(`Surface element '${params.elementId}' not found.`);
|
|
6498
|
+
}
|
|
6499
|
+
const elementType = el.get("type");
|
|
6500
|
+
const changed = [];
|
|
6501
|
+
const ignored = [];
|
|
6502
|
+
const geomProvided = params.x !== undefined ||
|
|
6503
|
+
params.y !== undefined ||
|
|
6504
|
+
params.width !== undefined ||
|
|
6505
|
+
params.height !== undefined;
|
|
6506
|
+
if (geomProvided) {
|
|
6507
|
+
if (elementType === "shape" || elementType === "text") {
|
|
6508
|
+
const current = parseXywhString(el.get("xywh")) ?? {
|
|
6509
|
+
x: 0,
|
|
6510
|
+
y: 0,
|
|
6511
|
+
width: 100,
|
|
6512
|
+
height: 100,
|
|
6513
|
+
};
|
|
6514
|
+
const nx = params.x ?? current.x;
|
|
6515
|
+
const ny = params.y ?? current.y;
|
|
6516
|
+
const nw = params.width ?? current.width;
|
|
6517
|
+
const nh = params.height ?? current.height;
|
|
6518
|
+
el.set("xywh", formatXywhString(nx, ny, nw, nh));
|
|
6519
|
+
changed.push("xywh");
|
|
6520
|
+
}
|
|
6521
|
+
else {
|
|
6522
|
+
if (params.x !== undefined)
|
|
6523
|
+
ignored.push("x");
|
|
6524
|
+
if (params.y !== undefined)
|
|
6525
|
+
ignored.push("y");
|
|
6526
|
+
if (params.width !== undefined)
|
|
6527
|
+
ignored.push("width");
|
|
6528
|
+
if (params.height !== undefined)
|
|
6529
|
+
ignored.push("height");
|
|
6530
|
+
}
|
|
6531
|
+
}
|
|
6532
|
+
const setYText = (key, value) => {
|
|
6533
|
+
const yText = new Y.Text();
|
|
6534
|
+
yText.insert(0, resolveSurfaceNewlines(value));
|
|
6535
|
+
el.set(key, yText);
|
|
6536
|
+
changed.push(key);
|
|
6537
|
+
};
|
|
6538
|
+
if (params.text !== undefined) {
|
|
6539
|
+
if (elementType === "shape" || elementType === "text" || elementType === "connector") {
|
|
6540
|
+
setYText("text", params.text);
|
|
6541
|
+
}
|
|
6542
|
+
else {
|
|
6543
|
+
ignored.push("text");
|
|
6544
|
+
}
|
|
6545
|
+
}
|
|
6546
|
+
if (params.label !== undefined) {
|
|
6547
|
+
if (elementType === "connector") {
|
|
6548
|
+
setYText("text", params.label);
|
|
6549
|
+
}
|
|
6550
|
+
else {
|
|
6551
|
+
ignored.push("label");
|
|
6552
|
+
}
|
|
6553
|
+
}
|
|
6554
|
+
if (params.title !== undefined) {
|
|
6555
|
+
if (elementType === "group") {
|
|
6556
|
+
setYText("title", params.title);
|
|
6557
|
+
}
|
|
6558
|
+
else {
|
|
6559
|
+
ignored.push("title");
|
|
6560
|
+
}
|
|
6561
|
+
}
|
|
6562
|
+
for (const [field, applicable] of Object.entries(FIELD_APPLICABILITY)) {
|
|
6563
|
+
const key = field;
|
|
6564
|
+
if (params[key] === undefined)
|
|
6565
|
+
continue;
|
|
6566
|
+
if (applicable.includes(elementType)) {
|
|
6567
|
+
el.set(field, params[key]);
|
|
6568
|
+
changed.push(field);
|
|
6569
|
+
}
|
|
6570
|
+
else {
|
|
6571
|
+
ignored.push(field);
|
|
6572
|
+
}
|
|
6573
|
+
}
|
|
6574
|
+
if (params.index !== undefined) {
|
|
6575
|
+
el.set("index", params.index);
|
|
6576
|
+
changed.push("index");
|
|
6577
|
+
}
|
|
6578
|
+
if (params.sourceId !== undefined || params.sourcePosition !== undefined) {
|
|
6579
|
+
if (elementType === "connector") {
|
|
6580
|
+
const source = {};
|
|
6581
|
+
if (params.sourceId)
|
|
6582
|
+
source.id = params.sourceId;
|
|
6583
|
+
if (params.sourcePosition)
|
|
6584
|
+
source.position = params.sourcePosition;
|
|
6585
|
+
el.set("source", source);
|
|
6586
|
+
changed.push("source");
|
|
6587
|
+
}
|
|
6588
|
+
else {
|
|
6589
|
+
if (params.sourceId !== undefined)
|
|
6590
|
+
ignored.push("sourceId");
|
|
6591
|
+
if (params.sourcePosition !== undefined)
|
|
6592
|
+
ignored.push("sourcePosition");
|
|
6593
|
+
}
|
|
6594
|
+
}
|
|
6595
|
+
if (params.targetId !== undefined || params.targetPosition !== undefined) {
|
|
6596
|
+
if (elementType === "connector") {
|
|
6597
|
+
const target = {};
|
|
6598
|
+
if (params.targetId)
|
|
6599
|
+
target.id = params.targetId;
|
|
6600
|
+
if (params.targetPosition)
|
|
6601
|
+
target.position = params.targetPosition;
|
|
6602
|
+
el.set("target", target);
|
|
6603
|
+
changed.push("target");
|
|
6604
|
+
}
|
|
6605
|
+
else {
|
|
6606
|
+
if (params.targetId !== undefined)
|
|
6607
|
+
ignored.push("targetId");
|
|
6608
|
+
if (params.targetPosition !== undefined)
|
|
6609
|
+
ignored.push("targetPosition");
|
|
6610
|
+
}
|
|
6611
|
+
}
|
|
6612
|
+
if (params.children !== undefined) {
|
|
6613
|
+
if (elementType === "group") {
|
|
6614
|
+
const childMap = new Y.Map();
|
|
6615
|
+
for (const childId of params.children)
|
|
6616
|
+
childMap.set(childId, true);
|
|
6617
|
+
el.set("children", childMap);
|
|
6618
|
+
changed.push("children");
|
|
6619
|
+
}
|
|
6620
|
+
else {
|
|
6621
|
+
ignored.push("children");
|
|
6622
|
+
}
|
|
6623
|
+
}
|
|
6624
|
+
const delta = Y.encodeStateAsUpdate(doc, prevSV);
|
|
6625
|
+
await pushDocUpdate(socket, workspaceId, params.docId, Buffer.from(delta).toString("base64"));
|
|
6626
|
+
return text({
|
|
6627
|
+
updated: changed.length > 0,
|
|
6628
|
+
elementId: params.elementId,
|
|
6629
|
+
type: typeof elementType === "string" ? elementType : null,
|
|
6630
|
+
changed,
|
|
6631
|
+
ignored,
|
|
6632
|
+
});
|
|
6633
|
+
}
|
|
6634
|
+
finally {
|
|
6635
|
+
socket.disconnect();
|
|
6636
|
+
}
|
|
6637
|
+
};
|
|
6638
|
+
const deleteSurfaceElementHandler = async (params) => {
|
|
6639
|
+
const workspaceId = params.workspaceId || defaults.workspaceId;
|
|
6640
|
+
if (!workspaceId) {
|
|
6641
|
+
throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
|
|
6642
|
+
}
|
|
6643
|
+
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
6644
|
+
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
6645
|
+
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
6646
|
+
try {
|
|
6647
|
+
await joinWorkspace(socket, workspaceId);
|
|
6648
|
+
const doc = new Y.Doc();
|
|
6649
|
+
const snapshot = await loadDoc(socket, workspaceId, params.docId);
|
|
6650
|
+
if (!snapshot.missing) {
|
|
6651
|
+
throw new Error(`Document '${params.docId}' not found or has no content.`);
|
|
6652
|
+
}
|
|
6653
|
+
Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
|
|
6654
|
+
const prevSV = Y.encodeStateVector(doc);
|
|
6655
|
+
const blocks = doc.getMap("blocks");
|
|
6656
|
+
const ctx = getSurfaceElementsValueMap(blocks, { create: false });
|
|
6657
|
+
if (!ctx) {
|
|
6658
|
+
return text({
|
|
6659
|
+
deleted: false,
|
|
6660
|
+
elementId: params.elementId,
|
|
6661
|
+
reason: "no-surface",
|
|
6662
|
+
prunedConnectors: [],
|
|
6663
|
+
});
|
|
6664
|
+
}
|
|
6665
|
+
const existing = ctx.value.get(params.elementId);
|
|
6666
|
+
if (!(existing instanceof Y.Map)) {
|
|
6667
|
+
return text({
|
|
6668
|
+
deleted: false,
|
|
6669
|
+
elementId: params.elementId,
|
|
6670
|
+
reason: "not-found",
|
|
6671
|
+
prunedConnectors: [],
|
|
6672
|
+
});
|
|
6673
|
+
}
|
|
6674
|
+
ctx.value.delete(params.elementId);
|
|
6675
|
+
const prunedConnectors = [];
|
|
6676
|
+
if (params.pruneConnectors) {
|
|
6677
|
+
const toDelete = [];
|
|
6678
|
+
for (const [otherId, otherVal] of ctx.value.entries()) {
|
|
6679
|
+
if (!(otherVal instanceof Y.Map))
|
|
6680
|
+
continue;
|
|
6681
|
+
if (otherVal.get("type") !== "connector")
|
|
6682
|
+
continue;
|
|
6683
|
+
const source = otherVal.get("source");
|
|
6684
|
+
const target = otherVal.get("target");
|
|
6685
|
+
const srcId = source && typeof source === "object" ? source.id : undefined;
|
|
6686
|
+
const tgtId = target && typeof target === "object" ? target.id : undefined;
|
|
6687
|
+
if (srcId === params.elementId || tgtId === params.elementId) {
|
|
6688
|
+
toDelete.push(String(otherId));
|
|
6689
|
+
}
|
|
6690
|
+
}
|
|
6691
|
+
for (const id of toDelete) {
|
|
6692
|
+
ctx.value.delete(id);
|
|
6693
|
+
prunedConnectors.push(id);
|
|
6694
|
+
}
|
|
6695
|
+
}
|
|
6696
|
+
pruneFromFrameChildElementIds(blocks, [params.elementId, ...prunedConnectors]);
|
|
6697
|
+
const delta = Y.encodeStateAsUpdate(doc, prevSV);
|
|
6698
|
+
await pushDocUpdate(socket, workspaceId, params.docId, Buffer.from(delta).toString("base64"));
|
|
6699
|
+
return text({ deleted: true, elementId: params.elementId, prunedConnectors });
|
|
6700
|
+
}
|
|
6701
|
+
finally {
|
|
6702
|
+
socket.disconnect();
|
|
6703
|
+
}
|
|
6704
|
+
};
|
|
6705
|
+
const updateFrameChildrenHandler = async (params) => {
|
|
6706
|
+
const workspaceId = params.workspaceId || defaults.workspaceId;
|
|
6707
|
+
if (!workspaceId) {
|
|
6708
|
+
throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
|
|
6709
|
+
}
|
|
6710
|
+
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
6711
|
+
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
6712
|
+
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
6713
|
+
try {
|
|
6714
|
+
await joinWorkspace(socket, workspaceId);
|
|
6715
|
+
const doc = new Y.Doc();
|
|
6716
|
+
const snapshot = await loadDoc(socket, workspaceId, params.docId);
|
|
6717
|
+
if (!snapshot.missing) {
|
|
6718
|
+
throw new Error(`Document '${params.docId}' not found or has no content.`);
|
|
6719
|
+
}
|
|
6720
|
+
Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
|
|
6721
|
+
const prevSV = Y.encodeStateVector(doc);
|
|
6722
|
+
const blocks = doc.getMap("blocks");
|
|
6723
|
+
const frameBlock = blocks.get(params.blockId);
|
|
6724
|
+
if (!(frameBlock instanceof Y.Map) || frameBlock.get("sys:flavour") !== "affine:frame") {
|
|
6725
|
+
throw new Error(`Frame block '${params.blockId}' not found.`);
|
|
6726
|
+
}
|
|
6727
|
+
// All-missing is legit here: it's how callers clear ownership.
|
|
6728
|
+
const surfaceCtx = getSurfaceElementsValueMap(blocks, { create: false });
|
|
6729
|
+
const surfaceValueMap = surfaceCtx?.value ?? new Y.Map();
|
|
6730
|
+
const ownedIds = [];
|
|
6731
|
+
const missing = [];
|
|
6732
|
+
const kids = [];
|
|
6733
|
+
for (const id of params.childElementIds) {
|
|
6734
|
+
const resolved = resolveChildBound(surfaceValueMap, blocks, id);
|
|
6735
|
+
if (resolved.kind === "missing") {
|
|
6736
|
+
missing.push(id);
|
|
6737
|
+
}
|
|
6738
|
+
else {
|
|
6739
|
+
ownedIds.push(id);
|
|
6740
|
+
if (resolved.bound)
|
|
6741
|
+
kids.push(resolved.bound);
|
|
6742
|
+
}
|
|
6743
|
+
}
|
|
6744
|
+
const childMap = new Y.Map();
|
|
6745
|
+
for (const id of ownedIds)
|
|
6746
|
+
childMap.set(id, true);
|
|
6747
|
+
frameBlock.set("prop:childElementIds", childMap);
|
|
6748
|
+
// Resize-to-fit is default; skip on all-missing so clear-ownership
|
|
6749
|
+
// doesn't collapse the frame to zero.
|
|
6750
|
+
const resize = params.resizeToFit !== false;
|
|
6751
|
+
const padding = Number.isFinite(params.padding)
|
|
6752
|
+
? Math.max(0, Math.floor(params.padding))
|
|
6753
|
+
: 40;
|
|
6754
|
+
let xywh = null;
|
|
6755
|
+
if (resize && kids.length > 0) {
|
|
6756
|
+
const wrapped = encloseBounds(kids, { padding, titleBand: 60 });
|
|
6757
|
+
if (wrapped) {
|
|
6758
|
+
xywh = { x: wrapped.x, y: wrapped.y, width: wrapped.w, height: wrapped.h };
|
|
6759
|
+
frameBlock.set("prop:xywh", formatXywhString(xywh.x, xywh.y, xywh.width, xywh.height));
|
|
6760
|
+
}
|
|
6761
|
+
}
|
|
6762
|
+
const delta = Y.encodeStateAsUpdate(doc, prevSV);
|
|
6763
|
+
await pushDocUpdate(socket, workspaceId, params.docId, Buffer.from(delta).toString("base64"));
|
|
6764
|
+
return text({
|
|
6765
|
+
updated: true,
|
|
6766
|
+
blockId: params.blockId,
|
|
6767
|
+
flavour: "affine:frame",
|
|
6768
|
+
ownedIds,
|
|
6769
|
+
missing,
|
|
6770
|
+
resized: xywh !== null,
|
|
6771
|
+
...(xywh ? { xywh } : {}),
|
|
6772
|
+
});
|
|
6773
|
+
}
|
|
6774
|
+
finally {
|
|
6775
|
+
socket.disconnect();
|
|
6776
|
+
}
|
|
6777
|
+
};
|
|
6778
|
+
const updateEdgelessBlockHandler = async (params) => {
|
|
6779
|
+
const workspaceId = params.workspaceId || defaults.workspaceId;
|
|
6780
|
+
if (!workspaceId) {
|
|
6781
|
+
throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
|
|
6782
|
+
}
|
|
6783
|
+
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
6784
|
+
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
6785
|
+
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
6786
|
+
try {
|
|
6787
|
+
await joinWorkspace(socket, workspaceId);
|
|
6788
|
+
const doc = new Y.Doc();
|
|
6789
|
+
const snapshot = await loadDoc(socket, workspaceId, params.docId);
|
|
6790
|
+
if (!snapshot.missing) {
|
|
6791
|
+
throw new Error(`Document '${params.docId}' not found or has no content.`);
|
|
6792
|
+
}
|
|
6793
|
+
Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
|
|
6794
|
+
const prevSV = Y.encodeStateVector(doc);
|
|
6795
|
+
const blocks = doc.getMap("blocks");
|
|
6796
|
+
const block = blocks.get(params.blockId);
|
|
6797
|
+
if (!(block instanceof Y.Map)) {
|
|
6798
|
+
throw new Error(`Block '${params.blockId}' not found.`);
|
|
6799
|
+
}
|
|
6800
|
+
const flavour = block.get("sys:flavour");
|
|
6801
|
+
if (flavour !== "affine:note" && flavour !== "affine:frame" && flavour !== "affine:edgeless-text") {
|
|
6802
|
+
throw new Error(`Block '${params.blockId}' has flavour '${String(flavour)}' — update_edgeless_block only mutates note/frame/edgeless-text blocks.`);
|
|
6803
|
+
}
|
|
6804
|
+
const changed = [];
|
|
6805
|
+
const ignored = [];
|
|
6806
|
+
if (params.x !== undefined || params.y !== undefined || params.width !== undefined || params.height !== undefined) {
|
|
6807
|
+
const prev = parseXywhString(block.get("prop:xywh")) ?? { x: 0, y: 0, width: 0, height: 0 };
|
|
6808
|
+
block.set("prop:xywh", formatXywhString(params.x ?? prev.x, params.y ?? prev.y, params.width ?? prev.width, params.height ?? prev.height));
|
|
6809
|
+
changed.push("xywh");
|
|
6810
|
+
}
|
|
6811
|
+
if (params.background !== undefined) {
|
|
6812
|
+
if (flavour === "affine:edgeless-text") {
|
|
6813
|
+
ignored.push("background"); // edgeless-text has no prop:background
|
|
6814
|
+
}
|
|
6815
|
+
else {
|
|
6816
|
+
const bg = params.background;
|
|
6817
|
+
if (bg && typeof bg === "object" && !Array.isArray(bg) && ("light" in bg || "dark" in bg)) {
|
|
6818
|
+
const bgMap = new Y.Map();
|
|
6819
|
+
if (typeof bg.light === "string")
|
|
6820
|
+
bgMap.set("light", bg.light);
|
|
6821
|
+
if (typeof bg.dark === "string")
|
|
6822
|
+
bgMap.set("dark", bg.dark);
|
|
6823
|
+
block.set("prop:background", bgMap);
|
|
6824
|
+
}
|
|
6825
|
+
else {
|
|
6826
|
+
block.set("prop:background", bg);
|
|
6827
|
+
}
|
|
6828
|
+
changed.push("background");
|
|
6829
|
+
}
|
|
6830
|
+
}
|
|
6831
|
+
const delta = Y.encodeStateAsUpdate(doc, prevSV);
|
|
6832
|
+
await pushDocUpdate(socket, workspaceId, params.docId, Buffer.from(delta).toString("base64"));
|
|
6833
|
+
return text({ updated: changed.length > 0, blockId: params.blockId, flavour, changed, ignored });
|
|
6834
|
+
}
|
|
6835
|
+
finally {
|
|
6836
|
+
socket.disconnect();
|
|
6837
|
+
}
|
|
6838
|
+
};
|
|
6839
|
+
const deleteBlockHandler = async (params) => {
|
|
6840
|
+
const workspaceId = params.workspaceId || defaults.workspaceId;
|
|
6841
|
+
if (!workspaceId) {
|
|
6842
|
+
throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
|
|
6843
|
+
}
|
|
6844
|
+
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
6845
|
+
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
6846
|
+
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
6847
|
+
try {
|
|
6848
|
+
await joinWorkspace(socket, workspaceId);
|
|
6849
|
+
const doc = new Y.Doc();
|
|
6850
|
+
const snapshot = await loadDoc(socket, workspaceId, params.docId);
|
|
6851
|
+
if (!snapshot.missing) {
|
|
6852
|
+
throw new Error(`Document '${params.docId}' not found or has no content.`);
|
|
6853
|
+
}
|
|
6854
|
+
Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
|
|
6855
|
+
const prevSV = Y.encodeStateVector(doc);
|
|
6856
|
+
const blocks = doc.getMap("blocks");
|
|
6857
|
+
const block = blocks.get(params.blockId);
|
|
6858
|
+
if (!(block instanceof Y.Map)) {
|
|
6859
|
+
return text({ deleted: false, blockId: params.blockId, reason: "not-found" });
|
|
6860
|
+
}
|
|
6861
|
+
const flavour = block.get("sys:flavour");
|
|
6862
|
+
if (flavour === "affine:page") {
|
|
6863
|
+
throw new Error(`Refusing to delete page-root block '${params.blockId}' — use delete_doc for whole-doc removal.`);
|
|
6864
|
+
}
|
|
6865
|
+
const deletedIds = [];
|
|
6866
|
+
const deleteRecursive = params.deleteChildren !== false;
|
|
6867
|
+
const walk = (id) => {
|
|
6868
|
+
const b = blocks.get(id);
|
|
6869
|
+
if (!(b instanceof Y.Map))
|
|
6870
|
+
return;
|
|
6871
|
+
if (deleteRecursive) {
|
|
6872
|
+
const children = b.get("sys:children");
|
|
6873
|
+
if (children instanceof Y.Array) {
|
|
6874
|
+
for (const c of children.toArray()) {
|
|
6875
|
+
if (typeof c === "string")
|
|
6876
|
+
walk(c);
|
|
6877
|
+
}
|
|
6878
|
+
}
|
|
6879
|
+
}
|
|
6880
|
+
blocks.delete(id);
|
|
6881
|
+
deletedIds.push(id);
|
|
6882
|
+
};
|
|
6883
|
+
walk(params.blockId);
|
|
6884
|
+
for (const [, maybeParent] of blocks.entries()) {
|
|
6885
|
+
if (!(maybeParent instanceof Y.Map))
|
|
6886
|
+
continue;
|
|
6887
|
+
const kids = maybeParent.get("sys:children");
|
|
6888
|
+
if (!(kids instanceof Y.Array))
|
|
6889
|
+
continue;
|
|
6890
|
+
const arr = kids.toArray();
|
|
6891
|
+
for (let i = arr.length - 1; i >= 0; i--) {
|
|
6892
|
+
if (typeof arr[i] === "string" && deletedIds.includes(arr[i])) {
|
|
6893
|
+
kids.delete(i, 1);
|
|
6894
|
+
}
|
|
6895
|
+
}
|
|
6896
|
+
}
|
|
6897
|
+
// Mirror delete_surface_element's pruneConnectors semantics.
|
|
6898
|
+
const prunedConnectors = [];
|
|
6899
|
+
if (params.pruneConnectors) {
|
|
6900
|
+
const ctx = getSurfaceElementsValueMap(blocks, { create: false });
|
|
6901
|
+
if (ctx) {
|
|
6902
|
+
const toDelete = [];
|
|
6903
|
+
for (const [otherId, otherVal] of ctx.value.entries()) {
|
|
6904
|
+
if (!(otherVal instanceof Y.Map))
|
|
6905
|
+
continue;
|
|
6906
|
+
if (otherVal.get("type") !== "connector")
|
|
6907
|
+
continue;
|
|
6908
|
+
const source = otherVal.get("source");
|
|
6909
|
+
const target = otherVal.get("target");
|
|
6910
|
+
const srcId = source && typeof source === "object" ? source.id : undefined;
|
|
6911
|
+
const tgtId = target && typeof target === "object" ? target.id : undefined;
|
|
6912
|
+
if ((typeof srcId === "string" && deletedIds.includes(srcId)) ||
|
|
6913
|
+
(typeof tgtId === "string" && deletedIds.includes(tgtId))) {
|
|
6914
|
+
toDelete.push(String(otherId));
|
|
6915
|
+
}
|
|
6916
|
+
}
|
|
6917
|
+
for (const id of toDelete) {
|
|
6918
|
+
ctx.value.delete(id);
|
|
6919
|
+
prunedConnectors.push(id);
|
|
6920
|
+
}
|
|
6921
|
+
}
|
|
6922
|
+
}
|
|
6923
|
+
pruneFromFrameChildElementIds(blocks, [...deletedIds, ...prunedConnectors]);
|
|
6924
|
+
const delta = Y.encodeStateAsUpdate(doc, prevSV);
|
|
6925
|
+
await pushDocUpdate(socket, workspaceId, params.docId, Buffer.from(delta).toString("base64"));
|
|
6926
|
+
return text({ deleted: true, blockId: params.blockId, deletedIds, prunedConnectors });
|
|
6927
|
+
}
|
|
6928
|
+
finally {
|
|
6929
|
+
socket.disconnect();
|
|
6930
|
+
}
|
|
6931
|
+
};
|
|
6932
|
+
const getEdgelessCanvasHandler = async (params) => {
|
|
6933
|
+
const workspaceId = params.workspaceId || defaults.workspaceId;
|
|
6934
|
+
if (!workspaceId) {
|
|
6935
|
+
throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
|
|
6936
|
+
}
|
|
6937
|
+
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
6938
|
+
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
6939
|
+
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
6940
|
+
try {
|
|
6941
|
+
await joinWorkspace(socket, workspaceId);
|
|
6942
|
+
const doc = new Y.Doc();
|
|
6943
|
+
const snapshot = await loadDoc(socket, workspaceId, params.docId);
|
|
6944
|
+
if (!snapshot.missing) {
|
|
6945
|
+
return text({
|
|
6946
|
+
docId: params.docId,
|
|
6947
|
+
exists: false,
|
|
6948
|
+
surfaceBlockId: null,
|
|
6949
|
+
edgelessBlocks: [],
|
|
6950
|
+
surfaceElements: [],
|
|
6951
|
+
bounds: null,
|
|
6952
|
+
elementCounts: { shape: 0, connector: 0, text: 0, group: 0 },
|
|
6953
|
+
});
|
|
6954
|
+
}
|
|
6955
|
+
Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
|
|
6956
|
+
const blocks = doc.getMap("blocks");
|
|
6957
|
+
const edgelessFlavours = new Set([
|
|
6958
|
+
"affine:frame",
|
|
6959
|
+
"affine:edgeless-text",
|
|
6960
|
+
"affine:note",
|
|
6961
|
+
]);
|
|
6962
|
+
const collectNoteText = (rootId) => {
|
|
6963
|
+
const out = [];
|
|
6964
|
+
const seen = new Set();
|
|
6965
|
+
const visit = (id) => {
|
|
6966
|
+
if (seen.has(id))
|
|
6967
|
+
return;
|
|
6968
|
+
seen.add(id);
|
|
6969
|
+
const b = blocks.get(id);
|
|
6970
|
+
if (!(b instanceof Y.Map))
|
|
6971
|
+
return;
|
|
6972
|
+
const t = asText(b.get("prop:text"));
|
|
6973
|
+
if (t)
|
|
6974
|
+
out.push(t);
|
|
6975
|
+
for (const childId of childIdsFrom(b.get("sys:children"))) {
|
|
6976
|
+
visit(childId);
|
|
6977
|
+
}
|
|
6978
|
+
};
|
|
6979
|
+
const root = blocks.get(rootId);
|
|
6980
|
+
if (root instanceof Y.Map) {
|
|
6981
|
+
for (const childId of childIdsFrom(root.get("sys:children"))) {
|
|
6982
|
+
visit(childId);
|
|
6983
|
+
}
|
|
6984
|
+
}
|
|
6985
|
+
return out;
|
|
6986
|
+
};
|
|
6987
|
+
// Structured tree rather than flat-joined text so markdown-seeded notes
|
|
6988
|
+
// round-trip with heading/paragraph/list structure intact.
|
|
6989
|
+
const collectNoteChildren = (rootId) => {
|
|
6990
|
+
const result = [];
|
|
6991
|
+
const seen = new Set();
|
|
6992
|
+
const visit = (id) => {
|
|
6993
|
+
if (seen.has(id))
|
|
6994
|
+
return null;
|
|
6995
|
+
seen.add(id);
|
|
6996
|
+
const b = blocks.get(id);
|
|
6997
|
+
if (!(b instanceof Y.Map))
|
|
6998
|
+
return null;
|
|
6999
|
+
const childFlavour = b.get("sys:flavour");
|
|
7000
|
+
if (typeof childFlavour !== "string")
|
|
7001
|
+
return null;
|
|
7002
|
+
const entry = { id, flavour: childFlavour };
|
|
7003
|
+
const t = asText(b.get("prop:text"));
|
|
7004
|
+
if (t)
|
|
7005
|
+
entry.text = t;
|
|
7006
|
+
const propType = b.get("prop:type");
|
|
7007
|
+
if (typeof propType === "string")
|
|
7008
|
+
entry.type = propType;
|
|
7009
|
+
const checked = b.get("prop:checked");
|
|
7010
|
+
if (typeof checked === "boolean")
|
|
7011
|
+
entry.checked = checked;
|
|
7012
|
+
const language = b.get("prop:language");
|
|
7013
|
+
if (typeof language === "string" && language.length > 0)
|
|
7014
|
+
entry.language = language;
|
|
7015
|
+
const subChildren = [];
|
|
7016
|
+
for (const childId of childIdsFrom(b.get("sys:children"))) {
|
|
7017
|
+
const c = visit(childId);
|
|
7018
|
+
if (c)
|
|
7019
|
+
subChildren.push(c);
|
|
7020
|
+
}
|
|
7021
|
+
if (subChildren.length)
|
|
7022
|
+
entry.children = subChildren;
|
|
7023
|
+
return entry;
|
|
7024
|
+
};
|
|
7025
|
+
const root = blocks.get(rootId);
|
|
7026
|
+
if (root instanceof Y.Map) {
|
|
7027
|
+
for (const childId of childIdsFrom(root.get("sys:children"))) {
|
|
7028
|
+
const c = visit(childId);
|
|
7029
|
+
if (c)
|
|
7030
|
+
result.push(c);
|
|
7031
|
+
}
|
|
7032
|
+
}
|
|
7033
|
+
return result;
|
|
7034
|
+
};
|
|
7035
|
+
const edgelessBlocks = [];
|
|
7036
|
+
for (const [id, raw] of blocks.entries()) {
|
|
7037
|
+
if (!(raw instanceof Y.Map))
|
|
7038
|
+
continue;
|
|
7039
|
+
const flavour = raw.get("sys:flavour");
|
|
7040
|
+
if (typeof flavour !== "string" || !edgelessFlavours.has(flavour))
|
|
7041
|
+
continue;
|
|
7042
|
+
const xywhRaw = raw.get("prop:xywh");
|
|
7043
|
+
const bounds = parseXywhString(xywhRaw);
|
|
7044
|
+
const propIndex = raw.get("prop:index");
|
|
7045
|
+
const entry = {
|
|
7046
|
+
id: String(id),
|
|
7047
|
+
flavour,
|
|
7048
|
+
xywh: typeof xywhRaw === "string" ? xywhRaw : null,
|
|
7049
|
+
bounds,
|
|
7050
|
+
index: typeof propIndex === "string" ? propIndex : null,
|
|
7051
|
+
};
|
|
7052
|
+
if (flavour === "affine:frame") {
|
|
7053
|
+
entry.title = asText(raw.get("prop:title")) || null;
|
|
7054
|
+
const bg = raw.get("prop:background");
|
|
7055
|
+
entry.background = bg instanceof Y.Map ? bg.toJSON() : bg ?? null;
|
|
7056
|
+
const owned = raw.get("prop:childElementIds");
|
|
7057
|
+
entry.childElementIds = owned instanceof Y.Map ? Object.keys(owned.toJSON()) : [];
|
|
7058
|
+
}
|
|
7059
|
+
else if (flavour === "affine:edgeless-text") {
|
|
7060
|
+
const lines = collectNoteText(String(id));
|
|
7061
|
+
entry.text = lines.length ? lines.join("\n") : null;
|
|
7062
|
+
entry.color = raw.get("prop:color") ?? null;
|
|
7063
|
+
}
|
|
7064
|
+
else if (flavour === "affine:note") {
|
|
7065
|
+
const lines = collectNoteText(String(id));
|
|
7066
|
+
entry.text = lines.length ? lines.join("\n") : null;
|
|
7067
|
+
// `text` is the flat-join legacy view; `children` carries structure.
|
|
7068
|
+
entry.children = collectNoteChildren(String(id));
|
|
7069
|
+
entry.displayMode = raw.get("prop:displayMode") ?? null;
|
|
7070
|
+
const bg = raw.get("prop:background");
|
|
7071
|
+
entry.background = bg instanceof Y.Map ? bg.toJSON() : bg ?? null;
|
|
7072
|
+
}
|
|
7073
|
+
edgelessBlocks.push(entry);
|
|
7074
|
+
}
|
|
7075
|
+
const ctx = getSurfaceElementsValueMap(blocks, { create: false });
|
|
7076
|
+
const surfaceElements = [];
|
|
7077
|
+
const counts = {
|
|
7078
|
+
shape: 0,
|
|
7079
|
+
connector: 0,
|
|
7080
|
+
text: 0,
|
|
7081
|
+
group: 0,
|
|
7082
|
+
};
|
|
7083
|
+
if (ctx) {
|
|
7084
|
+
for (const [elId, val] of ctx.value.entries()) {
|
|
7085
|
+
if (!(val instanceof Y.Map))
|
|
7086
|
+
continue;
|
|
7087
|
+
const serialized = serializeSurfaceElement(String(elId), val);
|
|
7088
|
+
surfaceElements.push(serialized);
|
|
7089
|
+
const t = serialized.type;
|
|
7090
|
+
if (t && t in counts)
|
|
7091
|
+
counts[t]++;
|
|
7092
|
+
}
|
|
7093
|
+
}
|
|
7094
|
+
const sortedSurfaceElements = sortByFractionalIndex(surfaceElements);
|
|
7095
|
+
let minX = Infinity;
|
|
7096
|
+
let minY = Infinity;
|
|
7097
|
+
let maxX = -Infinity;
|
|
7098
|
+
let maxY = -Infinity;
|
|
7099
|
+
let hasAny = false;
|
|
7100
|
+
const include = (b) => {
|
|
7101
|
+
if (!b)
|
|
7102
|
+
return;
|
|
7103
|
+
hasAny = true;
|
|
7104
|
+
minX = Math.min(minX, b.x);
|
|
7105
|
+
minY = Math.min(minY, b.y);
|
|
7106
|
+
maxX = Math.max(maxX, b.x + b.width);
|
|
7107
|
+
maxY = Math.max(maxY, b.y + b.height);
|
|
7108
|
+
};
|
|
7109
|
+
const sortedEdgelessBlocks = sortByFractionalIndex(edgelessBlocks);
|
|
7110
|
+
for (const eb of sortedEdgelessBlocks)
|
|
7111
|
+
include(eb.bounds);
|
|
7112
|
+
for (const se of sortedSurfaceElements)
|
|
7113
|
+
include(se.bounds);
|
|
7114
|
+
return text({
|
|
7115
|
+
docId: params.docId,
|
|
7116
|
+
exists: true,
|
|
7117
|
+
surfaceBlockId: ctx?.surfaceId ?? null,
|
|
7118
|
+
edgelessBlocks: sortedEdgelessBlocks,
|
|
7119
|
+
surfaceElements: sortedSurfaceElements,
|
|
7120
|
+
elementCounts: counts,
|
|
7121
|
+
bounds: hasAny
|
|
7122
|
+
? { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY }
|
|
7123
|
+
: null,
|
|
7124
|
+
});
|
|
7125
|
+
}
|
|
7126
|
+
finally {
|
|
7127
|
+
socket.disconnect();
|
|
7128
|
+
}
|
|
7129
|
+
};
|
|
7130
|
+
const surfaceElementFieldSchemas = {
|
|
7131
|
+
x: z.number().optional().describe("X position on canvas (shape/text; default 0)."),
|
|
7132
|
+
y: z.number().optional().describe("Y position on canvas (shape/text; default 0)."),
|
|
7133
|
+
width: z.number().optional().describe("Width (shape default 100, text default 200)."),
|
|
7134
|
+
height: z.number().optional().describe("Height (shape default 100, text default 30)."),
|
|
7135
|
+
shapeType: z
|
|
7136
|
+
.enum(["rect", "ellipse", "diamond", "triangle"])
|
|
7137
|
+
.optional()
|
|
7138
|
+
.describe("Shape type (default rect). Shape only."),
|
|
7139
|
+
radius: z.number().optional().describe("Corner radius for rect (0.1 = rounded). Shape only."),
|
|
7140
|
+
filled: z.boolean().optional().describe("Whether shape is filled (default true). Shape only."),
|
|
7141
|
+
fillColor: z
|
|
7142
|
+
.string()
|
|
7143
|
+
.optional()
|
|
7144
|
+
.describe("Fill color. Prefer the `--affine-palette-shape-<color>` family (yellow/orange/red/magenta/purple/navy/blue/green/teal/grey/white/black). These are fixed colors — AFFiNE shape colors are not theme-adaptive by design. Shape only."),
|
|
7145
|
+
strokeColor: z.string().optional().describe("Stroke color. Prefer the `--affine-palette-line-<color>` family (same color names as fillColor). Fixed colors, not theme-adaptive. Shape only."),
|
|
7146
|
+
strokeWidth: z.number().optional().describe("Stroke width (default 2). Shape/connector."),
|
|
7147
|
+
strokeStyle: z
|
|
7148
|
+
.enum(["solid", "dash", "none"])
|
|
7149
|
+
.optional()
|
|
7150
|
+
.describe("Stroke style. Shape/connector."),
|
|
7151
|
+
text: z
|
|
7152
|
+
.string()
|
|
7153
|
+
.optional()
|
|
7154
|
+
.describe("Text content (shape/text) or connector label. Replaces existing Y.Text on update."),
|
|
7155
|
+
color: z.string().optional().describe("Text color. Shape default `#000000` — keep unless the fill is dark, then pass a contrasting hex. Canvas text default `--affine-text-primary-color` (theme-adaptive). Shape/text."),
|
|
7156
|
+
fontSize: z
|
|
7157
|
+
.number()
|
|
7158
|
+
.optional()
|
|
7159
|
+
.describe("Font size (shape default 20, text default 16). Shape/text."),
|
|
7160
|
+
fontWeight: z
|
|
7161
|
+
.string()
|
|
7162
|
+
.optional()
|
|
7163
|
+
.describe("Font weight (shape default 600, text default 400). Shape/text."),
|
|
7164
|
+
sourceId: z.string().optional().describe("Connector source element id. Connector only."),
|
|
7165
|
+
targetId: z.string().optional().describe("Connector target element id. Connector only."),
|
|
7166
|
+
sourcePosition: z
|
|
7167
|
+
.array(z.number())
|
|
7168
|
+
.length(2)
|
|
7169
|
+
.optional()
|
|
7170
|
+
.describe("Source [x,y]: relative [0-1] if sourceId set, absolute otherwise. Connector only."),
|
|
7171
|
+
targetPosition: z
|
|
7172
|
+
.array(z.number())
|
|
7173
|
+
.length(2)
|
|
7174
|
+
.optional()
|
|
7175
|
+
.describe("Target [x,y]: relative [0-1] if targetId set, absolute otherwise. Connector only. When both source/target are bound by id and neither position is provided, endpoints snap to the BlockSuite side-midpoint facing the other endpoint so connectors flow in a clear direction. Pass [0.5,0] top, [0.5,1] bottom, [0,0.5] left, [1,0.5] right to force a specific side."),
|
|
7176
|
+
mode: z
|
|
7177
|
+
.number()
|
|
7178
|
+
.optional()
|
|
7179
|
+
.describe("Connector mode: 0=straight, 1=orthogonal (elbow), 2=curve (default 2). Connector only."),
|
|
7180
|
+
frontEndpointStyle: z
|
|
7181
|
+
.enum(["None", "Arrow", "Triangle", "Circle", "Diamond"])
|
|
7182
|
+
.optional()
|
|
7183
|
+
.describe("Front endpoint style (default None). Connector only."),
|
|
7184
|
+
rearEndpointStyle: z
|
|
7185
|
+
.enum(["None", "Arrow", "Triangle", "Circle", "Diamond"])
|
|
7186
|
+
.optional()
|
|
7187
|
+
.describe("Rear endpoint style (default Arrow). Connector only."),
|
|
7188
|
+
stroke: z.string().optional().describe("Connector stroke color (default '--affine-text-primary-color' — theme-adaptive, near-black in light / near-white in dark). Accepts any CSS color or AFFiNE palette token. Connector only."),
|
|
7189
|
+
label: z.string().optional().describe("Connector label (stored as text on the connector). Connector only."),
|
|
7190
|
+
children: z.array(z.string()).optional().describe("Child element ids. Group only."),
|
|
7191
|
+
title: z.string().optional().describe("Group title. Group only."),
|
|
7192
|
+
index: z
|
|
7193
|
+
.string()
|
|
7194
|
+
.optional()
|
|
7195
|
+
.describe("BlockSuite fractional-index string controlling z-order. On add, defaults to a key above every existing element's index (new elements render on top). On update, replaces the stored value — pass a key less than some existing index to send-to-back, or greater to bring-to-front. Use the value returned by list_surface_elements to pick a specific position."),
|
|
7196
|
+
};
|
|
7197
|
+
server.registerTool("add_surface_element", {
|
|
7198
|
+
title: "Add Surface Element",
|
|
7199
|
+
description: "Add a shape, connector, text, or group to the AFFiNE edgeless canvas surface. Shapes support rect/ellipse/diamond/triangle with fill, stroke, and text. Connectors draw arrows between shapes (by id) or between absolute points. Use for building diagrams programmatically. Style fields that don't apply to the chosen element type are reported in the response 'ignored' list (mirrors update_surface_element).",
|
|
7200
|
+
inputSchema: {
|
|
7201
|
+
workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
|
|
7202
|
+
docId: DocId.describe("Document ID"),
|
|
7203
|
+
type: z.enum(["shape", "connector", "text", "group"]).describe("Element type"),
|
|
7204
|
+
...surfaceElementFieldSchemas,
|
|
7205
|
+
},
|
|
7206
|
+
}, addSurfaceElementHandler);
|
|
7207
|
+
server.registerTool("list_surface_elements", {
|
|
7208
|
+
title: "List Surface Elements",
|
|
7209
|
+
description: "List all shape/connector/text/group elements on the AFFiNE edgeless canvas surface. Returns raw xywh strings plus parsed {x,y,width,height} bounds, with Y.Text fields serialized to plain strings. Optional filters by element type or id.",
|
|
7210
|
+
inputSchema: {
|
|
7211
|
+
workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
|
|
7212
|
+
docId: DocId.describe("Document ID"),
|
|
7213
|
+
type: z
|
|
7214
|
+
.enum(["shape", "connector", "text", "group"])
|
|
7215
|
+
.optional()
|
|
7216
|
+
.describe("Filter by element type"),
|
|
7217
|
+
elementId: z.string().optional().describe("Filter to a single element id"),
|
|
7218
|
+
},
|
|
7219
|
+
}, listSurfaceElementsHandler);
|
|
7220
|
+
server.registerTool("update_surface_element", {
|
|
7221
|
+
title: "Update Surface Element",
|
|
7222
|
+
description: "Partially update a surface element by id. x/y/width/height merge with the element's current xywh (move without resizing, or vice versa). text/label/title replace their Y.Text wholesale. Fields that don't apply to the element's type are reported in the response 'ignored' list.",
|
|
7223
|
+
inputSchema: {
|
|
7224
|
+
workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
|
|
7225
|
+
docId: DocId.describe("Document ID"),
|
|
7226
|
+
elementId: z.string().min(1).describe("Element ID to update"),
|
|
7227
|
+
...surfaceElementFieldSchemas,
|
|
7228
|
+
},
|
|
7229
|
+
}, updateSurfaceElementHandler);
|
|
7230
|
+
server.registerTool("delete_surface_element", {
|
|
7231
|
+
title: "Delete Surface Element",
|
|
7232
|
+
description: "Delete a surface element by id. Set pruneConnectors=true to also delete any connectors whose source or target referenced the deleted element.",
|
|
7233
|
+
inputSchema: {
|
|
7234
|
+
workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
|
|
7235
|
+
docId: DocId.describe("Document ID"),
|
|
7236
|
+
elementId: z.string().min(1).describe("Element ID to delete"),
|
|
7237
|
+
pruneConnectors: z
|
|
7238
|
+
.boolean()
|
|
7239
|
+
.optional()
|
|
7240
|
+
.describe("Also delete connectors referencing this element (default false)"),
|
|
7241
|
+
},
|
|
7242
|
+
}, deleteSurfaceElementHandler);
|
|
7243
|
+
server.registerTool("update_frame_children", {
|
|
7244
|
+
title: "Update Frame Children",
|
|
7245
|
+
description: "Replace a frame block's contents wholesale. Accepts ids of surface elements (shapes/connectors/groups) AND edgeless blocks (notes/frames/edgeless-text) — all go into BlockSuite's prop:childElementIds map, matching what the editor writes when you drag members into a frame. Dragging the frame drags every owned member. Ids that don't resolve come back under 'missing'. By default the frame is resized to fit its new contents (plus padding + title band); set resizeToFit=false to leave xywh untouched. Pass `[]` to clear ownership (resize is skipped in that case).",
|
|
7246
|
+
inputSchema: {
|
|
7247
|
+
workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
|
|
7248
|
+
docId: DocId.describe("Document ID"),
|
|
7249
|
+
blockId: z.string().min(1).describe("Frame block id (flavour affine:frame)."),
|
|
7250
|
+
childElementIds: z
|
|
7251
|
+
.array(z.string())
|
|
7252
|
+
.describe("Full list of ids the frame should own/contain. Replaces any existing ownership."),
|
|
7253
|
+
resizeToFit: z
|
|
7254
|
+
.boolean()
|
|
7255
|
+
.optional()
|
|
7256
|
+
.describe("If true (default), recompute xywh from the union of resolvable child bounds + padding + title band. Set to false to preserve the frame's current box (useful when you want ownership-only edits or manual positioning)."),
|
|
7257
|
+
padding: z
|
|
7258
|
+
.number()
|
|
7259
|
+
.int()
|
|
7260
|
+
.optional()
|
|
7261
|
+
.describe("Padding (px) used when resizeToFit is true (default 40). Ignored when resizeToFit=false."),
|
|
7262
|
+
},
|
|
7263
|
+
}, updateFrameChildrenHandler);
|
|
7264
|
+
server.registerTool("update_edgeless_block", {
|
|
7265
|
+
title: "Update Edgeless Block",
|
|
7266
|
+
description: "Partially update a note/frame/edgeless-text block by id. x/y/width/height merge with current prop:xywh (move without resizing, or vice versa). background replaces prop:background (AFFiNE token or `{light, dark}` hex object). Fields that don't apply to the block's flavour come back under `ignored`.",
|
|
7267
|
+
inputSchema: {
|
|
7268
|
+
workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
|
|
7269
|
+
docId: DocId.describe("Document ID"),
|
|
7270
|
+
blockId: z.string().min(1).describe("Block id (flavour affine:note/affine:frame/affine:edgeless-text)."),
|
|
7271
|
+
x: z.number().optional(),
|
|
7272
|
+
y: z.number().optional(),
|
|
7273
|
+
width: z.number().optional(),
|
|
7274
|
+
height: z.number().optional(),
|
|
7275
|
+
background: z
|
|
7276
|
+
.union([
|
|
7277
|
+
z.string(),
|
|
7278
|
+
z.object({ light: z.string().optional(), dark: z.string().optional() }),
|
|
7279
|
+
])
|
|
7280
|
+
.optional()
|
|
7281
|
+
.describe("Note/frame only. Prefer `--affine-note-background-<color>` or `{light, dark}` hex."),
|
|
7282
|
+
},
|
|
7283
|
+
}, updateEdgelessBlockHandler);
|
|
7284
|
+
server.registerTool("delete_block", {
|
|
7285
|
+
title: "Delete Block",
|
|
7286
|
+
description: "Delete a block by id. Removes descendants and unlinks from the parent's sys:children by default; set deleteChildren=false to keep descendants orphaned (for re-parenting), or pruneConnectors=true to also drop surface connectors referencing any deleted id. Refuses affine:page — use delete_doc for whole docs.",
|
|
7287
|
+
inputSchema: {
|
|
7288
|
+
workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
|
|
7289
|
+
docId: DocId.describe("Document ID"),
|
|
7290
|
+
blockId: z.string().min(1).describe("Block id to delete."),
|
|
7291
|
+
deleteChildren: z.boolean().optional().describe("Also delete descendants (default true)."),
|
|
7292
|
+
pruneConnectors: z.boolean().optional().describe("Also delete connectors bound to any deleted id (default false)."),
|
|
7293
|
+
},
|
|
7294
|
+
}, deleteBlockHandler);
|
|
7295
|
+
server.registerTool("get_edgeless_canvas", {
|
|
7296
|
+
title: "Get Edgeless Canvas",
|
|
7297
|
+
description: "Read the full edgeless canvas: all edgeless-positioned blocks (notes, frames, edgeless-text) with their xywh, plus all surface elements (shapes, connectors, text, groups). Includes aggregate bounding box and per-type element counts. Use this when you need to understand canvas layout end-to-end before placing new elements.",
|
|
7298
|
+
inputSchema: {
|
|
7299
|
+
workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
|
|
7300
|
+
docId: DocId.describe("Document ID"),
|
|
7301
|
+
},
|
|
7302
|
+
}, getEdgelessCanvasHandler);
|
|
4666
7303
|
}
|