affine-mcp-server 1.13.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 +9 -8
- package/dist/edgeless/layout.js +222 -0
- package/dist/index.js +34 -62
- package/dist/toolSurface.js +322 -0
- package/dist/tools/comments.js +0 -57
- package/dist/tools/docs.js +1675 -606
- package/docs/assets/edgeless-canvas-demo-advanced-dark.png +0 -0
- package/docs/assets/edgeless-canvas-demo-advanced-light.png +0 -0
- package/docs/configuration-and-deployment.md +58 -1
- package/docs/edgeless-canvas-cookbook.md +226 -0
- package/docs/tool-reference.md +28 -13
- package/docs/workflow-recipes.md +10 -10
- package/package.json +5 -1
- package/tool-manifest.json +9 -12
package/dist/tools/docs.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { generateKeyBetween } from "fractional-indexing";
|
|
2
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");
|
|
@@ -519,6 +521,24 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
519
521
|
}
|
|
520
522
|
return null;
|
|
521
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
|
+
}
|
|
522
542
|
function ensureNoteBlock(blocks) {
|
|
523
543
|
const existingNoteId = findBlockIdByFlavour(blocks, "affine:note");
|
|
524
544
|
if (existingNoteId) {
|
|
@@ -528,20 +548,29 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
528
548
|
if (!pageId) {
|
|
529
549
|
throw new Error("Document has no page block; unable to insert content.");
|
|
530
550
|
}
|
|
551
|
+
// Mirror BlockSuite's createDefaultDoc shape so the editor doesn't re-seed
|
|
552
|
+
// its own default note alongside ours.
|
|
531
553
|
const noteId = generateId();
|
|
532
554
|
const note = new Y.Map();
|
|
533
555
|
setSysFields(note, noteId, "affine:note");
|
|
534
556
|
note.set("sys:parent", null);
|
|
535
|
-
|
|
536
|
-
note.set("
|
|
557
|
+
const noteChildren = new Y.Array();
|
|
558
|
+
note.set("sys:children", noteChildren);
|
|
559
|
+
note.set("prop:xywh", DEFAULT_NOTE_XYWH);
|
|
537
560
|
note.set("prop:index", "a0");
|
|
538
561
|
note.set("prop:hidden", false);
|
|
539
562
|
note.set("prop:displayMode", "both");
|
|
540
|
-
|
|
541
|
-
background.set("light", "#ffffff");
|
|
542
|
-
background.set("dark", "#252525");
|
|
543
|
-
note.set("prop:background", background);
|
|
563
|
+
note.set("prop:background", buildDefaultNoteBackground());
|
|
544
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]);
|
|
545
574
|
const page = blocks.get(pageId);
|
|
546
575
|
let pageChildren = page.get("sys:children");
|
|
547
576
|
if (!(pageChildren instanceof Y.Array)) {
|
|
@@ -551,6 +580,12 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
551
580
|
pageChildren.push([noteId]);
|
|
552
581
|
return noteId;
|
|
553
582
|
}
|
|
583
|
+
function buildDefaultNoteBackground() {
|
|
584
|
+
const map = new Y.Map();
|
|
585
|
+
map.set("light", "#ffffff");
|
|
586
|
+
map.set("dark", "#252525");
|
|
587
|
+
return map;
|
|
588
|
+
}
|
|
554
589
|
function ensureSurfaceBlock(blocks) {
|
|
555
590
|
const existingSurfaceId = findBlockIdByFlavour(blocks, "affine:surface");
|
|
556
591
|
if (existingSurfaceId) {
|
|
@@ -834,9 +869,20 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
834
869
|
const design = parsed.design ?? "";
|
|
835
870
|
const reference = (parsed.reference ?? "").trim();
|
|
836
871
|
const refFlavour = (parsed.refFlavour ?? "").trim();
|
|
837
|
-
const
|
|
838
|
-
const
|
|
839
|
-
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");
|
|
840
886
|
const sourceId = (parsed.sourceId ?? "").trim();
|
|
841
887
|
const name = (parsed.name ?? "attachment").trim() || "attachment";
|
|
842
888
|
const mimeType = (parsed.mimeType ?? "application/octet-stream").trim() || "application/octet-stream";
|
|
@@ -860,6 +906,8 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
860
906
|
design,
|
|
861
907
|
reference,
|
|
862
908
|
refFlavour,
|
|
909
|
+
x,
|
|
910
|
+
y,
|
|
863
911
|
width,
|
|
864
912
|
height,
|
|
865
913
|
background,
|
|
@@ -882,6 +930,14 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
882
930
|
tableData,
|
|
883
931
|
deltas: parsed.deltas,
|
|
884
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,
|
|
885
941
|
};
|
|
886
942
|
validateNormalizedAppendBlockInput(normalized, parsed);
|
|
887
943
|
return normalized;
|
|
@@ -1565,10 +1621,15 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
1565
1621
|
block.set("sys:parent", null);
|
|
1566
1622
|
block.set("sys:children", new Y.Array());
|
|
1567
1623
|
block.set("prop:title", makeText(content || "Frame"));
|
|
1568
|
-
|
|
1569
|
-
|
|
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}]`);
|
|
1570
1628
|
block.set("prop:index", "a0");
|
|
1571
|
-
|
|
1629
|
+
const childIds = new Y.Map();
|
|
1630
|
+
for (const id of normalized._frameOwnedIds ?? [])
|
|
1631
|
+
childIds.set(id, true);
|
|
1632
|
+
block.set("prop:childElementIds", childIds);
|
|
1572
1633
|
block.set("prop:presentationIndex", "a0");
|
|
1573
1634
|
block.set("prop:lockedBySelf", false);
|
|
1574
1635
|
return { blockId, block, flavour: "affine:frame" };
|
|
@@ -1576,27 +1637,54 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
1576
1637
|
case "edgeless_text": {
|
|
1577
1638
|
setSysFields(block, blockId, "affine:edgeless-text");
|
|
1578
1639
|
block.set("sys:parent", null);
|
|
1579
|
-
|
|
1580
|
-
block.set("prop:xywh", `[
|
|
1640
|
+
const edgelessTextChildren = new Y.Array();
|
|
1641
|
+
block.set("prop:xywh", `[${normalized.x},${normalized.y},${normalized.width},${normalized.height}]`);
|
|
1581
1642
|
block.set("prop:index", "a0");
|
|
1582
1643
|
block.set("prop:lockedBySelf", false);
|
|
1583
1644
|
block.set("prop:scale", 1);
|
|
1584
1645
|
block.set("prop:rotate", 0);
|
|
1585
1646
|
block.set("prop:hasMaxWidth", false);
|
|
1586
1647
|
block.set("prop:comments", undefined);
|
|
1587
|
-
|
|
1648
|
+
// Theme-adaptive token so canvas text stays legible in dark mode.
|
|
1649
|
+
block.set("prop:color", "--affine-text-primary-color");
|
|
1588
1650
|
block.set("prop:fontFamily", "Inter");
|
|
1589
1651
|
block.set("prop:fontStyle", "normal");
|
|
1590
1652
|
block.set("prop:fontWeight", "regular");
|
|
1591
1653
|
block.set("prop:textAlign", "left");
|
|
1592
|
-
|
|
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 };
|
|
1593
1668
|
}
|
|
1594
1669
|
case "note": {
|
|
1595
1670
|
setSysFields(block, blockId, "affine:note");
|
|
1596
1671
|
block.set("sys:parent", null);
|
|
1597
|
-
|
|
1598
|
-
block.set("prop:xywh", `[
|
|
1599
|
-
|
|
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
|
+
}
|
|
1600
1688
|
block.set("prop:index", "a0");
|
|
1601
1689
|
block.set("prop:lockedBySelf", false);
|
|
1602
1690
|
block.set("prop:hidden", false);
|
|
@@ -1610,7 +1698,152 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
1610
1698
|
edgeless.set("style", style);
|
|
1611
1699
|
block.set("prop:edgeless", edgeless);
|
|
1612
1700
|
block.set("prop:comments", undefined);
|
|
1613
|
-
|
|
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;
|
|
1614
1847
|
}
|
|
1615
1848
|
}
|
|
1616
1849
|
}
|
|
@@ -1631,6 +1864,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
1631
1864
|
}
|
|
1632
1865
|
const prevSV = Y.encodeStateVector(doc);
|
|
1633
1866
|
const blocks = doc.getMap("blocks");
|
|
1867
|
+
resolveEdgelessLayoutHints(blocks, normalized);
|
|
1634
1868
|
const context = resolveInsertContext(blocks, normalized);
|
|
1635
1869
|
const { blockId, block, flavour, blockType, extraBlocks } = createBlock(normalized);
|
|
1636
1870
|
blocks.set(blockId, block);
|
|
@@ -1647,7 +1881,16 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
1647
1881
|
}
|
|
1648
1882
|
const delta = Y.encodeStateAsUpdate(doc, prevSV);
|
|
1649
1883
|
await pushDocUpdate(socket, workspaceId, normalized.docId, Buffer.from(delta).toString("base64"));
|
|
1650
|
-
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
|
+
};
|
|
1651
1894
|
}
|
|
1652
1895
|
finally {
|
|
1653
1896
|
socket.disconnect();
|
|
@@ -2420,30 +2663,26 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
2420
2663
|
setSysFields(note, noteId, "affine:note");
|
|
2421
2664
|
note.set("sys:parent", null);
|
|
2422
2665
|
note.set("prop:displayMode", "both");
|
|
2423
|
-
note.set("prop:xywh",
|
|
2666
|
+
note.set("prop:xywh", DEFAULT_NOTE_XYWH);
|
|
2424
2667
|
note.set("prop:index", "a0");
|
|
2425
2668
|
note.set("prop:hidden", false);
|
|
2426
|
-
|
|
2427
|
-
background.set("light", "#ffffff");
|
|
2428
|
-
background.set("dark", "#252525");
|
|
2429
|
-
note.set("prop:background", background);
|
|
2669
|
+
note.set("prop:background", buildDefaultNoteBackground());
|
|
2430
2670
|
const noteChildren = new Y.Array();
|
|
2431
2671
|
note.set("sys:children", noteChildren);
|
|
2432
2672
|
blocks.set(noteId, note);
|
|
2433
2673
|
children.push([noteId]);
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
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)
|
|
2442
2682
|
paragraphText.insert(0, parsed.content);
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
}
|
|
2683
|
+
para.set("prop:text", paragraphText);
|
|
2684
|
+
blocks.set(paraId, para);
|
|
2685
|
+
noteChildren.push([paraId]);
|
|
2447
2686
|
const meta = ydoc.getMap("meta");
|
|
2448
2687
|
meta.set("id", docId);
|
|
2449
2688
|
meta.set("title", title);
|
|
@@ -2562,16 +2801,23 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
2562
2801
|
setSysFields(note, noteId, "affine:note");
|
|
2563
2802
|
note.set("sys:parent", null);
|
|
2564
2803
|
note.set("prop:displayMode", "both");
|
|
2565
|
-
note.set("prop:xywh",
|
|
2804
|
+
note.set("prop:xywh", DEFAULT_NOTE_XYWH);
|
|
2566
2805
|
note.set("prop:index", "a0");
|
|
2567
2806
|
note.set("prop:hidden", false);
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
note.set("prop:background", background);
|
|
2572
|
-
note.set("sys:children", new Y.Array());
|
|
2807
|
+
note.set("prop:background", buildDefaultNoteBackground());
|
|
2808
|
+
const skeletonNoteChildren = new Y.Array();
|
|
2809
|
+
note.set("sys:children", skeletonNoteChildren);
|
|
2573
2810
|
blocks.set(noteId, note);
|
|
2574
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]);
|
|
2575
2821
|
const meta = doc.getMap("meta");
|
|
2576
2822
|
meta.set("id", docId);
|
|
2577
2823
|
meta.set("title", title);
|
|
@@ -3553,24 +3799,30 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3553
3799
|
knownUnsupportedFlavours: [...KNOWN_BLOCK_FLAVOURS].filter(flavour => !MARKDOWN_EXPORT_SUPPORTED_FLAVOURS.has(flavour)).sort(),
|
|
3554
3800
|
},
|
|
3555
3801
|
highLevelAuthoring: {
|
|
3556
|
-
semanticPageComposer:
|
|
3557
|
-
nativeTemplateInstantiation:
|
|
3558
|
-
createDocWithPlacement:
|
|
3559
|
-
semanticSectionEditing:
|
|
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,
|
|
3560
3812
|
},
|
|
3561
3813
|
},
|
|
3562
3814
|
database: {
|
|
3563
3815
|
supported: true,
|
|
3564
3816
|
columnTypes: [...DATABASE_COLUMN_TYPE_VALUES],
|
|
3565
3817
|
initialViewModes: [...APPEND_BLOCK_DATA_VIEW_MODE_VALUES],
|
|
3566
|
-
advancedViewMutation:
|
|
3567
|
-
intentDrivenComposition:
|
|
3818
|
+
advancedViewMutation: true,
|
|
3819
|
+
intentDrivenComposition: true,
|
|
3568
3820
|
linkedDocRows: true,
|
|
3569
3821
|
},
|
|
3570
3822
|
workspace: {
|
|
3571
3823
|
organizeToolsExperimental: true,
|
|
3572
|
-
ruleBackedCollections:
|
|
3573
|
-
workspaceBlueprints:
|
|
3824
|
+
ruleBackedCollections: true,
|
|
3825
|
+
workspaceBlueprints: true,
|
|
3574
3826
|
},
|
|
3575
3827
|
collaboration: {
|
|
3576
3828
|
docComments: true,
|
|
@@ -3859,36 +4111,31 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3859
4111
|
callouts: z.array(z.string().min(1)).optional().describe("Callout blocks to append under the new section."),
|
|
3860
4112
|
},
|
|
3861
4113
|
}, appendSemanticSectionHandler);
|
|
3862
|
-
// APPEND PARAGRAPH
|
|
3863
|
-
const appendParagraphHandler = async (parsed) => {
|
|
3864
|
-
const result = await appendBlockInternal({
|
|
3865
|
-
workspaceId: parsed.workspaceId,
|
|
3866
|
-
docId: parsed.docId,
|
|
3867
|
-
type: "paragraph",
|
|
3868
|
-
text: parsed.text,
|
|
3869
|
-
});
|
|
3870
|
-
return receipt("doc.append_paragraph", {
|
|
3871
|
-
workspaceId: parsed.workspaceId || defaults.workspaceId || null,
|
|
3872
|
-
docId: parsed.docId,
|
|
3873
|
-
appended: result.appended,
|
|
3874
|
-
blockId: result.blockId,
|
|
3875
|
-
paragraphId: result.blockId,
|
|
3876
|
-
blockType: result.blockType || null,
|
|
3877
|
-
normalizedType: result.normalizedType,
|
|
3878
|
-
legacyType: result.legacyType,
|
|
3879
|
-
});
|
|
3880
|
-
};
|
|
3881
|
-
server.registerTool('append_paragraph', {
|
|
3882
|
-
title: 'Append Paragraph',
|
|
3883
|
-
description: 'Append a text paragraph block to a document',
|
|
3884
|
-
inputSchema: {
|
|
3885
|
-
workspaceId: z.string().optional(),
|
|
3886
|
-
docId: z.string(),
|
|
3887
|
-
text: z.string(),
|
|
3888
|
-
},
|
|
3889
|
-
}, appendParagraphHandler);
|
|
3890
4114
|
const appendBlockHandler = async (parsed) => {
|
|
3891
|
-
|
|
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
|
+
}
|
|
3892
4139
|
return receipt("doc.append_block", {
|
|
3893
4140
|
workspaceId: parsed.workspaceId || defaults.workspaceId || null,
|
|
3894
4141
|
docId: parsed.docId,
|
|
@@ -3899,6 +4146,9 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3899
4146
|
blockType: result.blockType || null,
|
|
3900
4147
|
normalizedType: result.normalizedType,
|
|
3901
4148
|
legacyType: result.legacyType,
|
|
4149
|
+
...(result.ownedIds ? { ownedIds: result.ownedIds } : {}),
|
|
4150
|
+
...(result.missing ? { missing: result.missing } : {}),
|
|
4151
|
+
...(markdownApplied ? { markdown: markdownApplied } : {}),
|
|
3902
4152
|
});
|
|
3903
4153
|
};
|
|
3904
4154
|
server.registerTool("append_block", {
|
|
@@ -3916,9 +4166,19 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3916
4166
|
design: z.string().optional().describe("Design payload for embed_html"),
|
|
3917
4167
|
reference: z.string().optional().describe("Target id for surface_ref"),
|
|
3918
4168
|
refFlavour: z.string().optional().describe("Target flavour for surface_ref (e.g. affine:frame)"),
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
|
|
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)."),
|
|
3922
4182
|
sourceId: z.string().optional().describe("Blob source id for image/attachment"),
|
|
3923
4183
|
name: z.string().optional().describe("Attachment file name"),
|
|
3924
4184
|
mimeType: z.string().optional().describe("Attachment mime type"),
|
|
@@ -4103,7 +4363,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4103
4363
|
},
|
|
4104
4364
|
}, exportWithFidelityReportHandler);
|
|
4105
4365
|
// Core logic for creating a doc from markdown — returns structured data, no MCP envelope.
|
|
4106
|
-
// Used by
|
|
4366
|
+
// Used by createDocFromMarkdownHandler and internal markdown-based flows.
|
|
4107
4367
|
const createDocFromMarkdownCore = async (parsed) => {
|
|
4108
4368
|
const parsedMarkdown = parseMarkdownToOperations(parsed.markdown);
|
|
4109
4369
|
let operations = [...parsedMarkdown.operations];
|
|
@@ -4174,46 +4434,53 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4174
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)."),
|
|
4175
4435
|
},
|
|
4176
4436
|
}, createDocFromMarkdownHandler);
|
|
4177
|
-
|
|
4178
|
-
const batchCreateDocsHandler = async (parsed) => {
|
|
4437
|
+
const createDocFromTemplateCore = async (parsed) => {
|
|
4179
4438
|
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
4180
4439
|
if (!workspaceId)
|
|
4181
4440
|
throw new Error("workspaceId is required.");
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
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);
|
|
4197
4458
|
}
|
|
4198
|
-
|
|
4199
|
-
|
|
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();
|
|
4200
4479
|
}
|
|
4480
|
+
catch { /* already disconnected */ }
|
|
4481
|
+
throw err;
|
|
4201
4482
|
}
|
|
4202
|
-
const failed = results.filter(r => !r.docId).length;
|
|
4203
|
-
return text({ created: results.length - failed, failed, results });
|
|
4204
4483
|
};
|
|
4205
|
-
server.registerTool("batch_create_docs", {
|
|
4206
|
-
title: "Batch Create Documents",
|
|
4207
|
-
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.",
|
|
4208
|
-
inputSchema: {
|
|
4209
|
-
workspaceId: z.string().optional(),
|
|
4210
|
-
docs: z.array(z.object({
|
|
4211
|
-
title: z.string().describe("Document title."),
|
|
4212
|
-
markdown: z.string().describe("Markdown content."),
|
|
4213
|
-
parentDocId: z.string().optional().describe("Parent doc ID — if provided, the new doc is embedded under this parent in the sidebar."),
|
|
4214
|
-
})).min(1).max(20).describe("Array of docs to create (max 20)."),
|
|
4215
|
-
},
|
|
4216
|
-
}, batchCreateDocsHandler);
|
|
4217
4484
|
const appendMarkdownHandler = async (parsed) => {
|
|
4218
4485
|
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
4219
4486
|
if (!workspaceId) {
|
|
@@ -4352,194 +4619,6 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4352
4619
|
description: 'Delete a document and remove from workspace list',
|
|
4353
4620
|
inputSchema: { workspaceId: z.string().optional(), docId: z.string() },
|
|
4354
4621
|
}, deleteDocHandler);
|
|
4355
|
-
// ─── cleanup_orphan_embeds ──────────────────────────────────────────────────
|
|
4356
|
-
const cleanupOrphanEmbedsHandler = async (parsed) => {
|
|
4357
|
-
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
4358
|
-
if (!workspaceId)
|
|
4359
|
-
throw new Error("workspaceId is required.");
|
|
4360
|
-
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
4361
|
-
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
4362
|
-
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
4363
|
-
try {
|
|
4364
|
-
await joinWorkspace(socket, workspaceId);
|
|
4365
|
-
const snap = await loadDoc(socket, workspaceId, parsed.docId);
|
|
4366
|
-
if (!snap.missing)
|
|
4367
|
-
throw new Error(`Doc ${parsed.docId} not found.`);
|
|
4368
|
-
const doc = new Y.Doc();
|
|
4369
|
-
Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
|
|
4370
|
-
const blocks = doc.getMap("blocks");
|
|
4371
|
-
const orphans = [];
|
|
4372
|
-
for (const [blockId, raw] of blocks) {
|
|
4373
|
-
if (!(raw instanceof Y.Map))
|
|
4374
|
-
continue;
|
|
4375
|
-
if (raw.get("sys:flavour") !== "affine:embed-linked-doc")
|
|
4376
|
-
continue;
|
|
4377
|
-
const targetId = raw.get("prop:pageId");
|
|
4378
|
-
if (typeof targetId !== "string" || !targetId) {
|
|
4379
|
-
orphans.push({ blockId, targetDocId: targetId ?? "" });
|
|
4380
|
-
continue;
|
|
4381
|
-
}
|
|
4382
|
-
const targetSnap = await loadDoc(socket, workspaceId, targetId);
|
|
4383
|
-
if (!targetSnap.missing)
|
|
4384
|
-
orphans.push({ blockId, targetDocId: targetId });
|
|
4385
|
-
}
|
|
4386
|
-
if (parsed.dryRun || orphans.length === 0) {
|
|
4387
|
-
return text({ docId: parsed.docId, dryRun: parsed.dryRun ?? false, orphansFound: orphans.length, orphans });
|
|
4388
|
-
}
|
|
4389
|
-
const prevSV = Y.encodeStateVector(doc);
|
|
4390
|
-
for (const { blockId } of orphans) {
|
|
4391
|
-
for (const [, parentRaw] of blocks) {
|
|
4392
|
-
if (!(parentRaw instanceof Y.Map))
|
|
4393
|
-
continue;
|
|
4394
|
-
const children = parentRaw.get("sys:children");
|
|
4395
|
-
if (!(children instanceof Y.Array))
|
|
4396
|
-
continue;
|
|
4397
|
-
const ids = childIdsFrom(children);
|
|
4398
|
-
const idx = ids.indexOf(blockId);
|
|
4399
|
-
if (idx !== -1) {
|
|
4400
|
-
children.delete(idx, 1);
|
|
4401
|
-
break;
|
|
4402
|
-
}
|
|
4403
|
-
}
|
|
4404
|
-
blocks.delete(blockId);
|
|
4405
|
-
}
|
|
4406
|
-
const delta = Y.encodeStateAsUpdate(doc, prevSV);
|
|
4407
|
-
await pushDocUpdate(socket, workspaceId, parsed.docId, Buffer.from(delta).toString("base64"));
|
|
4408
|
-
return text({ docId: parsed.docId, dryRun: false, orphansRemoved: orphans.length, orphans });
|
|
4409
|
-
}
|
|
4410
|
-
finally {
|
|
4411
|
-
socket.disconnect();
|
|
4412
|
-
}
|
|
4413
|
-
};
|
|
4414
|
-
server.registerTool("cleanup_orphan_embeds", {
|
|
4415
|
-
title: "Cleanup Orphan Embed Links",
|
|
4416
|
-
description: "Remove embed_linked_doc blocks that point to deleted/non-existent docs. Use dryRun=true to preview without making changes.",
|
|
4417
|
-
inputSchema: {
|
|
4418
|
-
workspaceId: z.string().optional(),
|
|
4419
|
-
docId: z.string().describe("The doc to clean up orphan embeds from."),
|
|
4420
|
-
dryRun: z.boolean().optional().describe("If true, only report orphans without deleting (default: false)."),
|
|
4421
|
-
},
|
|
4422
|
-
}, cleanupOrphanEmbedsHandler);
|
|
4423
|
-
// ─── find_and_replace ───────────────────────────────────────────────────────
|
|
4424
|
-
const findAndReplaceHandler = async (parsed) => {
|
|
4425
|
-
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
4426
|
-
if (!workspaceId)
|
|
4427
|
-
throw new Error("workspaceId is required.");
|
|
4428
|
-
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
4429
|
-
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
4430
|
-
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
4431
|
-
try {
|
|
4432
|
-
await joinWorkspace(socket, workspaceId);
|
|
4433
|
-
const snap = await loadDoc(socket, workspaceId, parsed.docId);
|
|
4434
|
-
if (!snap.missing)
|
|
4435
|
-
throw new Error(`Doc ${parsed.docId} not found.`);
|
|
4436
|
-
const doc = new Y.Doc();
|
|
4437
|
-
Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
|
|
4438
|
-
const blocks = doc.getMap("blocks");
|
|
4439
|
-
let totalMatches = 0;
|
|
4440
|
-
const matchLog = [];
|
|
4441
|
-
const matchAll = parsed.matchAll !== false;
|
|
4442
|
-
for (const [blockId, raw] of blocks) {
|
|
4443
|
-
if (!(raw instanceof Y.Map))
|
|
4444
|
-
continue;
|
|
4445
|
-
const flavour = raw.get("sys:flavour");
|
|
4446
|
-
for (const [, val] of raw) {
|
|
4447
|
-
if (!(val instanceof Y.Text))
|
|
4448
|
-
continue;
|
|
4449
|
-
const original = val.toString();
|
|
4450
|
-
if (!original.includes(parsed.search))
|
|
4451
|
-
continue;
|
|
4452
|
-
const replaced = matchAll
|
|
4453
|
-
? original.split(parsed.search).join(parsed.replace)
|
|
4454
|
-
: original.replace(parsed.search, parsed.replace);
|
|
4455
|
-
const count = matchAll ? original.split(parsed.search).length - 1 : 1;
|
|
4456
|
-
totalMatches += count;
|
|
4457
|
-
matchLog.push({ blockId, flavour: flavour ?? "unknown", original, replaced });
|
|
4458
|
-
if (!parsed.dryRun) {
|
|
4459
|
-
const prevSV = Y.encodeStateVector(doc);
|
|
4460
|
-
val.delete(0, val.length);
|
|
4461
|
-
val.insert(0, replaced);
|
|
4462
|
-
const delta = Y.encodeStateAsUpdate(doc, prevSV);
|
|
4463
|
-
await pushDocUpdate(socket, workspaceId, parsed.docId, Buffer.from(delta).toString("base64"));
|
|
4464
|
-
}
|
|
4465
|
-
}
|
|
4466
|
-
}
|
|
4467
|
-
return text({
|
|
4468
|
-
docId: parsed.docId, search: parsed.search, replace: parsed.replace,
|
|
4469
|
-
dryRun: parsed.dryRun ?? false, totalMatches, blocksAffected: matchLog.length, matches: matchLog,
|
|
4470
|
-
});
|
|
4471
|
-
}
|
|
4472
|
-
finally {
|
|
4473
|
-
socket.disconnect();
|
|
4474
|
-
}
|
|
4475
|
-
};
|
|
4476
|
-
server.registerTool("find_and_replace", {
|
|
4477
|
-
title: "Find and Replace in Document",
|
|
4478
|
-
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.",
|
|
4479
|
-
inputSchema: {
|
|
4480
|
-
workspaceId: z.string().optional(),
|
|
4481
|
-
docId: z.string().describe("The doc to search in."),
|
|
4482
|
-
search: z.string().min(1).describe("Text to find (must not be empty)."),
|
|
4483
|
-
replace: z.string().describe("Replacement text."),
|
|
4484
|
-
matchAll: z.boolean().optional().describe("Replace all occurrences (default: true)."),
|
|
4485
|
-
dryRun: z.boolean().optional().describe("If true, only report matches without replacing (default: false)."),
|
|
4486
|
-
},
|
|
4487
|
-
}, findAndReplaceHandler);
|
|
4488
|
-
// ─── get_docs_by_tag ────────────────────────────────────────────────────────
|
|
4489
|
-
const getDocsByTagHandler = async (parsed) => {
|
|
4490
|
-
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
4491
|
-
if (!workspaceId)
|
|
4492
|
-
throw new Error("workspaceId is required.");
|
|
4493
|
-
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
4494
|
-
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
4495
|
-
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
4496
|
-
try {
|
|
4497
|
-
await joinWorkspace(socket, workspaceId);
|
|
4498
|
-
const wsSnap = await loadDoc(socket, workspaceId, workspaceId);
|
|
4499
|
-
if (!wsSnap.missing)
|
|
4500
|
-
return text({ tag: parsed.tag, count: 0, docs: [] });
|
|
4501
|
-
const wsDoc = new Y.Doc();
|
|
4502
|
-
Y.applyUpdate(wsDoc, Buffer.from(wsSnap.missing, "base64"));
|
|
4503
|
-
const meta = wsDoc.getMap("meta");
|
|
4504
|
-
const { byId, options } = getWorkspaceTagOptionMaps(meta);
|
|
4505
|
-
const q = parsed.tag.toLowerCase();
|
|
4506
|
-
const matchingTagIds = new Set(options.filter(o => o.value.toLowerCase().includes(q)).map(o => o.id));
|
|
4507
|
-
if (matchingTagIds.size === 0) {
|
|
4508
|
-
return text({
|
|
4509
|
-
tag: parsed.tag,
|
|
4510
|
-
count: 0,
|
|
4511
|
-
docs: [],
|
|
4512
|
-
availableTags: options.map(o => o.value),
|
|
4513
|
-
});
|
|
4514
|
-
}
|
|
4515
|
-
const pages = getWorkspacePageEntries(meta);
|
|
4516
|
-
const baseUrl = (process.env.AFFINE_BASE_URL || endpoint.replace(/\/graphql\/?$/, '')).replace(/\/$/, '');
|
|
4517
|
-
const matched = pages
|
|
4518
|
-
.map(p => {
|
|
4519
|
-
const rawTagIds = getStringArray(p.tagsArray);
|
|
4520
|
-
return { p, rawTagIds };
|
|
4521
|
-
})
|
|
4522
|
-
.filter(({ rawTagIds }) => rawTagIds.some(tid => matchingTagIds.has(tid)))
|
|
4523
|
-
.map(({ p, rawTagIds }) => ({
|
|
4524
|
-
docId: p.id,
|
|
4525
|
-
title: p.title ?? "Untitled",
|
|
4526
|
-
tags: resolveTagLabels(rawTagIds, byId),
|
|
4527
|
-
url: `${baseUrl}/workspace/${workspaceId}/${p.id}`,
|
|
4528
|
-
}));
|
|
4529
|
-
return text({ tag: parsed.tag, count: matched.length, docs: matched });
|
|
4530
|
-
}
|
|
4531
|
-
finally {
|
|
4532
|
-
socket.disconnect();
|
|
4533
|
-
}
|
|
4534
|
-
};
|
|
4535
|
-
server.registerTool("get_docs_by_tag", {
|
|
4536
|
-
title: "Get Documents by Tag",
|
|
4537
|
-
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.",
|
|
4538
|
-
inputSchema: {
|
|
4539
|
-
workspaceId: z.string().optional(),
|
|
4540
|
-
tag: z.string().describe("Tag name to filter by (substring match, case-insensitive)."),
|
|
4541
|
-
},
|
|
4542
|
-
}, getDocsByTagHandler);
|
|
4543
4622
|
// ─── list_workspace_tree ────────────────────────────────────────────────────
|
|
4544
4623
|
const listWorkspaceTreeHandler = async (parsed) => {
|
|
4545
4624
|
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
@@ -4774,260 +4853,49 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4774
4853
|
title: z.string().describe("New title."),
|
|
4775
4854
|
},
|
|
4776
4855
|
}, updateDocTitleHandler);
|
|
4777
|
-
|
|
4778
|
-
|
|
4779
|
-
|
|
4780
|
-
|
|
4781
|
-
throw new Error("workspaceId is required.");
|
|
4856
|
+
async function syncRawTagsToDoc(parsed) {
|
|
4857
|
+
if (parsed.tags.length === 0) {
|
|
4858
|
+
return;
|
|
4859
|
+
}
|
|
4782
4860
|
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
4783
4861
|
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
4784
4862
|
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
4785
4863
|
try {
|
|
4786
|
-
await joinWorkspace(socket, workspaceId);
|
|
4787
|
-
const
|
|
4788
|
-
if (!
|
|
4789
|
-
|
|
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
|
+
}
|
|
4790
4869
|
const wsDoc = new Y.Doc();
|
|
4791
|
-
Y.applyUpdate(wsDoc, Buffer.from(
|
|
4792
|
-
const
|
|
4793
|
-
const
|
|
4794
|
-
const
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
const
|
|
4799
|
-
|
|
4800
|
-
|
|
4801
|
-
|
|
4802
|
-
|
|
4803
|
-
|
|
4804
|
-
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
|
|
4808
|
-
const rendered = renderBlocksToMarkdown({ rootBlockIds: collected.rootBlockIds, blocksById: collected.blocksById });
|
|
4809
|
-
results.push({ docId: match.id, title: match.title, found: true, markdown: rendered.markdown,
|
|
4810
|
-
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}`);
|
|
4811
4887
|
}
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
4820
|
-
|
|
4821
|
-
|
|
4822
|
-
workspaceId
|
|
4823
|
-
query: z.string().describe("Title search query (case-insensitive substring match)."),
|
|
4824
|
-
limit: z.number().optional().describe("Max docs to return with content (default: 1)."),
|
|
4825
|
-
},
|
|
4826
|
-
}, getDocByTitleHandler);
|
|
4827
|
-
// ─── list_backlinks ──────────────────────────────────────────────────────────
|
|
4828
|
-
const listBacklinksHandler = async (parsed) => {
|
|
4829
|
-
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
4830
|
-
if (!workspaceId)
|
|
4831
|
-
throw new Error("workspaceId is required.");
|
|
4832
|
-
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
4833
|
-
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
4834
|
-
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
4835
|
-
try {
|
|
4836
|
-
await joinWorkspace(socket, workspaceId);
|
|
4837
|
-
const wsSnap = await loadDoc(socket, workspaceId, workspaceId);
|
|
4838
|
-
if (!wsSnap.missing)
|
|
4839
|
-
return text({ docId: parsed.docId, count: 0, backlinks: [] });
|
|
4840
|
-
const wsDoc = new Y.Doc();
|
|
4841
|
-
Y.applyUpdate(wsDoc, Buffer.from(wsSnap.missing, "base64"));
|
|
4842
|
-
const pages = getWorkspacePageEntries(wsDoc.getMap("meta"));
|
|
4843
|
-
const titleById = new Map(pages.map(p => [p.id, p.title]));
|
|
4844
|
-
const backlinks = [];
|
|
4845
|
-
for (const page of pages) {
|
|
4846
|
-
if (page.id === parsed.docId)
|
|
4847
|
-
continue;
|
|
4848
|
-
const snap = await loadDoc(socket, workspaceId, page.id);
|
|
4849
|
-
if (!snap.missing)
|
|
4850
|
-
continue;
|
|
4851
|
-
const doc = new Y.Doc();
|
|
4852
|
-
Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
|
|
4853
|
-
const blocks = doc.getMap("blocks");
|
|
4854
|
-
for (const [, raw] of blocks) {
|
|
4855
|
-
if (!(raw instanceof Y.Map))
|
|
4856
|
-
continue;
|
|
4857
|
-
if (raw.get("sys:flavour") === "affine:embed-linked-doc" && raw.get("prop:pageId") === parsed.docId) {
|
|
4858
|
-
backlinks.push({ docId: page.id, title: titleById.get(page.id) ?? null,
|
|
4859
|
-
url: `${(process.env.AFFINE_BASE_URL || endpoint.replace(/\/graphql\/?$/, '')).replace(/\/$/, '')}/workspace/${workspaceId}/${page.id}` });
|
|
4860
|
-
break;
|
|
4861
|
-
}
|
|
4862
|
-
}
|
|
4863
|
-
}
|
|
4864
|
-
return text({ docId: parsed.docId, count: backlinks.length, backlinks });
|
|
4865
|
-
}
|
|
4866
|
-
finally {
|
|
4867
|
-
socket.disconnect();
|
|
4868
|
-
}
|
|
4869
|
-
};
|
|
4870
|
-
server.registerTool("list_backlinks", {
|
|
4871
|
-
title: "List Document Backlinks",
|
|
4872
|
-
description: "Find all documents that embed-link to a given doc (its parents/references in the sidebar). Scans all docs — may be slow on large workspaces.",
|
|
4873
|
-
inputSchema: {
|
|
4874
|
-
workspaceId: z.string().optional(),
|
|
4875
|
-
docId: z.string().describe("The doc to find backlinks for."),
|
|
4876
|
-
},
|
|
4877
|
-
}, listBacklinksHandler);
|
|
4878
|
-
// ─── duplicate_doc ───────────────────────────────────────────────────────────
|
|
4879
|
-
const duplicateDocHandler = async (parsed) => {
|
|
4880
|
-
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
4881
|
-
if (!workspaceId)
|
|
4882
|
-
throw new Error("workspaceId is required.");
|
|
4883
|
-
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
4884
|
-
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
4885
|
-
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
4886
|
-
try {
|
|
4887
|
-
await joinWorkspace(socket, workspaceId);
|
|
4888
|
-
const snap = await loadDoc(socket, workspaceId, parsed.docId);
|
|
4889
|
-
if (!snap.missing)
|
|
4890
|
-
throw new Error(`Doc ${parsed.docId} not found.`);
|
|
4891
|
-
const doc = new Y.Doc();
|
|
4892
|
-
Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
|
|
4893
|
-
const collected = collectDocForMarkdown(doc, new Map());
|
|
4894
|
-
const rendered = renderBlocksToMarkdown({ rootBlockIds: collected.rootBlockIds, blocksById: collected.blocksById });
|
|
4895
|
-
const newTitle = (parsed.title ?? `${collected.title || "Untitled"} (copy)`).trim();
|
|
4896
|
-
socket.disconnect();
|
|
4897
|
-
const created = await createDocFromMarkdownCore({ workspaceId, title: newTitle, markdown: rendered.markdown, parentDocId: parsed.parentDocId });
|
|
4898
|
-
return receipt("doc.duplicate", {
|
|
4899
|
-
workspaceId,
|
|
4900
|
-
sourceDocId: parsed.docId,
|
|
4901
|
-
docId: created.docId,
|
|
4902
|
-
title: created.title,
|
|
4903
|
-
parentDocId: created.parentDocId,
|
|
4904
|
-
linkedToParent: created.linkedToParent,
|
|
4905
|
-
cloneMode: "markdown-roundtrip",
|
|
4906
|
-
lossy: Boolean(created.lossy ?? (created.warnings?.length ?? 0) > 0),
|
|
4907
|
-
warnings: created.warnings ?? [],
|
|
4908
|
-
stats: created.stats ?? null,
|
|
4909
|
-
});
|
|
4910
|
-
}
|
|
4911
|
-
catch (err) {
|
|
4912
|
-
try {
|
|
4913
|
-
socket.disconnect();
|
|
4914
|
-
}
|
|
4915
|
-
catch { /* already disconnected */ }
|
|
4916
|
-
throw err;
|
|
4917
|
-
}
|
|
4918
|
-
};
|
|
4919
|
-
server.registerTool("duplicate_doc", {
|
|
4920
|
-
title: "Duplicate Document",
|
|
4921
|
-
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.",
|
|
4922
|
-
inputSchema: {
|
|
4923
|
-
workspaceId: z.string().optional(),
|
|
4924
|
-
docId: z.string().describe("The source doc to duplicate."),
|
|
4925
|
-
title: z.string().optional().describe("Title for the new doc. Defaults to '<original title> (copy)'."),
|
|
4926
|
-
parentDocId: z.string().optional().describe("Parent doc to link the new doc under in the sidebar."),
|
|
4927
|
-
},
|
|
4928
|
-
}, duplicateDocHandler);
|
|
4929
|
-
// ─── create_doc_from_template ───────────────────────────────────────────────
|
|
4930
|
-
const createDocFromTemplateHandler = async (parsed) => {
|
|
4931
|
-
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
4932
|
-
if (!workspaceId)
|
|
4933
|
-
throw new Error("workspaceId is required.");
|
|
4934
|
-
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
4935
|
-
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
4936
|
-
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
4937
|
-
try {
|
|
4938
|
-
await joinWorkspace(socket, workspaceId);
|
|
4939
|
-
const snap = await loadDoc(socket, workspaceId, parsed.templateDocId);
|
|
4940
|
-
if (!snap.missing)
|
|
4941
|
-
throw new Error(`Template doc ${parsed.templateDocId} not found.`);
|
|
4942
|
-
const doc = new Y.Doc();
|
|
4943
|
-
Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
|
|
4944
|
-
const collected = collectDocForMarkdown(doc, new Map());
|
|
4945
|
-
const rendered = renderBlocksToMarkdown({ rootBlockIds: collected.rootBlockIds, blocksById: collected.blocksById });
|
|
4946
|
-
let markdown = rendered.markdown;
|
|
4947
|
-
const vars = parsed.variables ?? {};
|
|
4948
|
-
for (const [key, value] of Object.entries(vars)) {
|
|
4949
|
-
const pattern = new RegExp(`\\{\\{\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\}}`, "g");
|
|
4950
|
-
markdown = markdown.replace(pattern, value);
|
|
4951
|
-
}
|
|
4952
|
-
const unfilled = [...markdown.matchAll(/\{\{\s*[\w.-]+\s*\}\}/g)].map(match => match[0]);
|
|
4953
|
-
socket.disconnect();
|
|
4954
|
-
const created = await createDocFromMarkdownCore({ workspaceId, title: parsed.title, markdown, parentDocId: parsed.parentDocId });
|
|
4955
|
-
return receipt("doc.create_from_template", {
|
|
4956
|
-
workspaceId,
|
|
4957
|
-
sourceTemplateDocId: parsed.templateDocId,
|
|
4958
|
-
docId: created.docId,
|
|
4959
|
-
title: created.title,
|
|
4960
|
-
parentDocId: created.parentDocId,
|
|
4961
|
-
linkedToParent: created.linkedToParent,
|
|
4962
|
-
cloneMode: "markdown-roundtrip",
|
|
4963
|
-
lossy: Boolean(created.lossy ?? (created.warnings?.length ?? 0) > 0),
|
|
4964
|
-
warnings: created.warnings ?? [],
|
|
4965
|
-
stats: created.stats ?? null,
|
|
4966
|
-
unfilledVariables: unfilled,
|
|
4967
|
-
});
|
|
4968
|
-
}
|
|
4969
|
-
catch (err) {
|
|
4970
|
-
try {
|
|
4971
|
-
socket.disconnect();
|
|
4972
|
-
}
|
|
4973
|
-
catch { /* already disconnected */ }
|
|
4974
|
-
throw err;
|
|
4975
|
-
}
|
|
4976
|
-
};
|
|
4977
|
-
server.registerTool("create_doc_from_template", {
|
|
4978
|
-
title: "Create Document from Template",
|
|
4979
|
-
description: "Clone a template doc and substitute {{variable}} placeholders. Returns a warning for any unfilled variables. Optionally link to a parent doc in the sidebar.",
|
|
4980
|
-
inputSchema: {
|
|
4981
|
-
workspaceId: z.string().optional(),
|
|
4982
|
-
templateDocId: z.string().describe("The template doc to clone from."),
|
|
4983
|
-
title: z.string().describe("Title for the new doc."),
|
|
4984
|
-
variables: z.record(z.string(), z.string()).optional().describe("Key-value map of {{variable}} substitutions."),
|
|
4985
|
-
parentDocId: z.string().optional().describe("Parent doc to link the new doc under in the sidebar."),
|
|
4986
|
-
},
|
|
4987
|
-
}, createDocFromTemplateHandler);
|
|
4988
|
-
async function syncRawTagsToDoc(parsed) {
|
|
4989
|
-
if (parsed.tags.length === 0) {
|
|
4990
|
-
return;
|
|
4991
|
-
}
|
|
4992
|
-
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
4993
|
-
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
4994
|
-
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
4995
|
-
try {
|
|
4996
|
-
await joinWorkspace(socket, parsed.workspaceId);
|
|
4997
|
-
const workspaceSnapshot = await loadDoc(socket, parsed.workspaceId, parsed.workspaceId);
|
|
4998
|
-
if (!workspaceSnapshot.missing) {
|
|
4999
|
-
throw new Error(`Workspace root document not found for workspace ${parsed.workspaceId}`);
|
|
5000
|
-
}
|
|
5001
|
-
const wsDoc = new Y.Doc();
|
|
5002
|
-
Y.applyUpdate(wsDoc, Buffer.from(workspaceSnapshot.missing, "base64"));
|
|
5003
|
-
const wsPrevSV = Y.encodeStateVector(wsDoc);
|
|
5004
|
-
const wsMeta = wsDoc.getMap("meta");
|
|
5005
|
-
const page = getWorkspacePageEntries(wsMeta).find(entry => entry.id === parsed.docId);
|
|
5006
|
-
if (!page) {
|
|
5007
|
-
throw new Error(`docId ${parsed.docId} is not present in workspace ${parsed.workspaceId}`);
|
|
5008
|
-
}
|
|
5009
|
-
const pageTags = ensureTagArray(page.entry);
|
|
5010
|
-
pageTags.delete(0, pageTags.length);
|
|
5011
|
-
for (const tag of parsed.tags) {
|
|
5012
|
-
pageTags.push([tag]);
|
|
5013
|
-
}
|
|
5014
|
-
const wsDelta = Y.encodeStateAsUpdate(wsDoc, wsPrevSV);
|
|
5015
|
-
await pushDocUpdate(socket, parsed.workspaceId, parsed.workspaceId, Buffer.from(wsDelta).toString("base64"));
|
|
5016
|
-
const docSnapshot = await loadDoc(socket, parsed.workspaceId, parsed.docId);
|
|
5017
|
-
if (!docSnapshot.missing) {
|
|
5018
|
-
throw new Error(`Document ${parsed.docId} not found in workspace ${parsed.workspaceId}`);
|
|
5019
|
-
}
|
|
5020
|
-
const doc = new Y.Doc();
|
|
5021
|
-
Y.applyUpdate(doc, Buffer.from(docSnapshot.missing, "base64"));
|
|
5022
|
-
const docPrevSV = Y.encodeStateVector(doc);
|
|
5023
|
-
const docMeta = doc.getMap("meta");
|
|
5024
|
-
const docTags = ensureTagArray(docMeta);
|
|
5025
|
-
docTags.delete(0, docTags.length);
|
|
5026
|
-
for (const tag of parsed.tags) {
|
|
5027
|
-
docTags.push([tag]);
|
|
5028
|
-
}
|
|
5029
|
-
const docDelta = Y.encodeStateAsUpdate(doc, docPrevSV);
|
|
5030
|
-
await pushDocUpdate(socket, parsed.workspaceId, parsed.docId, Buffer.from(docDelta).toString("base64"));
|
|
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"));
|
|
5031
4899
|
}
|
|
5032
4900
|
finally {
|
|
5033
4901
|
socket.disconnect();
|
|
@@ -5120,14 +4988,13 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
5120
4988
|
if (!allowFallback) {
|
|
5121
4989
|
throw new Error(`Native template instantiation is not supported: ${nativeSummary.fallbackReasons.join(" | ")}`);
|
|
5122
4990
|
}
|
|
5123
|
-
const
|
|
4991
|
+
const created = await createDocFromTemplateCore({
|
|
5124
4992
|
workspaceId,
|
|
5125
4993
|
templateDocId: parsed.templateDocId,
|
|
5126
4994
|
title: targetTitle,
|
|
5127
4995
|
variables: parsed.variables,
|
|
5128
4996
|
parentDocId: parsed.parentDocId,
|
|
5129
4997
|
});
|
|
5130
|
-
const created = JSON.parse(fallbackResult.content[0].text);
|
|
5131
4998
|
return text({
|
|
5132
4999
|
...created,
|
|
5133
5000
|
mode: "markdown_fallback",
|
|
@@ -5930,56 +5797,6 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
5930
5797
|
databaseBlockId: z.string().min(1).describe("Block ID of the affine:database block"),
|
|
5931
5798
|
},
|
|
5932
5799
|
}, readDatabaseColumnsHandler);
|
|
5933
|
-
const updateDatabaseCellHandler = async (parsed) => {
|
|
5934
|
-
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
5935
|
-
if (!workspaceId)
|
|
5936
|
-
throw new Error("workspaceId is required");
|
|
5937
|
-
const ctx = await loadDatabaseDocContext(workspaceId, parsed.docId, parsed.databaseBlockId);
|
|
5938
|
-
try {
|
|
5939
|
-
const rowBlock = getDatabaseRowBlock(ctx.blocks, ctx.dbBlock, parsed.databaseBlockId, parsed.rowBlockId);
|
|
5940
|
-
const rowCells = ensureDatabaseRowCells(ctx.cellsMap, parsed.rowBlockId);
|
|
5941
|
-
const col = findDatabaseColumn(parsed.column, ctx);
|
|
5942
|
-
if (!col) {
|
|
5943
|
-
if (!isTitleAliasKey(parsed.column)) {
|
|
5944
|
-
throw new Error(`Column '${parsed.column}' not found. Available columns: ${availableDatabaseColumns(ctx)}`);
|
|
5945
|
-
}
|
|
5946
|
-
}
|
|
5947
|
-
else {
|
|
5948
|
-
writeDatabaseCellValue(rowCells, col, parsed.value, parsed.createOption ?? true);
|
|
5949
|
-
}
|
|
5950
|
-
if (parsed.linkedDocId) {
|
|
5951
|
-
rowBlock.set("prop:text", makeLinkedDocText(parsed.linkedDocId));
|
|
5952
|
-
}
|
|
5953
|
-
else if (isTitleAliasKey(parsed.column) || (col && (col.type === "title" || isTitleAliasKey(col.name)))) {
|
|
5954
|
-
rowBlock.set("prop:text", makeText(String(parsed.value ?? "")));
|
|
5955
|
-
}
|
|
5956
|
-
const delta = Y.encodeStateAsUpdate(ctx.doc, ctx.prevSV);
|
|
5957
|
-
await pushDocUpdate(ctx.socket, workspaceId, parsed.docId, Buffer.from(delta).toString("base64"));
|
|
5958
|
-
return text({
|
|
5959
|
-
updated: true,
|
|
5960
|
-
rowBlockId: parsed.rowBlockId,
|
|
5961
|
-
column: parsed.column,
|
|
5962
|
-
value: parsed.value ?? null,
|
|
5963
|
-
});
|
|
5964
|
-
}
|
|
5965
|
-
finally {
|
|
5966
|
-
ctx.socket.disconnect();
|
|
5967
|
-
}
|
|
5968
|
-
};
|
|
5969
|
-
server.registerTool("update_database_cell", {
|
|
5970
|
-
title: "Update Database Cell",
|
|
5971
|
-
description: "Update a single cell on an existing AFFiNE database row. Use `title` to update the row title shown in Kanban card headers.",
|
|
5972
|
-
inputSchema: {
|
|
5973
|
-
workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
|
|
5974
|
-
docId: DocId.describe("Document ID containing the database"),
|
|
5975
|
-
databaseBlockId: z.string().min(1).describe("Block ID of the affine:database block"),
|
|
5976
|
-
rowBlockId: z.string().min(1).describe("Row paragraph block ID"),
|
|
5977
|
-
column: z.string().min(1).describe("Column name or ID. Use `title` for the built-in row title."),
|
|
5978
|
-
value: z.unknown().describe("New cell value"),
|
|
5979
|
-
createOption: z.boolean().optional().describe("For select and multi-select columns, create the option label if it does not exist (default true)"),
|
|
5980
|
-
linkedDocId: z.string().optional().describe("Link this row to an existing doc by ID. Replaces any existing title with a linked doc reference."),
|
|
5981
|
-
},
|
|
5982
|
-
}, updateDatabaseCellHandler);
|
|
5983
5800
|
const updateDatabaseRowHandler = async (parsed) => {
|
|
5984
5801
|
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
5985
5802
|
if (!workspaceId)
|
|
@@ -6231,4 +6048,1256 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
6231
6048
|
width: z.number().optional().describe("Column width in pixels (default 200)"),
|
|
6232
6049
|
},
|
|
6233
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);
|
|
6234
7303
|
}
|