@firedrill-tools/notion 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +402 -0
  3. package/firedrill/agent.target.json +17 -0
  4. package/firedrill/baseline.scenario.json +5 -0
  5. package/firedrill/bounded.scenario.json +19 -0
  6. package/firedrill/conformance.suite.json +23 -0
  7. package/firedrill/notion-bounded.drill.json +318 -0
  8. package/firedrill/notion-byte-budget.drill.json +116 -0
  9. package/firedrill/notion-mcp-aliases.drill.json +150 -0
  10. package/firedrill/notion-page-authoring.drill.json +254 -0
  11. package/firedrill/notion-rate-limited.drill.json +118 -0
  12. package/firedrill/notion-schema-growth.drill.json +88 -0
  13. package/firedrill/notion-scope-agent-only.drill.json +131 -0
  14. package/firedrill/notion-scope-auditor.drill.json +86 -0
  15. package/firedrill/notion-scope-board-bot.drill.json +128 -0
  16. package/firedrill/notion-scope-notes-bot.drill.json +303 -0
  17. package/firedrill/notion-scope-stranger.drill.json +773 -0
  18. package/firedrill/notion-task-triage.drill.json +277 -0
  19. package/firedrill/notion-trash-and-restore.drill.json +186 -0
  20. package/firedrill/notion-update-lost.drill.json +88 -0
  21. package/firedrill/notion-workspace-read.drill.json +258 -0
  22. package/firedrill/notion-write-unavailable.drill.json +161 -0
  23. package/firedrill/rate-limited.scenario.json +11 -0
  24. package/firedrill/tools/notion/app/assets/ATTRIBUTION.md +35 -0
  25. package/firedrill/tools/notion/app/assets/fonts/OFL.txt +93 -0
  26. package/firedrill/tools/notion/app/assets/fonts/inter-latin.woff2 +0 -0
  27. package/firedrill/tools/notion/app/assets/notion-wordmark.svg +1 -0
  28. package/firedrill/tools/notion/app/assets/notion.svg +1 -0
  29. package/firedrill/tools/notion/app/site/app.js +797 -0
  30. package/firedrill/tools/notion/app/site/assets/fonts/inter-latin.woff2 +0 -0
  31. package/firedrill/tools/notion/app/site/assets/notion-wordmark.svg +1 -0
  32. package/firedrill/tools/notion/app/site/assets/notion.svg +1 -0
  33. package/firedrill/tools/notion/app/site/chrome.js +104 -0
  34. package/firedrill/tools/notion/app/site/cover-picker.js +83 -0
  35. package/firedrill/tools/notion/app/site/database.js +648 -0
  36. package/firedrill/tools/notion/app/site/editors.js +320 -0
  37. package/firedrill/tools/notion/app/site/format-bar.js +97 -0
  38. package/firedrill/tools/notion/app/site/icons.js +131 -0
  39. package/firedrill/tools/notion/app/site/index.html +125 -0
  40. package/firedrill/tools/notion/app/site/page.js +826 -0
  41. package/firedrill/tools/notion/app/site/rich.js +159 -0
  42. package/firedrill/tools/notion/app/site/state.js +170 -0
  43. package/firedrill/tools/notion/app/site/styles.css +826 -0
  44. package/firedrill/tools/notion/app/site/ui.js +418 -0
  45. package/firedrill/tools/notion/behavior.mjs +1123 -0
  46. package/firedrill/tools/notion/lib/blocks.mjs +371 -0
  47. package/firedrill/tools/notion/lib/identity.mjs +123 -0
  48. package/firedrill/tools/notion/lib/ids.mjs +63 -0
  49. package/firedrill/tools/notion/lib/json-depth.mjs +26 -0
  50. package/firedrill/tools/notion/lib/markdown.mjs +381 -0
  51. package/firedrill/tools/notion/lib/properties.mjs +513 -0
  52. package/firedrill/tools/notion/lib/query.mjs +272 -0
  53. package/firedrill/tools/notion/lib/render.mjs +137 -0
  54. package/firedrill/tools/notion/lib/rich-text.mjs +134 -0
  55. package/firedrill/tools/notion/lib/size.mjs +44 -0
  56. package/firedrill/tools/notion/lib/state.mjs +192 -0
  57. package/firedrill/tools/notion/lib/wire.mjs +89 -0
  58. package/firedrill/tools/notion/notion.tool.json +9837 -0
  59. package/firedrill/update-lost.scenario.json +11 -0
  60. package/firedrill/world.json +7039 -0
  61. package/firedrill/write-unavailable.scenario.json +11 -0
  62. package/firedrill.json +5 -0
  63. package/package.json +63 -0
  64. package/starter.json +6482 -0
  65. package/test/conformance.mjs +1186 -0
@@ -0,0 +1,797 @@
1
+ // Notion Tool browser app: shell, sidebar tree, router, Home, Inbox, Trash, search and the workspace menus.
2
+ // Every control calls the Tool's own operations through /_firedrill/client.js; nothing here is authoritative.
3
+ import { closePeek, closeSidePanel, openPeek, setBreadcrumb, setPlainBreadcrumb, setTopbarActions, skeleton } from "./chrome.js";
4
+ import { renderDatabaseView } from "./database.js";
5
+ import { hydrateIcons, icon } from "./icons.js";
6
+ import { openCommentsPanel, pageTopbarActions, renderPageView } from "./page.js";
7
+ import { emojiOf, objectIcon, pill, plain, propertyValue, richText, textToRich, userName } from "./rich.js";
8
+ import { ancestors, canComment, canWrite, loadContext, loadIndex, loadUsers, lookup, navigate, pageTitle, parseRoute, routeFor, state } from "./state.js";
9
+ import { $, $$, action, avatar, call, closePopovers, describe, el, greeting, iconButton, isEditing, key, longDate, openPopover, readPreference, relativeTime, shortDate, notSimulated, snackbar, textButton, ToolError, unsimulatedButton, watchWorld, writePreference } from "./ui.js";
10
+
11
+ const ui = {
12
+ controller: undefined, // current screen controller ({ refresh })
13
+ expanded: new Set(JSON.parse(readPreference("expanded", "null") ?? "[]")),
14
+ expandRootsOnce: readPreference("expanded", null) === null,
15
+ treeChildren: new Map(), // page id → [{ node }] loaded from blocks.children.list
16
+ treeTick: -1,
17
+ booted: false,
18
+ };
19
+
20
+ // ---------------------------------------------------------------------------------------------
21
+ // Boot
22
+ // ---------------------------------------------------------------------------------------------
23
+
24
+ hydrateIcons();
25
+ wireShell();
26
+ watchWorld(
27
+ async (first) => {
28
+ try {
29
+ await loadContext();
30
+ if (first) await loadUsers();
31
+ await loadIndex();
32
+ } catch (error) {
33
+ showConnectionError(error);
34
+ return;
35
+ }
36
+ renderSwitcher();
37
+ await renderTree();
38
+ if (first || !ui.controller) {
39
+ ui.booted = true;
40
+ route();
41
+ } else await ui.controller.refresh?.();
42
+ },
43
+ () => !isEditing(),
44
+ );
45
+
46
+ function showConnectionError(error) {
47
+ const main = $("#main");
48
+ const denied = error instanceof ToolError && error.denied;
49
+ main.replaceChildren(
50
+ el("div", { class: "connect-state" }, [
51
+ el("img", { class: "connect-logo", attrs: { src: "./assets/notion-wordmark.svg", alt: "Notion", width: "160", height: "56" } }),
52
+ el("p", { class: "connect-title", text: denied ? "This actor can't read the workspace" : "Can't connect to the workspace" }),
53
+ el("p", { class: "connect-text", text: denied ? "The selected actor has no grant for workspace.context. Grant the operations listed in the README to this actor and reload." : error?.message ?? "Open this Tool app from its local Firedrill app link." }),
54
+ textButton("Retry", { class: "primary", onClick: () => location.reload() }),
55
+ ]),
56
+ );
57
+ setPlainBreadcrumb("");
58
+ setTopbarActions([]);
59
+ $("#switcher-name").textContent = "Notion";
60
+ $("#switcher-icon").replaceChildren(el("img", { attrs: { src: "./assets/notion.svg", alt: "", width: "18", height: "18" } }));
61
+ $("#tree").replaceChildren(el("div", { class: "tree-empty", text: denied ? "No pages: this actor has no grants." : "No pages inside" }));
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------------------------
65
+ // Shell wiring
66
+ // ---------------------------------------------------------------------------------------------
67
+
68
+ function wireShell() {
69
+ window.addEventListener("hashchange", () => { if (ui.booted) route(); });
70
+ $("#nav-search").addEventListener("click", () => openSearch());
71
+ $("#nav-inbox").addEventListener("click", () => openInbox($("#nav-inbox")));
72
+ $("#nav-trash").addEventListener("click", () => openTrash($("#nav-trash")));
73
+ $("#nav-settings").addEventListener("click", () => openSettings($("#nav-settings")));
74
+ $("#nav-templates").addEventListener("click", () => openTemplates($("#nav-templates")));
75
+ $("#nav-invite").addEventListener("click", () => openMembers($("#nav-invite")));
76
+ $("#nav-ai").addEventListener("click", () => notSimulated($("#nav-ai"), "Notion AI", "AI chat, writing and Q&A over the workspace are not part of the API subset this Tool serves.", { side: "right" }));
77
+ $("#nav-meetings").addEventListener("click", () => notSimulated($("#nav-meetings"), "Meetings", "Meeting notes capture, transcription and calendar sync are not part of the API subset this Tool serves. Meeting-note pages in the tree are ordinary pages.", { side: "right" }));
78
+ $("#nav-marketplace").addEventListener("click", () => notSimulated($("#nav-marketplace"), "Marketplace", "The template marketplace is not part of the API subset this Tool serves.", { side: "right" }));
79
+ const teamspaceText = "Teamspaces, their membership and teamspace page trees are not part of the public API, so this Tool does not model them. Pages shared with the integration are listed under Private.";
80
+ const sharedText = "The Shared section lists pages other members shared with you. Per-member sharing is not part of the public API, so this Tool does not model it.";
81
+ for (const id of ["nav-teamspaces", "teamspaces-more", "teamspaces-add", "teamspaces-browse"]) $(`#${id}`).addEventListener("click", (event) => notSimulated(event.currentTarget, "Teamspaces", teamspaceText, { side: "right" }));
82
+ for (const id of ["nav-shared", "shared-add", "shared-start"]) $(`#${id}`).addEventListener("click", (event) => notSimulated(event.currentTarget, "Shared", sharedText, { side: "right" }));
83
+ $("#new-page-top").addEventListener("click", () => createPage());
84
+ $("#new-page-section").addEventListener("click", () => createPage());
85
+ $("#switcher").addEventListener("click", () => openSwitcherMenu());
86
+ $("#help").addEventListener("click", () => openHelp());
87
+ $("#sidebar-collapse").addEventListener("click", () => setSidebar(false));
88
+ $("#sidebar-open").addEventListener("click", () => setSidebar(true));
89
+ $("#sidebar-scrim").addEventListener("click", () => setSidebar(false));
90
+ $("#snackbar-close").addEventListener("click", () => { $("#snackbar").hidden = true; });
91
+ $("#search-close").addEventListener("click", () => $("#search-dialog").close());
92
+ document.addEventListener("keydown", (event) => {
93
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
94
+ event.preventDefault();
95
+ openSearch();
96
+ } else if ((event.metaKey || event.ctrlKey) && event.key === "\\") {
97
+ event.preventDefault();
98
+ setSidebar(!document.body.classList.contains("sidebar-open") && compact() ? true : document.body.classList.contains("sidebar-collapsed"));
99
+ } else if (event.key === "Escape") {
100
+ if (!$("#peek").hidden && !document.querySelector(".popover")) closePeek();
101
+ }
102
+ });
103
+ wireSearch();
104
+ if (readPreference("sidebar", "open") === "closed") document.body.classList.add("sidebar-collapsed");
105
+ }
106
+
107
+ const compact = () => window.matchMedia("(max-width: 768px)").matches;
108
+ function setSidebar(open) {
109
+ if (compact()) {
110
+ document.body.classList.toggle("sidebar-open", open);
111
+ $("#sidebar-scrim").hidden = !open;
112
+ } else {
113
+ document.body.classList.toggle("sidebar-collapsed", !open);
114
+ writePreference("sidebar", open ? "open" : "closed");
115
+ }
116
+ }
117
+
118
+ function renderSwitcher() {
119
+ const workspace = state.context.workspace;
120
+ const iconHost = $("#switcher-icon");
121
+ iconHost.replaceChildren();
122
+ const emoji = emojiOf(workspace.icon);
123
+ if (emoji !== undefined) iconHost.textContent = emoji;
124
+ else iconHost.textContent = workspace.name.charAt(0).toUpperCase();
125
+ iconHost.classList.toggle("letter", emoji === undefined);
126
+ $("#switcher-name").textContent = workspace.name;
127
+ document.title = `${workspace.name} · Notion (synthetic)`;
128
+ }
129
+
130
+ function openSwitcherMenu() {
131
+ const context = state.context;
132
+ const box = el("div", { class: "switcher-menu" });
133
+ box.append(el("div", { class: "switcher-account" }, [el("img", { attrs: { src: "./assets/notion.svg", alt: "", width: "18", height: "18" } }), el("span", { text: context.user.type === "bot" ? `${context.user.name} (integration bot)` : context.user.name })]));
134
+ const workspaceRow = el("div", { class: "switcher-row current" }, [
135
+ el("span", { class: "switcher-icon", text: emojiOf(context.workspace.icon) ?? context.workspace.name.charAt(0) }),
136
+ el("div", { class: "switcher-text" }, [el("div", { class: "switcher-title", text: context.workspace.name }), el("div", { class: "switcher-sub", text: `${state.users.size > 0 ? `${[...state.users.values()].filter((user) => user.type === "person").length} members · ` : ""}${context.workspace.domain}.notion.site` })]),
137
+ icon("check", "menu-check"),
138
+ ]);
139
+ box.append(workspaceRow);
140
+ box.append(el("div", { class: "menu-divider" }));
141
+ box.append(el("div", { class: "switcher-integration" }, [el("div", { class: "menu-header", text: "Connected as" }), el("div", { class: "switcher-sub", text: `${context.integration.name} · content: ${context.integration.capabilities.content.replace(/_/g, " ")} · comments: ${context.integration.capabilities.comments.replace(/_/g, " ")} · users: ${context.integration.capabilities.user_information.replace(/_/g, " ")}` }), el("div", { class: "switcher-sub", text: `Virtual time ${longDate(state.nowMs)}, ${shortDate(context.now, state.nowMs)} UTC` })]));
142
+ box.append(el("div", { class: "menu-divider" }));
143
+ box.append(el("p", { class: "panel-note", text: "Synthetic workspace for agent testing. Nothing here reaches notion.so; workspace switching and account settings are not part of the API." }));
144
+ openPopover($("#switcher"), box, { width: 320 });
145
+ }
146
+
147
+ function openHelp() {
148
+ const box = el("div", { class: "help-menu" });
149
+ box.append(el("img", { attrs: { src: "./assets/notion-wordmark.svg", alt: "Notion", width: "120", height: "42" } }));
150
+ box.append(el("p", { class: "panel-note", text: "This is a synthetic Notion workspace served by Firedrill for testing agents. It mirrors a bounded subset of the Notion API (pages, data sources, blocks, comments, search, users). Notion and its logo are trademarks of Notion Labs, Inc., used only to identify the simulated service." }));
151
+ box.append(el("div", { class: "help-shortcuts" }, [shortcut("⌘/Ctrl K", "Search"), shortcut("⌘/Ctrl \\", "Toggle sidebar"), shortcut("/", "Insert a block"), shortcut("Esc", "Close")]));
152
+ openPopover($("#help"), box, { width: 320, align: "end" });
153
+ }
154
+ function shortcut(keys, label) {
155
+ return el("div", { class: "help-shortcut" }, [el("span", { text: label }), el("kbd", { text: keys })]);
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------------------------
159
+ // Sidebar tree
160
+ // ---------------------------------------------------------------------------------------------
161
+
162
+ function treeNodesAtRoot() {
163
+ const pages = [...state.pages.values()].filter((page) => page.parent?.type !== "data_source_id" && !page.inTrash);
164
+ const roots = pages.filter((page) => page.parent?.type === "workspace" || (page.parent?.type === "page_id" && !state.pages.has(page.parent.page_id)));
165
+ const databases = [...state.databases.values()].filter((database) => !database.isInline && (database.parent?.type === "workspace" || (database.parent?.type === "page_id" && !state.pages.has(database.parent.page_id))));
166
+ return [...roots, ...databases].sort((left, right) => (left.created ?? left.lastEdited ?? "").localeCompare(right.created ?? right.lastEdited ?? "") || left.title.localeCompare(right.title));
167
+ }
168
+
169
+ const TREE_CHILD_REQUESTS = 10;
170
+
171
+ async function childrenOf(pageId) {
172
+ if (ui.treeTick !== state.tick) {
173
+ ui.treeChildren.clear();
174
+ ui.treeTick = state.tick;
175
+ }
176
+ if (ui.treeChildren.has(pageId)) return ui.treeChildren.get(pageId);
177
+ const nodes = [];
178
+ let cursor;
179
+ for (let request = 0; ; request += 1) {
180
+ if (request === TREE_CHILD_REQUESTS) {
181
+ nodes.truncated = true;
182
+ break;
183
+ }
184
+ const page = await call("blocks.children.list", { block_id: pageId, page_size: 100, ...(cursor ? { start_cursor: cursor } : {}) });
185
+ for (const block of page.results) {
186
+ if (block.type === "child_page") {
187
+ const node = state.pages.get(block.id);
188
+ if (node && !node.inTrash) nodes.push(node);
189
+ } else if (block.type === "child_database") {
190
+ const node = state.databases.get(block.id);
191
+ if (node && !node.isInline) nodes.push(node);
192
+ }
193
+ }
194
+ if (!page.has_more) break;
195
+ cursor = page.next_cursor;
196
+ }
197
+ ui.treeChildren.set(pageId, nodes);
198
+ return nodes;
199
+ }
200
+
201
+ async function renderTree() {
202
+ const tree = $("#tree");
203
+ tree.replaceChildren();
204
+ if (state.indexError) {
205
+ tree.append(el("div", { class: "tree-empty", text: describe(state.indexError) }));
206
+ return;
207
+ }
208
+ const roots = treeNodesAtRoot();
209
+ if (ui.expandRootsOnce) {
210
+ // First visit: open the root pages so the workspace is visible, the way a workspace member's sidebar looks.
211
+ ui.expandRootsOnce = false;
212
+ for (const node of roots) if (node.kind === "page") ui.expanded.add(node.id);
213
+ }
214
+ if (roots.length === 0) {
215
+ tree.append(el("div", { class: "tree-empty", text: "No pages inside" }));
216
+ return;
217
+ }
218
+ if (state.indexTruncated) tree.append(el("div", { class: "tree-notice", text: `Showing the ${state.pages.size} most recently edited pages; search to find older ones.` }));
219
+ // Expand the ancestors of the current page so the selection is visible, as the product does.
220
+ const current = currentNode();
221
+ if (current) for (const ancestor of ancestors(current)) if (ancestor.kind === "page") ui.expanded.add(ancestor.id);
222
+ for (const node of roots) tree.append(await treeItem(node, 0));
223
+ }
224
+
225
+ function currentNode() {
226
+ const route = state.route;
227
+ if (route.name === "page") return state.pages.get(route.id);
228
+ if (route.name === "database") return state.databases.get(route.id);
229
+ return undefined;
230
+ }
231
+
232
+ async function treeItem(node, depth) {
233
+ const item = el("div", { class: "tree-node", attrs: { role: "treeitem", "aria-expanded": node.kind === "page" ? String(ui.expanded.has(node.id)) : undefined } });
234
+ const route = routeFor(node);
235
+ const selected = (state.route.name === "page" && route.name === "page" && state.route.id === node.id) || (state.route.name === "database" && route.name === "database" && state.route.id === node.id);
236
+ const row = el("a", { class: `tree-row ${selected ? "selected" : ""}`.trim(), attrs: { href: route.name === "database" ? `#/database/${node.id}/table` : `#/page/${node.id}`, "aria-current": selected ? "page" : undefined } });
237
+ row.style.paddingLeft = `${8 + depth * 14}px`;
238
+ const toggle = el("button", { class: "tree-toggle", attrs: { type: "button", "aria-label": ui.expanded.has(node.id) ? "Collapse" : "Expand", tabindex: "-1" } }, icon("chevron_right"));
239
+ toggle.classList.toggle("open", ui.expanded.has(node.id));
240
+ if (node.kind === "page") {
241
+ toggle.addEventListener("click", async (event) => {
242
+ event.preventDefault();
243
+ event.stopPropagation();
244
+ if (ui.expanded.has(node.id)) ui.expanded.delete(node.id);
245
+ else ui.expanded.add(node.id);
246
+ writePreference("expanded", JSON.stringify([...ui.expanded]));
247
+ await renderTree();
248
+ });
249
+ } else toggle.classList.add("none");
250
+ row.append(el("span", { class: "tree-icon-slot" }, [toggle, objectIcon(node.iconObject, node.kind, "tree-icon")]));
251
+ row.append(el("span", { class: "tree-title", text: node.title }));
252
+ const actions = el("span", { class: "tree-actions" });
253
+ const more = iconButton("more", "More", { class: "tiny", tooltip: false });
254
+ more.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); openTreeMenu(more, node); });
255
+ actions.append(more);
256
+ if (node.kind === "page" && canWrite("read_update_insert")) {
257
+ const add = iconButton("plus", "Add a page inside", { class: "tiny", tooltip: false });
258
+ add.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); void createPage(node.id); });
259
+ actions.append(add);
260
+ }
261
+ row.append(actions);
262
+ row.addEventListener("keydown", async (event) => {
263
+ if (event.key === "ArrowRight" && node.kind === "page" && !ui.expanded.has(node.id)) { ui.expanded.add(node.id); await renderTree(); focusRow(node.id); }
264
+ if (event.key === "ArrowLeft" && ui.expanded.has(node.id)) { ui.expanded.delete(node.id); await renderTree(); focusRow(node.id); }
265
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
266
+ event.preventDefault();
267
+ const rows = $$("#tree .tree-row");
268
+ const index = rows.indexOf(row);
269
+ rows[(index + (event.key === "ArrowDown" ? 1 : rows.length - 1)) % rows.length]?.focus();
270
+ }
271
+ });
272
+ row.dataset.nodeId = node.id;
273
+ item.append(row);
274
+ if (node.kind === "page" && ui.expanded.has(node.id)) {
275
+ const childHost = el("div", { class: "tree-children", attrs: { role: "group" } });
276
+ try {
277
+ const children = await childrenOf(node.id);
278
+ if (children.length === 0) {
279
+ const empty = el("div", { class: "tree-empty", text: "No pages inside" });
280
+ empty.style.paddingLeft = `${8 + (depth + 1) * 14 + 22}px`;
281
+ childHost.append(empty);
282
+ }
283
+ for (const child of children) childHost.append(await treeItem(child, depth + 1));
284
+ if (children.truncated) {
285
+ const notice = el("div", { class: "tree-notice", text: "More blocks than the sidebar loads; open the page to see every subpage." });
286
+ notice.style.paddingLeft = `${8 + (depth + 1) * 14 + 22}px`;
287
+ childHost.append(notice);
288
+ }
289
+ } catch (error) {
290
+ const failed = el("div", { class: "tree-empty", text: describe(error) });
291
+ failed.style.paddingLeft = `${8 + (depth + 1) * 14 + 22}px`;
292
+ childHost.append(failed);
293
+ }
294
+ item.append(childHost);
295
+ }
296
+ return item;
297
+ }
298
+
299
+ function focusRow(id) {
300
+ $(`#tree .tree-row[data-node-id="${id}"]`)?.focus();
301
+ }
302
+
303
+ function openTreeMenu(anchor, node) {
304
+ const items = [
305
+ { label: "Copy link", icon: "link", onSelect: () => navigator.clipboard?.writeText(node.raw?.url ?? `${location.origin}${location.pathname}#/${node.kind}/${node.id}`).then(() => snackbar("Link copied"), () => snackbar("Copy failed")) },
306
+ ];
307
+ if (node.kind === "page" && canWrite("read_update_insert")) items.push({ label: "Add a page inside", icon: "plus", onSelect: () => createPage(node.id) });
308
+ if (node.kind === "page" && canWrite()) {
309
+ items.push("divider", { label: "Move to Trash", icon: "trash", danger: true, onSelect: () => action(async () => {
310
+ await call("pages.update", { page_id: node.id, in_trash: true }, key());
311
+ snackbar("Moved to Trash", { actionLabel: "Undo", onAction: async () => { await call("pages.update", { page_id: node.id, in_trash: false }, key()); await refreshAll(); } });
312
+ await refreshAll();
313
+ if (state.route.name === "page" && state.route.id === node.id) navigate({ name: "home" });
314
+ }) });
315
+ }
316
+ items.push("divider", { header: `Last edited ${relativeTime(node.lastEdited, state.nowMs)}` });
317
+ openPopover(anchor, items, { width: 240 });
318
+ }
319
+
320
+ async function refreshAll() {
321
+ await loadIndex();
322
+ await renderTree();
323
+ await ui.controller?.refresh?.();
324
+ }
325
+
326
+ async function createPage(parentId) {
327
+ if (!canWrite("read_update_insert")) {
328
+ snackbar("This integration can't create pages (no insert capability).", { error: true });
329
+ return;
330
+ }
331
+ // Integrations cannot create at the workspace root: new pages go under the first root page, which the product
332
+ // would show as "Private" — the app says so once instead of failing.
333
+ const parent = parentId ?? treeNodesAtRoot().find((node) => node.kind === "page")?.id;
334
+ if (!parent) {
335
+ snackbar("No page is shared with this integration to create a new page under.", { error: true });
336
+ return;
337
+ }
338
+ await action(async () => {
339
+ const created = await call("pages.create", { parent: { page_id: parent }, properties: { title: [] } }, key());
340
+ ui.expanded.add(parent);
341
+ writePreference("expanded", JSON.stringify([...ui.expanded]));
342
+ await loadIndex();
343
+ if (parentId === undefined) snackbar(`New page created inside ${state.pages.get(parent)?.title ?? "the root page"} (integrations can't create at the workspace root).`);
344
+ navigate({ name: "page", id: created.id });
345
+ });
346
+ }
347
+
348
+ // ---------------------------------------------------------------------------------------------
349
+ // Router
350
+ // ---------------------------------------------------------------------------------------------
351
+
352
+ function route() {
353
+ closePopovers();
354
+ closeSidePanel();
355
+ const next = parseRoute(location.hash);
356
+ const samePage = state.route.name === next.name && state.route.id === next.id && state.route.view === next.view;
357
+ state.route = next;
358
+ if (compact()) setSidebar(false);
359
+ for (const link of $$("#sidebar-main a[data-route]")) link.classList.toggle("selected", link.dataset.route === next.name);
360
+ void renderTree();
361
+ const main = $("#main");
362
+ if (!samePage) main.scrollTop = 0;
363
+ if (next.name === "page") {
364
+ const node = state.pages.get(next.id);
365
+ setBreadcrumb(node ?? { title: "Page", kind: "page" });
366
+ setTopbarActions([]);
367
+ ui.controller = renderPageView(main, next.id, {
368
+ onLoaded: (view) => {
369
+ if (view.page) {
370
+ const node = state.pages.get(view.page.id) ?? { id: view.page.id, kind: "page", title: pageTitle(view.page), iconObject: view.page.icon, parent: view.page.parent };
371
+ setBreadcrumb(node);
372
+ setTopbarActions(pageTopbarActions(view, { onChanged: () => refreshTreeSoon() }));
373
+ }
374
+ },
375
+ onChanged: () => refreshTreeSoon(),
376
+ });
377
+ } else if (next.name === "database") {
378
+ const node = state.databases.get(next.id);
379
+ setBreadcrumb(node ?? { title: "Database", kind: "database" });
380
+ setTopbarActions([]);
381
+ ui.controller = renderDatabaseView(main, next.id, {
382
+ view: next.view,
383
+ onLoaded: (view) => {
384
+ if (view.database) {
385
+ const node = state.databases.get(view.database.id) ?? { id: view.database.id, kind: "database", title: plain(view.database.title) || "Untitled", iconObject: view.database.icon, parent: view.database.parent };
386
+ setBreadcrumb(node);
387
+ setTopbarActions([el("span", { class: "topbar-edited", text: `Edited ${relativeTime(view.database.last_edited_time, state.nowMs)}` }), textButton("Share", { class: "share", onClick: (event) => notSimulated(event.currentTarget, "Share", "Sharing settings are not part of the public API.", { align: "end" }) }), unsimulatedButton("comment", "Comments", "Database-level discussions are not part of the API subset; open a row to comment on it.", { align: "end" }), unsimulatedButton("clock", "View all updates", "Page history and the updates feed are not part of the API subset this Tool serves.", { align: "end" }), unsimulatedButton("star", "Add to Favorites", "Favorites are a per-user app preference that the API does not expose.", { align: "end" }), iconButton("more", "More", { onClick: (event) => openPopover(event.currentTarget, [{ label: "Reload", icon: "refresh", onSelect: () => view.refresh() }], { align: "end", width: 200 }) })]);
388
+ }
389
+ },
390
+ onChanged: () => refreshTreeSoon(),
391
+ });
392
+ if (next.peek) openRowPeek(next.peek);
393
+ } else {
394
+ ui.controller = renderHome(main);
395
+ }
396
+ }
397
+
398
+ let treeTimer;
399
+ function refreshTreeSoon() {
400
+ clearTimeout(treeTimer);
401
+ treeTimer = setTimeout(() => void loadIndex().then(renderTree), 50);
402
+ }
403
+
404
+ async function openRowPeek(pageId) {
405
+ const body = el("div", { class: "peek-page" });
406
+ const view = renderPageView(body, pageId, { peek: true, onChanged: () => ui.controller?.refresh?.() });
407
+ openPeek(body, { onExpand: () => { closePeek(); navigate({ name: "page", id: pageId }); } });
408
+ const head = $("#peek .peek-head");
409
+ const actions = el("div", { class: "peek-actions" });
410
+ head.append(actions);
411
+ const wait = setInterval(() => { if (view.page) { clearInterval(wait); actions.replaceChildren(...pageTopbarActions(view, {}).slice(2)); } if (!body.isConnected) clearInterval(wait); }, 150);
412
+ }
413
+
414
+ // ---------------------------------------------------------------------------------------------
415
+ // Home
416
+ // ---------------------------------------------------------------------------------------------
417
+
418
+ function renderHome(main) {
419
+ setPlainBreadcrumb("Home", "home");
420
+ setTopbarActions([]);
421
+ const controller = { refresh: () => fill() };
422
+ const fill = async () => {
423
+ const context = state.context;
424
+ const host = el("div", { class: "home" });
425
+ const name = context.user.type === "person" ? context.user.name.split(" ")[0] : context.user.name;
426
+ host.append(el("h1", { class: "home-greeting", text: `${greeting(state.nowMs)}, ${name}` }));
427
+ host.append(el("p", { class: "home-date", text: longDate(state.nowMs) }));
428
+
429
+ // Recently visited: the product's card strip, from the index sorted by last edit (no visit history in the API).
430
+ const recent = [...state.pages.values(), ...[...state.databases.values()].filter((database) => !database.isInline)].sort((left, right) => (right.lastEdited ?? "").localeCompare(left.lastEdited ?? "")).slice(0, 8);
431
+ const section = el("section", { class: "home-section" }, [el("h2", { class: "home-section-title" }, [icon("clock", "home-section-icon"), el("span", { text: "Recently visited" })])]);
432
+ const strip = el("div", { class: "recent-strip" });
433
+ if (recent.length === 0) strip.append(el("div", { class: "home-empty", text: "No pages are shared with this integration yet." }));
434
+ for (const node of recent) {
435
+ const card = el("a", { class: "recent-card", attrs: { href: node.kind === "database" ? `#/database/${node.id}/table` : `#/page/${node.id}` } });
436
+ const top = el("div", { class: "recent-top" });
437
+ top.dataset.seed = String(node.title.length % 6);
438
+ top.append(objectIcon(node.iconObject, node.kind, "recent-icon"));
439
+ card.append(top, el("div", { class: "recent-title", text: node.title }), el("div", { class: "recent-meta" }, [avatar({ id: node.raw?.last_edited_by?.id ?? node.id, name: userName(node.raw?.last_edited_by, state.users) || node.title }, "tiny"), el("span", { text: relativeTime(node.lastEdited, state.nowMs) })]));
440
+ strip.append(card);
441
+ }
442
+ section.append(strip);
443
+ host.append(section);
444
+
445
+ // Upcoming events: needs a connected calendar, which the API does not model; shown in place, not simulated.
446
+ const events = el("section", { class: "home-section" }, [el("h2", { class: "home-section-title" }, [icon("calendar", "home-section-icon"), el("span", { text: "Upcoming events" })])]);
447
+ const connect = textButton("Connect calendar", { class: "ghost", onClick: (event) => notSimulated(event.currentTarget, "Connect calendar", "Calendar connections and meeting events are not part of the API subset this Tool serves.") });
448
+ events.append(el("div", { class: "home-events" }, [
449
+ el("div", { class: "home-events-lead" }, [icon("calendar"), el("span", { text: "See your upcoming events and join meetings from Home." }), connect]),
450
+ el("div", { class: "home-events-list", text: "No calendar is connected in this workspace." }),
451
+ ]));
452
+ host.append(events);
453
+
454
+ // Learn: the product's getting-started guides, drawn as text-only cards (no vendor illustrations).
455
+ // Guides live on the vendor's help center, which is outside this Tool; each card opens a not-simulated panel.
456
+ const learn = el("section", { class: "home-section" }, [el("h2", { class: "home-section-title" }, [icon("learn", "home-section-icon"), el("span", { text: "Learn" })])]);
457
+ const learnStrip = el("div", { class: "learn-strip" });
458
+ const guides = [
459
+ ["What is a block?", "3m read", "block_text"],
460
+ ["Create your first page", "2m read", "page"],
461
+ ["Create a subpage", "2m read", "page"],
462
+ ["Customize & style your content", "9m read", "text_color"],
463
+ ];
464
+ guides.forEach(([title, meta, glyph], index) => {
465
+ const card = el("button", { class: "learn-card", attrs: { type: "button", "aria-label": `${title}, ${meta}` } });
466
+ const top = el("div", { class: "learn-top", attrs: { "aria-hidden": "true" } }, [icon(glyph, "learn-glyph")]);
467
+ top.dataset.seed = String(index % 4);
468
+ card.append(top, el("div", { class: "learn-title", text: title }), el("div", { class: "learn-meta" }, [icon("page", "learn-meta-icon"), el("span", { text: meta })]));
469
+ card.addEventListener("click", () => notSimulated(card, title, "Getting-started guides open on the vendor's help center, which is not part of this Tool."));
470
+ learnStrip.append(card);
471
+ });
472
+ learn.append(learnStrip);
473
+ host.append(learn);
474
+
475
+ // Home database view: the data source with a people + status property, filtered to the acting user.
476
+ const taskSource = [...state.dataSources.values()].find((source) => Object.values(source.properties).some((property) => property.type === "people") && Object.values(source.properties).some((property) => property.type === "status"));
477
+ if (taskSource) {
478
+ const people = Object.values(taskSource.properties).find((property) => property.type === "people");
479
+ const status = Object.values(taskSource.properties).find((property) => property.type === "status");
480
+ const database = state.databases.get(taskSource.parent?.database_id);
481
+ const widget = el("section", { class: "home-section" }, [el("h2", { class: "home-section-title" }, [icon("database", "home-section-icon"), el("span", { text: "Home database views" })])]);
482
+ const card = el("div", { class: "home-widget" });
483
+ const head = el("div", { class: "widget-head" }, [
484
+ el("a", { class: "widget-title", attrs: { href: `#/database/${database?.id}/table` } }, [objectIcon(database?.iconObject, "database", "widget-icon"), el("span", { text: database?.title ?? plain(taskSource.title) })]),
485
+ el("span", { class: "widget-filter", text: `${people.name} is ${context.user.name}` }),
486
+ ]);
487
+ card.append(head);
488
+ const list = el("div", { class: "widget-list" }, skeleton(3));
489
+ card.append(list);
490
+ widget.append(card);
491
+ host.append(widget);
492
+ void (async () => {
493
+ try {
494
+ const result = await call("data-sources.query", { data_source_id: taskSource.id, filter: { property: people.name, people: { contains: context.user.id } }, sorts: [{ property: status.name, direction: "ascending" }], page_size: 10 });
495
+ list.replaceChildren();
496
+ if (result.results.length === 0) list.append(el("div", { class: "home-empty", text: `No pages where ${people.name} is ${context.user.name}.` }));
497
+ const maps = lookup();
498
+ for (const row of result.results) {
499
+ const item = el("a", { class: "widget-row", attrs: { href: `#/database/${database?.id}/table?peek=${row.id}` } });
500
+ item.append(objectIcon(row.icon, "page", "row-icon"), el("span", { class: "widget-row-title", text: pageTitle(row) }));
501
+ const statusValue = row.properties[status.name];
502
+ if (statusValue?.status) item.append(pill(statusValue.status, { status: true }));
503
+ const due = Object.values(row.properties).find((property) => property.type === "date");
504
+ if (due?.date) item.append(el("span", { class: "widget-row-date", text: propertyValue(due, maps).textContent }));
505
+ list.append(item);
506
+ }
507
+ if (result.has_more) list.append(el("a", { class: "widget-more", text: "See all", attrs: { href: `#/database/${database?.id}/table` } }));
508
+ } catch (error) {
509
+ list.replaceChildren(el("div", { class: "home-empty", text: describe(error) }));
510
+ }
511
+ })();
512
+ }
513
+ main.replaceChildren(host);
514
+ };
515
+ void fill();
516
+ return controller;
517
+ }
518
+
519
+ // ---------------------------------------------------------------------------------------------
520
+ // Inbox (comments across recently edited pages)
521
+ // ---------------------------------------------------------------------------------------------
522
+
523
+ const INBOX_PAGES = 25;
524
+ const INBOX_REQUESTS_PER_PAGE = 10;
525
+
526
+ function openInbox(anchor) {
527
+ const box = el("div", { class: "inbox" });
528
+ box.append(el("div", { class: "panel-head plain" }, [el("span", { class: "panel-title", text: "Inbox" }), el("div", { class: "panel-tabs inline" }, [el("button", { class: "panel-tab active", text: "All", attrs: { type: "button" } }), el("button", { class: "panel-tab", text: "Unread", attrs: { type: "button", disabled: "" } })])]));
529
+ const list = el("div", { class: "inbox-list" }, skeleton(4));
530
+ box.append(list);
531
+ openPopover(anchor, box, { side: "right", width: 400, className: "sidebar-panel" });
532
+ void (async () => {
533
+ if (!canComment("read")) {
534
+ list.replaceChildren(el("div", { class: "panel-empty", text: "This integration can't read comments, so nothing lands in the inbox." }));
535
+ return;
536
+ }
537
+ try {
538
+ // Comments come from the most recently edited pages; every comment of those pages is read (cursor paging), and
539
+ // both caps are said out loud when they trip.
540
+ const live = [...state.pages.values()].filter((page) => !page.inTrash).sort((left, right) => (right.lastEdited ?? "").localeCompare(left.lastEdited ?? ""));
541
+ const pages = live.slice(0, INBOX_PAGES);
542
+ const entries = [];
543
+ let partial = false;
544
+ for (const page of pages) {
545
+ let cursor;
546
+ for (let request = 0; ; request += 1) {
547
+ if (request === INBOX_REQUESTS_PER_PAGE) {
548
+ partial = true;
549
+ break;
550
+ }
551
+ const comments = await call("comments.list", { block_id: page.id, page_size: 100, ...(cursor ? { start_cursor: cursor } : {}) });
552
+ for (const comment of comments.results) entries.push({ page, comment });
553
+ if (!comments.has_more) break;
554
+ cursor = comments.next_cursor;
555
+ }
556
+ }
557
+ entries.sort((left, right) => right.comment.created_time.localeCompare(left.comment.created_time));
558
+ list.replaceChildren();
559
+ if (live.length > pages.length) list.append(el("div", { class: "inbox-notice", text: `Showing comments from the ${pages.length} most recently edited pages.` }));
560
+ if (partial) list.append(el("div", { class: "inbox-notice", text: "Some pages have more comments than the inbox loads; open a page to see all of its comments." }));
561
+ if (entries.length === 0) list.append(el("div", { class: "panel-empty" }, [icon("inbox", "panel-empty-icon"), el("p", { text: "You're all caught up" }), el("p", { class: "panel-note", text: "Comments on pages shared with this integration appear here." })]));
562
+ for (const { page, comment } of entries) {
563
+ const author = comment.display_name?.resolved_name ?? userName(comment.created_by, state.users);
564
+ const row = el("a", { class: "inbox-row", attrs: { href: `#/page/${page.id}` } }, [
565
+ avatar({ id: comment.created_by?.id, name: author }, "small"),
566
+ el("div", { class: "inbox-body" }, [
567
+ el("div", { class: "inbox-line" }, [el("span", { class: "inbox-author", text: author }), el("span", { text: " commented in " }), el("span", { class: "inbox-page" }, [objectIcon(page.iconObject, "page", "inbox-icon"), el("span", { text: page.title })])]),
568
+ el("div", { class: "inbox-quote" }, richText(comment.rich_text)),
569
+ el("div", { class: "inbox-time", text: relativeTime(comment.created_time, state.nowMs) }),
570
+ ]),
571
+ ]);
572
+ row.addEventListener("click", () => closePopovers());
573
+ list.append(row);
574
+ }
575
+ } catch (error) {
576
+ list.replaceChildren(el("div", { class: "panel-empty", text: describe(error) }));
577
+ }
578
+ })();
579
+ }
580
+
581
+ // ---------------------------------------------------------------------------------------------
582
+ // Trash
583
+ // ---------------------------------------------------------------------------------------------
584
+
585
+ function openTrash(anchor) {
586
+ const box = el("div", { class: "trash" });
587
+ const search = el("input", { class: "editor-search", attrs: { type: "text", placeholder: "Search pages in Trash", "aria-label": "Search pages in Trash" } });
588
+ const list = el("div", { class: "trash-list" }, skeleton(3));
589
+ box.append(search, list);
590
+ let items = [];
591
+ let cursor = null;
592
+ let more = false;
593
+ const render = () => {
594
+ list.replaceChildren();
595
+ const needle = search.value.trim().toLowerCase();
596
+ const visible = items.filter((item) => item.title.toLowerCase().includes(needle));
597
+ if (visible.length === 0) list.append(el("div", { class: "panel-empty" }, [icon("trash", "panel-empty-icon"), el("p", { text: needle ? "No results" : "No pages in Trash" })]));
598
+ for (const item of visible) {
599
+ const row = el("div", { class: "trash-row" });
600
+ const open = el("a", { class: "trash-main", attrs: { href: item.kind === "database" ? `#/database/${item.id}/table` : `#/page/${item.id}` } }, [objectIcon(item.iconObject, item.kind, "trash-icon"), el("div", { class: "trash-text" }, [el("div", { class: "trash-title", text: item.title }), el("div", { class: "trash-path", text: item.path })])]);
601
+ open.addEventListener("click", () => closePopovers());
602
+ const restore = iconButton("restore", `Restore ${item.title}`, { class: "small", onClick: () => action(async () => {
603
+ if (item.kind === "database") await call("data-sources.update", { data_source_id: item.dataSourceId, in_trash: false }, key());
604
+ else await call("pages.update", { page_id: item.id, in_trash: false }, key());
605
+ snackbar(`Restored ${item.title}`);
606
+ await loadIndex();
607
+ await renderTree();
608
+ await ui.controller?.refresh?.();
609
+ await loadItems(false);
610
+ }) });
611
+ restore.disabled = !canWrite();
612
+ row.append(open, restore);
613
+ list.append(row);
614
+ }
615
+ if (more) list.append(textButton("Load more", { class: "load-more", onClick: () => loadItems(true) }));
616
+ list.append(el("p", { class: "panel-note", text: "Pages stay restorable; permanent deletion is not part of the public API." }));
617
+ };
618
+ const loadItems = async (append) => {
619
+ try {
620
+ const result = await call("workspace.trash", { page_size: 25, ...(append && cursor ? { start_cursor: cursor } : {}) });
621
+ const mapped = result.results.map((object) => object.object === "database"
622
+ ? { id: object.id, kind: "database", title: plain(object.title) || "Untitled", iconObject: object.icon, dataSourceId: object.data_sources?.[0]?.id, path: pathOf(object.parent) }
623
+ : { id: object.id, kind: "page", title: pageTitle(object), iconObject: object.icon, path: pathOf(object.parent) });
624
+ items = append ? [...items, ...mapped] : mapped;
625
+ cursor = result.next_cursor;
626
+ more = result.has_more;
627
+ render();
628
+ } catch (error) {
629
+ list.replaceChildren(el("div", { class: "panel-empty", text: describe(error) }));
630
+ }
631
+ };
632
+ search.addEventListener("input", render);
633
+ openPopover(anchor, box, { side: "right", width: 400, className: "sidebar-panel" });
634
+ void loadItems(false);
635
+ }
636
+
637
+ function pathOf(parent) {
638
+ if (!parent) return "";
639
+ if (parent.type === "workspace") return state.context.workspace.name;
640
+ if (parent.type === "page_id") return state.pages.get(parent.page_id)?.title ?? "Page";
641
+ if (parent.type === "data_source_id") return state.databases.get(parent.database_id)?.title ?? "Database";
642
+ return "";
643
+ }
644
+
645
+ // ---------------------------------------------------------------------------------------------
646
+ // Settings, templates, members (informational panels — the API subset has no settings surface)
647
+ // ---------------------------------------------------------------------------------------------
648
+
649
+ function openSettings(anchor) {
650
+ const context = state.context;
651
+ const box = el("div", { class: "settings" });
652
+ box.append(el("div", { class: "panel-head plain" }, [el("span", { class: "panel-title", text: "Settings" })]));
653
+ const rows = [
654
+ ["Workspace", context.workspace.name],
655
+ ["Domain", `${context.workspace.domain}.notion.site`],
656
+ ["Integration", context.integration.name],
657
+ ["Content capability", context.integration.capabilities.content.replace(/_/g, " ")],
658
+ ["Comment capability", context.integration.capabilities.comments.replace(/_/g, " ")],
659
+ ["User information", context.integration.capabilities.user_information.replace(/_/g, " ")],
660
+ ["Access", context.integration.access.type === "workspace" ? "Entire workspace" : `${context.integration.access.root_ids?.length ?? 0} shared root(s)`],
661
+ ["Virtual time", `${longDate(state.nowMs)}, ${shortDate(context.now, state.nowMs)} UTC`],
662
+ ["Row bound", `${context.limits.max_rows_per_namespace.toLocaleString("en-US")} rows per namespace`],
663
+ ];
664
+ for (const [label, value] of rows) box.append(el("div", { class: "settings-row" }, [el("span", { class: "settings-label", text: label }), el("span", { class: "settings-value", text: value })]));
665
+ box.append(el("p", { class: "panel-note", text: "Workspace settings, billing and integrations management are not part of the public API; this panel shows the identity the app is acting with." }));
666
+ openPopover(anchor, box, { side: "right", width: 400, className: "sidebar-panel" });
667
+ }
668
+
669
+ function openTemplates(anchor) {
670
+ const box = el("div", { class: "settings" });
671
+ box.append(el("div", { class: "panel-head plain" }, [el("span", { class: "panel-title", text: "Templates" })]));
672
+ box.append(el("p", { class: "panel-note", text: "The template gallery is not part of the public API. Start from a blank page instead, or duplicate content with the Markdown panel." }));
673
+ box.append(el("div", { class: "panel-actions" }, [textButton("New page", { class: "primary", icon: "compose", onClick: () => { closePopovers(); void createPage(); } })]));
674
+ openPopover(anchor, box, { side: "right", width: 360, className: "sidebar-panel" });
675
+ }
676
+
677
+ function openMembers(anchor) {
678
+ const box = el("div", { class: "settings" });
679
+ box.append(el("div", { class: "panel-head plain" }, [el("span", { class: "panel-title", text: "Members" })]));
680
+ if (state.usersDenied) box.append(el("p", { class: "panel-note", text: "This integration has no user-information capability, so the member list is hidden." }));
681
+ const people = [...state.users.values()].filter((user) => user.type === "person");
682
+ const bots = [...state.users.values()].filter((user) => user.type === "bot");
683
+ for (const user of [...people, ...bots]) {
684
+ box.append(el("div", { class: "member-row" }, [avatar(user, "small"), el("div", { class: "member-text" }, [el("div", { class: "member-name", text: user.name }), el("div", { class: "member-sub", text: user.type === "bot" ? "Integration bot" : user.person?.email ?? (user.is_guest ? "Guest" : "Member") })]), el("span", { class: "member-role", text: user.type === "bot" ? "Bot" : user.is_guest ? "Guest" : "Member" })]));
685
+ }
686
+ box.append(el("p", { class: "panel-note", text: "Inviting members is not part of the public API." }));
687
+ openPopover(anchor, box, { side: "right", width: 380, className: "sidebar-panel" });
688
+ }
689
+
690
+ // ---------------------------------------------------------------------------------------------
691
+ // Search (⌘K)
692
+ // ---------------------------------------------------------------------------------------------
693
+
694
+ const search = { filter: "all", sort: "descending", results: [], selected: 0, more: false, cursor: null, timer: undefined, query: "" };
695
+
696
+ function wireSearch() {
697
+ const dialog = $("#search-dialog");
698
+ const input = $("#search-input");
699
+ input.addEventListener("input", () => {
700
+ clearTimeout(search.timer);
701
+ search.timer = setTimeout(() => runSearch(false), 200);
702
+ });
703
+ for (const chip of $$("#search-filters .chip[data-filter]")) chip.addEventListener("click", () => {
704
+ search.filter = chip.dataset.filter;
705
+ for (const other of $$("#search-filters .chip[data-filter]")) other.setAttribute("aria-pressed", String(other === chip));
706
+ void runSearch(false);
707
+ });
708
+ $("#search-sort").addEventListener("click", () => {
709
+ search.sort = search.sort === "descending" ? "ascending" : "descending";
710
+ $("#search-sort").textContent = search.sort === "descending" ? "Sort: Last edited" : "Sort: Oldest edited";
711
+ void runSearch(false);
712
+ });
713
+ dialog.addEventListener("keydown", (event) => {
714
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
715
+ event.preventDefault();
716
+ if (search.results.length === 0) return;
717
+ search.selected = (search.selected + (event.key === "ArrowDown" ? 1 : search.results.length - 1)) % search.results.length;
718
+ renderSearchResults();
719
+ } else if (event.key === "Enter") {
720
+ event.preventDefault();
721
+ const chosen = search.results[search.selected];
722
+ if (chosen) openSearchResult(chosen);
723
+ }
724
+ });
725
+ dialog.addEventListener("click", (event) => { if (event.target === dialog) dialog.close(); });
726
+ }
727
+
728
+ function openSearch() {
729
+ const dialog = $("#search-dialog");
730
+ if (dialog.open) return;
731
+ $("#search-input").placeholder = `Search in ${state.context?.workspace?.name ?? "workspace"}…`;
732
+ dialog.showModal();
733
+ $("#search-input").focus();
734
+ $("#search-input").select();
735
+ void runSearch(false);
736
+ }
737
+
738
+ async function runSearch(append) {
739
+ const host = $("#search-results");
740
+ const query = $("#search-input").value.trim();
741
+ if (!append) {
742
+ search.selected = 0;
743
+ search.query = query;
744
+ host.replaceChildren(el("div", { class: "search-loading", text: "Searching…" }));
745
+ }
746
+ try {
747
+ const request = { page_size: 10, sort: { timestamp: "last_edited_time", direction: search.sort } };
748
+ if (query) request.query = query;
749
+ if (search.filter !== "all") request.filter = { property: "object", value: search.filter };
750
+ if (append && search.cursor) request.start_cursor = search.cursor;
751
+ const result = await call("search", request);
752
+ if (query !== $("#search-input").value.trim()) return; // stale
753
+ search.results = append ? [...search.results, ...result.results] : result.results;
754
+ search.cursor = result.next_cursor;
755
+ search.more = result.has_more;
756
+ renderSearchResults();
757
+ } catch (error) {
758
+ host.replaceChildren(el("div", { class: "search-empty", text: describe(error) }));
759
+ }
760
+ }
761
+
762
+ function renderSearchResults() {
763
+ const host = $("#search-results");
764
+ host.replaceChildren();
765
+ if (search.results.length === 0) {
766
+ host.append(el("div", { class: "search-empty" }, [el("p", { class: "search-empty-title", text: "No results" }), el("p", { class: "search-empty-text", text: "Some results may be in pages not shared with this integration. Search matches titles only." })]));
767
+ return;
768
+ }
769
+ host.append(el("div", { class: "search-group", text: search.query ? "Best matches" : search.sort === "descending" ? "Recently edited" : "Oldest edits" }));
770
+ search.results.forEach((result, index) => {
771
+ const isPage = result.object === "page";
772
+ const node = isPage ? state.pages.get(result.id) : state.databases.get(result.parent?.database_id);
773
+ const title = isPage ? pageTitle(result) : plain(result.title) || "Untitled";
774
+ const crumbs = node ? ancestors(node).reverse().map((ancestor) => ancestor.title) : [];
775
+ const row = el("button", { class: `search-row ${index === search.selected ? "selected" : ""}`.trim(), attrs: { type: "button", role: "option", "aria-selected": String(index === search.selected) } });
776
+ row.append(objectIcon(result.icon, isPage ? "page" : "database", "search-icon"));
777
+ const text = el("div", { class: "search-text" }, [el("div", { class: "search-title", text: title })]);
778
+ if (crumbs.length > 0) text.append(el("div", { class: "search-path", text: crumbs.join(" / ") }));
779
+ row.append(text, el("span", { class: "search-date", text: shortDate(result.last_edited_time, state.nowMs) }));
780
+ row.addEventListener("click", () => openSearchResult(result));
781
+ row.addEventListener("mousemove", () => { if (search.selected !== index) { search.selected = index; renderSearchResults(); } });
782
+ host.append(row);
783
+ });
784
+ if (search.more) host.append(textButton("Show more results", { class: "load-more", onClick: () => runSearch(true) }));
785
+ host.querySelector(".search-row.selected")?.scrollIntoView({ block: "nearest" });
786
+ }
787
+
788
+ function openSearchResult(result) {
789
+ $("#search-dialog").close();
790
+ if (result.object === "page") navigate({ name: "page", id: result.id });
791
+ else {
792
+ const databaseId = result.parent?.database_id ?? [...state.databases.values()].find((database) => database.dataSourceId === result.id)?.id;
793
+ if (databaseId) navigate({ name: "database", id: databaseId, view: "table" });
794
+ }
795
+ }
796
+
797
+ export { openCommentsPanel, textToRich };