@sentry/junior-notion 0.8.0 → 0.9.0
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/README.md +13 -29
- package/package.json +1 -1
- package/plugin.yaml +6 -10
- package/skills/notion/SKILL.md +27 -24
- package/skills/notion/scripts/notion-cli.mjs +0 -557
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @sentry/junior-notion
|
|
2
2
|
|
|
3
|
-
`@sentry/junior-notion` adds read-only Notion search workflows for pages and data sources to Junior
|
|
3
|
+
`@sentry/junior-notion` adds read-only Notion search workflows for pages and data sources to Junior through Notion's hosted MCP server.
|
|
4
4
|
|
|
5
5
|
Install it alongside `@sentry/junior`:
|
|
6
6
|
|
|
@@ -8,21 +8,6 @@ Install it alongside `@sentry/junior`:
|
|
|
8
8
|
pnpm add @sentry/junior @sentry/junior-notion
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
Create an internal Notion integration by following Notion's Authorization guide:
|
|
12
|
-
|
|
13
|
-
- https://developers.notion.com/guides/get-started/authorization
|
|
14
|
-
|
|
15
|
-
In the Notion integration settings:
|
|
16
|
-
|
|
17
|
-
- choose the workspace where the integration will live
|
|
18
|
-
- enable the `Read content` capability
|
|
19
|
-
- copy the integration secret from the `Configuration` tab
|
|
20
|
-
- share any pages or data sources Junior should read via `•••` -> `Add connections`
|
|
21
|
-
|
|
22
|
-
Set that value in your host environment:
|
|
23
|
-
|
|
24
|
-
- `NOTION_TOKEN`
|
|
25
|
-
|
|
26
11
|
Then register the plugin package in `withJunior(...)`:
|
|
27
12
|
|
|
28
13
|
```js
|
|
@@ -33,24 +18,23 @@ export default withJunior({
|
|
|
33
18
|
});
|
|
34
19
|
```
|
|
35
20
|
|
|
36
|
-
|
|
21
|
+
This package does not use `NOTION_TOKEN` or a shared workspace integration. Each user connects their own Notion account the first time Junior calls a Notion MCP tool. Junior sends the OAuth link privately and resumes the thread automatically after the user authorizes.
|
|
37
22
|
|
|
38
|
-
|
|
23
|
+
Junior intentionally keeps this package read-only by exposing only Notion's `notion-search` and `notion-fetch` MCP tools. The plugin does not expose create, update, move, or other write-capable Notion tools.
|
|
39
24
|
|
|
40
|
-
|
|
25
|
+
## Search limitations
|
|
41
26
|
|
|
42
|
-
|
|
43
|
-
- Results can differ from the UI even when the user can see a page in the Notion app.
|
|
44
|
-
- The most common cause of missing results is that the target page or data source is not directly shared with the integration.
|
|
45
|
-
- Newly shared content can also lag behind search indexing.
|
|
27
|
+
This package uses Notion MCP search and fetch rather than the older REST helper flow.
|
|
46
28
|
|
|
47
|
-
|
|
29
|
+
- Search is still title-biased, so prompts work best when users search for the actual page or data source title.
|
|
30
|
+
- Results can differ from the Notion UI even when the user can see a page in the app.
|
|
31
|
+
- Search across connected sources like Slack, Google Drive, and Jira requires a Notion AI plan. Without Notion AI, search is limited to the user's Notion workspace.
|
|
32
|
+
- Missing results are usually a permissions problem on the user's Notion account or a weak query phrase.
|
|
48
33
|
|
|
49
|
-
|
|
34
|
+
## Auth model
|
|
50
35
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
```
|
|
36
|
+
- Notion MCP requires user-based OAuth and does not support bearer token authentication.
|
|
37
|
+
- This package is not suitable for fully headless or unattended automation.
|
|
38
|
+
- Users can disconnect from Junior App Home with `Unlink`, or by asking Junior to disconnect Notion.
|
|
55
39
|
|
|
56
40
|
Full setup guide: https://junior.sentry.dev/extend/notion-plugin/
|
package/package.json
CHANGED
package/plugin.yaml
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
name: notion
|
|
2
2
|
description: Notion page search and summarization
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
- api.notion.com
|
|
11
|
-
api-headers:
|
|
12
|
-
Notion-Version: "2025-09-03"
|
|
13
|
-
auth-token-env: NOTION_TOKEN
|
|
4
|
+
mcp:
|
|
5
|
+
transport: http
|
|
6
|
+
url: https://mcp.notion.com/mcp
|
|
7
|
+
allowed-tools:
|
|
8
|
+
- notion-search
|
|
9
|
+
- notion-fetch
|
package/skills/notion/SKILL.md
CHANGED
|
@@ -1,48 +1,51 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: notion
|
|
3
3
|
description: Search Notion pages and data sources and summarize the best match. Use when users ask to look up docs, specs, notes, meeting notes, project context, roadmaps, trackers, or internal references stored in Notion.
|
|
4
|
-
requires-capabilities: notion.api
|
|
5
|
-
allowed-tools: bash
|
|
6
4
|
---
|
|
7
5
|
|
|
8
6
|
# Notion Operations
|
|
9
7
|
|
|
10
|
-
Use this skill for
|
|
8
|
+
Use this skill for Notion search and summarization workflows in the harness.
|
|
11
9
|
|
|
12
10
|
## Workflow
|
|
13
11
|
|
|
14
|
-
1.
|
|
12
|
+
1. Treat the request as a read-only query.
|
|
15
13
|
|
|
16
|
-
|
|
17
|
-
- `disconnect`: explain that there is no per-user Notion connection to remove because the plugin uses a shared internal integration.
|
|
18
|
-
- otherwise treat the request as a read-only query.
|
|
14
|
+
2. Keep tool work mostly silent:
|
|
19
15
|
|
|
20
|
-
|
|
16
|
+
- Send at most one short acknowledgment before Notion tool work.
|
|
17
|
+
- Keep intermediate search/fetch reasoning internal.
|
|
18
|
+
- Do not narrate each step with "let me...", "I found...", or partial findings while tools are still running.
|
|
19
|
+
- Reply with the real answer once you have enough evidence, or explain the actual blocker if you cannot finish.
|
|
21
20
|
|
|
22
|
-
|
|
23
|
-
- If credential issuance fails, explain that Notion is not configured on the host and the admin must set `NOTION_TOKEN`.
|
|
21
|
+
3. Search with MCP:
|
|
24
22
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
-
|
|
23
|
+
- `loadSkill` returns `available_tools` for this skill, including the exact `tool_name` values and argument schemas for the Notion tools exposed in this turn.
|
|
24
|
+
- Use `useTool` with those exact `tool_name` values.
|
|
25
|
+
- Use `searchTools` only if you need to rediscover or filter the active Notion tools later in the turn.
|
|
28
26
|
- Decide the actual search phrases first. Notion search is title-biased, so search for the likely page or data source title, not the user's full sentence.
|
|
29
27
|
- Use 1-3 short explicit search phrases.
|
|
30
28
|
- Good: `deployment pipeline`, `launch tracker`, `incident review`
|
|
31
29
|
- Bad: `how do we handle deployment pipelines for mobile releases`
|
|
32
|
-
-
|
|
33
|
-
|
|
34
|
-
-
|
|
35
|
-
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
-
|
|
30
|
+
- For list/report/calendar requests, search for the canonical container first:
|
|
31
|
+
- page title: `holidays`, `company holidays`
|
|
32
|
+
- data source title: `people calendar`
|
|
33
|
+
- Prefer one refinement round at most. If the first search already found a plausible canonical page or data source, fetch it before searching again.
|
|
34
|
+
|
|
35
|
+
4. Fetch efficiently:
|
|
36
|
+
|
|
37
|
+
- Search returns ranked page and data-source candidates only. Pick the best candidate, then fetch content with the disclosed Notion fetch tool via `useTool` using the returned URL or ID.
|
|
38
|
+
- If a fetched page clearly points at an inline data source or database, fetch that data source next and work from it.
|
|
39
|
+
- If the fetched data source already contains the rows and fields needed to answer, stop there and answer from that result.
|
|
40
|
+
- Do not serially fetch many individual row pages when the container page or data source already exposes the needed fields.
|
|
41
|
+
- Fetch individual rows only when a small number of important fields are still missing or ambiguous after fetching the canonical page or data source.
|
|
42
|
+
- Once you have enough evidence to answer, stop fetching and respond.
|
|
40
43
|
|
|
41
44
|
## Guardrails
|
|
42
45
|
|
|
43
46
|
- Read-only only.
|
|
44
|
-
- Do not
|
|
45
|
-
- The runtime injects `Authorization` and `Notion-Version`; the helper handles request-specific headers.
|
|
47
|
+
- Junior intentionally exposes only Notion search and fetch tools for this skill. Do not ask for writes, comments, or page moves.
|
|
46
48
|
- Search results may be pages or data sources. Do not treat data sources as unsupported.
|
|
47
|
-
-
|
|
49
|
+
- For scoped requests like "US holidays" or "2026 holidays", apply the user's scope when reading the fetched content and state any assumption you made if the source mixes multiple geos or years.
|
|
50
|
+
- If search returns no accessible matches, say that no accessible pages or data sources matched and note that the content may be outside the user's Notion permissions or poorly matched by title.
|
|
48
51
|
- If content retrieval fails for the top result, return the best matching Notion URL and explain that the result could not be fetched for summarization.
|
|
@@ -1,557 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import { pathToFileURL } from "node:url";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Unified Notion helper for LLM-facing search and fetch operations.
|
|
7
|
-
*
|
|
8
|
-
* `search` preserves the public v1 Search API's native ordering as closely as possible and
|
|
9
|
-
* returns broad candidate results for the raw query without forcing a winner.
|
|
10
|
-
*
|
|
11
|
-
* `fetch` loads normalized content for a specific page or data source chosen from search
|
|
12
|
-
* results so the model can summarize a stable payload.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
const DEFAULT_API_BASE_URL = "https://api.notion.com/v1";
|
|
16
|
-
// Keep this pinned in sync with packages/junior-notion/plugin.yaml.
|
|
17
|
-
const DEFAULT_NOTION_VERSION = "2025-09-03";
|
|
18
|
-
const DEFAULT_PAGE_SIZE = 100;
|
|
19
|
-
const DEFAULT_ROW_LIMIT = 10;
|
|
20
|
-
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
21
|
-
const DEFAULT_RETRY_LIMIT = 2;
|
|
22
|
-
|
|
23
|
-
function parseArgs(argv) {
|
|
24
|
-
const parsed = {};
|
|
25
|
-
for (let index = 0; index < argv.length; index += 1) {
|
|
26
|
-
const token = argv[index];
|
|
27
|
-
if (!token.startsWith("--")) {
|
|
28
|
-
continue;
|
|
29
|
-
}
|
|
30
|
-
const [rawKey, inlineValue] = token.slice(2).split("=", 2);
|
|
31
|
-
if (inlineValue !== undefined) {
|
|
32
|
-
if (parsed[rawKey] === undefined) {
|
|
33
|
-
parsed[rawKey] = inlineValue;
|
|
34
|
-
} else if (Array.isArray(parsed[rawKey])) {
|
|
35
|
-
parsed[rawKey].push(inlineValue);
|
|
36
|
-
} else {
|
|
37
|
-
parsed[rawKey] = [parsed[rawKey], inlineValue];
|
|
38
|
-
}
|
|
39
|
-
continue;
|
|
40
|
-
}
|
|
41
|
-
const next = argv[index + 1];
|
|
42
|
-
if (!next || next.startsWith("--")) {
|
|
43
|
-
parsed[rawKey] = "true";
|
|
44
|
-
continue;
|
|
45
|
-
}
|
|
46
|
-
if (parsed[rawKey] === undefined) {
|
|
47
|
-
parsed[rawKey] = next;
|
|
48
|
-
} else if (Array.isArray(parsed[rawKey])) {
|
|
49
|
-
parsed[rawKey].push(next);
|
|
50
|
-
} else {
|
|
51
|
-
parsed[rawKey] = [parsed[rawKey], next];
|
|
52
|
-
}
|
|
53
|
-
index += 1;
|
|
54
|
-
}
|
|
55
|
-
return parsed;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function normalizeWhitespace(value) {
|
|
59
|
-
return String(value ?? "")
|
|
60
|
-
.replace(/\s+/g, " ")
|
|
61
|
-
.trim();
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function toStringArray(value) {
|
|
65
|
-
if (Array.isArray(value)) {
|
|
66
|
-
return value.map((item) => normalizeWhitespace(item)).filter(Boolean);
|
|
67
|
-
}
|
|
68
|
-
const normalized = normalizeWhitespace(value);
|
|
69
|
-
return normalized ? [normalized] : [];
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function buildSearchQueries(queries) {
|
|
73
|
-
const normalizedQueries = [];
|
|
74
|
-
const seenQueries = new Set();
|
|
75
|
-
for (const value of toStringArray(queries)) {
|
|
76
|
-
if (seenQueries.has(value)) {
|
|
77
|
-
continue;
|
|
78
|
-
}
|
|
79
|
-
seenQueries.add(value);
|
|
80
|
-
normalizedQueries.push(value);
|
|
81
|
-
}
|
|
82
|
-
return normalizedQueries;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function extractPlainText(value) {
|
|
86
|
-
if (typeof value === "string") {
|
|
87
|
-
return value;
|
|
88
|
-
}
|
|
89
|
-
if (!Array.isArray(value)) {
|
|
90
|
-
return "";
|
|
91
|
-
}
|
|
92
|
-
return value
|
|
93
|
-
.map((item) => {
|
|
94
|
-
if (typeof item?.plain_text === "string") {
|
|
95
|
-
return item.plain_text;
|
|
96
|
-
}
|
|
97
|
-
if (typeof item?.text?.content === "string") {
|
|
98
|
-
return item.text.content;
|
|
99
|
-
}
|
|
100
|
-
return "";
|
|
101
|
-
})
|
|
102
|
-
.join("")
|
|
103
|
-
.trim();
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function extractTitleFromProperties(properties) {
|
|
107
|
-
if (!properties || typeof properties !== "object") {
|
|
108
|
-
return "";
|
|
109
|
-
}
|
|
110
|
-
for (const property of Object.values(properties)) {
|
|
111
|
-
if (property?.type === "title") {
|
|
112
|
-
return extractPlainText(property.title);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
return "";
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function extractResultTitle(result) {
|
|
119
|
-
if (!result || typeof result !== "object") {
|
|
120
|
-
return "";
|
|
121
|
-
}
|
|
122
|
-
if (Array.isArray(result.title)) {
|
|
123
|
-
return extractPlainText(result.title);
|
|
124
|
-
}
|
|
125
|
-
if (typeof result.title === "string") {
|
|
126
|
-
return result.title.trim();
|
|
127
|
-
}
|
|
128
|
-
if (result.properties) {
|
|
129
|
-
return extractTitleFromProperties(result.properties);
|
|
130
|
-
}
|
|
131
|
-
return "";
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function simplifySearchResult(result) {
|
|
135
|
-
return {
|
|
136
|
-
id: String(result?.id ?? ""),
|
|
137
|
-
object: String(result?.object ?? ""),
|
|
138
|
-
title: extractResultTitle(result),
|
|
139
|
-
url: String(result?.url ?? ""),
|
|
140
|
-
last_edited_time: result?.last_edited_time ?? null,
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function buildHeaders(extraHeaders) {
|
|
145
|
-
const headers = {
|
|
146
|
-
Accept: "application/json",
|
|
147
|
-
"Notion-Version": process.env.NOTION_VERSION || DEFAULT_NOTION_VERSION,
|
|
148
|
-
...extraHeaders,
|
|
149
|
-
};
|
|
150
|
-
const token = normalizeWhitespace(process.env.NOTION_TOKEN);
|
|
151
|
-
// In sandboxed runs the broker can set a placeholder value here and inject the
|
|
152
|
-
// real Authorization header later, so skip the placeholder rather than sending it.
|
|
153
|
-
if (token && token !== "host_managed_credential") {
|
|
154
|
-
headers.Authorization = `Bearer ${token}`;
|
|
155
|
-
}
|
|
156
|
-
return headers;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function getApiBaseUrl() {
|
|
160
|
-
return normalizeWhitespace(process.env.NOTION_API_BASE_URL) || DEFAULT_API_BASE_URL;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function isRetryableStatus(status) {
|
|
164
|
-
return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function parseRetryAfterMs(value) {
|
|
168
|
-
const seconds = Number.parseFloat(String(value ?? ""));
|
|
169
|
-
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
170
|
-
return 0;
|
|
171
|
-
}
|
|
172
|
-
return Math.ceil(seconds * 1000);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function sleep(ms) {
|
|
176
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
async function notionRequest(pathname, init = {}, options = {}) {
|
|
180
|
-
const retryLimit = options.retryLimit ?? DEFAULT_RETRY_LIMIT;
|
|
181
|
-
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
182
|
-
const method = init.method ?? "GET";
|
|
183
|
-
const headers = buildHeaders(init.headers ?? {});
|
|
184
|
-
const url = `${getApiBaseUrl()}${pathname}`;
|
|
185
|
-
|
|
186
|
-
for (let attempt = 0; attempt <= retryLimit; attempt += 1) {
|
|
187
|
-
const controller = new AbortController();
|
|
188
|
-
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
189
|
-
try {
|
|
190
|
-
const response = await fetch(url, {
|
|
191
|
-
...init,
|
|
192
|
-
method,
|
|
193
|
-
headers,
|
|
194
|
-
signal: controller.signal,
|
|
195
|
-
});
|
|
196
|
-
if (!response.ok) {
|
|
197
|
-
const bodyText = await response.text();
|
|
198
|
-
if (attempt < retryLimit && isRetryableStatus(response.status)) {
|
|
199
|
-
const retryAfterMs = parseRetryAfterMs(response.headers.get("retry-after"));
|
|
200
|
-
await sleep(retryAfterMs || 250 * (attempt + 1));
|
|
201
|
-
continue;
|
|
202
|
-
}
|
|
203
|
-
throw new Error(
|
|
204
|
-
`Notion API ${method} ${pathname} failed with ${response.status}: ${bodyText || response.statusText}`,
|
|
205
|
-
);
|
|
206
|
-
}
|
|
207
|
-
const contentType = response.headers.get("content-type") || "";
|
|
208
|
-
if (contentType.includes("application/json")) {
|
|
209
|
-
return await response.json();
|
|
210
|
-
}
|
|
211
|
-
return await response.text();
|
|
212
|
-
} finally {
|
|
213
|
-
clearTimeout(timeout);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
throw new Error(`Notion API ${method} ${pathname} failed after retries`);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
async function searchOnce(query, pageSize, object) {
|
|
221
|
-
const body = {
|
|
222
|
-
query,
|
|
223
|
-
page_size: pageSize,
|
|
224
|
-
};
|
|
225
|
-
if (object) {
|
|
226
|
-
body.filter = {
|
|
227
|
-
property: "object",
|
|
228
|
-
value: object,
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
return await notionRequest("/search", {
|
|
232
|
-
method: "POST",
|
|
233
|
-
headers: {
|
|
234
|
-
"Content-Type": "application/json",
|
|
235
|
-
},
|
|
236
|
-
body: JSON.stringify(body),
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
async function searchNotion({ queries = [], pageSize = DEFAULT_PAGE_SIZE, object = "" } = {}) {
|
|
241
|
-
const searchQueries = buildSearchQueries(queries);
|
|
242
|
-
const attempts = [];
|
|
243
|
-
const candidateMap = new Map();
|
|
244
|
-
|
|
245
|
-
for (const variant of searchQueries) {
|
|
246
|
-
const response = await searchOnce(variant, pageSize, object || undefined);
|
|
247
|
-
const results = Array.isArray(response?.results) ? response.results : [];
|
|
248
|
-
attempts.push({
|
|
249
|
-
query: variant,
|
|
250
|
-
object: object || "page_or_data_source",
|
|
251
|
-
result_count: results.length,
|
|
252
|
-
has_more: Boolean(response?.has_more),
|
|
253
|
-
next_cursor: response?.next_cursor ?? null,
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
for (const result of results) {
|
|
257
|
-
if (!result?.id || candidateMap.has(result.id)) {
|
|
258
|
-
continue;
|
|
259
|
-
}
|
|
260
|
-
candidateMap.set(result.id, {
|
|
261
|
-
...simplifySearchResult(result),
|
|
262
|
-
query: variant,
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
return {
|
|
268
|
-
ok: true,
|
|
269
|
-
query_variants: searchQueries,
|
|
270
|
-
attempts,
|
|
271
|
-
result_count: candidateMap.size,
|
|
272
|
-
results: [...candidateMap.values()].map((candidate) => ({
|
|
273
|
-
id: candidate.id,
|
|
274
|
-
object: candidate.object,
|
|
275
|
-
title: candidate.title,
|
|
276
|
-
url: candidate.url,
|
|
277
|
-
last_edited_time: candidate.last_edited_time,
|
|
278
|
-
query: candidate.query,
|
|
279
|
-
})),
|
|
280
|
-
};
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
function simplifyFormulaValue(formula) {
|
|
284
|
-
if (!formula || typeof formula !== "object") {
|
|
285
|
-
return null;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
switch (formula.type) {
|
|
289
|
-
case "string":
|
|
290
|
-
return formula.string ?? null;
|
|
291
|
-
case "number":
|
|
292
|
-
return formula.number ?? null;
|
|
293
|
-
case "boolean":
|
|
294
|
-
return formula.boolean ?? null;
|
|
295
|
-
case "date":
|
|
296
|
-
return formula.date
|
|
297
|
-
? {
|
|
298
|
-
start: formula.date.start ?? null,
|
|
299
|
-
end: formula.date.end ?? null,
|
|
300
|
-
}
|
|
301
|
-
: null;
|
|
302
|
-
case "page":
|
|
303
|
-
return formula.page?.id ?? null;
|
|
304
|
-
case "person":
|
|
305
|
-
return formula.person?.name || formula.person?.id || null;
|
|
306
|
-
case "list":
|
|
307
|
-
return Array.isArray(formula.list)
|
|
308
|
-
? formula.list.map((item) => simplifyFormulaValue(item)).filter((item) => item !== null)
|
|
309
|
-
: [];
|
|
310
|
-
default:
|
|
311
|
-
return null;
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
function simplifyPropertyValue(property) {
|
|
316
|
-
if (!property || typeof property !== "object") {
|
|
317
|
-
return null;
|
|
318
|
-
}
|
|
319
|
-
switch (property.type) {
|
|
320
|
-
case "title":
|
|
321
|
-
return extractPlainText(property.title);
|
|
322
|
-
case "rich_text":
|
|
323
|
-
return extractPlainText(property.rich_text);
|
|
324
|
-
case "status":
|
|
325
|
-
return property.status?.name ?? null;
|
|
326
|
-
case "select":
|
|
327
|
-
return property.select?.name ?? null;
|
|
328
|
-
case "multi_select":
|
|
329
|
-
return Array.isArray(property.multi_select)
|
|
330
|
-
? property.multi_select.map((item) => item?.name).filter(Boolean)
|
|
331
|
-
: [];
|
|
332
|
-
case "number":
|
|
333
|
-
return property.number ?? null;
|
|
334
|
-
case "checkbox":
|
|
335
|
-
return property.checkbox ?? null;
|
|
336
|
-
case "url":
|
|
337
|
-
return property.url ?? null;
|
|
338
|
-
case "email":
|
|
339
|
-
return property.email ?? null;
|
|
340
|
-
case "phone_number":
|
|
341
|
-
return property.phone_number ?? null;
|
|
342
|
-
case "date":
|
|
343
|
-
return property.date
|
|
344
|
-
? {
|
|
345
|
-
start: property.date.start ?? null,
|
|
346
|
-
end: property.date.end ?? null,
|
|
347
|
-
}
|
|
348
|
-
: null;
|
|
349
|
-
case "people":
|
|
350
|
-
return Array.isArray(property.people)
|
|
351
|
-
? property.people.map((item) => item?.name || item?.id).filter(Boolean)
|
|
352
|
-
: [];
|
|
353
|
-
case "relation":
|
|
354
|
-
return Array.isArray(property.relation)
|
|
355
|
-
? property.relation.map((item) => item?.id).filter(Boolean)
|
|
356
|
-
: [];
|
|
357
|
-
case "formula":
|
|
358
|
-
return simplifyFormulaValue(property.formula);
|
|
359
|
-
case "created_time":
|
|
360
|
-
return property.created_time ?? null;
|
|
361
|
-
case "last_edited_time":
|
|
362
|
-
return property.last_edited_time ?? null;
|
|
363
|
-
case "unique_id":
|
|
364
|
-
if (!property.unique_id) {
|
|
365
|
-
return null;
|
|
366
|
-
}
|
|
367
|
-
if (property.unique_id.prefix) {
|
|
368
|
-
return `${property.unique_id.prefix}-${property.unique_id.number ?? ""}`;
|
|
369
|
-
}
|
|
370
|
-
return property.unique_id.number ?? null;
|
|
371
|
-
default:
|
|
372
|
-
return null;
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
function simplifyPageRecord(page) {
|
|
377
|
-
const properties = {};
|
|
378
|
-
if (page?.properties && typeof page.properties === "object") {
|
|
379
|
-
for (const [key, value] of Object.entries(page.properties)) {
|
|
380
|
-
properties[key] = simplifyPropertyValue(value);
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
return {
|
|
385
|
-
id: String(page?.id ?? ""),
|
|
386
|
-
object: String(page?.object ?? "page"),
|
|
387
|
-
title: extractResultTitle(page),
|
|
388
|
-
url: String(page?.url ?? ""),
|
|
389
|
-
last_edited_time: page?.last_edited_time ?? null,
|
|
390
|
-
properties,
|
|
391
|
-
};
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
function simplifyDataSourceSchema(dataSource) {
|
|
395
|
-
const properties = dataSource?.properties;
|
|
396
|
-
if (!properties || typeof properties !== "object") {
|
|
397
|
-
return [];
|
|
398
|
-
}
|
|
399
|
-
return Object.entries(properties).map(([name, value]) => ({
|
|
400
|
-
name,
|
|
401
|
-
type: String(value?.type ?? "unknown"),
|
|
402
|
-
}));
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
async function fetchPageMetadata(pageId) {
|
|
406
|
-
const page = await notionRequest(`/pages/${pageId}`);
|
|
407
|
-
return simplifySearchResult(page);
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
async function fetchPageMarkdown(pageId) {
|
|
411
|
-
const response = await notionRequest(`/pages/${pageId}/markdown`);
|
|
412
|
-
if (typeof response === "string") {
|
|
413
|
-
return response;
|
|
414
|
-
}
|
|
415
|
-
if (typeof response?.markdown === "string") {
|
|
416
|
-
return response.markdown;
|
|
417
|
-
}
|
|
418
|
-
if (Array.isArray(response?.results)) {
|
|
419
|
-
return response.results
|
|
420
|
-
.map((item) => (typeof item === "string" ? item : item?.markdown ?? ""))
|
|
421
|
-
.filter(Boolean)
|
|
422
|
-
.join("\n");
|
|
423
|
-
}
|
|
424
|
-
return JSON.stringify(response, null, 2);
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
async function fetchDataSourceContent(dataSourceId, rowLimit) {
|
|
428
|
-
const dataSource = await notionRequest(`/data_sources/${dataSourceId}`);
|
|
429
|
-
const target = {
|
|
430
|
-
id: String(dataSource?.id ?? dataSourceId),
|
|
431
|
-
object: "data_source",
|
|
432
|
-
title: extractResultTitle(dataSource),
|
|
433
|
-
url: String(dataSource?.url ?? ""),
|
|
434
|
-
last_edited_time: dataSource?.last_edited_time ?? null,
|
|
435
|
-
};
|
|
436
|
-
|
|
437
|
-
try {
|
|
438
|
-
const rowsResponse = await notionRequest(`/data_sources/${dataSourceId}/query`, {
|
|
439
|
-
method: "POST",
|
|
440
|
-
headers: {
|
|
441
|
-
"Content-Type": "application/json",
|
|
442
|
-
},
|
|
443
|
-
body: JSON.stringify({ page_size: rowLimit }),
|
|
444
|
-
});
|
|
445
|
-
|
|
446
|
-
return {
|
|
447
|
-
target,
|
|
448
|
-
content: {
|
|
449
|
-
type: "data_source",
|
|
450
|
-
schema: simplifyDataSourceSchema(dataSource),
|
|
451
|
-
rows: Array.isArray(rowsResponse?.results)
|
|
452
|
-
? rowsResponse.results.map((row) => simplifyPageRecord(row))
|
|
453
|
-
: [],
|
|
454
|
-
},
|
|
455
|
-
};
|
|
456
|
-
} catch (error) {
|
|
457
|
-
return {
|
|
458
|
-
target,
|
|
459
|
-
content: null,
|
|
460
|
-
content_error: error instanceof Error ? error.message : String(error),
|
|
461
|
-
};
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
export async function fetchContent({ id, object, rowLimit = DEFAULT_ROW_LIMIT } = {}) {
|
|
466
|
-
if (!id) {
|
|
467
|
-
throw new Error("notion fetch requires --id");
|
|
468
|
-
}
|
|
469
|
-
if (object !== "page" && object !== "data_source") {
|
|
470
|
-
throw new Error("notion fetch requires --object page|data_source");
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
if (object === "page") {
|
|
474
|
-
let target = {
|
|
475
|
-
id,
|
|
476
|
-
object: "page",
|
|
477
|
-
title: "",
|
|
478
|
-
url: "",
|
|
479
|
-
last_edited_time: null,
|
|
480
|
-
};
|
|
481
|
-
try {
|
|
482
|
-
target = await fetchPageMetadata(id);
|
|
483
|
-
const markdown = await fetchPageMarkdown(id);
|
|
484
|
-
return {
|
|
485
|
-
ok: true,
|
|
486
|
-
target,
|
|
487
|
-
content: {
|
|
488
|
-
type: "page",
|
|
489
|
-
markdown,
|
|
490
|
-
},
|
|
491
|
-
};
|
|
492
|
-
} catch (error) {
|
|
493
|
-
return {
|
|
494
|
-
ok: true,
|
|
495
|
-
target,
|
|
496
|
-
content: null,
|
|
497
|
-
content_error: error instanceof Error ? error.message : String(error),
|
|
498
|
-
};
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
try {
|
|
503
|
-
return {
|
|
504
|
-
ok: true,
|
|
505
|
-
...(await fetchDataSourceContent(id, rowLimit)),
|
|
506
|
-
};
|
|
507
|
-
} catch (error) {
|
|
508
|
-
return {
|
|
509
|
-
ok: true,
|
|
510
|
-
target: {
|
|
511
|
-
id,
|
|
512
|
-
object: "data_source",
|
|
513
|
-
title: "",
|
|
514
|
-
url: "",
|
|
515
|
-
last_edited_time: null,
|
|
516
|
-
},
|
|
517
|
-
content: null,
|
|
518
|
-
content_error: error instanceof Error ? error.message : String(error),
|
|
519
|
-
};
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
async function main() {
|
|
524
|
-
const [command, ...rest] = process.argv.slice(2);
|
|
525
|
-
if (command !== "search" && command !== "fetch") {
|
|
526
|
-
throw new Error("notion-cli requires a subcommand: search | fetch");
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
const args = parseArgs(rest[0] === "--" ? rest.slice(1) : rest);
|
|
530
|
-
let result;
|
|
531
|
-
if (command === "search") {
|
|
532
|
-
const queries = toStringArray(args.query);
|
|
533
|
-
if (queries.length === 0) {
|
|
534
|
-
throw new Error("notion search requires at least one --query");
|
|
535
|
-
}
|
|
536
|
-
result = await searchNotion({
|
|
537
|
-
queries,
|
|
538
|
-
pageSize: args["page-size"] ? Number.parseInt(args["page-size"], 10) : DEFAULT_PAGE_SIZE,
|
|
539
|
-
object: normalizeWhitespace(args.object),
|
|
540
|
-
});
|
|
541
|
-
} else {
|
|
542
|
-
result = await fetchContent({
|
|
543
|
-
id: normalizeWhitespace(args.id),
|
|
544
|
-
object: normalizeWhitespace(args.object),
|
|
545
|
-
rowLimit: args["row-limit"] ? Number.parseInt(args["row-limit"], 10) : DEFAULT_ROW_LIMIT,
|
|
546
|
-
});
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
553
|
-
main().catch((error) => {
|
|
554
|
-
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
555
|
-
process.exitCode = 1;
|
|
556
|
-
});
|
|
557
|
-
}
|