@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,272 @@
|
|
|
1
|
+
// Data-source query semantics: Notion's per-type filter conditions, compound filters (two levels, ≤ 100
|
|
2
|
+
// conditions per group), relative date windows from virtual time, and stable sorts.
|
|
3
|
+
import { TEXT_TYPES, findDefinition, propertyValue } from "./properties.mjs";
|
|
4
|
+
import { plainText } from "./rich-text.mjs";
|
|
5
|
+
import { isMangled, mangledError, shown, validationError } from "./state.mjs";
|
|
6
|
+
|
|
7
|
+
const DAY_MS = 86400000;
|
|
8
|
+
const MAX_GROUP = 100;
|
|
9
|
+
const MAX_DEPTH = 2;
|
|
10
|
+
const TEXT_CONDITIONS = ["equals", "does_not_equal", "contains", "does_not_contain", "starts_with", "ends_with", "is_empty", "is_not_empty"];
|
|
11
|
+
const NUMBER_CONDITIONS = ["equals", "does_not_equal", "greater_than", "less_than", "greater_than_or_equal_to", "less_than_or_equal_to", "is_empty", "is_not_empty"];
|
|
12
|
+
const CHECKBOX_CONDITIONS = ["equals", "does_not_equal"];
|
|
13
|
+
const OPTION_CONDITIONS = ["equals", "does_not_equal", "is_empty", "is_not_empty"];
|
|
14
|
+
const CONTAINS_CONDITIONS = ["contains", "does_not_contain", "is_empty", "is_not_empty"];
|
|
15
|
+
const DATE_CONDITIONS = ["equals", "before", "after", "on_or_before", "on_or_after", "is_empty", "is_not_empty", "this_week", "past_week", "past_month", "past_year", "next_week", "next_month", "next_year"];
|
|
16
|
+
const DATE_TYPES = ["date", "created_time", "last_edited_time"];
|
|
17
|
+
|
|
18
|
+
function isObject(value) {
|
|
19
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function conditionsFor(type) {
|
|
23
|
+
if (TEXT_TYPES.includes(type)) return TEXT_CONDITIONS;
|
|
24
|
+
if (type === "number") return NUMBER_CONDITIONS;
|
|
25
|
+
if (type === "checkbox") return CHECKBOX_CONDITIONS;
|
|
26
|
+
if (type === "select" || type === "status") return OPTION_CONDITIONS;
|
|
27
|
+
if (type === "multi_select" || type === "people" || type === "relation" || type === "created_by" || type === "last_edited_by") return CONTAINS_CONDITIONS;
|
|
28
|
+
if (DATE_TYPES.includes(type)) return DATE_CONDITIONS;
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function textOf(type, value) {
|
|
33
|
+
if (value === null || value === undefined) return "";
|
|
34
|
+
return type === "title" || type === "rich_text" ? plainText(value) : String(value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function dayIndex(ms) {
|
|
38
|
+
return Math.floor(ms / DAY_MS);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Compare a stored date (`{start}` or timestamp) with a filter value; date-only values compare by UTC day. */
|
|
42
|
+
function compareDates(stored, filterValue) {
|
|
43
|
+
const storedString = isObject(stored) ? stored.start : stored;
|
|
44
|
+
const storedMs = Date.parse(storedString);
|
|
45
|
+
const filterMs = Date.parse(filterValue);
|
|
46
|
+
if (Number.isNaN(storedMs) || Number.isNaN(filterMs)) return undefined;
|
|
47
|
+
const dayOnly = storedString.length === 10 || filterValue.length === 10;
|
|
48
|
+
const left = dayOnly ? dayIndex(storedMs) : storedMs;
|
|
49
|
+
const right = dayOnly ? dayIndex(filterMs) : filterMs;
|
|
50
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function windowFor(condition, nowMs) {
|
|
54
|
+
const dayStart = dayIndex(nowMs) * DAY_MS;
|
|
55
|
+
switch (condition) {
|
|
56
|
+
case "past_week":
|
|
57
|
+
return [nowMs - 7 * DAY_MS, nowMs];
|
|
58
|
+
case "past_month":
|
|
59
|
+
return [nowMs - 30 * DAY_MS, nowMs];
|
|
60
|
+
case "past_year":
|
|
61
|
+
return [nowMs - 365 * DAY_MS, nowMs];
|
|
62
|
+
case "next_week":
|
|
63
|
+
return [nowMs, nowMs + 7 * DAY_MS];
|
|
64
|
+
case "next_month":
|
|
65
|
+
return [nowMs, nowMs + 30 * DAY_MS];
|
|
66
|
+
case "next_year":
|
|
67
|
+
return [nowMs, nowMs + 365 * DAY_MS];
|
|
68
|
+
case "this_week": {
|
|
69
|
+
const weekday = (new Date(dayStart).getUTCDay() + 6) % 7; // Monday = 0
|
|
70
|
+
const monday = dayStart - weekday * DAY_MS;
|
|
71
|
+
return [monday, monday + 7 * DAY_MS - 1];
|
|
72
|
+
}
|
|
73
|
+
default:
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function evaluate(type, condition, expected, value, nowMs) {
|
|
79
|
+
if (TEXT_TYPES.includes(type)) {
|
|
80
|
+
const text = textOf(type, value).toLowerCase();
|
|
81
|
+
const needle = typeof expected === "string" ? expected.toLowerCase() : "";
|
|
82
|
+
switch (condition) {
|
|
83
|
+
case "equals": return text === needle;
|
|
84
|
+
case "does_not_equal": return text !== needle;
|
|
85
|
+
case "contains": return text.includes(needle);
|
|
86
|
+
case "does_not_contain": return !text.includes(needle);
|
|
87
|
+
case "starts_with": return text.startsWith(needle);
|
|
88
|
+
case "ends_with": return text.endsWith(needle);
|
|
89
|
+
case "is_empty": return text.length === 0;
|
|
90
|
+
case "is_not_empty": return text.length > 0;
|
|
91
|
+
default: return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (type === "number") {
|
|
95
|
+
const empty = value === null || value === undefined;
|
|
96
|
+
switch (condition) {
|
|
97
|
+
case "is_empty": return empty;
|
|
98
|
+
case "is_not_empty": return !empty;
|
|
99
|
+
case "equals": return !empty && value === expected;
|
|
100
|
+
case "does_not_equal": return empty || value !== expected;
|
|
101
|
+
case "greater_than": return !empty && value > expected;
|
|
102
|
+
case "less_than": return !empty && value < expected;
|
|
103
|
+
case "greater_than_or_equal_to": return !empty && value >= expected;
|
|
104
|
+
case "less_than_or_equal_to": return !empty && value <= expected;
|
|
105
|
+
default: return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (type === "checkbox") return condition === "equals" ? value === expected : value !== expected;
|
|
109
|
+
if (type === "select" || type === "status") {
|
|
110
|
+
const name = value === null ? null : value.name;
|
|
111
|
+
switch (condition) {
|
|
112
|
+
case "equals": return name === expected;
|
|
113
|
+
case "does_not_equal": return name !== expected;
|
|
114
|
+
case "is_empty": return name === null;
|
|
115
|
+
case "is_not_empty": return name !== null;
|
|
116
|
+
default: return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (type === "multi_select" || type === "people" || type === "relation" || type === "created_by" || type === "last_edited_by") {
|
|
120
|
+
const items = Array.isArray(value) ? value : value === null || value === undefined ? [] : [value];
|
|
121
|
+
const keys = items.map((item) => (type === "multi_select" ? item.name : item.id));
|
|
122
|
+
switch (condition) {
|
|
123
|
+
case "contains": return keys.includes(expected);
|
|
124
|
+
case "does_not_contain": return !keys.includes(expected);
|
|
125
|
+
case "is_empty": return keys.length === 0;
|
|
126
|
+
case "is_not_empty": return keys.length > 0;
|
|
127
|
+
default: return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (DATE_TYPES.includes(type)) {
|
|
131
|
+
const empty = value === null || value === undefined;
|
|
132
|
+
if (condition === "is_empty") return empty;
|
|
133
|
+
if (condition === "is_not_empty") return !empty;
|
|
134
|
+
if (empty) return false;
|
|
135
|
+
const window = windowFor(condition, nowMs);
|
|
136
|
+
if (window !== undefined) {
|
|
137
|
+
const start = isObject(value) ? value.start : value;
|
|
138
|
+
const ms = Date.parse(start);
|
|
139
|
+
if (Number.isNaN(ms)) return false;
|
|
140
|
+
// Date-only values are compared by UTC calendar day, so "today" falls inside next_week / this_week.
|
|
141
|
+
if (start.length === 10) return dayIndex(ms) >= dayIndex(window[0]) && dayIndex(ms) <= dayIndex(window[1]);
|
|
142
|
+
return ms >= window[0] && ms <= window[1];
|
|
143
|
+
}
|
|
144
|
+
const order = compareDates(value, expected);
|
|
145
|
+
if (order === undefined) return false;
|
|
146
|
+
switch (condition) {
|
|
147
|
+
case "equals": return order === 0;
|
|
148
|
+
case "before": return order < 0;
|
|
149
|
+
case "after": return order > 0;
|
|
150
|
+
case "on_or_before": return order <= 0;
|
|
151
|
+
case "on_or_after": return order >= 0;
|
|
152
|
+
default: return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function expectedValueValid(type, condition, expected) {
|
|
159
|
+
if (condition === "is_empty" || condition === "is_not_empty" || windowFor(condition, 0) !== undefined) return isObject(expected) || expected === true;
|
|
160
|
+
if (TEXT_TYPES.includes(type) || type === "select" || type === "status" || type === "multi_select") return typeof expected === "string";
|
|
161
|
+
if (type === "number") return typeof expected === "number" && Number.isFinite(expected);
|
|
162
|
+
if (type === "checkbox") return typeof expected === "boolean";
|
|
163
|
+
if (DATE_TYPES.includes(type)) return typeof expected === "string" && !Number.isNaN(Date.parse(expected));
|
|
164
|
+
return typeof expected === "string";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Compile a filter object into a predicate over page rows; throws declared VALIDATION_ERRORs. */
|
|
168
|
+
export function compileFilter(context, definitions, filter, path = "filter", depth = 1) {
|
|
169
|
+
if (!isObject(filter)) return validationError(context, `body failed validation: body.${path} should be an object.`);
|
|
170
|
+
const compound = filter.and !== undefined ? "and" : filter.or !== undefined ? "or" : undefined;
|
|
171
|
+
if (compound !== undefined) {
|
|
172
|
+
if (depth > MAX_DEPTH) return validationError(context, `body failed validation: body.${path}.${compound} nests compound filters deeper than ${MAX_DEPTH} levels.`);
|
|
173
|
+
const group = filter[compound];
|
|
174
|
+
if (!Array.isArray(group)) return validationError(context, `body failed validation: body.${path}.${compound} should be an array.`);
|
|
175
|
+
if (group.length > MAX_GROUP) return validationError(context, `body failed validation: body.${path}.${compound}.length should be ≤ ${MAX_GROUP}, instead was ${group.length}.`);
|
|
176
|
+
const predicates = group.map((child, index) => compileFilter(context, definitions, child, `${path}.${compound}[${index}]`, depth + 1));
|
|
177
|
+
return compound === "and" ? (page, nowMs) => predicates.every((predicate) => predicate(page, nowMs)) : (page, nowMs) => predicates.some((predicate) => predicate(page, nowMs));
|
|
178
|
+
}
|
|
179
|
+
let type;
|
|
180
|
+
let read;
|
|
181
|
+
if (typeof filter.timestamp === "string") {
|
|
182
|
+
if (filter.timestamp !== "created_time" && filter.timestamp !== "last_edited_time") {
|
|
183
|
+
return validationError(context, `body failed validation: body.${path}.timestamp should be "created_time" or "last_edited_time", instead was \`${filter.timestamp}\`.`);
|
|
184
|
+
}
|
|
185
|
+
type = filter.timestamp;
|
|
186
|
+
read = (page) => page[type];
|
|
187
|
+
} else {
|
|
188
|
+
if (typeof filter.property !== "string") return validationError(context, `body failed validation: body.${path}.property should be a string.`);
|
|
189
|
+
if (isMangled(filter.property)) return mangledError(context, `body.${path}.property`);
|
|
190
|
+
const definition = findDefinition(definitions, filter.property);
|
|
191
|
+
if (definition === undefined) return validationError(context, `Could not find property with name or id: ${filter.property}`);
|
|
192
|
+
type = definition.type;
|
|
193
|
+
read = (page) => propertyValue(page, definition);
|
|
194
|
+
}
|
|
195
|
+
const filterKey = filter[type] !== undefined ? type : type === "title" && filter.rich_text !== undefined ? "rich_text" : undefined;
|
|
196
|
+
if (filterKey === undefined || !isObject(filter[filterKey])) {
|
|
197
|
+
return validationError(context, `body failed validation: body.${path}.${type} should be defined, instead was \`undefined\`.`);
|
|
198
|
+
}
|
|
199
|
+
const conditions = Object.keys(filter[filterKey]);
|
|
200
|
+
if (conditions.length !== 1) return validationError(context, `body failed validation: body.${path}.${filterKey} should contain exactly one condition.`);
|
|
201
|
+
const condition = conditions[0];
|
|
202
|
+
const allowed = conditionsFor(type);
|
|
203
|
+
if (!allowed.includes(condition)) {
|
|
204
|
+
return validationError(context, `body failed validation: body.${path}.${filterKey}.${condition} is not a supported condition for ${type} properties (supported: ${allowed.join(", ")}).`);
|
|
205
|
+
}
|
|
206
|
+
const expected = filter[filterKey][condition];
|
|
207
|
+
// A filter value carrying U+FFFD is a mangled request, not a literal to match (RECIPE, lenient decoding).
|
|
208
|
+
if (isMangled(expected)) return mangledError(context, `body.${path}.${filterKey}.${condition}`);
|
|
209
|
+
if (!expectedValueValid(type, condition, expected)) {
|
|
210
|
+
return validationError(context, `body failed validation: body.${path}.${filterKey}.${condition} should be a valid ${type} filter value, instead was \`${JSON.stringify(expected)}\`.`);
|
|
211
|
+
}
|
|
212
|
+
return (page, nowMs) => evaluate(type, condition, expected, read(page), nowMs);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function sortKey(type, value, definition) {
|
|
216
|
+
if (value === null || value === undefined) return undefined;
|
|
217
|
+
if (TEXT_TYPES.includes(type)) {
|
|
218
|
+
const text = textOf(type, value);
|
|
219
|
+
return text.length === 0 ? undefined : text.toLowerCase();
|
|
220
|
+
}
|
|
221
|
+
if (type === "number") return value;
|
|
222
|
+
if (type === "checkbox") return value ? 1 : 0;
|
|
223
|
+
if (type === "select" || type === "status") {
|
|
224
|
+
const index = definition.config.options.findIndex((option) => option.id === value.id);
|
|
225
|
+
return index < 0 ? undefined : index;
|
|
226
|
+
}
|
|
227
|
+
if (type === "multi_select") return value.length === 0 ? undefined : value[0].name.toLowerCase();
|
|
228
|
+
if (type === "people" || type === "relation") return value.length === 0 ? undefined : value[0].id;
|
|
229
|
+
if (type === "created_by" || type === "last_edited_by") return value.id;
|
|
230
|
+
if (DATE_TYPES.includes(type)) {
|
|
231
|
+
const ms = Date.parse(isObject(value) ? value.start : value);
|
|
232
|
+
return Number.isNaN(ms) ? undefined : ms;
|
|
233
|
+
}
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Compile `sorts` into a comparator; empties sort last, ties keep creation (row-id) order. */
|
|
238
|
+
export function compileSorts(context, definitions, sorts) {
|
|
239
|
+
if (sorts === undefined) return () => 0;
|
|
240
|
+
if (!Array.isArray(sorts)) return validationError(context, "body failed validation: body.sorts should be an array.");
|
|
241
|
+
const keys = sorts.map((sort, index) => {
|
|
242
|
+
if (!isObject(sort)) return validationError(context, `body failed validation: body.sorts[${index}] should be an object.`);
|
|
243
|
+
if (sort.direction !== "ascending" && sort.direction !== "descending") {
|
|
244
|
+
return validationError(context, `body failed validation: body.sorts[${index}].direction should be "ascending" or "descending", instead was \`${shown(sort.direction)}\`.`);
|
|
245
|
+
}
|
|
246
|
+
const sign = sort.direction === "ascending" ? 1 : -1;
|
|
247
|
+
if (typeof sort.timestamp === "string") {
|
|
248
|
+
if (sort.timestamp !== "created_time" && sort.timestamp !== "last_edited_time") {
|
|
249
|
+
return validationError(context, `body failed validation: body.sorts[${index}].timestamp should be "created_time" or "last_edited_time".`);
|
|
250
|
+
}
|
|
251
|
+
const field = sort.timestamp;
|
|
252
|
+
return { sign, key: (page) => Date.parse(page[field]) };
|
|
253
|
+
}
|
|
254
|
+
if (typeof sort.property !== "string") return validationError(context, `body failed validation: body.sorts[${index}].property should be a string.`);
|
|
255
|
+
if (isMangled(sort.property)) return mangledError(context, `body.sorts[${index}].property`);
|
|
256
|
+
const definition = findDefinition(definitions, sort.property);
|
|
257
|
+
if (definition === undefined) return validationError(context, `Could not find sort property with name or id: ${sort.property}`);
|
|
258
|
+
return { sign, key: (page) => sortKey(definition.type, propertyValue(page, definition), definition) };
|
|
259
|
+
});
|
|
260
|
+
return (left, right) => {
|
|
261
|
+
for (const { sign, key } of keys) {
|
|
262
|
+
const a = key(left);
|
|
263
|
+
const b = key(right);
|
|
264
|
+
if (a === undefined && b === undefined) continue;
|
|
265
|
+
if (a === undefined) return 1;
|
|
266
|
+
if (b === undefined) return -1;
|
|
267
|
+
if (a < b) return -sign;
|
|
268
|
+
if (a > b) return sign;
|
|
269
|
+
}
|
|
270
|
+
return 0;
|
|
271
|
+
};
|
|
272
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Notion object rendering (page, database, data source, block, comment, list envelopes). Pure functions
|
|
2
|
+
// over state rows; reads through context only to expand users and child titles.
|
|
3
|
+
import { compactId } from "./ids.mjs";
|
|
4
|
+
import { renderPageProperties, renderSchema, renderUser, userReference } from "./properties.mjs";
|
|
5
|
+
import { plainText } from "./rich-text.mjs";
|
|
6
|
+
import { slug } from "./state.mjs";
|
|
7
|
+
|
|
8
|
+
export { renderUser };
|
|
9
|
+
|
|
10
|
+
export function objectUrl(titlePlain, id) {
|
|
11
|
+
const part = slug(titlePlain);
|
|
12
|
+
return `https://www.notion.so/${part.length > 0 ? `${part}-` : ""}${compactId(id)}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function stamps(row) {
|
|
16
|
+
return {
|
|
17
|
+
created_time: row.created_time,
|
|
18
|
+
last_edited_time: row.last_edited_time,
|
|
19
|
+
created_by: { object: "user", id: row.created_by.id },
|
|
20
|
+
last_edited_by: { object: "user", id: row.last_edited_by.id },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function renderPage(context, identity, page, filterIds) {
|
|
25
|
+
return {
|
|
26
|
+
object: "page",
|
|
27
|
+
id: page.id,
|
|
28
|
+
...stamps(page),
|
|
29
|
+
cover: page.cover,
|
|
30
|
+
icon: page.icon,
|
|
31
|
+
parent: page.parent,
|
|
32
|
+
archived: page.in_trash,
|
|
33
|
+
in_trash: page.in_trash,
|
|
34
|
+
properties: renderPageProperties(context, identity, page, filterIds),
|
|
35
|
+
url: objectUrl(page.title_plain, page.id),
|
|
36
|
+
public_url: null,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function renderDatabase(context, database) {
|
|
41
|
+
const sources = database.data_source_ids.map((id) => {
|
|
42
|
+
const source = context.state.get("data-sources", id);
|
|
43
|
+
return { id, name: source === null ? "" : source.name };
|
|
44
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
object: "database",
|
|
47
|
+
id: database.id,
|
|
48
|
+
...stamps(database),
|
|
49
|
+
title: database.title,
|
|
50
|
+
description: database.description,
|
|
51
|
+
icon: database.icon,
|
|
52
|
+
cover: database.cover,
|
|
53
|
+
parent: database.parent,
|
|
54
|
+
is_inline: database.is_inline,
|
|
55
|
+
is_locked: false,
|
|
56
|
+
archived: database.in_trash,
|
|
57
|
+
in_trash: database.in_trash,
|
|
58
|
+
url: objectUrl(database.title_plain, database.id),
|
|
59
|
+
public_url: null,
|
|
60
|
+
data_sources: sources,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function renderDataSource(context, source) {
|
|
65
|
+
const database = context.state.get("databases", source.parent.database_id);
|
|
66
|
+
return {
|
|
67
|
+
object: "data_source",
|
|
68
|
+
id: source.id,
|
|
69
|
+
name: source.name,
|
|
70
|
+
...stamps(source),
|
|
71
|
+
title: source.title,
|
|
72
|
+
description: source.description,
|
|
73
|
+
icon: database === null ? null : database.icon,
|
|
74
|
+
properties: renderSchema(source.properties),
|
|
75
|
+
parent: source.parent,
|
|
76
|
+
database_parent: database === null ? { type: "workspace", workspace: true } : database.parent,
|
|
77
|
+
is_inline: database === null ? false : database.is_inline,
|
|
78
|
+
archived: source.in_trash,
|
|
79
|
+
in_trash: source.in_trash,
|
|
80
|
+
url: objectUrl(plainText(source.title), source.id),
|
|
81
|
+
public_url: null,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function blockContent(context, block) {
|
|
86
|
+
if (block.type === "child_page") {
|
|
87
|
+
const page = context.state.get("pages", block.id);
|
|
88
|
+
return { title: page === null ? block.content.title ?? "" : page.title_plain };
|
|
89
|
+
}
|
|
90
|
+
if (block.type === "child_database") {
|
|
91
|
+
const database = context.state.get("databases", block.id);
|
|
92
|
+
return { title: database === null ? block.content.title ?? "" : database.title_plain };
|
|
93
|
+
}
|
|
94
|
+
return block.content;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function renderBlock(context, block) {
|
|
98
|
+
return {
|
|
99
|
+
object: "block",
|
|
100
|
+
id: block.id,
|
|
101
|
+
parent: block.parent,
|
|
102
|
+
...stamps(block),
|
|
103
|
+
has_children: block.has_children,
|
|
104
|
+
archived: block.in_trash,
|
|
105
|
+
in_trash: block.in_trash,
|
|
106
|
+
type: block.type,
|
|
107
|
+
[block.type]: blockContent(context, block),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function renderComment(context, identity, comment) {
|
|
112
|
+
return {
|
|
113
|
+
object: "comment",
|
|
114
|
+
id: comment.id,
|
|
115
|
+
parent: comment.parent,
|
|
116
|
+
discussion_id: comment.discussion_id,
|
|
117
|
+
created_time: comment.created_time,
|
|
118
|
+
last_edited_time: comment.last_edited_time,
|
|
119
|
+
created_by: userReference(context, identity, comment.created_by.id),
|
|
120
|
+
rich_text: comment.rich_text,
|
|
121
|
+
attachments: [],
|
|
122
|
+
display_name: comment.display_name,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Notion's paginated list envelope (`type` names the element kind; `request_status` on query/search). */
|
|
127
|
+
export function listResponse(type, results, pagination, extra = {}) {
|
|
128
|
+
return {
|
|
129
|
+
object: "list",
|
|
130
|
+
results,
|
|
131
|
+
next_cursor: pagination.next_cursor,
|
|
132
|
+
has_more: pagination.has_more,
|
|
133
|
+
type,
|
|
134
|
+
[type]: {},
|
|
135
|
+
...extra,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Notion rich text: validation of request items (`text`, `mention`) and the normalised stored/returned form.
|
|
2
|
+
import { normalizeId } from "./ids.mjs";
|
|
3
|
+
import { jsonBytes, OBJECT_BUDGET_BYTES } from "./size.mjs";
|
|
4
|
+
import { shown, validationError } from "./state.mjs";
|
|
5
|
+
|
|
6
|
+
export const COLORS = Object.freeze([
|
|
7
|
+
"default", "gray", "brown", "orange", "yellow", "green", "blue", "purple", "pink", "red",
|
|
8
|
+
"gray_background", "brown_background", "orange_background", "yellow_background", "green_background",
|
|
9
|
+
"blue_background", "purple_background", "pink_background", "red_background",
|
|
10
|
+
]);
|
|
11
|
+
const MAX_ITEMS = 100;
|
|
12
|
+
const MAX_CONTENT = 2000;
|
|
13
|
+
const MAX_URL = 2000;
|
|
14
|
+
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
|
15
|
+
const DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})$/;
|
|
16
|
+
|
|
17
|
+
export function isDateString(value) {
|
|
18
|
+
return typeof value === "string" && (DATE_ONLY.test(value) || DATE_TIME.test(value));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function defaultAnnotations() {
|
|
22
|
+
return { bold: false, italic: false, strikethrough: false, underline: false, code: false, color: "default" };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isObject(value) {
|
|
26
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function annotations(context, value, path) {
|
|
30
|
+
const result = defaultAnnotations();
|
|
31
|
+
if (value === undefined) return result;
|
|
32
|
+
if (!isObject(value)) return validationError(context, `body failed validation: body.${path}.annotations should be an object.`);
|
|
33
|
+
for (const key of ["bold", "italic", "strikethrough", "underline", "code"]) {
|
|
34
|
+
if (value[key] === undefined) continue;
|
|
35
|
+
if (typeof value[key] !== "boolean") return validationError(context, `body failed validation: body.${path}.annotations.${key} should be a boolean.`);
|
|
36
|
+
result[key] = value[key];
|
|
37
|
+
}
|
|
38
|
+
if (value.color !== undefined) {
|
|
39
|
+
if (!COLORS.includes(value.color)) return validationError(context, `body failed validation: body.${path}.annotations.color should be a valid color, instead was \`${shown(value.color)}\`.`);
|
|
40
|
+
result.color = value.color;
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function textItem(content, options = {}) {
|
|
46
|
+
const link = options.link ?? null;
|
|
47
|
+
return {
|
|
48
|
+
type: "text",
|
|
49
|
+
text: { content, link },
|
|
50
|
+
annotations: { ...defaultAnnotations(), ...(options.annotations ?? {}) },
|
|
51
|
+
plain_text: content,
|
|
52
|
+
href: link === null ? null : link.url,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Validate and normalise a rich text array from a request body. `path` names the field for messages. */
|
|
57
|
+
export function normalizeRichText(context, value, path) {
|
|
58
|
+
if (!Array.isArray(value)) return validationError(context, `body failed validation: body.${path} should be an array, instead was \`${JSON.stringify(value)}\`.`);
|
|
59
|
+
if (value.length > MAX_ITEMS) return validationError(context, `body failed validation: body.${path}.length should be ≤ ${MAX_ITEMS}, instead was ${value.length}.`);
|
|
60
|
+
const items = [];
|
|
61
|
+
// Rendered text appears twice per item (text.content and plain_text), so the array is bounded by the encoded bytes
|
|
62
|
+
// it renders to, measured as items are admitted; one stored block, page or comment always fits one response.
|
|
63
|
+
let bytes = 0;
|
|
64
|
+
const admit = (normalized) => {
|
|
65
|
+
bytes += jsonBytes(normalized) + 1;
|
|
66
|
+
if (bytes > OBJECT_BUDGET_BYTES) {
|
|
67
|
+
return validationError(context, `body failed validation: body.${path} would be more than ${OBJECT_BUDGET_BYTES} bytes once rendered (text.content and plain_text both carry the text).`);
|
|
68
|
+
}
|
|
69
|
+
items.push(normalized);
|
|
70
|
+
};
|
|
71
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
72
|
+
const item = value[index];
|
|
73
|
+
const itemPath = `${path}[${index}]`;
|
|
74
|
+
if (!isObject(item)) return validationError(context, `body failed validation: body.${itemPath} should be an object.`);
|
|
75
|
+
const type = item.type ?? (item.text !== undefined ? "text" : item.mention !== undefined ? "mention" : undefined);
|
|
76
|
+
if (type === "text") {
|
|
77
|
+
if (!isObject(item.text) || typeof item.text.content !== "string") {
|
|
78
|
+
return validationError(context, `body failed validation: body.${itemPath}.text.content should be a string.`);
|
|
79
|
+
}
|
|
80
|
+
if (item.text.content.length > MAX_CONTENT) {
|
|
81
|
+
return validationError(context, `body failed validation: body.${itemPath}.text.content.length should be ≤ ${MAX_CONTENT}, instead was ${item.text.content.length}.`);
|
|
82
|
+
}
|
|
83
|
+
let link = null;
|
|
84
|
+
if (item.text.link !== undefined && item.text.link !== null) {
|
|
85
|
+
if (!isObject(item.text.link) || typeof item.text.link.url !== "string" || item.text.link.url.length === 0) {
|
|
86
|
+
return validationError(context, `body failed validation: body.${itemPath}.text.link.url should be a string.`);
|
|
87
|
+
}
|
|
88
|
+
if (item.text.link.url.length > MAX_URL) {
|
|
89
|
+
return validationError(context, `body failed validation: body.${itemPath}.text.link.url.length should be ≤ ${MAX_URL}, instead was ${item.text.link.url.length}.`);
|
|
90
|
+
}
|
|
91
|
+
link = { url: item.text.link.url };
|
|
92
|
+
}
|
|
93
|
+
admit(textItem(item.text.content, { link, annotations: annotations(context, item.annotations, itemPath) }));
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (type === "mention") {
|
|
97
|
+
const mention = item.mention;
|
|
98
|
+
if (!isObject(mention)) return validationError(context, `body failed validation: body.${itemPath}.mention should be an object.`);
|
|
99
|
+
const mentionType = mention.type ?? (mention.user !== undefined ? "user" : mention.page !== undefined ? "page" : mention.date !== undefined ? "date" : undefined);
|
|
100
|
+
const base = { type: "mention", annotations: annotations(context, item.annotations, itemPath), href: null };
|
|
101
|
+
if (mentionType === "user") {
|
|
102
|
+
const id = isObject(mention.user) ? normalizeId(mention.user.id) : undefined;
|
|
103
|
+
const user = id === undefined ? null : context.state.get("users", id);
|
|
104
|
+
if (user === null) return validationError(context, `body failed validation: body.${itemPath}.mention.user.id should be the id of a user in the workspace.`);
|
|
105
|
+
admit({ ...base, mention: { type: "user", user: { object: "user", id: user.id } }, plain_text: `@${user.name}` });
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (mentionType === "page") {
|
|
109
|
+
const id = isObject(mention.page) ? normalizeId(mention.page.id) : undefined;
|
|
110
|
+
const page = id === undefined ? null : context.state.get("pages", id);
|
|
111
|
+
if (page === null) return validationError(context, `body failed validation: body.${itemPath}.mention.page.id should be the id of a page in the workspace.`);
|
|
112
|
+
admit({ ...base, mention: { type: "page", page: { id: page.id } }, plain_text: page.title_plain, href: `https://www.notion.so/${page.id.replaceAll("-", "")}` });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (mentionType === "date") {
|
|
116
|
+
const date = mention.date;
|
|
117
|
+
if (!isObject(date) || !isDateString(date.start) || (date.end !== undefined && date.end !== null && !isDateString(date.end))) {
|
|
118
|
+
return validationError(context, `body failed validation: body.${itemPath}.mention.date.start should be a valid ISO 8601 date string.`);
|
|
119
|
+
}
|
|
120
|
+
const end = date.end ?? null;
|
|
121
|
+
const timeZone = typeof date.time_zone === "string" ? date.time_zone : null;
|
|
122
|
+
admit({ ...base, mention: { type: "date", date: { start: date.start, end, time_zone: timeZone } }, plain_text: end === null ? date.start : `${date.start} → ${end}` });
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
return validationError(context, `body failed validation: body.${itemPath}.mention.type should be "user", "page" or "date", instead was \`${shown(mentionType)}\`.`);
|
|
126
|
+
}
|
|
127
|
+
return validationError(context, `body failed validation: body.${itemPath}.type should be "text" or "mention", instead was \`${shown(type)}\`.`);
|
|
128
|
+
}
|
|
129
|
+
return items;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function plainText(items) {
|
|
133
|
+
return items.map((item) => item.plain_text).join("");
|
|
134
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// UTF-8 byte accounting for response budgets. The framework refuses an HTTP response body over 1 MiB, so every
|
|
2
|
+
// rendered object is bounded when it is written and every list page is filled by encoded bytes. No module state.
|
|
3
|
+
|
|
4
|
+
/** Hard bound on one encoded response body (the framework's cap is 1,048,576 bytes; the rest is headroom). */
|
|
5
|
+
export const MAX_RESPONSE_BYTES = 1_000_000;
|
|
6
|
+
/** Encoded bytes of `results` one list page may fill before it ends early with a real next_cursor. */
|
|
7
|
+
export const PAGE_BUDGET_BYTES = 900_000;
|
|
8
|
+
/** Rendered size a stored page, block, comment, database or data source may reach when it is written. */
|
|
9
|
+
export const OBJECT_BUDGET_BYTES = 800_000;
|
|
10
|
+
/** Rendered size of one data source schema (its property keys are repeated in every page of the data source). */
|
|
11
|
+
export const SCHEMA_BUDGET_BYTES = 150_000;
|
|
12
|
+
/**
|
|
13
|
+
* Worst-case bytes a data source schema adds to the rendering of each of its pages (every property with an empty
|
|
14
|
+
* value, created_by / last_edited_by as the widest expanded user). A page is bounded by OBJECT_BUDGET_BYTES when it
|
|
15
|
+
* is written, including the schema of that moment; a later schema change adds at most this much to it, so
|
|
16
|
+
* OBJECT_BUDGET_BYTES + SCHEMA_PAGE_BUDGET_BYTES stays under one list item (MAX_RESPONSE_BYTES - 4096).
|
|
17
|
+
*/
|
|
18
|
+
export const SCHEMA_PAGE_BUDGET_BYTES = 150_000;
|
|
19
|
+
/** Block content (rich text, captions, urls) one create or append request may add. */
|
|
20
|
+
export const REQUEST_CONTENT_BYTES = 900_000;
|
|
21
|
+
|
|
22
|
+
/** UTF-8 length of a string computed from its code points (1, 2, 3 or 4 bytes each). */
|
|
23
|
+
export function utf8Length(text) {
|
|
24
|
+
let bytes = 0;
|
|
25
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
26
|
+
const unit = text.charCodeAt(index);
|
|
27
|
+
if (unit < 0x80) bytes += 1;
|
|
28
|
+
else if (unit < 0x800) bytes += 2;
|
|
29
|
+
else if (unit >= 0xd800 && unit <= 0xdbff && index + 1 < text.length) {
|
|
30
|
+
const next = text.charCodeAt(index + 1);
|
|
31
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
32
|
+
bytes += 4;
|
|
33
|
+
index += 1;
|
|
34
|
+
} else bytes += 3;
|
|
35
|
+
} else bytes += 3;
|
|
36
|
+
}
|
|
37
|
+
return bytes;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Encoded bytes of a value as it is sent: JSON text (escapes included) measured in UTF-8. */
|
|
41
|
+
export function jsonBytes(value) {
|
|
42
|
+
const text = JSON.stringify(value);
|
|
43
|
+
return typeof text === "string" ? utf8Length(text) : 0;
|
|
44
|
+
}
|