@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,513 @@
|
|
|
1
|
+
// Data-source schemas and page property values: validation from request bodies, the stored form
|
|
2
|
+
// (definition `{id, name, type, config}`, value `{id, type, value}`) and Notion-shaped rendering.
|
|
3
|
+
import { nextShortId, normalizeId } from "./ids.mjs";
|
|
4
|
+
import { isDateString, normalizeRichText } from "./rich-text.mjs";
|
|
5
|
+
import { isMangled, mangledError, shown, validationError } from "./state.mjs";
|
|
6
|
+
|
|
7
|
+
export const PROPERTY_TYPES = Object.freeze([
|
|
8
|
+
"title", "rich_text", "number", "select", "multi_select", "status", "date", "checkbox", "url", "email",
|
|
9
|
+
"phone_number", "people", "relation", "created_time", "created_by", "last_edited_time", "last_edited_by",
|
|
10
|
+
]);
|
|
11
|
+
export const COMPUTED_TYPES = Object.freeze(["created_time", "created_by", "last_edited_time", "last_edited_by"]);
|
|
12
|
+
export const TEXT_TYPES = Object.freeze(["title", "rich_text", "url", "email", "phone_number"]);
|
|
13
|
+
export const LIST_ITEM_TYPES = Object.freeze(["title", "rich_text", "relation", "people"]);
|
|
14
|
+
export const OPTION_COLORS = Object.freeze(["default", "gray", "brown", "orange", "yellow", "green", "blue", "purple", "pink", "red"]);
|
|
15
|
+
const NUMBER_FORMATS = Object.freeze(["number", "number_with_commas", "percent", "dollar", "euro", "pound", "yen", "ruble", "rupee", "won", "yuan"]);
|
|
16
|
+
const DEFAULT_STATUS = Object.freeze([
|
|
17
|
+
{ name: "Not started", color: "default", group: "To-do" },
|
|
18
|
+
{ name: "In progress", color: "blue", group: "In progress" },
|
|
19
|
+
{ name: "Done", color: "green", group: "Complete" },
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
function isObject(value) {
|
|
23
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function emptyValue(type) {
|
|
27
|
+
switch (type) {
|
|
28
|
+
case "title":
|
|
29
|
+
case "rich_text":
|
|
30
|
+
case "multi_select":
|
|
31
|
+
case "people":
|
|
32
|
+
case "relation":
|
|
33
|
+
return [];
|
|
34
|
+
case "checkbox":
|
|
35
|
+
return false;
|
|
36
|
+
default:
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------------------------
|
|
42
|
+
// Users (people values and created_by expansions share this rendering)
|
|
43
|
+
// ---------------------------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
export function renderUser(context, identity, row, partial = false) {
|
|
46
|
+
if (partial || row === null) return { object: "user", id: row === null ? undefined : row.id };
|
|
47
|
+
const user = { object: "user", id: row.id, name: row.name, avatar_url: row.avatar_url, type: row.type };
|
|
48
|
+
if (row.type === "person") {
|
|
49
|
+
user.person = identity.capabilities.user_information === "with_emails" && row.person !== null ? { email: row.person.email } : {};
|
|
50
|
+
} else {
|
|
51
|
+
user.bot = row.bot === null ? {} : row.bot;
|
|
52
|
+
}
|
|
53
|
+
return user;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Expanded user object, or the partial `{ object, id }` when the integration lacks user information. */
|
|
57
|
+
export function userReference(context, identity, id) {
|
|
58
|
+
const row = context.state.get("users", id);
|
|
59
|
+
if (row === null || identity.capabilities.user_information === "none") return { object: "user", id };
|
|
60
|
+
return renderUser(context, identity, row);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------------------------
|
|
64
|
+
// Schema definitions
|
|
65
|
+
// ---------------------------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
function detectType(value, allowed) {
|
|
68
|
+
if (!isObject(value)) return undefined;
|
|
69
|
+
if (typeof value.type === "string") return value.type;
|
|
70
|
+
return allowed.find((type) => value[type] !== undefined);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizeOptions(context, raw, path, existing = []) {
|
|
74
|
+
if (raw === undefined) return existing.map((option) => ({ ...option }));
|
|
75
|
+
if (!Array.isArray(raw)) return validationError(context, `body failed validation: body.${path}.options should be an array.`);
|
|
76
|
+
const options = [];
|
|
77
|
+
for (let index = 0; index < raw.length; index += 1) {
|
|
78
|
+
const option = raw[index];
|
|
79
|
+
if (!isObject(option) || typeof option.name !== "string" || option.name.trim().length === 0) {
|
|
80
|
+
return validationError(context, `body failed validation: body.${path}.options[${index}].name should be a non-empty string.`);
|
|
81
|
+
}
|
|
82
|
+
if (option.name.includes(",")) return validationError(context, `body failed validation: body.${path}.options[${index}].name should not contain commas.`);
|
|
83
|
+
if (options.some((known) => known.name === option.name)) {
|
|
84
|
+
return validationError(context, `body failed validation: body.${path}.options should not contain duplicate names (${option.name}).`);
|
|
85
|
+
}
|
|
86
|
+
const color = option.color ?? "default";
|
|
87
|
+
if (!OPTION_COLORS.includes(color)) return validationError(context, `body failed validation: body.${path}.options[${index}].color should be a valid option color, instead was \`${shown(color)}\`.`);
|
|
88
|
+
const known = existing.find((candidate) => candidate.name === option.name || (typeof option.id === "string" && candidate.id === option.id));
|
|
89
|
+
options.push({ id: known === undefined ? nextShortId(context) : known.id, name: option.name, color, description: null });
|
|
90
|
+
}
|
|
91
|
+
return options;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function statusConfig(context, raw, path, existing) {
|
|
95
|
+
const seed = raw.options === undefined && existing === undefined ? DEFAULT_STATUS : undefined;
|
|
96
|
+
const options = seed === undefined ? normalizeOptions(context, raw.options, path, existing?.options ?? []) : seed.map((option) => ({ id: nextShortId(context), name: option.name, color: option.color, description: null }));
|
|
97
|
+
const groupNames = ["To-do", "In progress", "Complete"];
|
|
98
|
+
const groups = groupNames.map((name, index) => ({
|
|
99
|
+
id: existing?.groups?.[index]?.id ?? nextShortId(context),
|
|
100
|
+
name,
|
|
101
|
+
color: ["gray", "blue", "green"][index],
|
|
102
|
+
option_ids: [],
|
|
103
|
+
}));
|
|
104
|
+
options.forEach((option, index) => {
|
|
105
|
+
const seeded = seed === undefined ? undefined : seed[index].group;
|
|
106
|
+
const previous = existing?.groups?.find((group) => group.option_ids.includes(option.id))?.name;
|
|
107
|
+
const groupName = seeded ?? previous ?? (index === options.length - 1 && options.length > 1 ? "Complete" : "To-do");
|
|
108
|
+
groups[groupNames.indexOf(groupName)].option_ids.push(option.id);
|
|
109
|
+
});
|
|
110
|
+
return { options, groups };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Validate one property definition from a request (`{ rich_text: {} }`, `{ select: { options } }`, …). */
|
|
114
|
+
function normalizeDefinition(context, name, raw, path, existing) {
|
|
115
|
+
const type = detectType(raw, PROPERTY_TYPES);
|
|
116
|
+
if (type === undefined || !PROPERTY_TYPES.includes(type)) {
|
|
117
|
+
return validationError(context, `body failed validation: body.${path} should be an object with one supported property type (${PROPERTY_TYPES.join(", ")}).`);
|
|
118
|
+
}
|
|
119
|
+
const config = isObject(raw[type]) ? raw[type] : {};
|
|
120
|
+
const id = existing?.id ?? (type === "title" ? "title" : nextShortId(context));
|
|
121
|
+
switch (type) {
|
|
122
|
+
case "number": {
|
|
123
|
+
const format = config.format ?? "number";
|
|
124
|
+
if (!NUMBER_FORMATS.includes(format)) return validationError(context, `body failed validation: body.${path}.number.format should be a valid number format, instead was \`${shown(format)}\`.`);
|
|
125
|
+
return { id, name, type, config: { format } };
|
|
126
|
+
}
|
|
127
|
+
case "select":
|
|
128
|
+
case "multi_select":
|
|
129
|
+
return { id, name, type, config: { options: normalizeOptions(context, config.options, `${path}.${type}`, existing?.type === type ? existing.config.options : []) } };
|
|
130
|
+
case "status":
|
|
131
|
+
return { id, name, type, config: statusConfig(context, config, `${path}.status`, existing?.type === "status" ? existing.config : undefined) };
|
|
132
|
+
case "relation": {
|
|
133
|
+
const sourceId = normalizeId(config.data_source_id);
|
|
134
|
+
const source = sourceId === undefined ? null : context.state.get("data-sources", sourceId);
|
|
135
|
+
if (source === null) return validationError(context, `body failed validation: body.${path}.relation.data_source_id should be the id of an existing data source.`);
|
|
136
|
+
return { id, name, type, config: { data_source_id: source.id, database_id: source.parent.database_id, type: "single_property", single_property: {} } };
|
|
137
|
+
}
|
|
138
|
+
default:
|
|
139
|
+
return { id, name, type, config: {} };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* `__proto__` cannot be an own key of the plain objects that hold schemas and page values (assigning it replaces the
|
|
145
|
+
* object's prototype and the property silently disappears), so it is rejected with a validation error instead.
|
|
146
|
+
*/
|
|
147
|
+
function reservedName(context, path) {
|
|
148
|
+
return validationError(context, `body failed validation: body.${path} uses the reserved property name __proto__.`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The complete schema of a new data source; exactly one `title` property is required. */
|
|
152
|
+
export function normalizeSchema(context, raw, path) {
|
|
153
|
+
if (!isObject(raw)) return validationError(context, `body failed validation: body.${path} should be an object.`);
|
|
154
|
+
const properties = {};
|
|
155
|
+
for (const [name, value] of Object.entries(raw)) {
|
|
156
|
+
if (name.trim().length === 0) return validationError(context, `body failed validation: body.${path} property names should be non-empty.`);
|
|
157
|
+
if (name === "__proto__") return reservedName(context, `${path}.${name}`);
|
|
158
|
+
properties[name] = normalizeDefinition(context, name, value, `${path}.${name}`);
|
|
159
|
+
}
|
|
160
|
+
const titles = Object.values(properties).filter((definition) => definition.type === "title");
|
|
161
|
+
if (titles.length !== 1) return validationError(context, `body failed validation: body.${path} should contain exactly one property of type title, instead had ${titles.length}.`);
|
|
162
|
+
return properties;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** decodeURIComponent that never throws (undefined for malformed percent-encoding). */
|
|
166
|
+
function safeDecode(value) {
|
|
167
|
+
if (!value.includes("%")) return value;
|
|
168
|
+
try {
|
|
169
|
+
return decodeURIComponent(value);
|
|
170
|
+
} catch {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Resolve a `property_id` path parameter. Notion property ids are URL-encoded strings (e.g. `%3AUPp` for `:UPp`), and
|
|
177
|
+
* clients either put that id in the path verbatim (the server's single path decode yields `:UPp`) or percent-encode it
|
|
178
|
+
* again (`%253AUPp`, decoded once to `%3AUPp`). The framework has already decoded the segment once, so a property
|
|
179
|
+
* matches when its name or id equals the value, or its id decodes to the value, or the value decodes (once, guarded)
|
|
180
|
+
* to its name or id. Malformed percent-encoding never throws; it simply matches nothing.
|
|
181
|
+
*/
|
|
182
|
+
export function findPropertyById(properties, value) {
|
|
183
|
+
const definitions = Object.values(properties);
|
|
184
|
+
const candidates = [];
|
|
185
|
+
for (const candidate of [value, upperEscapes(value), safeDecode(value), safeDecode(upperEscapes(value))]) {
|
|
186
|
+
if (typeof candidate === "string" && !candidates.includes(candidate)) candidates.push(candidate);
|
|
187
|
+
}
|
|
188
|
+
for (const candidate of candidates) {
|
|
189
|
+
const direct = findDefinition(properties, candidate);
|
|
190
|
+
if (direct !== undefined) return direct;
|
|
191
|
+
}
|
|
192
|
+
return definitions.find((definition) => {
|
|
193
|
+
if (typeof definition.id !== "string") return false;
|
|
194
|
+
const decodedId = safeDecode(definition.id);
|
|
195
|
+
const encodedId = upperEscapes(definition.id);
|
|
196
|
+
return candidates.some((candidate) => candidate === decodedId || upperEscapes(candidate) === encodedId);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Percent escapes with upper-case hexadecimal, so `%3a` and `%3A` name the same property id. */
|
|
201
|
+
function upperEscapes(value) {
|
|
202
|
+
return typeof value === "string" ? value.replace(/%([0-9a-fA-F]{2})/g, (_, hex) => `%${hex.toUpperCase()}`) : value;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function findDefinition(properties, nameOrId) {
|
|
206
|
+
if (Object.hasOwn(properties, nameOrId)) return properties[nameOrId];
|
|
207
|
+
return Object.values(properties).find((definition) => definition.id === nameOrId);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Apply a `data-sources.update` properties patch: `null` removes, `{ name }` renames, a definition adds or
|
|
212
|
+
* retypes. Returns the new schema and the list of changes to apply to the source's pages.
|
|
213
|
+
*/
|
|
214
|
+
export function applySchemaPatch(context, properties, patch) {
|
|
215
|
+
if (!isObject(patch)) return validationError(context, "body failed validation: body.properties should be an object.");
|
|
216
|
+
const next = { ...properties };
|
|
217
|
+
const changes = [];
|
|
218
|
+
for (const [key, raw] of Object.entries(patch)) {
|
|
219
|
+
if (key === "__proto__") return reservedName(context, `properties.${key}`);
|
|
220
|
+
const current = findDefinition(next, key);
|
|
221
|
+
if (raw === null) {
|
|
222
|
+
if (current === undefined) return validationError(context, `${key} is not a property that exists.`);
|
|
223
|
+
if (current.type === "title") return validationError(context, "The title property cannot be removed.");
|
|
224
|
+
delete next[current.name];
|
|
225
|
+
changes.push({ kind: "remove", name: current.name });
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (!isObject(raw)) return validationError(context, `body failed validation: body.properties.${key} should be an object or null.`);
|
|
229
|
+
const renamed = typeof raw.name === "string" ? raw.name : undefined;
|
|
230
|
+
if (renamed === "__proto__") return reservedName(context, `properties.${key}.name`);
|
|
231
|
+
const type = detectType(raw, PROPERTY_TYPES);
|
|
232
|
+
if (current !== undefined && renamed !== undefined && type === undefined) {
|
|
233
|
+
if (renamed.trim().length === 0) return validationError(context, `body failed validation: body.properties.${key}.name should be non-empty.`);
|
|
234
|
+
if (renamed !== current.name && findDefinition(next, renamed) !== undefined) return validationError(context, `A property named ${renamed} already exists.`);
|
|
235
|
+
delete next[current.name];
|
|
236
|
+
next[renamed] = { ...current, name: renamed };
|
|
237
|
+
changes.push({ kind: "rename", from: current.name, to: renamed });
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (type === undefined) return validationError(context, `body failed validation: body.properties.${key} should be a property definition, a rename ({ name }) or null.`);
|
|
241
|
+
if (current === undefined) {
|
|
242
|
+
if (type === "title") return validationError(context, "A data source can only have one title property.");
|
|
243
|
+
const definition = normalizeDefinition(context, key, raw, `properties.${key}`);
|
|
244
|
+
next[key] = definition;
|
|
245
|
+
changes.push({ kind: "add", name: key, definition });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (current.type === "title" && type !== "title") return validationError(context, "The title property cannot change its type.");
|
|
249
|
+
if (type === "title" && current.type !== "title") return validationError(context, "A data source can only have one title property.");
|
|
250
|
+
const definition = normalizeDefinition(context, renamed ?? current.name, raw, `properties.${key}`, current);
|
|
251
|
+
delete next[current.name];
|
|
252
|
+
next[definition.name] = definition;
|
|
253
|
+
if (renamed !== undefined && renamed !== current.name) changes.push({ kind: "rename", from: current.name, to: renamed });
|
|
254
|
+
if (current.type !== type) changes.push({ kind: "retype", name: definition.name, definition });
|
|
255
|
+
else changes.push({ kind: "reconfigure", name: definition.name, definition });
|
|
256
|
+
}
|
|
257
|
+
return { properties: next, changes };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function renderSchema(properties) {
|
|
261
|
+
const rendered = {};
|
|
262
|
+
for (const [name, definition] of Object.entries(properties)) {
|
|
263
|
+
rendered[name] = { id: definition.id, name, type: definition.type, description: null, [definition.type]: definition.config };
|
|
264
|
+
}
|
|
265
|
+
return rendered;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The `properties` of a page of this schema rendered at its largest before any value is set: empty values, the
|
|
270
|
+
* fixed-width timestamp for created_time / last_edited_time and `widestUser` for created_by / last_edited_by.
|
|
271
|
+
*/
|
|
272
|
+
export function renderSchemaPageShape(properties, widestUser) {
|
|
273
|
+
const rendered = {};
|
|
274
|
+
for (const [name, definition] of Object.entries(properties)) {
|
|
275
|
+
const type = definition.type;
|
|
276
|
+
let value = emptyValue(type);
|
|
277
|
+
if (type === "created_time" || type === "last_edited_time") value = "0000-00-00T00:00:00.000Z";
|
|
278
|
+
else if (type === "created_by" || type === "last_edited_by") value = widestUser;
|
|
279
|
+
const entry = { id: definition.id, type, [type]: value };
|
|
280
|
+
if (type === "relation") entry.has_more = false;
|
|
281
|
+
rendered[name] = entry;
|
|
282
|
+
}
|
|
283
|
+
return rendered;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---------------------------------------------------------------------------------------------
|
|
287
|
+
// Page property values
|
|
288
|
+
// ---------------------------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
function optionByRef(options, ref) {
|
|
291
|
+
if (!isObject(ref)) return undefined;
|
|
292
|
+
if (typeof ref.id === "string") return options.find((option) => option.id === ref.id);
|
|
293
|
+
if (typeof ref.name === "string") return options.find((option) => option.name === ref.name);
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Validate one page property value; may add select options (returns `{ value, definition }`). */
|
|
298
|
+
function normalizeValue(context, definition, raw, name) {
|
|
299
|
+
const type = definition.type;
|
|
300
|
+
if (COMPUTED_TYPES.includes(type)) return validationError(context, `Cannot update property "${name}" of type ${type}: it is computed by the workspace.`);
|
|
301
|
+
// Notion accepts the bare rich text array for title / rich_text values (`properties: { title: [...] }`).
|
|
302
|
+
if (Array.isArray(raw) && (type === "title" || type === "rich_text")) raw = { [type]: raw };
|
|
303
|
+
if (!isObject(raw)) return validationError(context, `${name} is expected to be ${type}.`);
|
|
304
|
+
const given = detectType(raw, PROPERTY_TYPES);
|
|
305
|
+
const value = given === undefined ? undefined : raw[given];
|
|
306
|
+
if (given !== type && !(type === "title" && given === "rich_text")) return validationError(context, `${name} is expected to be ${type}.`);
|
|
307
|
+
const expected = (condition, message) => (condition ? undefined : validationError(context, `body failed validation: body.properties.${name}.${type} ${message}`));
|
|
308
|
+
switch (type) {
|
|
309
|
+
case "title":
|
|
310
|
+
case "rich_text":
|
|
311
|
+
return { value: normalizeRichText(context, value, `properties.${name}.${type}`), definition };
|
|
312
|
+
case "number":
|
|
313
|
+
expected(value === null || (typeof value === "number" && Number.isFinite(value)), "should be a number or null.");
|
|
314
|
+
return { value, definition };
|
|
315
|
+
case "checkbox":
|
|
316
|
+
expected(typeof value === "boolean", "should be a boolean.");
|
|
317
|
+
return { value, definition };
|
|
318
|
+
case "url":
|
|
319
|
+
case "email":
|
|
320
|
+
case "phone_number":
|
|
321
|
+
expected(value === null || typeof value === "string", "should be a string or null.");
|
|
322
|
+
return { value: value === "" ? null : value, definition };
|
|
323
|
+
case "date": {
|
|
324
|
+
if (value === null) return { value: null, definition };
|
|
325
|
+
expected(isObject(value) && isDateString(value.start), "should be an object with a valid ISO 8601 `start`, or null.");
|
|
326
|
+
expected(value.end === undefined || value.end === null || isDateString(value.end), "should have a valid ISO 8601 `end` or null.");
|
|
327
|
+
return { value: { start: value.start, end: value.end ?? null, time_zone: typeof value.time_zone === "string" ? value.time_zone : null }, definition };
|
|
328
|
+
}
|
|
329
|
+
case "select": {
|
|
330
|
+
if (value === null) return { value: null, definition };
|
|
331
|
+
expected(isObject(value) && (typeof value.name === "string" || typeof value.id === "string"), "should be an object with `name` or `id`, or null.");
|
|
332
|
+
const known = optionByRef(definition.config.options, value);
|
|
333
|
+
if (known !== undefined) return { value: { id: known.id, name: known.name, color: known.color }, definition };
|
|
334
|
+
if (typeof value.name !== "string" || value.name.trim().length === 0) return validationError(context, `Select option with id ${shown(value.id)} does not exist on property ${name}.`);
|
|
335
|
+
if (value.name.includes(",")) return validationError(context, `body failed validation: body.properties.${name}.select.name should not contain commas.`);
|
|
336
|
+
const option = { id: nextShortId(context), name: value.name, color: OPTION_COLORS.includes(value.color) ? value.color : "default", description: null };
|
|
337
|
+
const updated = { ...definition, config: { options: [...definition.config.options, option] } };
|
|
338
|
+
return { value: { id: option.id, name: option.name, color: option.color }, definition: updated };
|
|
339
|
+
}
|
|
340
|
+
case "multi_select": {
|
|
341
|
+
expected(Array.isArray(value), "should be an array.");
|
|
342
|
+
let updated = definition;
|
|
343
|
+
const values = [];
|
|
344
|
+
for (const item of value) {
|
|
345
|
+
expected(isObject(item) && (typeof item.name === "string" || typeof item.id === "string"), "items should be objects with `name` or `id`.");
|
|
346
|
+
const known = optionByRef(updated.config.options, item);
|
|
347
|
+
if (known !== undefined) {
|
|
348
|
+
if (!values.some((existing) => existing.id === known.id)) values.push({ id: known.id, name: known.name, color: known.color });
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (typeof item.name !== "string" || item.name.trim().length === 0) return validationError(context, `Multi-select option with id ${shown(item.id)} does not exist on property ${name}.`);
|
|
352
|
+
if (item.name.includes(",")) return validationError(context, `body failed validation: body.properties.${name}.multi_select names should not contain commas.`);
|
|
353
|
+
const option = { id: nextShortId(context), name: item.name, color: OPTION_COLORS.includes(item.color) ? item.color : "default", description: null };
|
|
354
|
+
updated = { ...updated, config: { options: [...updated.config.options, option] } };
|
|
355
|
+
values.push({ id: option.id, name: option.name, color: option.color });
|
|
356
|
+
}
|
|
357
|
+
return { value: values, definition: updated };
|
|
358
|
+
}
|
|
359
|
+
case "status": {
|
|
360
|
+
if (value === null) return { value: null, definition };
|
|
361
|
+
expected(isObject(value) && (typeof value.name === "string" || typeof value.id === "string"), "should be an object with `name` or `id`, or null.");
|
|
362
|
+
const known = optionByRef(definition.config.options, value);
|
|
363
|
+
if (known === undefined) return validationError(context, `Status option ${JSON.stringify(value.name ?? value.id)} does not exist on property ${name}. Status options cannot be created through the API.`);
|
|
364
|
+
return { value: { id: known.id, name: known.name, color: known.color }, definition };
|
|
365
|
+
}
|
|
366
|
+
case "people": {
|
|
367
|
+
expected(Array.isArray(value), "should be an array of user references.");
|
|
368
|
+
const users = [];
|
|
369
|
+
for (const item of value) {
|
|
370
|
+
const id = isObject(item) ? normalizeId(item.id) : undefined;
|
|
371
|
+
const user = id === undefined ? null : context.state.get("users", id);
|
|
372
|
+
if (user === null) return validationError(context, `body failed validation: body.properties.${name}.people should reference existing users, instead had \`${JSON.stringify(item)}\`.`);
|
|
373
|
+
if (!users.some((existing) => existing.id === user.id)) users.push({ object: "user", id: user.id });
|
|
374
|
+
}
|
|
375
|
+
return { value: users, definition };
|
|
376
|
+
}
|
|
377
|
+
case "relation": {
|
|
378
|
+
expected(Array.isArray(value), "should be an array of page references.");
|
|
379
|
+
const pages = [];
|
|
380
|
+
for (const item of value) {
|
|
381
|
+
const id = isObject(item) ? normalizeId(item.id) : undefined;
|
|
382
|
+
const page = id === undefined ? null : context.state.get("pages", id);
|
|
383
|
+
const valid = page !== null && page.parent.type === "data_source_id" && page.parent.data_source_id === definition.config.data_source_id;
|
|
384
|
+
if (!valid) return validationError(context, `body failed validation: body.properties.${name}.relation should reference pages of the related data source, instead had \`${JSON.stringify(item)}\`.`);
|
|
385
|
+
if (!pages.some((existing) => existing.id === page.id)) pages.push({ id: page.id });
|
|
386
|
+
}
|
|
387
|
+
return { value: pages, definition };
|
|
388
|
+
}
|
|
389
|
+
default:
|
|
390
|
+
return validationError(context, `${name} is expected to be ${type}.`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Validate the `properties` of a create/update against a schema. Returns the values to store (only the
|
|
396
|
+
* given ones on update, every property on create) and the possibly extended schema.
|
|
397
|
+
*/
|
|
398
|
+
export function normalizePropertyValues(context, properties, raw, { create }) {
|
|
399
|
+
if (raw === undefined) return { values: {}, properties, changed: false };
|
|
400
|
+
if (!isObject(raw)) return validationError(context, "body failed validation: body.properties should be an object.");
|
|
401
|
+
let schema = properties;
|
|
402
|
+
let changed = false;
|
|
403
|
+
const values = {};
|
|
404
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
405
|
+
const definition = findDefinition(schema, key);
|
|
406
|
+
if (definition === undefined) return validationError(context, `${key} is not a property that exists.`);
|
|
407
|
+
if (value === null) {
|
|
408
|
+
if (definition.type === "title") return validationError(context, "body failed validation: body.properties.title should be an array of rich text objects, instead was null.");
|
|
409
|
+
values[definition.name] = { id: definition.id, type: definition.type, value: emptyValue(definition.type) };
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
const result = normalizeValue(context, definition, value, definition.name);
|
|
413
|
+
if (result.definition !== definition) {
|
|
414
|
+
schema = { ...schema, [definition.name]: result.definition };
|
|
415
|
+
changed = true;
|
|
416
|
+
}
|
|
417
|
+
values[definition.name] = { id: definition.id, type: definition.type, value: result.value };
|
|
418
|
+
}
|
|
419
|
+
if (create) {
|
|
420
|
+
for (const definition of Object.values(schema)) {
|
|
421
|
+
if (COMPUTED_TYPES.includes(definition.type) || values[definition.name] !== undefined) continue;
|
|
422
|
+
values[definition.name] = { id: definition.id, type: definition.type, value: emptyValue(definition.type) };
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return { values, properties: schema, changed };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** The value a page holds for a definition, computed for the read-only timestamp/user types. */
|
|
429
|
+
export function propertyValue(page, definition) {
|
|
430
|
+
switch (definition.type) {
|
|
431
|
+
case "created_time":
|
|
432
|
+
return page.created_time;
|
|
433
|
+
case "last_edited_time":
|
|
434
|
+
return page.last_edited_time;
|
|
435
|
+
case "created_by":
|
|
436
|
+
return page.created_by;
|
|
437
|
+
case "last_edited_by":
|
|
438
|
+
return page.last_edited_by;
|
|
439
|
+
default: {
|
|
440
|
+
const stored = page.properties[definition.name];
|
|
441
|
+
return stored === undefined ? emptyValue(definition.type) : stored.value;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function renderValue(context, identity, type, value) {
|
|
447
|
+
switch (type) {
|
|
448
|
+
case "people":
|
|
449
|
+
return value.map((user) => userReference(context, identity, user.id));
|
|
450
|
+
case "created_by":
|
|
451
|
+
case "last_edited_by":
|
|
452
|
+
return userReference(context, identity, value.id);
|
|
453
|
+
default:
|
|
454
|
+
return value;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Schema definitions of a page: the data source's for database pages, the implicit `title` otherwise. */
|
|
459
|
+
export function definitionsOf(context, page) {
|
|
460
|
+
if (page.parent.type === "data_source_id") {
|
|
461
|
+
const source = context.state.get("data-sources", page.parent.data_source_id);
|
|
462
|
+
if (source !== null) return source.properties;
|
|
463
|
+
}
|
|
464
|
+
return { title: { id: "title", name: "title", type: "title", config: {} } };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export function renderPageProperties(context, identity, page, filterIds) {
|
|
468
|
+
const definitions = definitionsOf(context, page);
|
|
469
|
+
const rendered = {};
|
|
470
|
+
for (const definition of Object.values(definitions)) {
|
|
471
|
+
if (filterIds !== undefined && !filterIds.includes(definition.id)) continue;
|
|
472
|
+
const value = propertyValue(page, definition);
|
|
473
|
+
const entry = { id: definition.id, type: definition.type, [definition.type]: renderValue(context, identity, definition.type, value) };
|
|
474
|
+
if (definition.type === "relation") entry.has_more = false;
|
|
475
|
+
rendered[definition.name] = entry;
|
|
476
|
+
}
|
|
477
|
+
return rendered;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Resolve `filter_properties` (ids or names) to property ids. `field` is the label the call site uses in messages
|
|
482
|
+
* (`query.filter_properties` on both routes, where Notion documents the parameter), the same convention as `pageSize`
|
|
483
|
+
* and the uuid checks. The declared input schema already refuses a non-array, so the array check is defensive.
|
|
484
|
+
*/
|
|
485
|
+
export function validateFilterProperties(context, definitions, filterProperties, field = "query.filter_properties") {
|
|
486
|
+
if (filterProperties === undefined) return undefined;
|
|
487
|
+
const section = field.split(".")[0];
|
|
488
|
+
if (!Array.isArray(filterProperties)) return validationError(context, `${section} failed validation: ${field} should be an array of property ids.`);
|
|
489
|
+
const ids = [];
|
|
490
|
+
for (const id of filterProperties) {
|
|
491
|
+
if (isMangled(id)) return mangledError(context, field);
|
|
492
|
+
const definition = findDefinition(definitions, String(id));
|
|
493
|
+
if (definition === undefined) return validationError(context, `Could not find property with name or id: ${shown(id)}`);
|
|
494
|
+
ids.push(definition.id);
|
|
495
|
+
}
|
|
496
|
+
return ids;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** `pages.retrieve-property`: paginated item lists for title/rich_text/relation/people, one object otherwise. */
|
|
500
|
+
export function propertyItems(context, identity, page, definition) {
|
|
501
|
+
const value = propertyValue(page, definition);
|
|
502
|
+
const type = definition.type;
|
|
503
|
+
if (!LIST_ITEM_TYPES.includes(type)) {
|
|
504
|
+
return { list: false, item: { object: "property_item", id: definition.id, type, [type]: renderValue(context, identity, type, value) } };
|
|
505
|
+
}
|
|
506
|
+
const items = value.map((element) => ({
|
|
507
|
+
object: "property_item",
|
|
508
|
+
id: definition.id,
|
|
509
|
+
type,
|
|
510
|
+
[type]: type === "people" ? userReference(context, identity, element.id) : element,
|
|
511
|
+
}));
|
|
512
|
+
return { list: true, items };
|
|
513
|
+
}
|