@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.
- package/LICENSE +201 -0
- package/README.md +402 -0
- package/firedrill/agent.target.json +17 -0
- package/firedrill/baseline.scenario.json +5 -0
- package/firedrill/bounded.scenario.json +19 -0
- package/firedrill/conformance.suite.json +23 -0
- package/firedrill/notion-bounded.drill.json +318 -0
- package/firedrill/notion-byte-budget.drill.json +116 -0
- package/firedrill/notion-mcp-aliases.drill.json +150 -0
- package/firedrill/notion-page-authoring.drill.json +254 -0
- package/firedrill/notion-rate-limited.drill.json +118 -0
- package/firedrill/notion-schema-growth.drill.json +88 -0
- package/firedrill/notion-scope-agent-only.drill.json +131 -0
- package/firedrill/notion-scope-auditor.drill.json +86 -0
- package/firedrill/notion-scope-board-bot.drill.json +128 -0
- package/firedrill/notion-scope-notes-bot.drill.json +303 -0
- package/firedrill/notion-scope-stranger.drill.json +773 -0
- package/firedrill/notion-task-triage.drill.json +277 -0
- package/firedrill/notion-trash-and-restore.drill.json +186 -0
- package/firedrill/notion-update-lost.drill.json +88 -0
- package/firedrill/notion-workspace-read.drill.json +258 -0
- package/firedrill/notion-write-unavailable.drill.json +161 -0
- package/firedrill/rate-limited.scenario.json +11 -0
- package/firedrill/tools/notion/app/assets/ATTRIBUTION.md +35 -0
- package/firedrill/tools/notion/app/assets/fonts/OFL.txt +93 -0
- package/firedrill/tools/notion/app/assets/fonts/inter-latin.woff2 +0 -0
- package/firedrill/tools/notion/app/assets/notion-wordmark.svg +1 -0
- package/firedrill/tools/notion/app/assets/notion.svg +1 -0
- package/firedrill/tools/notion/app/site/app.js +797 -0
- package/firedrill/tools/notion/app/site/assets/fonts/inter-latin.woff2 +0 -0
- package/firedrill/tools/notion/app/site/assets/notion-wordmark.svg +1 -0
- package/firedrill/tools/notion/app/site/assets/notion.svg +1 -0
- package/firedrill/tools/notion/app/site/chrome.js +104 -0
- package/firedrill/tools/notion/app/site/cover-picker.js +83 -0
- package/firedrill/tools/notion/app/site/database.js +648 -0
- package/firedrill/tools/notion/app/site/editors.js +320 -0
- package/firedrill/tools/notion/app/site/format-bar.js +97 -0
- package/firedrill/tools/notion/app/site/icons.js +131 -0
- package/firedrill/tools/notion/app/site/index.html +125 -0
- package/firedrill/tools/notion/app/site/page.js +826 -0
- package/firedrill/tools/notion/app/site/rich.js +159 -0
- package/firedrill/tools/notion/app/site/state.js +170 -0
- package/firedrill/tools/notion/app/site/styles.css +826 -0
- package/firedrill/tools/notion/app/site/ui.js +418 -0
- package/firedrill/tools/notion/behavior.mjs +1123 -0
- package/firedrill/tools/notion/lib/blocks.mjs +371 -0
- package/firedrill/tools/notion/lib/identity.mjs +123 -0
- package/firedrill/tools/notion/lib/ids.mjs +63 -0
- package/firedrill/tools/notion/lib/json-depth.mjs +26 -0
- package/firedrill/tools/notion/lib/markdown.mjs +381 -0
- package/firedrill/tools/notion/lib/properties.mjs +513 -0
- package/firedrill/tools/notion/lib/query.mjs +272 -0
- package/firedrill/tools/notion/lib/render.mjs +137 -0
- package/firedrill/tools/notion/lib/rich-text.mjs +134 -0
- package/firedrill/tools/notion/lib/size.mjs +44 -0
- package/firedrill/tools/notion/lib/state.mjs +192 -0
- package/firedrill/tools/notion/lib/wire.mjs +89 -0
- package/firedrill/tools/notion/notion.tool.json +9837 -0
- package/firedrill/update-lost.scenario.json +11 -0
- package/firedrill/world.json +7039 -0
- package/firedrill/write-unavailable.scenario.json +11 -0
- package/firedrill.json +5 -0
- package/package.json +63 -0
- package/starter.json +6482 -0
- package/test/conformance.mjs +1186 -0
|
@@ -0,0 +1,1123 @@
|
|
|
1
|
+
// Synthetic Notion workspace. Every operation computes from context.state: ids come from the
|
|
2
|
+
// `meta/counters` row, timestamps from the virtual clock, visibility and capabilities from the calling
|
|
3
|
+
// integration. Nothing here contacts Notion; no notification, e-mail or webhook is ever sent.
|
|
4
|
+
import {
|
|
5
|
+
APPENDABLE_BLOCK_TYPES,
|
|
6
|
+
CONTAINER_TYPES,
|
|
7
|
+
blockTree,
|
|
8
|
+
childrenOf,
|
|
9
|
+
createBlocks,
|
|
10
|
+
descendantIds,
|
|
11
|
+
indexBlocks,
|
|
12
|
+
nextPosition,
|
|
13
|
+
placeInOrder,
|
|
14
|
+
removeBlockRow,
|
|
15
|
+
addBlockRow,
|
|
16
|
+
normalizeBlockInputs,
|
|
17
|
+
normalizeContent,
|
|
18
|
+
pageOfBlock,
|
|
19
|
+
blockLevel,
|
|
20
|
+
requestDepth,
|
|
21
|
+
MAX_TREE_LEVELS,
|
|
22
|
+
parentKey,
|
|
23
|
+
setBlockTrashed,
|
|
24
|
+
setDataSourceTrashed,
|
|
25
|
+
setPageTrashed,
|
|
26
|
+
touchPage,
|
|
27
|
+
} from "./lib/blocks.mjs";
|
|
28
|
+
import {
|
|
29
|
+
ancestry,
|
|
30
|
+
isVisible,
|
|
31
|
+
requireBlockRow,
|
|
32
|
+
requireComments,
|
|
33
|
+
requireContent,
|
|
34
|
+
requireDataSource,
|
|
35
|
+
requireDatabase,
|
|
36
|
+
requireIdentity,
|
|
37
|
+
requirePage,
|
|
38
|
+
requireUser,
|
|
39
|
+
requireUserInformation,
|
|
40
|
+
requireUuid,
|
|
41
|
+
} from "./lib/identity.mjs";
|
|
42
|
+
import { nextId } from "./lib/ids.mjs";
|
|
43
|
+
import { parseMarkdown, renderMarkdown } from "./lib/markdown.mjs";
|
|
44
|
+
import {
|
|
45
|
+
COMPUTED_TYPES,
|
|
46
|
+
definitionsOf,
|
|
47
|
+
emptyValue,
|
|
48
|
+
findDefinition,
|
|
49
|
+
findPropertyById,
|
|
50
|
+
normalizePropertyValues,
|
|
51
|
+
normalizeSchema,
|
|
52
|
+
applySchemaPatch,
|
|
53
|
+
propertyItems,
|
|
54
|
+
renderSchema,
|
|
55
|
+
renderSchemaPageShape,
|
|
56
|
+
validateFilterProperties,
|
|
57
|
+
} from "./lib/properties.mjs";
|
|
58
|
+
import { compileFilter, compileSorts } from "./lib/query.mjs";
|
|
59
|
+
import { listResponse, renderBlock, renderComment, renderDataSource, renderDatabase, renderPage, renderUser } from "./lib/render.mjs";
|
|
60
|
+
import { normalizeRichText, plainText } from "./lib/rich-text.mjs";
|
|
61
|
+
import { jsonBytes, SCHEMA_BUDGET_BYTES, SCHEMA_PAGE_BUDGET_BYTES, utf8Length } from "./lib/size.mjs";
|
|
62
|
+
import { allRows, isMangled, isoNow, limits, mangledError, notFound, pageSize, paginate, shown, sized, validationError, withinBudget } from "./lib/state.mjs";
|
|
63
|
+
import { defined, encodeOutcome, intOrRaw, jsonBody, list, operationInput, str } from "./lib/wire.mjs";
|
|
64
|
+
|
|
65
|
+
const TRASHED_EDIT = "Can't edit block that is archived. You must unarchive the block before editing.";
|
|
66
|
+
const MARKDOWN_TYPES = ["replace_content", "update_content", "insert_content"];
|
|
67
|
+
|
|
68
|
+
function isObject(value) {
|
|
69
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Mutable copies of every block row, indexed by id and by parent for the duration of one operation. */
|
|
73
|
+
function blockRows(context) {
|
|
74
|
+
return indexBlocks(allRows(context, "blocks").map((row) => ({ ...row })));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Most `content_updates` entries one update_content request may carry. */
|
|
78
|
+
const MAX_CONTENT_UPDATES = 100;
|
|
79
|
+
|
|
80
|
+
/** Offsets of `needle` in `text` (non-overlapping, left to right), stopping once `limit` are found. */
|
|
81
|
+
function occurrencesOf(text, needle, limit) {
|
|
82
|
+
const found = [];
|
|
83
|
+
let from = 0;
|
|
84
|
+
while (found.length < limit) {
|
|
85
|
+
const at = text.indexOf(needle, from);
|
|
86
|
+
if (at < 0) break;
|
|
87
|
+
found.push(at);
|
|
88
|
+
from = at + needle.length;
|
|
89
|
+
}
|
|
90
|
+
return found;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Literal replacement at known offsets (no `$&`-style patterns, one join). */
|
|
94
|
+
function replaceAt(text, offsets, needleLength, replacement) {
|
|
95
|
+
const parts = [];
|
|
96
|
+
let last = 0;
|
|
97
|
+
for (const at of offsets) {
|
|
98
|
+
parts.push(text.slice(last, at), replacement);
|
|
99
|
+
last = at + needleLength;
|
|
100
|
+
}
|
|
101
|
+
parts.push(text.slice(last));
|
|
102
|
+
return parts.join("");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function author(identity) {
|
|
106
|
+
return { object: "user", id: identity.user.id };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function parentInfo(context, parent) {
|
|
110
|
+
if (parent.type === "workspace") {
|
|
111
|
+
const workspace = context.state.get("workspace", "workspace");
|
|
112
|
+
return { parent_type: "workspace", parent_id: workspace === null ? "workspace" : workspace.id };
|
|
113
|
+
}
|
|
114
|
+
return { parent_type: parent.type, parent_id: parentKey(parent) };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function icon(context, value, path) {
|
|
118
|
+
if (value === undefined) return undefined;
|
|
119
|
+
if (value === null) return null;
|
|
120
|
+
if (isObject(value) && value.type === "emoji" && typeof value.emoji === "string" && value.emoji.length > 0) return { type: "emoji", emoji: value.emoji };
|
|
121
|
+
if (isObject(value) && (value.type === "external" || value.external !== undefined) && isObject(value.external) && typeof value.external.url === "string") {
|
|
122
|
+
return { type: "external", external: { url: value.external.url } };
|
|
123
|
+
}
|
|
124
|
+
return validationError(context, `body failed validation: body.${path} should be an emoji ({ type: "emoji", emoji }) or an external image ({ type: "external", external: { url } }), or null.`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function cover(context, value) {
|
|
128
|
+
if (value === undefined) return undefined;
|
|
129
|
+
if (value === null) return null;
|
|
130
|
+
if (isObject(value) && (value.type === "external" || value.external !== undefined) && isObject(value.external) && typeof value.external.url === "string") {
|
|
131
|
+
return { type: "external", external: { url: value.external.url } };
|
|
132
|
+
}
|
|
133
|
+
return validationError(context, 'body failed validation: body.cover should be an external image ({ type: "external", external: { url } }) or null.');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Resolve the `parent` object of a create/move request into a target page or data source row. */
|
|
137
|
+
function resolveParent(context, identity, parent, { allowDatabase }) {
|
|
138
|
+
if (!isObject(parent)) return validationError(context, "body failed validation: body.parent should be an object.");
|
|
139
|
+
if (parent.type === "workspace" || parent.workspace === true) {
|
|
140
|
+
return validationError(context, "body failed validation: body.parent.type should be \"page_id\" or \"data_source_id\" (integrations cannot create pages at the workspace root).");
|
|
141
|
+
}
|
|
142
|
+
if (parent.page_id !== undefined) {
|
|
143
|
+
const page = requirePage(context, identity, parent.page_id, "body.parent.page_id");
|
|
144
|
+
if (page.in_trash) return validationError(context, "Can't create or move content under a page that is in the trash.");
|
|
145
|
+
return { kind: "page", row: page };
|
|
146
|
+
}
|
|
147
|
+
if (parent.data_source_id !== undefined) {
|
|
148
|
+
const source = requireDataSource(context, identity, parent.data_source_id, "body.parent.data_source_id");
|
|
149
|
+
if (source.in_trash) return validationError(context, "Can't create or move content in a data source that is in the trash.");
|
|
150
|
+
return { kind: "data_source", row: source };
|
|
151
|
+
}
|
|
152
|
+
if (allowDatabase && parent.database_id !== undefined) {
|
|
153
|
+
const database = requireDatabase(context, identity, parent.database_id, "body.parent.database_id");
|
|
154
|
+
if (database.in_trash) return validationError(context, "Can't create content in a database that is in the trash.");
|
|
155
|
+
const source = context.state.get("data-sources", database.data_source_ids[0]);
|
|
156
|
+
if (source === null) return notFound(context, "database", database.id);
|
|
157
|
+
return { kind: "data_source", row: source };
|
|
158
|
+
}
|
|
159
|
+
return validationError(context, `body failed validation: body.parent should contain page_id${allowDatabase ? ", data_source_id or database_id" : " or data_source_id"}.`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function titlePlainOf(values, definitions) {
|
|
163
|
+
const title = Object.values(definitions).find((definition) => definition.type === "title");
|
|
164
|
+
const stored = title === undefined ? undefined : values[title.name];
|
|
165
|
+
return stored === undefined ? "" : plainText(stored.value);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Stored values for a plain (non-database) page: only `title` exists. */
|
|
169
|
+
function plainPageValues(context, raw) {
|
|
170
|
+
const definitions = { title: { id: "title", name: "title", type: "title", config: {} } };
|
|
171
|
+
return normalizePropertyValues(context, definitions, raw, { create: true }).values;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function persistSchema(context, source, properties, now, identity) {
|
|
175
|
+
checkSchema(context, properties);
|
|
176
|
+
const updated = { ...source, properties, last_edited_time: now, last_edited_by: author(identity) };
|
|
177
|
+
context.state.put("data-sources", source.id, updated);
|
|
178
|
+
return updated;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* A schema is bounded twice: by its own rendering, and by the most it adds to the rendering of each page of its data
|
|
183
|
+
* source. Pages are size-checked when written, so without the second bound a later schema change (say many
|
|
184
|
+
* created_by properties, which render an expanded user in every page) could push stored pages and whole query or
|
|
185
|
+
* search pages past the response limit for good.
|
|
186
|
+
*/
|
|
187
|
+
function checkSchema(context, properties) {
|
|
188
|
+
const shape = renderSchemaPageShape(properties, widestUser(context));
|
|
189
|
+
const pageBytes = jsonBytes(shape);
|
|
190
|
+
if (pageBytes > SCHEMA_PAGE_BUDGET_BYTES) {
|
|
191
|
+
return validationError(
|
|
192
|
+
context,
|
|
193
|
+
`body failed validation: the data source schema would add ${pageBytes} bytes to the rendering of each of its pages; the limit is ${SCHEMA_PAGE_BUDGET_BYTES} bytes.`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
withinBudget(context, renderSchema(properties), "the data source schema", SCHEMA_BUDGET_BYTES);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The largest expanded user object any integration could be shown (with emails). */
|
|
200
|
+
function widestUser(context) {
|
|
201
|
+
const reader = { capabilities: { user_information: "with_emails" } };
|
|
202
|
+
let widest = { object: "user", id: "00000000-0000-0000-0000-000000000000" };
|
|
203
|
+
let widestBytes = jsonBytes(widest);
|
|
204
|
+
for (const row of allRows(context, "users")) {
|
|
205
|
+
const rendered = renderUser(context, reader, row);
|
|
206
|
+
const bytes = jsonBytes(rendered);
|
|
207
|
+
if (bytes > widestBytes) {
|
|
208
|
+
widest = rendered;
|
|
209
|
+
widestBytes = bytes;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return widest;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Re-key the values of every page of a data source after a schema patch. */
|
|
216
|
+
function applyChangesToPages(context, source, changes) {
|
|
217
|
+
if (changes.length === 0) return;
|
|
218
|
+
for (const page of allRows(context, "pages")) {
|
|
219
|
+
if (page.parent.type !== "data_source_id" || page.parent.data_source_id !== source.id) continue;
|
|
220
|
+
const values = { ...page.properties };
|
|
221
|
+
let changed = false;
|
|
222
|
+
for (const change of changes) {
|
|
223
|
+
if (change.kind === "remove") delete values[change.name];
|
|
224
|
+
else if (change.kind === "rename") {
|
|
225
|
+
if (values[change.from] !== undefined) {
|
|
226
|
+
values[change.to] = values[change.from];
|
|
227
|
+
delete values[change.from];
|
|
228
|
+
}
|
|
229
|
+
} else if (change.kind === "retype") {
|
|
230
|
+
values[change.name] = { id: change.definition.id, type: change.definition.type, value: emptyValue(change.definition.type) };
|
|
231
|
+
} else if (change.kind === "add") {
|
|
232
|
+
if (!COMPUTED_TYPES.includes(change.definition.type)) values[change.name] = { id: change.definition.id, type: change.definition.type, value: emptyValue(change.definition.type) };
|
|
233
|
+
} else if (change.kind === "reconfigure") {
|
|
234
|
+
const current = values[change.name];
|
|
235
|
+
const options = change.definition.config.options;
|
|
236
|
+
if (current !== undefined && options !== undefined) {
|
|
237
|
+
if (current.type === "multi_select") values[change.name] = { ...current, value: current.value.filter((item) => options.some((option) => option.id === item.id)) };
|
|
238
|
+
else if (current.value !== null && !options.some((option) => option.id === current.value.id)) values[change.name] = { ...current, value: null };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// Computed-type adds and option-preserving reconfigures leave a page as it was: do not rewrite it.
|
|
243
|
+
const keys = Object.keys(values);
|
|
244
|
+
changed = keys.length !== Object.keys(page.properties).length || keys.some((key) => values[key] !== page.properties[key]);
|
|
245
|
+
if (changed) context.state.put("pages", page.id, { ...page, properties: values });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function contentUpdated(context, identity, pageId, blockIds) {
|
|
250
|
+
context.events.emit("page.content_updated", { entity_id: pageId, updated_blocks: blockIds, author_id: identity.user.id });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Set the order of a page's live top-level blocks to `orderedIds` (others keep their relative order after). */
|
|
254
|
+
function arrangeChildren(context, rows, parentId, orderedIds) {
|
|
255
|
+
const siblings = childrenOf(rows, parentId);
|
|
256
|
+
const byId = new Map(siblings.map((block) => [block.id, block]));
|
|
257
|
+
const wanted = new Set(orderedIds);
|
|
258
|
+
const ordered = [];
|
|
259
|
+
for (const id of wanted) if (byId.has(id)) ordered.push(byId.get(id));
|
|
260
|
+
placeInOrder(context, [...ordered, ...siblings.filter((block) => !wanted.has(block.id))]);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function pageMarkdown(context, rows, page) {
|
|
264
|
+
const rendered = renderMarkdown(context, blockTree(rows, page.id));
|
|
265
|
+
const result = { object: "page_markdown", id: page.id, markdown: rendered.markdown, truncated: false, unknown_block_ids: rendered.unknown_block_ids };
|
|
266
|
+
return sized(context, result, "the page markdown");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Validate parsed markdown entries: `keep` references must be live top-level children of the page. */
|
|
270
|
+
function splitParsed(context, rows, pageId, parsed, { allowKeep }) {
|
|
271
|
+
const live = childrenOf(rows, pageId);
|
|
272
|
+
const liveIds = new Set(live.map((block) => block.id));
|
|
273
|
+
for (const entry of parsed) {
|
|
274
|
+
if (entry.keep === undefined) continue;
|
|
275
|
+
if (!allowKeep) return validationError(context, "body failed validation: inserted markdown cannot reference existing blocks (notion:// links to child pages or <unknown/> tags).");
|
|
276
|
+
if (!liveIds.has(entry.keep)) return validationError(context, `body failed validation: the markdown references block ${entry.keep}, which is not a child of this page.`);
|
|
277
|
+
}
|
|
278
|
+
return live;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const operations = {
|
|
282
|
+
// ---------------------------------------------------------------------------------------------
|
|
283
|
+
// Users
|
|
284
|
+
// ---------------------------------------------------------------------------------------------
|
|
285
|
+
"users.me": (input, context) => {
|
|
286
|
+
const identity = requireIdentity(context);
|
|
287
|
+
return renderUser(context, identity, identity.bot);
|
|
288
|
+
},
|
|
289
|
+
"users.list": (input, context) => {
|
|
290
|
+
const identity = requireIdentity(context);
|
|
291
|
+
requireUserInformation(context, identity);
|
|
292
|
+
const users = allRows(context, "users");
|
|
293
|
+
const pagination = paginate(context, users, input, undefined, (user) => renderUser(context, identity, user));
|
|
294
|
+
return listResponse("user", pagination.results, pagination);
|
|
295
|
+
},
|
|
296
|
+
"users.get": (input, context) => {
|
|
297
|
+
const identity = requireIdentity(context);
|
|
298
|
+
// GET /v1/users/me reaches this operation: the framework cannot declare a literal path next to /v1/users/{user_id}.
|
|
299
|
+
if (input.user_id === "me") return renderUser(context, identity, identity.bot);
|
|
300
|
+
requireUserInformation(context, identity);
|
|
301
|
+
return renderUser(context, identity, requireUser(context, input.user_id));
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
// ---------------------------------------------------------------------------------------------
|
|
305
|
+
// Search
|
|
306
|
+
// ---------------------------------------------------------------------------------------------
|
|
307
|
+
search: (input, context) => {
|
|
308
|
+
const identity = requireIdentity(context);
|
|
309
|
+
let objectFilter;
|
|
310
|
+
if (input.filter !== undefined) {
|
|
311
|
+
if (!isObject(input.filter) || input.filter.property !== "object" || !["page", "data_source"].includes(input.filter.value)) {
|
|
312
|
+
return validationError(context, 'body failed validation: body.filter should be { property: "object", value: "page" | "data_source" }.');
|
|
313
|
+
}
|
|
314
|
+
objectFilter = input.filter.value;
|
|
315
|
+
}
|
|
316
|
+
let direction = "descending";
|
|
317
|
+
if (input.sort !== undefined) {
|
|
318
|
+
if (!isObject(input.sort) || input.sort.timestamp !== "last_edited_time" || !["ascending", "descending"].includes(input.sort.direction)) {
|
|
319
|
+
return validationError(context, 'body failed validation: body.sort should be { timestamp: "last_edited_time", direction: "ascending" | "descending" }.');
|
|
320
|
+
}
|
|
321
|
+
direction = input.sort.direction;
|
|
322
|
+
}
|
|
323
|
+
if (input.query !== undefined && typeof input.query !== "string") return validationError(context, "body failed validation: body.query should be a string.");
|
|
324
|
+
// A search term carrying U+FFFD reached the server mangled (lenient percent-decoding, or a literal %EF%BF%BD).
|
|
325
|
+
// Running it would silently match nothing, so fail with Notion's validation_error instead.
|
|
326
|
+
if (isMangled(input.query)) return mangledError(context, "body.query");
|
|
327
|
+
const tokens = (input.query ?? "").toLowerCase().split(/\s+/).filter((token) => token.length > 0);
|
|
328
|
+
const matches = (title) => tokens.every((token) => title.toLowerCase().includes(token));
|
|
329
|
+
const candidates = [];
|
|
330
|
+
if (identity.capabilities.content !== "none") {
|
|
331
|
+
if (objectFilter !== "data_source") {
|
|
332
|
+
for (const page of allRows(context, "pages")) {
|
|
333
|
+
if (!page.in_trash && matches(page.title_plain) && isVisible(context, identity, page)) candidates.push({ kind: "page", row: page });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (objectFilter !== "page") {
|
|
337
|
+
for (const source of allRows(context, "data-sources")) {
|
|
338
|
+
const database = context.state.get("databases", source.parent.database_id);
|
|
339
|
+
if (source.in_trash || database === null || database.in_trash || !matches(plainText(source.title))) continue;
|
|
340
|
+
if (isVisible(context, identity, source)) candidates.push({ kind: "data_source", row: source });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
const sign = direction === "ascending" ? 1 : -1;
|
|
345
|
+
candidates.sort((left, right) => {
|
|
346
|
+
const byTime = Date.parse(left.row.last_edited_time) - Date.parse(right.row.last_edited_time);
|
|
347
|
+
if (byTime !== 0) return byTime * sign;
|
|
348
|
+
return left.row.id < right.row.id ? -1 : 1;
|
|
349
|
+
});
|
|
350
|
+
const render = (candidate) => (candidate.kind === "page" ? renderPage(context, identity, candidate.row) : renderDataSource(context, candidate.row));
|
|
351
|
+
const pagination = paginate(context, candidates, input, (candidate) => candidate.row.id, render, "body.page_size");
|
|
352
|
+
return listResponse("page_or_data_source", pagination.results, pagination, { request_status: { type: "complete" } });
|
|
353
|
+
},
|
|
354
|
+
|
|
355
|
+
// ---------------------------------------------------------------------------------------------
|
|
356
|
+
// Pages
|
|
357
|
+
// ---------------------------------------------------------------------------------------------
|
|
358
|
+
"pages.create": (input, context) => {
|
|
359
|
+
const identity = requireIdentity(context);
|
|
360
|
+
requireContent(context, identity, "read_update_insert");
|
|
361
|
+
const target = resolveParent(context, identity, input.parent, { allowDatabase: true });
|
|
362
|
+
const now = isoNow(context);
|
|
363
|
+
let values;
|
|
364
|
+
let schema;
|
|
365
|
+
if (target.kind === "page") {
|
|
366
|
+
if (isObject(input.properties)) {
|
|
367
|
+
for (const key of Object.keys(input.properties)) if (key !== "title") return validationError(context, `${key} is not a property that exists.`);
|
|
368
|
+
}
|
|
369
|
+
values = plainPageValues(context, input.properties ?? {});
|
|
370
|
+
} else {
|
|
371
|
+
const normalized = normalizePropertyValues(context, target.row.properties, input.properties ?? {}, { create: true });
|
|
372
|
+
values = normalized.values;
|
|
373
|
+
schema = normalized.changed ? normalized.properties : undefined;
|
|
374
|
+
}
|
|
375
|
+
const pageIcon = icon(context, input.icon, "icon") ?? null;
|
|
376
|
+
const pageCover = cover(context, input.cover) ?? null;
|
|
377
|
+
let children = [];
|
|
378
|
+
if (input.children !== undefined) {
|
|
379
|
+
children = normalizeBlockInputs(context, input.children, "children");
|
|
380
|
+
const max = limits(context).max_children_per_append;
|
|
381
|
+
if (children.length > max) return validationError(context, `body failed validation: body.children.length should be ≤ ${max}, instead was ${children.length}.`);
|
|
382
|
+
}
|
|
383
|
+
// Every check passed: write.
|
|
384
|
+
const rows = blockRows(context);
|
|
385
|
+
const id = nextId(context, "pages");
|
|
386
|
+
const parent = target.kind === "page" ? { type: "page_id", page_id: target.row.id } : { type: "data_source_id", data_source_id: target.row.id, database_id: target.row.parent.database_id };
|
|
387
|
+
const definitions = target.kind === "page" ? { title: { id: "title", name: "title", type: "title", config: {} } } : (schema ?? target.row.properties);
|
|
388
|
+
const page = {
|
|
389
|
+
id,
|
|
390
|
+
parent,
|
|
391
|
+
properties: values,
|
|
392
|
+
icon: pageIcon,
|
|
393
|
+
cover: pageCover,
|
|
394
|
+
in_trash: false,
|
|
395
|
+
created_time: now,
|
|
396
|
+
last_edited_time: now,
|
|
397
|
+
created_by: author(identity),
|
|
398
|
+
last_edited_by: author(identity),
|
|
399
|
+
title_plain: titlePlainOf(values, definitions),
|
|
400
|
+
};
|
|
401
|
+
context.state.put("pages", id, page);
|
|
402
|
+
if (schema !== undefined) persistSchema(context, target.row, schema, now, identity);
|
|
403
|
+
if (target.kind === "page") {
|
|
404
|
+
const block = {
|
|
405
|
+
id,
|
|
406
|
+
parent: { type: "page_id", page_id: target.row.id },
|
|
407
|
+
type: "child_page",
|
|
408
|
+
content: { title: page.title_plain },
|
|
409
|
+
position: nextPosition(rows, target.row.id),
|
|
410
|
+
has_children: false,
|
|
411
|
+
in_trash: false,
|
|
412
|
+
created_time: now,
|
|
413
|
+
last_edited_time: now,
|
|
414
|
+
created_by: author(identity),
|
|
415
|
+
last_edited_by: author(identity),
|
|
416
|
+
};
|
|
417
|
+
context.state.put("blocks", id, block);
|
|
418
|
+
addBlockRow(rows, { ...block });
|
|
419
|
+
touchPage(context, target.row.id, now, identity.user.id);
|
|
420
|
+
} else {
|
|
421
|
+
const source = context.state.get("data-sources", target.row.id);
|
|
422
|
+
if (source !== null) context.state.put("data-sources", source.id, { ...source, last_edited_time: now, last_edited_by: author(identity) });
|
|
423
|
+
}
|
|
424
|
+
const created = children.length === 0 ? [] : createBlocks(context, { type: "page_id", page_id: id }, children, { now, authorId: identity.user.id, rows });
|
|
425
|
+
context.events.emit("page.created", {
|
|
426
|
+
entity_id: id,
|
|
427
|
+
...parentInfo(context, parent),
|
|
428
|
+
database_id: parent.type === "data_source_id" ? parent.database_id : null,
|
|
429
|
+
author_id: identity.user.id,
|
|
430
|
+
});
|
|
431
|
+
if (created.length > 0) contentUpdated(context, identity, id, created.map((block) => block.id));
|
|
432
|
+
return withinBudget(context, renderPage(context, identity, context.state.get("pages", id)), "the page");
|
|
433
|
+
},
|
|
434
|
+
"pages.retrieve": (input, context) => {
|
|
435
|
+
const identity = requireIdentity(context);
|
|
436
|
+
const page = requirePage(context, identity, input.page_id);
|
|
437
|
+
const filter = validateFilterProperties(context, definitionsOf(context, page), input.filter_properties, "query.filter_properties");
|
|
438
|
+
return sized(context, renderPage(context, identity, page, filter), "the page");
|
|
439
|
+
},
|
|
440
|
+
"pages.update": (input, context) => {
|
|
441
|
+
const identity = requireIdentity(context);
|
|
442
|
+
requireContent(context, identity, "read_update");
|
|
443
|
+
let page = requirePage(context, identity, input.page_id);
|
|
444
|
+
const trash = input.in_trash ?? input.archived;
|
|
445
|
+
const editing = input.properties !== undefined || input.icon !== undefined || input.cover !== undefined;
|
|
446
|
+
if (page.in_trash && trash !== false && editing) return validationError(context, TRASHED_EDIT);
|
|
447
|
+
const now = isoNow(context);
|
|
448
|
+
const definitions = definitionsOf(context, page);
|
|
449
|
+
let values = page.properties;
|
|
450
|
+
let schema;
|
|
451
|
+
const updatedIds = [];
|
|
452
|
+
if (input.properties !== undefined) {
|
|
453
|
+
if (page.parent.type !== "data_source_id") {
|
|
454
|
+
if (!isObject(input.properties)) return validationError(context, "body failed validation: body.properties should be an object.");
|
|
455
|
+
for (const key of Object.keys(input.properties)) if (key !== "title") return validationError(context, `${key} is not a property that exists.`);
|
|
456
|
+
}
|
|
457
|
+
const normalized = normalizePropertyValues(context, definitions, input.properties, { create: false });
|
|
458
|
+
values = { ...page.properties, ...normalized.values };
|
|
459
|
+
updatedIds.push(...Object.values(normalized.values).map((value) => value.id));
|
|
460
|
+
if (normalized.changed) schema = normalized.properties;
|
|
461
|
+
}
|
|
462
|
+
const nextIcon = icon(context, input.icon, "icon");
|
|
463
|
+
const nextCover = cover(context, input.cover);
|
|
464
|
+
if (nextIcon !== undefined) updatedIds.push("icon");
|
|
465
|
+
if (nextCover !== undefined) updatedIds.push("cover");
|
|
466
|
+
// Writes.
|
|
467
|
+
const rows = blockRows(context);
|
|
468
|
+
if (trash === true && !page.in_trash) {
|
|
469
|
+
setPageTrashed(context, rows, page.id, true, now, identity.user.id);
|
|
470
|
+
context.events.emit("page.deleted", { entity_id: page.id, ...parentInfo(context, page.parent), author_id: identity.user.id });
|
|
471
|
+
} else if (trash === false && page.in_trash) {
|
|
472
|
+
setPageTrashed(context, rows, page.id, false, now, identity.user.id);
|
|
473
|
+
context.events.emit("page.undeleted", { entity_id: page.id, ...parentInfo(context, page.parent), author_id: identity.user.id });
|
|
474
|
+
}
|
|
475
|
+
page = context.state.get("pages", page.id);
|
|
476
|
+
if (editing) {
|
|
477
|
+
if (schema !== undefined) {
|
|
478
|
+
const source = context.state.get("data-sources", page.parent.data_source_id);
|
|
479
|
+
persistSchema(context, source, schema, now, identity);
|
|
480
|
+
}
|
|
481
|
+
const updated = {
|
|
482
|
+
...page,
|
|
483
|
+
properties: values,
|
|
484
|
+
icon: nextIcon === undefined ? page.icon : nextIcon,
|
|
485
|
+
cover: nextCover === undefined ? page.cover : nextCover,
|
|
486
|
+
last_edited_time: now,
|
|
487
|
+
last_edited_by: author(identity),
|
|
488
|
+
title_plain: titlePlainOf(values, schema ?? definitions),
|
|
489
|
+
};
|
|
490
|
+
context.state.put("pages", page.id, updated);
|
|
491
|
+
if (updated.title_plain !== page.title_plain) {
|
|
492
|
+
const block = context.state.get("blocks", page.id);
|
|
493
|
+
if (block !== null && block.type === "child_page") context.state.put("blocks", page.id, { ...block, content: { title: updated.title_plain }, last_edited_time: now, last_edited_by: author(identity) });
|
|
494
|
+
}
|
|
495
|
+
if (page.parent.type === "page_id") touchPage(context, page.parent.page_id, now, identity.user.id);
|
|
496
|
+
context.events.emit("page.properties_updated", { entity_id: page.id, ...parentInfo(context, page.parent), updated_properties: updatedIds, author_id: identity.user.id });
|
|
497
|
+
}
|
|
498
|
+
return withinBudget(context, renderPage(context, identity, context.state.get("pages", page.id)), "the page");
|
|
499
|
+
},
|
|
500
|
+
"pages.retrieve-property": (input, context) => {
|
|
501
|
+
const identity = requireIdentity(context);
|
|
502
|
+
const page = requirePage(context, identity, input.page_id);
|
|
503
|
+
if (typeof input.property_id !== "string" || input.property_id.trim().length === 0) return validationError(context, "path failed validation: path.property_id should be a non-empty string.");
|
|
504
|
+
pageSize(context, input);
|
|
505
|
+
const definition = findPropertyById(definitionsOf(context, page), input.property_id);
|
|
506
|
+
if (definition === undefined) return validationError(context, `Could not find property with name or id: ${input.property_id}`);
|
|
507
|
+
const items = propertyItems(context, identity, page, definition);
|
|
508
|
+
if (!items.list) {
|
|
509
|
+
if (input.start_cursor !== undefined || input.page_size !== undefined) {
|
|
510
|
+
return validationError(context, `body failed validation: ${definition.type} properties are single property items and cannot be paginated.`);
|
|
511
|
+
}
|
|
512
|
+
return sized(context, items.item, "the property item");
|
|
513
|
+
}
|
|
514
|
+
const indexed = items.items.map((item, index) => ({ cursor: String(index), item }));
|
|
515
|
+
const pagination = paginate(context, indexed, input, (entry) => entry.cursor, (entry) => entry.item);
|
|
516
|
+
return {
|
|
517
|
+
object: "list",
|
|
518
|
+
results: pagination.results,
|
|
519
|
+
next_cursor: pagination.next_cursor,
|
|
520
|
+
has_more: pagination.has_more,
|
|
521
|
+
type: "property_item",
|
|
522
|
+
property_item: { id: definition.id, type: definition.type, next_url: null, [definition.type]: {} },
|
|
523
|
+
};
|
|
524
|
+
},
|
|
525
|
+
"pages.move": (input, context) => {
|
|
526
|
+
const identity = requireIdentity(context);
|
|
527
|
+
requireContent(context, identity, "read_update");
|
|
528
|
+
const page = requirePage(context, identity, input.page_id);
|
|
529
|
+
if (page.in_trash) return validationError(context, TRASHED_EDIT);
|
|
530
|
+
const target = resolveParent(context, identity, input.parent, { allowDatabase: false });
|
|
531
|
+
if (target.kind === "page" && ancestry(context, target.row).includes(page.id)) {
|
|
532
|
+
return validationError(context, "body failed validation: body.parent.page_id should not be the page itself or one of its descendants.");
|
|
533
|
+
}
|
|
534
|
+
const now = isoNow(context);
|
|
535
|
+
const rows = blockRows(context);
|
|
536
|
+
const previous = page.parent;
|
|
537
|
+
const oldDefinitions = definitionsOf(context, page);
|
|
538
|
+
const titleValue = page.properties[Object.values(oldDefinitions).find((definition) => definition.type === "title")?.name ?? "title"];
|
|
539
|
+
const richTitle = titleValue === undefined ? [] : titleValue.value;
|
|
540
|
+
// Detach from the old parent page (the child_page block id equals the page id).
|
|
541
|
+
if (previous.type === "page_id" && context.state.get("blocks", page.id) !== null) {
|
|
542
|
+
context.state.delete("blocks", page.id);
|
|
543
|
+
removeBlockRow(rows, page.id);
|
|
544
|
+
placeInOrder(context, childrenOf(rows, previous.page_id));
|
|
545
|
+
touchPage(context, previous.page_id, now, identity.user.id);
|
|
546
|
+
}
|
|
547
|
+
let parent;
|
|
548
|
+
let values;
|
|
549
|
+
if (target.kind === "page") {
|
|
550
|
+
parent = { type: "page_id", page_id: target.row.id };
|
|
551
|
+
values = { title: { id: "title", type: "title", value: richTitle } };
|
|
552
|
+
context.state.put("blocks", page.id, {
|
|
553
|
+
id: page.id,
|
|
554
|
+
parent,
|
|
555
|
+
type: "child_page",
|
|
556
|
+
content: { title: page.title_plain },
|
|
557
|
+
position: nextPosition(rows, target.row.id),
|
|
558
|
+
has_children: childrenOf(rows, page.id).length > 0,
|
|
559
|
+
in_trash: false,
|
|
560
|
+
created_time: page.created_time,
|
|
561
|
+
last_edited_time: now,
|
|
562
|
+
created_by: page.created_by,
|
|
563
|
+
last_edited_by: author(identity),
|
|
564
|
+
});
|
|
565
|
+
touchPage(context, target.row.id, now, identity.user.id);
|
|
566
|
+
} else {
|
|
567
|
+
parent = { type: "data_source_id", data_source_id: target.row.id, database_id: target.row.parent.database_id };
|
|
568
|
+
const titleDefinition = Object.values(target.row.properties).find((definition) => definition.type === "title");
|
|
569
|
+
values = normalizePropertyValues(context, target.row.properties, { [titleDefinition.name]: { title: richTitle } }, { create: true }).values;
|
|
570
|
+
}
|
|
571
|
+
context.state.put("pages", page.id, { ...page, parent, properties: values, last_edited_time: now, last_edited_by: author(identity) });
|
|
572
|
+
context.events.emit("page.properties_updated", { entity_id: page.id, ...parentInfo(context, parent), updated_properties: [], author_id: identity.user.id });
|
|
573
|
+
return withinBudget(context, renderPage(context, identity, context.state.get("pages", page.id)), "the page");
|
|
574
|
+
},
|
|
575
|
+
"pages.retrieve-markdown": (input, context) => {
|
|
576
|
+
const identity = requireIdentity(context);
|
|
577
|
+
const page = requirePage(context, identity, input.page_id);
|
|
578
|
+
return pageMarkdown(context, blockRows(context), page);
|
|
579
|
+
},
|
|
580
|
+
"pages.update-markdown": (input, context) => {
|
|
581
|
+
const identity = requireIdentity(context);
|
|
582
|
+
requireContent(context, identity, "read_update");
|
|
583
|
+
const page = requirePage(context, identity, input.page_id);
|
|
584
|
+
if (page.in_trash) return validationError(context, TRASHED_EDIT);
|
|
585
|
+
if (input.allow_async === true) return validationError(context, "body failed validation: body.allow_async is not supported; updates are applied synchronously.");
|
|
586
|
+
if (!MARKDOWN_TYPES.includes(input.type)) return validationError(context, `body failed validation: body.type should be one of ${MARKDOWN_TYPES.join(", ")}, instead was \`${shown(input.type)}\`.`);
|
|
587
|
+
const maxBytes = limits(context).max_markdown_bytes;
|
|
588
|
+
const now = isoNow(context);
|
|
589
|
+
const rows = blockRows(context);
|
|
590
|
+
const allowDeleting = input.allow_deleting_content === true;
|
|
591
|
+
const pageRef = { type: "page_id", page_id: page.id };
|
|
592
|
+
// `ordered` lists the page's top-level blocks in their new order: an existing block id (string) or `{ inputs }`
|
|
593
|
+
// for a block still to create. Every new block is created by ONE createBlocks call once all checks have passed,
|
|
594
|
+
// so a document of n blocks costs one sort of the page's live children, never one per entry.
|
|
595
|
+
const pendingOf = (entry) => (entry.keep !== undefined ? entry.keep : { inputs: normalizeBlockInputs(context, [entry], "content") });
|
|
596
|
+
const createPending = (ordered, touched) => {
|
|
597
|
+
const inputs = [];
|
|
598
|
+
for (const item of ordered) if (typeof item !== "string") inputs.push(...item.inputs);
|
|
599
|
+
const created = inputs.length === 0 ? [] : createBlocks(context, pageRef, inputs, { now, authorId: identity.user.id, rows });
|
|
600
|
+
let next = 0;
|
|
601
|
+
return ordered.map((item) => {
|
|
602
|
+
if (typeof item === "string") return item;
|
|
603
|
+
const id = created[next].id;
|
|
604
|
+
next += 1;
|
|
605
|
+
touched.add(id);
|
|
606
|
+
return id;
|
|
607
|
+
});
|
|
608
|
+
};
|
|
609
|
+
let touched;
|
|
610
|
+
if (input.type === "replace_content") {
|
|
611
|
+
const parsed = parseMarkdown(context, input.new_str, maxBytes);
|
|
612
|
+
const live = splitParsed(context, rows, page.id, parsed, { allowKeep: true });
|
|
613
|
+
const kept = new Set(parsed.filter((entry) => entry.keep !== undefined).map((entry) => entry.keep));
|
|
614
|
+
const removed = live.filter((block) => !kept.has(block.id));
|
|
615
|
+
if (removed.some((block) => block.type === "child_page" || block.type === "child_database")) {
|
|
616
|
+
return validationError(context, "body failed validation: child pages and databases cannot be removed through markdown; keep their notion:// links or trash them with the pages API.");
|
|
617
|
+
}
|
|
618
|
+
if (removed.length > 0 && !allowDeleting) return validationError(context, "This update would delete existing content. Set allow_deleting_content to true to confirm.");
|
|
619
|
+
const pending = parsed.map(pendingOf);
|
|
620
|
+
for (const block of removed) setBlockTrashed(context, rows, block, true, now, identity.user.id);
|
|
621
|
+
touched = new Set();
|
|
622
|
+
arrangeChildren(context, rows, page.id, createPending(pending, touched));
|
|
623
|
+
for (const block of removed) touched.add(block.id);
|
|
624
|
+
} else if (input.type === "insert_content") {
|
|
625
|
+
const parsed = parseMarkdown(context, input.content, maxBytes);
|
|
626
|
+
splitParsed(context, rows, page.id, parsed, { allowKeep: false });
|
|
627
|
+
const position = input.position === undefined ? "end" : isObject(input.position) ? input.position.type : undefined;
|
|
628
|
+
if (position !== "start" && position !== "end") return validationError(context, 'body failed validation: body.position.type should be "start" or "end".');
|
|
629
|
+
const before = childrenOf(rows, page.id).map((block) => block.id);
|
|
630
|
+
touched = new Set();
|
|
631
|
+
const created = createPending(parsed.map(pendingOf), touched);
|
|
632
|
+
arrangeChildren(context, rows, page.id, position === "start" ? [...created, ...before] : [...before, ...created]);
|
|
633
|
+
} else {
|
|
634
|
+
if (!Array.isArray(input.content_updates) || input.content_updates.length === 0) return validationError(context, "body failed validation: body.content_updates should be a non-empty array.");
|
|
635
|
+
if (input.content_updates.length > MAX_CONTENT_UPDATES) {
|
|
636
|
+
return validationError(context, `body failed validation: body.content_updates.length should be ≤ ${MAX_CONTENT_UPDATES}, instead was ${input.content_updates.length}.`);
|
|
637
|
+
}
|
|
638
|
+
const current = pageMarkdown(context, rows, page).markdown;
|
|
639
|
+
let markdown = current;
|
|
640
|
+
let markdownBytes = utf8Length(current);
|
|
641
|
+
for (let index = 0; index < input.content_updates.length; index += 1) {
|
|
642
|
+
const update = input.content_updates[index];
|
|
643
|
+
if (!isObject(update) || typeof update.old_str !== "string" || update.old_str.length === 0 || typeof update.new_str !== "string") {
|
|
644
|
+
return validationError(context, `body failed validation: body.content_updates[${index}] should have a non-empty old_str and a new_str.`);
|
|
645
|
+
}
|
|
646
|
+
const replaceAll = update.replace_all_matches === true;
|
|
647
|
+
// A unique match only needs to know whether a second one exists.
|
|
648
|
+
const found = occurrencesOf(markdown, update.old_str, replaceAll ? Number.POSITIVE_INFINITY : 2);
|
|
649
|
+
if (found.length === 0) return validationError(context, `body failed validation: body.content_updates[${index}].old_str was not found in the page content.`);
|
|
650
|
+
if (found.length > 1 && !replaceAll) {
|
|
651
|
+
return validationError(context, `body failed validation: body.content_updates[${index}].old_str matched more than one place; make it unique or set replace_all_matches to true.`);
|
|
652
|
+
}
|
|
653
|
+
// The size of the result is known before it is built: a step may never grow the markdown past the limit.
|
|
654
|
+
const resulting = markdownBytes + found.length * (utf8Length(update.new_str) - utf8Length(update.old_str));
|
|
655
|
+
if (resulting > Math.max(maxBytes, markdownBytes)) {
|
|
656
|
+
return validationError(context, `body failed validation: the resulting markdown exceeds ${maxBytes} bytes (at body.content_updates[${index}]).`);
|
|
657
|
+
}
|
|
658
|
+
markdown = replaceAt(markdown, found, update.old_str.length, update.new_str);
|
|
659
|
+
markdownBytes = resulting;
|
|
660
|
+
}
|
|
661
|
+
if (markdownBytes > maxBytes) return validationError(context, `body failed validation: the resulting markdown exceeds ${maxBytes} bytes.`);
|
|
662
|
+
const live = childrenOf(rows, page.id);
|
|
663
|
+
const oldEntries = parseMarkdown(context, current, maxBytes, { stored: true });
|
|
664
|
+
const newEntries = parseMarkdown(context, markdown, maxBytes, { stored: true });
|
|
665
|
+
splitParsed(context, rows, page.id, newEntries, { allowKeep: true });
|
|
666
|
+
const aligned = oldEntries.length === live.length;
|
|
667
|
+
const keyOf = (entry) => JSON.stringify(entry);
|
|
668
|
+
// Longest common subsequence over serialised entries keeps unchanged blocks (ids and children).
|
|
669
|
+
const oldKeys = aligned ? oldEntries.map(keyOf) : [];
|
|
670
|
+
const newKeys = newEntries.map(keyOf);
|
|
671
|
+
const table = Array.from({ length: oldKeys.length + 1 }, () => new Array(newKeys.length + 1).fill(0));
|
|
672
|
+
for (let i = oldKeys.length - 1; i >= 0; i -= 1) {
|
|
673
|
+
for (let j = newKeys.length - 1; j >= 0; j -= 1) {
|
|
674
|
+
table[i][j] = oldKeys[i] === newKeys[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
const ordered = [];
|
|
678
|
+
const removed = [];
|
|
679
|
+
const updatedInPlace = new Set();
|
|
680
|
+
let pendingOld = [];
|
|
681
|
+
let pendingNew = [];
|
|
682
|
+
const flush = () => {
|
|
683
|
+
// Pair leftover old/new entries of the same type in order: in-place updates; extras insert or delete.
|
|
684
|
+
const pairs = Math.min(pendingOld.length, pendingNew.length);
|
|
685
|
+
for (let k = 0; k < pairs; k += 1) {
|
|
686
|
+
const oldBlock = pendingOld[k];
|
|
687
|
+
const entry = pendingNew[k];
|
|
688
|
+
if (entry.keep === undefined && oldBlock.type === entry.type && oldBlock.type !== "child_page" && oldBlock.type !== "child_database") {
|
|
689
|
+
const content = normalizeContent(context, entry.type, entry[entry.type], "content");
|
|
690
|
+
const stored = context.state.get("blocks", oldBlock.id);
|
|
691
|
+
const children = childrenOf(rows, oldBlock.id);
|
|
692
|
+
const updated = { ...stored, content, last_edited_time: now, last_edited_by: author(identity) };
|
|
693
|
+
context.state.put("blocks", oldBlock.id, updated);
|
|
694
|
+
Object.assign(oldBlock, updated);
|
|
695
|
+
for (const child of children) setBlockTrashed(context, rows, child, true, now, identity.user.id, { updateParent: false });
|
|
696
|
+
if (entry.children !== undefined) {
|
|
697
|
+
createBlocks(context, { type: "block_id", block_id: oldBlock.id }, normalizeBlockInputs(context, entry.children, "content", 1), { now, authorId: identity.user.id, rows });
|
|
698
|
+
} else if (updated.has_children) {
|
|
699
|
+
context.state.put("blocks", oldBlock.id, { ...updated, has_children: false });
|
|
700
|
+
oldBlock.has_children = false;
|
|
701
|
+
}
|
|
702
|
+
ordered.push(oldBlock.id);
|
|
703
|
+
updatedInPlace.add(oldBlock.id);
|
|
704
|
+
} else {
|
|
705
|
+
removed.push(oldBlock);
|
|
706
|
+
ordered.push(pendingOf(entry));
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
for (let k = pairs; k < pendingOld.length; k += 1) removed.push(pendingOld[k]);
|
|
710
|
+
for (let k = pairs; k < pendingNew.length; k += 1) ordered.push(pendingOf(pendingNew[k]));
|
|
711
|
+
pendingOld = [];
|
|
712
|
+
pendingNew = [];
|
|
713
|
+
};
|
|
714
|
+
let i = 0;
|
|
715
|
+
let j = 0;
|
|
716
|
+
while (i < oldKeys.length || j < newKeys.length) {
|
|
717
|
+
if (i < oldKeys.length && j < newKeys.length && oldKeys[i] === newKeys[j]) {
|
|
718
|
+
flush();
|
|
719
|
+
ordered.push(live[i].id);
|
|
720
|
+
i += 1;
|
|
721
|
+
j += 1;
|
|
722
|
+
} else if (j < newKeys.length && (i >= oldKeys.length || table[i][j + 1] >= table[i + 1][j])) {
|
|
723
|
+
pendingNew.push(newEntries[j]);
|
|
724
|
+
j += 1;
|
|
725
|
+
} else {
|
|
726
|
+
pendingOld.push(live[i]);
|
|
727
|
+
i += 1;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
flush();
|
|
731
|
+
if (!aligned) removed.push(...live);
|
|
732
|
+
const orderedIds = new Set(ordered.filter((item) => typeof item === "string"));
|
|
733
|
+
const actuallyRemoved = removed.filter((block) => !orderedIds.has(block.id));
|
|
734
|
+
if (actuallyRemoved.some((block) => block.type === "child_page" || block.type === "child_database")) {
|
|
735
|
+
return validationError(context, "body failed validation: child pages and databases cannot be removed through markdown; keep their notion:// links or trash them with the pages API.");
|
|
736
|
+
}
|
|
737
|
+
if (actuallyRemoved.length > 0 && !allowDeleting) return validationError(context, "This update would delete existing content. Set allow_deleting_content to true to confirm.");
|
|
738
|
+
for (const block of actuallyRemoved) setBlockTrashed(context, rows, block, true, now, identity.user.id);
|
|
739
|
+
const created = new Set();
|
|
740
|
+
const finalIds = createPending(ordered, created);
|
|
741
|
+
// Event order as before: updated or created blocks in page order, then the removed ones.
|
|
742
|
+
touched = new Set(finalIds.filter((id) => created.has(id) || updatedInPlace.has(id)));
|
|
743
|
+
for (const block of actuallyRemoved) touched.add(block.id);
|
|
744
|
+
arrangeChildren(context, rows, page.id, finalIds);
|
|
745
|
+
}
|
|
746
|
+
touchPage(context, page.id, now, identity.user.id);
|
|
747
|
+
contentUpdated(context, identity, page.id, [...touched]);
|
|
748
|
+
// The operation's block index already reflects every write above; rendering from it avoids a second full scan.
|
|
749
|
+
return pageMarkdown(context, rows, context.state.get("pages", page.id));
|
|
750
|
+
},
|
|
751
|
+
|
|
752
|
+
// ---------------------------------------------------------------------------------------------
|
|
753
|
+
// Databases and data sources
|
|
754
|
+
// ---------------------------------------------------------------------------------------------
|
|
755
|
+
"databases.create": (input, context) => {
|
|
756
|
+
const identity = requireIdentity(context);
|
|
757
|
+
requireContent(context, identity, "read_update_insert");
|
|
758
|
+
if (!isObject(input.parent) || input.parent.page_id === undefined) return validationError(context, "body failed validation: body.parent.page_id should be defined (databases are created under a page).");
|
|
759
|
+
const parentPage = requirePage(context, identity, input.parent.page_id, "body.parent.page_id");
|
|
760
|
+
if (parentPage.in_trash) return validationError(context, "Can't create or move content under a page that is in the trash.");
|
|
761
|
+
const rawProperties = isObject(input.initial_data_source) ? input.initial_data_source.properties : input.properties;
|
|
762
|
+
if (rawProperties === undefined) return validationError(context, "body failed validation: body.initial_data_source.properties should be defined.");
|
|
763
|
+
const title = normalizeRichText(context, input.title ?? [], "title");
|
|
764
|
+
const description = normalizeRichText(context, input.description ?? [], "description");
|
|
765
|
+
const databaseIcon = icon(context, input.icon, "icon") ?? null;
|
|
766
|
+
const databaseCover = cover(context, input.cover) ?? null;
|
|
767
|
+
if (input.is_inline !== undefined && typeof input.is_inline !== "boolean") return validationError(context, "body failed validation: body.is_inline should be a boolean.");
|
|
768
|
+
const properties = normalizeSchema(context, rawProperties, isObject(input.initial_data_source) ? "initial_data_source.properties" : "properties");
|
|
769
|
+
checkSchema(context, properties);
|
|
770
|
+
const now = isoNow(context);
|
|
771
|
+
const rows = blockRows(context);
|
|
772
|
+
const databaseId = nextId(context, "databases");
|
|
773
|
+
const sourceId = nextId(context, "data-sources");
|
|
774
|
+
const stamp = { created_time: now, last_edited_time: now, created_by: author(identity), last_edited_by: author(identity) };
|
|
775
|
+
const titlePlain = plainText(title);
|
|
776
|
+
context.state.put("databases", databaseId, {
|
|
777
|
+
id: databaseId,
|
|
778
|
+
parent: { type: "page_id", page_id: parentPage.id },
|
|
779
|
+
title,
|
|
780
|
+
description,
|
|
781
|
+
icon: databaseIcon,
|
|
782
|
+
cover: databaseCover,
|
|
783
|
+
is_inline: input.is_inline === true,
|
|
784
|
+
in_trash: false,
|
|
785
|
+
data_source_ids: [sourceId],
|
|
786
|
+
...stamp,
|
|
787
|
+
title_plain: titlePlain,
|
|
788
|
+
});
|
|
789
|
+
context.state.put("data-sources", sourceId, {
|
|
790
|
+
id: sourceId,
|
|
791
|
+
parent: { type: "database_id", database_id: databaseId },
|
|
792
|
+
name: titlePlain,
|
|
793
|
+
title,
|
|
794
|
+
description,
|
|
795
|
+
properties,
|
|
796
|
+
in_trash: false,
|
|
797
|
+
...stamp,
|
|
798
|
+
});
|
|
799
|
+
context.state.put("blocks", databaseId, {
|
|
800
|
+
id: databaseId,
|
|
801
|
+
parent: { type: "page_id", page_id: parentPage.id },
|
|
802
|
+
type: "child_database",
|
|
803
|
+
content: { title: titlePlain },
|
|
804
|
+
position: nextPosition(rows, parentPage.id),
|
|
805
|
+
has_children: false,
|
|
806
|
+
in_trash: false,
|
|
807
|
+
...stamp,
|
|
808
|
+
});
|
|
809
|
+
touchPage(context, parentPage.id, now, identity.user.id);
|
|
810
|
+
withinBudget(context, renderDataSource(context, context.state.get("data-sources", sourceId)), "the data source");
|
|
811
|
+
return withinBudget(context, renderDatabase(context, context.state.get("databases", databaseId)), "the database");
|
|
812
|
+
},
|
|
813
|
+
"databases.retrieve": (input, context) => {
|
|
814
|
+
const identity = requireIdentity(context);
|
|
815
|
+
return sized(context, renderDatabase(context, requireDatabase(context, identity, input.database_id)), "the database");
|
|
816
|
+
},
|
|
817
|
+
"data-sources.retrieve": (input, context) => {
|
|
818
|
+
const identity = requireIdentity(context);
|
|
819
|
+
return sized(context, renderDataSource(context, requireDataSource(context, identity, input.data_source_id)), "the data source");
|
|
820
|
+
},
|
|
821
|
+
"data-sources.query": (input, context) => {
|
|
822
|
+
const identity = requireIdentity(context);
|
|
823
|
+
let source;
|
|
824
|
+
if (input.data_source_id !== undefined) source = requireDataSource(context, identity, input.data_source_id);
|
|
825
|
+
else if (input.database_id !== undefined) {
|
|
826
|
+
const database = requireDatabase(context, identity, input.database_id);
|
|
827
|
+
source = context.state.get("data-sources", database.data_source_ids[0]);
|
|
828
|
+
if (source === null) return notFound(context, "database", database.id);
|
|
829
|
+
} else return validationError(context, "path failed validation: path.data_source_id should be defined.");
|
|
830
|
+
// Notion documents `filter_properties` as a query-string parameter of this route (the codec also maps a JSON-body
|
|
831
|
+
// copy onto the same argument), so its messages are labelled `query.filter_properties`, as on pages.retrieve.
|
|
832
|
+
const filterIds = validateFilterProperties(context, source.properties, input.filter_properties, "query.filter_properties");
|
|
833
|
+
const predicate = input.filter === undefined ? () => true : compileFilter(context, source.properties, input.filter);
|
|
834
|
+
const comparator = compileSorts(context, source.properties, input.sorts);
|
|
835
|
+
const trashed = input.in_trash ?? input.archived;
|
|
836
|
+
if (trashed !== undefined && typeof trashed !== "boolean") return validationError(context, "body failed validation: body.in_trash should be a boolean.");
|
|
837
|
+
const nowMs = Math.floor(context.clock.nowUs() / 1000);
|
|
838
|
+
const pages = allRows(context, "pages").filter(
|
|
839
|
+
(page) => page.parent.type === "data_source_id" && page.parent.data_source_id === source.id && page.in_trash === (trashed === true) && predicate(page, nowMs),
|
|
840
|
+
);
|
|
841
|
+
const sorted = pages.map((page, index) => ({ page, index })).sort((left, right) => comparator(left.page, right.page) || left.index - right.index).map((entry) => entry.page);
|
|
842
|
+
const pagination = paginate(context, sorted, input, undefined, (page) => renderPage(context, identity, page, filterIds), "body.page_size");
|
|
843
|
+
return listResponse("page_or_data_source", pagination.results, pagination, { request_status: { type: "complete" } });
|
|
844
|
+
},
|
|
845
|
+
"data-sources.update": (input, context) => {
|
|
846
|
+
const identity = requireIdentity(context);
|
|
847
|
+
requireContent(context, identity, "read_update");
|
|
848
|
+
const source = requireDataSource(context, identity, input.data_source_id);
|
|
849
|
+
const trash = input.in_trash ?? input.archived;
|
|
850
|
+
const editing = input.title !== undefined || input.description !== undefined || input.properties !== undefined;
|
|
851
|
+
if (source.in_trash && trash !== false && editing) return validationError(context, TRASHED_EDIT);
|
|
852
|
+
const title = input.title === undefined ? undefined : normalizeRichText(context, input.title, "title");
|
|
853
|
+
const description = input.description === undefined ? undefined : normalizeRichText(context, input.description, "description");
|
|
854
|
+
const patch = input.properties === undefined ? undefined : applySchemaPatch(context, source.properties, input.properties);
|
|
855
|
+
if (patch !== undefined) checkSchema(context, patch.properties);
|
|
856
|
+
const now = isoNow(context);
|
|
857
|
+
const rows = blockRows(context);
|
|
858
|
+
if (trash === true && !source.in_trash) setDataSourceTrashed(context, rows, source.id, true, now, identity.user.id);
|
|
859
|
+
else if (trash === false && source.in_trash) setDataSourceTrashed(context, rows, source.id, false, now, identity.user.id);
|
|
860
|
+
const current = context.state.get("data-sources", source.id);
|
|
861
|
+
const updated = {
|
|
862
|
+
...current,
|
|
863
|
+
title: title ?? current.title,
|
|
864
|
+
description: description ?? current.description,
|
|
865
|
+
properties: patch === undefined ? current.properties : patch.properties,
|
|
866
|
+
name: title === undefined ? current.name : plainText(title),
|
|
867
|
+
last_edited_time: now,
|
|
868
|
+
last_edited_by: author(identity),
|
|
869
|
+
};
|
|
870
|
+
context.state.put("data-sources", source.id, updated);
|
|
871
|
+
withinBudget(context, renderDataSource(context, updated), "the data source");
|
|
872
|
+
if (patch !== undefined) applyChangesToPages(context, updated, patch.changes);
|
|
873
|
+
const database = context.state.get("databases", source.parent.database_id);
|
|
874
|
+
if (database !== null) {
|
|
875
|
+
const databaseTitle = title !== undefined && database.data_source_ids[0] === source.id ? { title, title_plain: plainText(title) } : {};
|
|
876
|
+
context.state.put("databases", database.id, { ...database, ...databaseTitle, last_edited_time: now, last_edited_by: author(identity) });
|
|
877
|
+
}
|
|
878
|
+
return renderDataSource(context, context.state.get("data-sources", source.id));
|
|
879
|
+
},
|
|
880
|
+
|
|
881
|
+
// ---------------------------------------------------------------------------------------------
|
|
882
|
+
// Blocks
|
|
883
|
+
// ---------------------------------------------------------------------------------------------
|
|
884
|
+
"blocks.retrieve": (input, context) => {
|
|
885
|
+
const identity = requireIdentity(context);
|
|
886
|
+
return sized(context, renderBlock(context, requireBlockRow(context, identity, input.block_id)), "the block");
|
|
887
|
+
},
|
|
888
|
+
"blocks.children.list": (input, context) => {
|
|
889
|
+
const identity = requireIdentity(context);
|
|
890
|
+
const id = requireUuid(context, input.block_id, "block_id");
|
|
891
|
+
const container = context.state.get("blocks", id) ?? context.state.get("pages", id);
|
|
892
|
+
if (container === null || !isVisible(context, identity, container)) return notFound(context, "block", id);
|
|
893
|
+
const rows = blockRows(context);
|
|
894
|
+
const pagination = paginate(context, childrenOf(rows, id), input, undefined, (block) => renderBlock(context, block));
|
|
895
|
+
return listResponse("block", pagination.results, pagination);
|
|
896
|
+
},
|
|
897
|
+
"blocks.children.append": (input, context) => {
|
|
898
|
+
const identity = requireIdentity(context);
|
|
899
|
+
requireContent(context, identity, "read_update_insert");
|
|
900
|
+
const id = requireUuid(context, input.block_id, "block_id");
|
|
901
|
+
const block = context.state.get("blocks", id);
|
|
902
|
+
const page = block === null ? context.state.get("pages", id) : null;
|
|
903
|
+
const container = block ?? page;
|
|
904
|
+
if (container === null || !isVisible(context, identity, container)) return notFound(context, "block", id);
|
|
905
|
+
if (container.in_trash) return validationError(context, TRASHED_EDIT);
|
|
906
|
+
if (block !== null && block.type !== "child_page" && !CONTAINER_TYPES.includes(block.type) && block.type !== "toggle") {
|
|
907
|
+
return validationError(context, `body failed validation: blocks of type ${block.type} do not support children.`);
|
|
908
|
+
}
|
|
909
|
+
const children = normalizeBlockInputs(context, input.children, "children");
|
|
910
|
+
if (block !== null && block.type !== "child_page") {
|
|
911
|
+
// Stored trees stay within MAX_TREE_LEVELS so every later walk over them is bounded.
|
|
912
|
+
const levels = blockLevel(context, block) + 1 + requestDepth(children);
|
|
913
|
+
if (levels > MAX_TREE_LEVELS) {
|
|
914
|
+
return validationError(context, `body failed validation: body.children would nest blocks ${levels} levels below the page; the limit is ${MAX_TREE_LEVELS}.`);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
const max = limits(context).max_children_per_append;
|
|
918
|
+
if (children.length === 0) return validationError(context, "body failed validation: body.children should be a non-empty array.");
|
|
919
|
+
if (children.length > max) return validationError(context, `body failed validation: body.children.length should be ≤ ${max}, instead was ${children.length}.`);
|
|
920
|
+
let afterId;
|
|
921
|
+
if (input.after !== undefined) afterId = requireUuid(context, input.after, "body.after");
|
|
922
|
+
const now = isoNow(context);
|
|
923
|
+
const rows = blockRows(context);
|
|
924
|
+
const parent = block !== null && block.type !== "child_page" ? { type: "block_id", block_id: id } : { type: "page_id", page_id: id };
|
|
925
|
+
const created = createBlocks(context, parent, children, { afterId, now, authorId: identity.user.id, rows });
|
|
926
|
+
const pageId = parent.type === "page_id" ? id : pageOfBlock(context, block);
|
|
927
|
+
if (pageId !== undefined) touchPage(context, pageId, now, identity.user.id);
|
|
928
|
+
contentUpdated(context, identity, pageId ?? id, created.map((row) => row.id));
|
|
929
|
+
return sized(context, listResponse("block", created.map((row) => renderBlock(context, context.state.get("blocks", row.id))), { next_cursor: null, has_more: false }), "the appended blocks");
|
|
930
|
+
},
|
|
931
|
+
"blocks.update": (input, context) => {
|
|
932
|
+
const identity = requireIdentity(context);
|
|
933
|
+
requireContent(context, identity, "read_update");
|
|
934
|
+
const block = requireBlockRow(context, identity, input.block_id);
|
|
935
|
+
if (block.type === "child_page" || block.type === "child_database") return validationError(context, `body failed validation: ${block.type} blocks are updated through the pages and data sources APIs.`);
|
|
936
|
+
const wrapper = isObject(input.type) ? input.type : input;
|
|
937
|
+
const givenType = APPENDABLE_BLOCK_TYPES.find((type) => wrapper[type] !== undefined);
|
|
938
|
+
if (givenType !== undefined && givenType !== block.type) return validationError(context, `body failed validation: body.${givenType} does not match the block type ${block.type}; a block's type cannot be changed.`);
|
|
939
|
+
const archived = input.archived ?? input.in_trash;
|
|
940
|
+
if (block.in_trash && archived !== false && givenType !== undefined) return validationError(context, TRASHED_EDIT);
|
|
941
|
+
let content = block.content;
|
|
942
|
+
if (givenType !== undefined) {
|
|
943
|
+
const body = wrapper[givenType];
|
|
944
|
+
if (!isObject(body)) return validationError(context, `body failed validation: body.${givenType} should be an object.`);
|
|
945
|
+
const merged = { ...block.content };
|
|
946
|
+
if (body.rich_text !== undefined) merged.rich_text = body.rich_text;
|
|
947
|
+
for (const key of ["checked", "color", "language", "icon", "url", "caption", "is_toggleable", "external"]) if (body[key] !== undefined) merged[key] = body[key];
|
|
948
|
+
content = normalizeContent(context, block.type, merged, "");
|
|
949
|
+
if (block.type === "callout" && body.icon === undefined) content.icon = block.content.icon;
|
|
950
|
+
}
|
|
951
|
+
const now = isoNow(context);
|
|
952
|
+
const rows = blockRows(context);
|
|
953
|
+
let current = block;
|
|
954
|
+
if (archived === true && !block.in_trash) current = setBlockTrashed(context, rows, block, true, now, identity.user.id);
|
|
955
|
+
else if (archived === false && block.in_trash) current = setBlockTrashed(context, rows, block, false, now, identity.user.id);
|
|
956
|
+
const updated = { ...current, content, last_edited_time: now, last_edited_by: author(identity) };
|
|
957
|
+
context.state.put("blocks", block.id, updated);
|
|
958
|
+
const pageId = pageOfBlock(context, updated);
|
|
959
|
+
if (pageId !== undefined) touchPage(context, pageId, now, identity.user.id);
|
|
960
|
+
contentUpdated(context, identity, pageId ?? block.id, [block.id]);
|
|
961
|
+
return renderBlock(context, updated);
|
|
962
|
+
},
|
|
963
|
+
"blocks.delete": (input, context) => {
|
|
964
|
+
const identity = requireIdentity(context);
|
|
965
|
+
requireContent(context, identity, "read_update");
|
|
966
|
+
const block = requireBlockRow(context, identity, input.block_id);
|
|
967
|
+
if (block.type === "child_page" || block.type === "child_database") {
|
|
968
|
+
return validationError(context, `body failed validation: ${block.type} blocks cannot be deleted here; move the page or database to the trash with in_trash: true instead.`);
|
|
969
|
+
}
|
|
970
|
+
const now = isoNow(context);
|
|
971
|
+
const rows = blockRows(context);
|
|
972
|
+
const updated = block.in_trash ? block : setBlockTrashed(context, rows, block, true, now, identity.user.id);
|
|
973
|
+
const pageId = pageOfBlock(context, updated);
|
|
974
|
+
if (pageId !== undefined) touchPage(context, pageId, now, identity.user.id);
|
|
975
|
+
contentUpdated(context, identity, pageId ?? block.id, [block.id, ...descendantIds(rows, block.id)]);
|
|
976
|
+
return renderBlock(context, updated);
|
|
977
|
+
},
|
|
978
|
+
|
|
979
|
+
// ---------------------------------------------------------------------------------------------
|
|
980
|
+
// Comments
|
|
981
|
+
// ---------------------------------------------------------------------------------------------
|
|
982
|
+
"comments.create": (input, context) => {
|
|
983
|
+
const identity = requireIdentity(context);
|
|
984
|
+
requireComments(context, identity, "read_insert");
|
|
985
|
+
const hasParent = input.parent !== undefined;
|
|
986
|
+
const hasDiscussion = input.discussion_id !== undefined;
|
|
987
|
+
if (hasParent === hasDiscussion) return validationError(context, "body failed validation: exactly one of body.parent or body.discussion_id should be defined.");
|
|
988
|
+
const richText = normalizeRichText(context, input.rich_text, "rich_text");
|
|
989
|
+
if (richText.length === 0 || plainText(richText).length === 0) return validationError(context, "body failed validation: body.rich_text should be a non-empty array of rich text objects.");
|
|
990
|
+
let parent;
|
|
991
|
+
let discussionId;
|
|
992
|
+
if (hasParent) {
|
|
993
|
+
if (!isObject(input.parent) || input.parent.page_id === undefined) return validationError(context, "body failed validation: body.parent.page_id should be defined.");
|
|
994
|
+
const page = requirePage(context, identity, input.parent.page_id, "body.parent.page_id");
|
|
995
|
+
if (page.in_trash) return validationError(context, TRASHED_EDIT);
|
|
996
|
+
parent = { type: "page_id", page_id: page.id };
|
|
997
|
+
} else {
|
|
998
|
+
discussionId = requireUuid(context, input.discussion_id, "body.discussion_id");
|
|
999
|
+
const first = allRows(context, "comments").find((comment) => comment.discussion_id === discussionId);
|
|
1000
|
+
if (first === undefined) return notFound(context, "discussion", discussionId);
|
|
1001
|
+
const container = first.parent.type === "page_id" ? context.state.get("pages", first.parent.page_id) : context.state.get("blocks", first.parent.block_id);
|
|
1002
|
+
if (container === null || !isVisible(context, identity, container)) return notFound(context, "discussion", discussionId);
|
|
1003
|
+
if (container.in_trash) return validationError(context, TRASHED_EDIT);
|
|
1004
|
+
parent = first.parent;
|
|
1005
|
+
}
|
|
1006
|
+
const now = isoNow(context);
|
|
1007
|
+
const id = nextId(context, "comments");
|
|
1008
|
+
const comment = {
|
|
1009
|
+
id,
|
|
1010
|
+
parent,
|
|
1011
|
+
discussion_id: discussionId ?? nextId(context, "discussions"),
|
|
1012
|
+
rich_text: richText,
|
|
1013
|
+
created_time: now,
|
|
1014
|
+
last_edited_time: now,
|
|
1015
|
+
created_by: author(identity),
|
|
1016
|
+
display_name: identity.user.type === "bot" ? { type: "integration", resolved_name: identity.integration.name } : { type: "user", resolved_name: identity.user.name },
|
|
1017
|
+
};
|
|
1018
|
+
context.state.put("comments", id, comment);
|
|
1019
|
+
const pageId = parent.type === "page_id" ? parent.page_id : pageOfBlock(context, context.state.get("blocks", parent.block_id));
|
|
1020
|
+
if (pageId !== undefined) touchPage(context, pageId, now, identity.user.id);
|
|
1021
|
+
context.events.emit("comment.created", {
|
|
1022
|
+
entity_id: id,
|
|
1023
|
+
discussion_id: comment.discussion_id,
|
|
1024
|
+
parent_type: parent.type,
|
|
1025
|
+
parent_id: parentKey(parent),
|
|
1026
|
+
page_id: pageId ?? parentKey(parent),
|
|
1027
|
+
author_id: identity.user.id,
|
|
1028
|
+
});
|
|
1029
|
+
return withinBudget(context, renderComment(context, identity, comment), "the comment");
|
|
1030
|
+
},
|
|
1031
|
+
"comments.list": (input, context) => {
|
|
1032
|
+
const identity = requireIdentity(context);
|
|
1033
|
+
requireComments(context, identity, "read");
|
|
1034
|
+
if (input.block_id === undefined) return validationError(context, "query failed validation: query.block_id should be defined, instead was `undefined`.");
|
|
1035
|
+
const id = requireUuid(context, input.block_id, "query.block_id");
|
|
1036
|
+
const container = context.state.get("blocks", id) ?? context.state.get("pages", id);
|
|
1037
|
+
if (container === null || !isVisible(context, identity, container)) return notFound(context, "block", id);
|
|
1038
|
+
const comments = allRows(context, "comments")
|
|
1039
|
+
.filter((comment) => parentKey(comment.parent) === id)
|
|
1040
|
+
.sort((left, right) => Date.parse(left.created_time) - Date.parse(right.created_time) || (left.id < right.id ? -1 : 1));
|
|
1041
|
+
const pagination = paginate(context, comments, input, undefined, (comment) => renderComment(context, identity, comment));
|
|
1042
|
+
return listResponse("comment", pagination.results, pagination);
|
|
1043
|
+
},
|
|
1044
|
+
|
|
1045
|
+
// ---------------------------------------------------------------------------------------------
|
|
1046
|
+
// Workspace context (canonical only; lets a UI render "today" from virtual time)
|
|
1047
|
+
// ---------------------------------------------------------------------------------------------
|
|
1048
|
+
"workspace.context": (input, context) => {
|
|
1049
|
+
const identity = requireIdentity(context);
|
|
1050
|
+
const workspace = context.state.get("workspace", "workspace") ?? { id: "workspace", name: "Workspace", icon: null, domain: "workspace" };
|
|
1051
|
+
return {
|
|
1052
|
+
workspace: { id: workspace.id, name: workspace.name, icon: workspace.icon, domain: workspace.domain },
|
|
1053
|
+
user: { id: identity.user.id, name: identity.user.name, type: identity.user.type, avatar_url: identity.user.avatar_url },
|
|
1054
|
+
integration: { id: identity.integration.id, name: identity.integration.name, capabilities: identity.capabilities, access: identity.access },
|
|
1055
|
+
now: isoNow(context),
|
|
1056
|
+
limits: limits(context),
|
|
1057
|
+
};
|
|
1058
|
+
},
|
|
1059
|
+
"workspace.trash": (input, context) => {
|
|
1060
|
+
// Trash roots: trashed pages and databases whose own parent is live (a restored root brings its subtree back).
|
|
1061
|
+
const identity = requireIdentity(context);
|
|
1062
|
+
const candidates = [];
|
|
1063
|
+
if (identity.capabilities.content !== "none") {
|
|
1064
|
+
const parentTrashed = (parent) => {
|
|
1065
|
+
if (parent.type === "page_id") return context.state.get("pages", parent.page_id)?.in_trash === true;
|
|
1066
|
+
if (parent.type === "data_source_id") return context.state.get("data-sources", parent.data_source_id)?.in_trash === true;
|
|
1067
|
+
if (parent.type === "database_id") return context.state.get("databases", parent.database_id)?.in_trash === true;
|
|
1068
|
+
if (parent.type === "block_id") return context.state.get("blocks", parent.block_id)?.in_trash === true;
|
|
1069
|
+
return false;
|
|
1070
|
+
};
|
|
1071
|
+
for (const page of allRows(context, "pages")) {
|
|
1072
|
+
if (page.in_trash && !parentTrashed(page.parent) && isVisible(context, identity, page)) candidates.push({ kind: "page", row: page });
|
|
1073
|
+
}
|
|
1074
|
+
for (const database of allRows(context, "databases")) {
|
|
1075
|
+
if (database.in_trash && !parentTrashed(database.parent) && isVisible(context, identity, database)) candidates.push({ kind: "database", row: database });
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
candidates.sort((left, right) => {
|
|
1079
|
+
const byTime = Date.parse(right.row.last_edited_time) - Date.parse(left.row.last_edited_time);
|
|
1080
|
+
if (byTime !== 0) return byTime;
|
|
1081
|
+
return left.row.id < right.row.id ? -1 : 1;
|
|
1082
|
+
});
|
|
1083
|
+
const render = (candidate) => (candidate.kind === "page" ? renderPage(context, identity, candidate.row) : renderDatabase(context, candidate.row));
|
|
1084
|
+
const pagination = paginate(context, candidates, input, (candidate) => candidate.row.id, render, "body.page_size");
|
|
1085
|
+
return listResponse("page_or_database", pagination.results, pagination);
|
|
1086
|
+
},
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
// ---------------------------------------------------------------------------------------------
|
|
1090
|
+
// Provider-shaped HTTP routes (pure codecs)
|
|
1091
|
+
// ---------------------------------------------------------------------------------------------
|
|
1092
|
+
|
|
1093
|
+
const cursorQuery = (request) => defined({ start_cursor: str(request.query, "start_cursor"), page_size: intOrRaw(request.query, "page_size") });
|
|
1094
|
+
const read = (decode) => ({ decode: (request) => ({ arguments: decode(request) }), encode: encodeOutcome });
|
|
1095
|
+
const write = (decode) => ({ decode: (request) => operationInput(request, decode(request)), encode: encodeOutcome });
|
|
1096
|
+
|
|
1097
|
+
const http = {
|
|
1098
|
+
"list-users": read((request) => cursorQuery(request)),
|
|
1099
|
+
"get-user": read((request) => ({ user_id: request.path.user_id })),
|
|
1100
|
+
search: read((request) => jsonBody(request)),
|
|
1101
|
+
"create-page": write((request) => jsonBody(request)),
|
|
1102
|
+
"retrieve-page": read((request) => defined({ page_id: request.path.page_id, filter_properties: list(request.query, "filter_properties") })),
|
|
1103
|
+
"update-page": write((request) => ({ ...jsonBody(request), page_id: request.path.page_id })),
|
|
1104
|
+
"retrieve-page-property": read((request) => ({ page_id: request.path.page_id, property_id: request.path.property_id, ...cursorQuery(request) })),
|
|
1105
|
+
"move-page": write((request) => ({ ...jsonBody(request), page_id: request.path.page_id })),
|
|
1106
|
+
"retrieve-page-markdown": read((request) => ({ page_id: request.path.page_id })),
|
|
1107
|
+
"update-page-markdown": write((request) => ({ ...jsonBody(request), page_id: request.path.page_id })),
|
|
1108
|
+
"create-database": write((request) => jsonBody(request)),
|
|
1109
|
+
"retrieve-database": read((request) => ({ database_id: request.path.database_id })),
|
|
1110
|
+
"query-database-legacy": read((request) => defined({ ...jsonBody(request), database_id: request.path.database_id, filter_properties: list(request.query, "filter_properties") ?? jsonBody(request).filter_properties })),
|
|
1111
|
+
"retrieve-data-source": read((request) => ({ data_source_id: request.path.data_source_id })),
|
|
1112
|
+
"query-data-source": read((request) => defined({ ...jsonBody(request), data_source_id: request.path.data_source_id, filter_properties: list(request.query, "filter_properties") ?? jsonBody(request).filter_properties })),
|
|
1113
|
+
"update-data-source": write((request) => ({ ...jsonBody(request), data_source_id: request.path.data_source_id })),
|
|
1114
|
+
"retrieve-block": read((request) => ({ block_id: request.path.block_id })),
|
|
1115
|
+
"list-block-children": read((request) => ({ block_id: request.path.block_id, ...cursorQuery(request) })),
|
|
1116
|
+
"append-block-children": write((request) => ({ ...jsonBody(request), block_id: request.path.block_id })),
|
|
1117
|
+
"update-block": write((request) => ({ ...jsonBody(request), block_id: request.path.block_id })),
|
|
1118
|
+
"delete-block": write((request) => ({ block_id: request.path.block_id })),
|
|
1119
|
+
"create-comment": write((request) => jsonBody(request)),
|
|
1120
|
+
"list-comments": read((request) => defined({ block_id: str(request.query, "block_id"), ...cursorQuery(request) })),
|
|
1121
|
+
};
|
|
1122
|
+
|
|
1123
|
+
export default { operations, http };
|