@m2ai-mcp/notion-mcp 0.1.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/.env.example +7 -0
- package/LICENSE +21 -0
- package/README.md +228 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +374 -0
- package/dist/index.js.map +1 -0
- package/dist/tools/blocks.d.ts +43 -0
- package/dist/tools/blocks.d.ts.map +1 -0
- package/dist/tools/blocks.js +124 -0
- package/dist/tools/blocks.js.map +1 -0
- package/dist/tools/databases.d.ts +71 -0
- package/dist/tools/databases.d.ts.map +1 -0
- package/dist/tools/databases.js +121 -0
- package/dist/tools/databases.js.map +1 -0
- package/dist/tools/index.d.ts +9 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +9 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/pages.d.ts +72 -0
- package/dist/tools/pages.d.ts.map +1 -0
- package/dist/tools/pages.js +153 -0
- package/dist/tools/pages.js.map +1 -0
- package/dist/tools/search.d.ts +28 -0
- package/dist/tools/search.d.ts.map +1 -0
- package/dist/tools/search.js +62 -0
- package/dist/tools/search.js.map +1 -0
- package/dist/tools/users.d.ts +33 -0
- package/dist/tools/users.d.ts.map +1 -0
- package/dist/tools/users.js +51 -0
- package/dist/tools/users.js.map +1 -0
- package/dist/utils/markdown-converter.d.ts +31 -0
- package/dist/utils/markdown-converter.d.ts.map +1 -0
- package/dist/utils/markdown-converter.js +355 -0
- package/dist/utils/markdown-converter.js.map +1 -0
- package/dist/utils/notion-client.d.ts +32 -0
- package/dist/utils/notion-client.d.ts.map +1 -0
- package/dist/utils/notion-client.js +111 -0
- package/dist/utils/notion-client.js.map +1 -0
- package/dist/utils/types.d.ts +212 -0
- package/dist/utils/types.d.ts.map +1 -0
- package/dist/utils/types.js +18 -0
- package/dist/utils/types.js.map +1 -0
- package/jest.config.cjs +33 -0
- package/package.json +53 -0
- package/server.json +92 -0
- package/src/index.ts +435 -0
- package/src/tools/blocks.ts +184 -0
- package/src/tools/databases.ts +216 -0
- package/src/tools/index.ts +9 -0
- package/src/tools/pages.ts +253 -0
- package/src/tools/search.ts +96 -0
- package/src/tools/users.ts +93 -0
- package/src/utils/markdown-converter.ts +408 -0
- package/src/utils/notion-client.ts +159 -0
- package/src/utils/types.ts +237 -0
- package/tests/markdown-converter.test.ts +252 -0
- package/tests/notion-client.test.ts +67 -0
- package/tests/tools.test.ts +448 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown to Notion Block Converter
|
|
3
|
+
* Converts markdown text to Notion block format
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface NotionRichText {
|
|
7
|
+
type: 'text';
|
|
8
|
+
text: {
|
|
9
|
+
content: string;
|
|
10
|
+
link?: { url: string } | null;
|
|
11
|
+
};
|
|
12
|
+
annotations?: {
|
|
13
|
+
bold?: boolean;
|
|
14
|
+
italic?: boolean;
|
|
15
|
+
strikethrough?: boolean;
|
|
16
|
+
underline?: boolean;
|
|
17
|
+
code?: boolean;
|
|
18
|
+
color?: string;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface NotionBlock {
|
|
23
|
+
object: 'block';
|
|
24
|
+
type: string;
|
|
25
|
+
[key: string]: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Parse inline formatting (bold, italic, code, links, strikethrough)
|
|
29
|
+
export function parseInlineFormatting(text: string): NotionRichText[] {
|
|
30
|
+
const result: NotionRichText[] = [];
|
|
31
|
+
let remaining = text;
|
|
32
|
+
|
|
33
|
+
// Regex patterns for inline formatting
|
|
34
|
+
const patterns = [
|
|
35
|
+
{ regex: /\*\*(.+?)\*\*/g, annotation: 'bold' },
|
|
36
|
+
{ regex: /\*(.+?)\*/g, annotation: 'italic' },
|
|
37
|
+
{ regex: /~~(.+?)~~/g, annotation: 'strikethrough' },
|
|
38
|
+
{ regex: /`(.+?)`/g, annotation: 'code' },
|
|
39
|
+
{ regex: /\[([^\]]+)\]\(([^)]+)\)/g, annotation: 'link' }
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
// Simple approach: find the first match of any pattern
|
|
43
|
+
while (remaining.length > 0) {
|
|
44
|
+
let earliestMatch: { index: number; length: number; content: string; annotation: string; url?: string } | null = null;
|
|
45
|
+
|
|
46
|
+
for (const { regex, annotation } of patterns) {
|
|
47
|
+
regex.lastIndex = 0;
|
|
48
|
+
const match = regex.exec(remaining);
|
|
49
|
+
if (match && (!earliestMatch || match.index < earliestMatch.index)) {
|
|
50
|
+
if (annotation === 'link') {
|
|
51
|
+
earliestMatch = {
|
|
52
|
+
index: match.index,
|
|
53
|
+
length: match[0].length,
|
|
54
|
+
content: match[1],
|
|
55
|
+
annotation,
|
|
56
|
+
url: match[2]
|
|
57
|
+
};
|
|
58
|
+
} else {
|
|
59
|
+
earliestMatch = {
|
|
60
|
+
index: match.index,
|
|
61
|
+
length: match[0].length,
|
|
62
|
+
content: match[1],
|
|
63
|
+
annotation
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (earliestMatch) {
|
|
70
|
+
// Add plain text before the match
|
|
71
|
+
if (earliestMatch.index > 0) {
|
|
72
|
+
result.push({
|
|
73
|
+
type: 'text',
|
|
74
|
+
text: { content: remaining.substring(0, earliestMatch.index) }
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Add formatted text
|
|
79
|
+
const richText: NotionRichText = {
|
|
80
|
+
type: 'text',
|
|
81
|
+
text: {
|
|
82
|
+
content: earliestMatch.content,
|
|
83
|
+
link: earliestMatch.url ? { url: earliestMatch.url } : null
|
|
84
|
+
},
|
|
85
|
+
annotations: {}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
if (earliestMatch.annotation !== 'link') {
|
|
89
|
+
richText.annotations = { [earliestMatch.annotation]: true };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
result.push(richText);
|
|
93
|
+
|
|
94
|
+
remaining = remaining.substring(earliestMatch.index + earliestMatch.length);
|
|
95
|
+
} else {
|
|
96
|
+
// No more matches, add remaining text
|
|
97
|
+
if (remaining.length > 0) {
|
|
98
|
+
result.push({
|
|
99
|
+
type: 'text',
|
|
100
|
+
text: { content: remaining }
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return result.length > 0 ? result : [{ type: 'text', text: { content: text } }];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Convert a single line to a Notion block
|
|
111
|
+
function lineToBlock(line: string): NotionBlock | null {
|
|
112
|
+
const trimmed = line.trim();
|
|
113
|
+
|
|
114
|
+
if (trimmed === '') {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Heading 1
|
|
119
|
+
if (trimmed.startsWith('# ')) {
|
|
120
|
+
return {
|
|
121
|
+
object: 'block',
|
|
122
|
+
type: 'heading_1',
|
|
123
|
+
heading_1: {
|
|
124
|
+
rich_text: parseInlineFormatting(trimmed.substring(2))
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Heading 2
|
|
130
|
+
if (trimmed.startsWith('## ')) {
|
|
131
|
+
return {
|
|
132
|
+
object: 'block',
|
|
133
|
+
type: 'heading_2',
|
|
134
|
+
heading_2: {
|
|
135
|
+
rich_text: parseInlineFormatting(trimmed.substring(3))
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Heading 3
|
|
141
|
+
if (trimmed.startsWith('### ')) {
|
|
142
|
+
return {
|
|
143
|
+
object: 'block',
|
|
144
|
+
type: 'heading_3',
|
|
145
|
+
heading_3: {
|
|
146
|
+
rich_text: parseInlineFormatting(trimmed.substring(4))
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Checkbox / To-do
|
|
152
|
+
if (trimmed.startsWith('- [ ] ') || trimmed.startsWith('- [x] ')) {
|
|
153
|
+
const checked = trimmed.startsWith('- [x] ');
|
|
154
|
+
const content = trimmed.substring(6);
|
|
155
|
+
return {
|
|
156
|
+
object: 'block',
|
|
157
|
+
type: 'to_do',
|
|
158
|
+
to_do: {
|
|
159
|
+
rich_text: parseInlineFormatting(content),
|
|
160
|
+
checked
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Bullet list
|
|
166
|
+
if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
|
|
167
|
+
return {
|
|
168
|
+
object: 'block',
|
|
169
|
+
type: 'bulleted_list_item',
|
|
170
|
+
bulleted_list_item: {
|
|
171
|
+
rich_text: parseInlineFormatting(trimmed.substring(2))
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Numbered list
|
|
177
|
+
const numberedMatch = trimmed.match(/^(\d+)\.\s+(.+)$/);
|
|
178
|
+
if (numberedMatch) {
|
|
179
|
+
return {
|
|
180
|
+
object: 'block',
|
|
181
|
+
type: 'numbered_list_item',
|
|
182
|
+
numbered_list_item: {
|
|
183
|
+
rich_text: parseInlineFormatting(numberedMatch[2])
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Blockquote
|
|
189
|
+
if (trimmed.startsWith('> ')) {
|
|
190
|
+
return {
|
|
191
|
+
object: 'block',
|
|
192
|
+
type: 'quote',
|
|
193
|
+
quote: {
|
|
194
|
+
rich_text: parseInlineFormatting(trimmed.substring(2))
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Horizontal rule
|
|
200
|
+
if (trimmed === '---' || trimmed === '***' || trimmed === '___') {
|
|
201
|
+
return {
|
|
202
|
+
object: 'block',
|
|
203
|
+
type: 'divider',
|
|
204
|
+
divider: {}
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Default: paragraph
|
|
209
|
+
return {
|
|
210
|
+
object: 'block',
|
|
211
|
+
type: 'paragraph',
|
|
212
|
+
paragraph: {
|
|
213
|
+
rich_text: parseInlineFormatting(trimmed)
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Handle code blocks
|
|
219
|
+
function parseCodeBlocks(markdown: string): { content: string; isCode: boolean; language?: string }[] {
|
|
220
|
+
const parts: { content: string; isCode: boolean; language?: string }[] = [];
|
|
221
|
+
const lines = markdown.split('\n');
|
|
222
|
+
let inCodeBlock = false;
|
|
223
|
+
let codeContent: string[] = [];
|
|
224
|
+
let codeLanguage = '';
|
|
225
|
+
let textContent: string[] = [];
|
|
226
|
+
|
|
227
|
+
for (const line of lines) {
|
|
228
|
+
if (line.startsWith('```')) {
|
|
229
|
+
if (inCodeBlock) {
|
|
230
|
+
// End of code block
|
|
231
|
+
parts.push({
|
|
232
|
+
content: codeContent.join('\n'),
|
|
233
|
+
isCode: true,
|
|
234
|
+
language: codeLanguage || 'plain text'
|
|
235
|
+
});
|
|
236
|
+
codeContent = [];
|
|
237
|
+
codeLanguage = '';
|
|
238
|
+
inCodeBlock = false;
|
|
239
|
+
} else {
|
|
240
|
+
// Start of code block
|
|
241
|
+
if (textContent.length > 0) {
|
|
242
|
+
parts.push({
|
|
243
|
+
content: textContent.join('\n'),
|
|
244
|
+
isCode: false
|
|
245
|
+
});
|
|
246
|
+
textContent = [];
|
|
247
|
+
}
|
|
248
|
+
codeLanguage = line.substring(3).trim();
|
|
249
|
+
inCodeBlock = true;
|
|
250
|
+
}
|
|
251
|
+
} else if (inCodeBlock) {
|
|
252
|
+
codeContent.push(line);
|
|
253
|
+
} else {
|
|
254
|
+
textContent.push(line);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Handle remaining content
|
|
259
|
+
if (textContent.length > 0) {
|
|
260
|
+
parts.push({
|
|
261
|
+
content: textContent.join('\n'),
|
|
262
|
+
isCode: false
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
if (codeContent.length > 0) {
|
|
266
|
+
parts.push({
|
|
267
|
+
content: codeContent.join('\n'),
|
|
268
|
+
isCode: true,
|
|
269
|
+
language: codeLanguage || 'plain text'
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return parts;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Main conversion function
|
|
277
|
+
export function markdownToBlocks(markdown: string): NotionBlock[] {
|
|
278
|
+
const blocks: NotionBlock[] = [];
|
|
279
|
+
const parts = parseCodeBlocks(markdown);
|
|
280
|
+
|
|
281
|
+
for (const part of parts) {
|
|
282
|
+
if (part.isCode) {
|
|
283
|
+
blocks.push({
|
|
284
|
+
object: 'block',
|
|
285
|
+
type: 'code',
|
|
286
|
+
code: {
|
|
287
|
+
rich_text: [{ type: 'text', text: { content: part.content } }],
|
|
288
|
+
language: part.language || 'plain text'
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
} else {
|
|
292
|
+
const lines = part.content.split('\n');
|
|
293
|
+
for (const line of lines) {
|
|
294
|
+
const block = lineToBlock(line);
|
|
295
|
+
if (block) {
|
|
296
|
+
blocks.push(block);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return blocks;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Convert Notion blocks to markdown
|
|
306
|
+
export function blocksToMarkdown(blocks: unknown[]): string {
|
|
307
|
+
const lines: string[] = [];
|
|
308
|
+
|
|
309
|
+
for (const block of blocks) {
|
|
310
|
+
const b = block as Record<string, unknown>;
|
|
311
|
+
const type = b.type as string;
|
|
312
|
+
|
|
313
|
+
switch (type) {
|
|
314
|
+
case 'paragraph':
|
|
315
|
+
lines.push(richTextToMarkdown((b.paragraph as Record<string, unknown>).rich_text as NotionRichText[]));
|
|
316
|
+
break;
|
|
317
|
+
case 'heading_1':
|
|
318
|
+
lines.push('# ' + richTextToMarkdown((b.heading_1 as Record<string, unknown>).rich_text as NotionRichText[]));
|
|
319
|
+
break;
|
|
320
|
+
case 'heading_2':
|
|
321
|
+
lines.push('## ' + richTextToMarkdown((b.heading_2 as Record<string, unknown>).rich_text as NotionRichText[]));
|
|
322
|
+
break;
|
|
323
|
+
case 'heading_3':
|
|
324
|
+
lines.push('### ' + richTextToMarkdown((b.heading_3 as Record<string, unknown>).rich_text as NotionRichText[]));
|
|
325
|
+
break;
|
|
326
|
+
case 'bulleted_list_item':
|
|
327
|
+
lines.push('- ' + richTextToMarkdown((b.bulleted_list_item as Record<string, unknown>).rich_text as NotionRichText[]));
|
|
328
|
+
break;
|
|
329
|
+
case 'numbered_list_item':
|
|
330
|
+
lines.push('1. ' + richTextToMarkdown((b.numbered_list_item as Record<string, unknown>).rich_text as NotionRichText[]));
|
|
331
|
+
break;
|
|
332
|
+
case 'to_do': {
|
|
333
|
+
const todo = b.to_do as Record<string, unknown>;
|
|
334
|
+
const checked = todo.checked ? 'x' : ' ';
|
|
335
|
+
lines.push(`- [${checked}] ` + richTextToMarkdown(todo.rich_text as NotionRichText[]));
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
case 'quote':
|
|
339
|
+
lines.push('> ' + richTextToMarkdown((b.quote as Record<string, unknown>).rich_text as NotionRichText[]));
|
|
340
|
+
break;
|
|
341
|
+
case 'code': {
|
|
342
|
+
const code = b.code as Record<string, unknown>;
|
|
343
|
+
const lang = code.language || '';
|
|
344
|
+
lines.push('```' + lang);
|
|
345
|
+
lines.push(richTextToMarkdown(code.rich_text as NotionRichText[]));
|
|
346
|
+
lines.push('```');
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
case 'divider':
|
|
350
|
+
lines.push('---');
|
|
351
|
+
break;
|
|
352
|
+
default:
|
|
353
|
+
// For unsupported blocks, try to extract any rich_text
|
|
354
|
+
if (b[type] && (b[type] as Record<string, unknown>).rich_text) {
|
|
355
|
+
lines.push(richTextToMarkdown((b[type] as Record<string, unknown>).rich_text as NotionRichText[]));
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return lines.join('\n\n');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Convert rich text array to markdown string
|
|
364
|
+
function richTextToMarkdown(richText: NotionRichText[]): string {
|
|
365
|
+
if (!richText || !Array.isArray(richText)) return '';
|
|
366
|
+
|
|
367
|
+
return richText.map(rt => {
|
|
368
|
+
let text = rt.text?.content || '';
|
|
369
|
+
const annotations = rt.annotations;
|
|
370
|
+
|
|
371
|
+
if (annotations?.code) {
|
|
372
|
+
text = `\`${text}\``;
|
|
373
|
+
}
|
|
374
|
+
if (annotations?.bold) {
|
|
375
|
+
text = `**${text}**`;
|
|
376
|
+
}
|
|
377
|
+
if (annotations?.italic) {
|
|
378
|
+
text = `*${text}*`;
|
|
379
|
+
}
|
|
380
|
+
if (annotations?.strikethrough) {
|
|
381
|
+
text = `~~${text}~~`;
|
|
382
|
+
}
|
|
383
|
+
if (rt.text?.link?.url) {
|
|
384
|
+
text = `[${text}](${rt.text.link.url})`;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return text;
|
|
388
|
+
}).join('');
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Convert blocks to plain text
|
|
392
|
+
export function blocksToPlainText(blocks: unknown[]): string {
|
|
393
|
+
const lines: string[] = [];
|
|
394
|
+
|
|
395
|
+
for (const block of blocks) {
|
|
396
|
+
const b = block as Record<string, unknown>;
|
|
397
|
+
const type = b.type as string;
|
|
398
|
+
const blockContent = b[type] as Record<string, unknown>;
|
|
399
|
+
|
|
400
|
+
if (blockContent?.rich_text) {
|
|
401
|
+
const richText = blockContent.rich_text as NotionRichText[];
|
|
402
|
+
const text = richText.map(rt => rt.text?.content || '').join('');
|
|
403
|
+
if (text) lines.push(text);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return lines.join('\n');
|
|
408
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notion API Client
|
|
3
|
+
* Handles all HTTP communication with Notion API
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const NOTION_API_BASE = 'https://api.notion.com/v1';
|
|
7
|
+
const NOTION_VERSION = '2022-06-28';
|
|
8
|
+
|
|
9
|
+
export interface NotionClientConfig {
|
|
10
|
+
apiKey: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface NotionResponse<T = unknown> {
|
|
14
|
+
success: boolean;
|
|
15
|
+
data?: T;
|
|
16
|
+
error?: string;
|
|
17
|
+
status?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RateLimitConfig {
|
|
21
|
+
requestsPerSecond: number;
|
|
22
|
+
minDelayMs: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class NotionClient {
|
|
26
|
+
private apiKey: string;
|
|
27
|
+
private lastRequestTime: number = 0;
|
|
28
|
+
private rateLimitConfig: RateLimitConfig = {
|
|
29
|
+
requestsPerSecond: 3,
|
|
30
|
+
minDelayMs: 334 // ~3 requests per second
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
constructor(config: NotionClientConfig) {
|
|
34
|
+
this.apiKey = config.apiKey;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private async rateLimit(): Promise<void> {
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
const timeSinceLastRequest = now - this.lastRequestTime;
|
|
40
|
+
|
|
41
|
+
if (timeSinceLastRequest < this.rateLimitConfig.minDelayMs) {
|
|
42
|
+
const delay = this.rateLimitConfig.minDelayMs - timeSinceLastRequest;
|
|
43
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
this.lastRequestTime = Date.now();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private getHeaders(): Record<string, string> {
|
|
50
|
+
return {
|
|
51
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
52
|
+
'Notion-Version': NOTION_VERSION,
|
|
53
|
+
'Content-Type': 'application/json'
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async request<T>(
|
|
58
|
+
method: string,
|
|
59
|
+
endpoint: string,
|
|
60
|
+
body?: unknown
|
|
61
|
+
): Promise<NotionResponse<T>> {
|
|
62
|
+
await this.rateLimit();
|
|
63
|
+
|
|
64
|
+
const url = `${NOTION_API_BASE}${endpoint}`;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const options: RequestInit = {
|
|
68
|
+
method,
|
|
69
|
+
headers: this.getHeaders()
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
if (body && (method === 'POST' || method === 'PATCH')) {
|
|
73
|
+
options.body = JSON.stringify(body);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const response = await fetch(url, options);
|
|
77
|
+
const data = await response.json() as Record<string, unknown>;
|
|
78
|
+
|
|
79
|
+
if (!response.ok) {
|
|
80
|
+
return {
|
|
81
|
+
success: false,
|
|
82
|
+
error: (data.message as string) || `HTTP ${response.status}: ${response.statusText}`,
|
|
83
|
+
status: response.status
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
success: true,
|
|
89
|
+
data: data as T,
|
|
90
|
+
status: response.status
|
|
91
|
+
};
|
|
92
|
+
} catch (error) {
|
|
93
|
+
return {
|
|
94
|
+
success: false,
|
|
95
|
+
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async get<T>(endpoint: string): Promise<NotionResponse<T>> {
|
|
101
|
+
return this.request<T>('GET', endpoint);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async post<T>(endpoint: string, body?: unknown): Promise<NotionResponse<T>> {
|
|
105
|
+
return this.request<T>('POST', endpoint, body);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async patch<T>(endpoint: string, body?: unknown): Promise<NotionResponse<T>> {
|
|
109
|
+
return this.request<T>('PATCH', endpoint, body);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async delete<T>(endpoint: string): Promise<NotionResponse<T>> {
|
|
113
|
+
return this.request<T>('DELETE', endpoint);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Helper to format ID with dashes
|
|
118
|
+
function formatWithDashes(id: string): string {
|
|
119
|
+
const clean = id.toLowerCase().replace(/-/g, '');
|
|
120
|
+
return clean.replace(
|
|
121
|
+
/([a-f0-9]{8})([a-f0-9]{4})([a-f0-9]{4})([a-f0-9]{4})([a-f0-9]{12})/,
|
|
122
|
+
'$1-$2-$3-$4-$5'
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Extract page/database ID from URL if provided
|
|
127
|
+
export function extractNotionId(input: string): string {
|
|
128
|
+
// If it's already a UUID format (with or without dashes)
|
|
129
|
+
const uuidPattern = /^[a-f0-9]{8}-?[a-f0-9]{4}-?[a-f0-9]{4}-?[a-f0-9]{4}-?[a-f0-9]{12}$/i;
|
|
130
|
+
|
|
131
|
+
if (uuidPattern.test(input)) {
|
|
132
|
+
return formatWithDashes(input);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Check if it's just a 32-char hex string
|
|
136
|
+
if (/^[a-f0-9]{32}$/i.test(input)) {
|
|
137
|
+
return formatWithDashes(input);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Try to extract ID from Notion URL - look for 32 hex chars at end of path
|
|
141
|
+
// Pattern: matches ID that appears after a dash following text (like "Page-Title-abc123...")
|
|
142
|
+
const urlWithTitlePattern = /[/-]([a-f0-9]{32})(?:\?|$|#)/i;
|
|
143
|
+
const urlMatch = input.match(urlWithTitlePattern);
|
|
144
|
+
|
|
145
|
+
if (urlMatch) {
|
|
146
|
+
return formatWithDashes(urlMatch[1]);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Try pattern with dashes in URL (like /12345678-90ab-cdef-1234-567890abcdef)
|
|
150
|
+
const urlPatternDashes = /([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/i;
|
|
151
|
+
const dashMatch = input.match(urlPatternDashes);
|
|
152
|
+
|
|
153
|
+
if (dashMatch) {
|
|
154
|
+
return formatWithDashes(dashMatch[1]);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Return as-is if no pattern matches (will fail at API level)
|
|
158
|
+
return input;
|
|
159
|
+
}
|