list_pages gains an opt-in `tree` parameter on both surfaces (the
@docmost/mcp server tool and the AI-chat agent tool), which share the
same DocmostClient.listPages. Default behavior (recent-by-updatedAt flat
list) is unchanged.
- client.ts: listPages(spaceId?, limit=50, tree=false); when tree is
true it requires spaceId (throws a specific error otherwise), walks the
sidebar tree via the existing bounded/cycle-safe enumerateSpacePages,
and returns a nested tree; limit is ignored in tree mode.
- lib/tree.ts: new pure buildPageTree() — lean nodes { id, slugId, title,
children? }, children sorted by position (code-unit order), orphans
promoted to roots, cycle-safe.
- index.ts + ai-chat-tools.service.ts: expose `tree` in the tool schemas
and descriptions; docmost-client.loader.ts: mirror the new signature.
- tests: add packages/mcp/test/unit/tree.test.mjs (nesting, ordering,
lean shape, orphan promotion, cycle/self-reference safety).
- rebuild @docmost/mcp (build/ is tracked and loaded at runtime).
90 lines
3.8 KiB
JavaScript
90 lines
3.8 KiB
JavaScript
/**
|
|
* Pure tree-builder: turn a flat array of sidebar-style page nodes (as produced
|
|
* by `enumerateSpacePages`) into a nested tree.
|
|
*
|
|
* Input: a flat array of nodes. Each node is expected to carry at least
|
|
* { id, slugId, title, position, parentPageId } (extra fields are ignored).
|
|
*
|
|
* Output: an array of ROOT nodes, each shaped as
|
|
* { id, slugId, title, children? }
|
|
* where `children` is the array of child nodes (same shape, recursively). The
|
|
* `children` key is OMITTED entirely when a node has no children — consistent
|
|
* with how `filterPage` omits an empty `subpages` array — to keep the payload
|
|
* lean (nesting alone conveys the structure; parentPageId/position/hasChildren
|
|
* are intentionally dropped from the output).
|
|
*
|
|
* Linking rule: a node is attached as a child of `parentPageId` only when that
|
|
* parent id is actually present in the input. Otherwise — including a null /
|
|
* undefined `parentPageId`, or a parent that was capped out of the bounded walk
|
|
* — the node is promoted to a ROOT. So "orphan whose parent is missing" is the
|
|
* defined behavior: it surfaces at the top level rather than disappearing.
|
|
*
|
|
* Ordering rule: the roots array and every `children` array are sorted ascending
|
|
* by the node's `position` string. The comparator is a plain code-unit (byte)
|
|
* comparison — NOT localeCompare — because the server orders sidebar pages by
|
|
* `collate "C"` (byte order), which a raw `<`/`>` compare approximates for the
|
|
* fractional-index ASCII keys (e.g. "a0", "a1"). Nodes with a missing/undefined
|
|
* `position` sort last.
|
|
*
|
|
* Pure: no I/O, no network, deterministic.
|
|
*/
|
|
export function buildPageTree(nodes) {
|
|
// Map id -> output node. Build the lean output shape up front.
|
|
const byId = new Map();
|
|
// Preserve the original position string for sorting (kept off the output).
|
|
const positionById = new Map();
|
|
for (const node of nodes) {
|
|
if (!node || typeof node !== "object" || !node.id)
|
|
continue;
|
|
// Defensive against duplicate ids: last one wins (overwrites the earlier
|
|
// entry). `enumerateSpacePages` already dedups, so this is belt-and-braces.
|
|
byId.set(node.id, {
|
|
id: node.id,
|
|
slugId: node.slugId,
|
|
title: node.title,
|
|
});
|
|
positionById.set(node.id, node.position);
|
|
}
|
|
// Stable comparator on the position string: code-unit order, missing last.
|
|
const byPosition = (aId, bId) => {
|
|
const a = positionById.get(aId);
|
|
const b = positionById.get(bId);
|
|
if (a === undefined || a === null)
|
|
return b === undefined || b === null ? 0 : 1;
|
|
if (b === undefined || b === null)
|
|
return -1;
|
|
if (a < b)
|
|
return -1;
|
|
if (a > b)
|
|
return 1;
|
|
return 0;
|
|
};
|
|
const roots = [];
|
|
const childrenIdsByParent = new Map();
|
|
for (const node of nodes) {
|
|
if (!node || typeof node !== "object" || !node.id)
|
|
continue;
|
|
const parentId = node.parentPageId;
|
|
// Child only when the parent is actually present in the input; otherwise
|
|
// (null/undefined parent, or parent capped out of the walk) -> root.
|
|
if (parentId && byId.has(parentId)) {
|
|
const list = childrenIdsByParent.get(parentId) ?? [];
|
|
list.push(node.id);
|
|
childrenIdsByParent.set(parentId, list);
|
|
}
|
|
else {
|
|
roots.push(node.id);
|
|
}
|
|
}
|
|
// Attach sorted children arrays to each parent, omitting empty ones.
|
|
for (const [parentId, childIds] of childrenIdsByParent) {
|
|
const parent = byId.get(parentId);
|
|
if (!parent)
|
|
continue;
|
|
childIds.sort(byPosition);
|
|
parent.children = childIds.map((id) => byId.get(id));
|
|
}
|
|
roots.sort(byPosition);
|
|
return roots.map((id) => byId.get(id));
|
|
}
|