@moxn/kb-migrate 0.3.0 → 0.4.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/dist/client.d.ts +42 -0
- package/dist/client.js +115 -0
- package/dist/index.js +209 -6
- package/dist/sources/index.d.ts +1 -0
- package/dist/sources/index.js +1 -3
- package/dist/sources/notion-api.d.ts +240 -0
- package/dist/sources/notion-api.js +196 -0
- package/dist/sources/notion-blocks.d.ts +30 -0
- package/dist/sources/notion-blocks.js +505 -0
- package/dist/sources/notion-databases.d.ts +59 -0
- package/dist/sources/notion-databases.js +266 -0
- package/dist/sources/notion-media.d.ts +30 -0
- package/dist/sources/notion-media.js +133 -0
- package/dist/sources/notion.d.ts +66 -0
- package/dist/sources/notion.js +390 -0
- package/dist/types.d.ts +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin HTTP client for Notion API with rate limiting.
|
|
3
|
+
*
|
|
4
|
+
* No @notionhq/client dependency — raw REST calls with retry on 429.
|
|
5
|
+
* Designed for the migration use case: read-only traversal of workspaces.
|
|
6
|
+
*/
|
|
7
|
+
const NOTION_API_BASE = 'https://api.notion.com/v1';
|
|
8
|
+
const NOTION_API_VERSION = '2022-06-28';
|
|
9
|
+
/** Minimum delay between API calls (~3 req/sec limit) */
|
|
10
|
+
const MIN_REQUEST_INTERVAL_MS = 350;
|
|
11
|
+
/** Max retries on 429 */
|
|
12
|
+
const MAX_RETRIES = 5;
|
|
13
|
+
// Notion color → hex mapping for tag colors
|
|
14
|
+
const NOTION_COLOR_MAP = {
|
|
15
|
+
default: '#6B7280',
|
|
16
|
+
gray: '#6B7280',
|
|
17
|
+
brown: '#92400E',
|
|
18
|
+
orange: '#F97316',
|
|
19
|
+
yellow: '#EAB308',
|
|
20
|
+
green: '#22C55E',
|
|
21
|
+
blue: '#3B82F6',
|
|
22
|
+
purple: '#8B5CF6',
|
|
23
|
+
pink: '#EC4899',
|
|
24
|
+
red: '#EF4444',
|
|
25
|
+
};
|
|
26
|
+
export function notionColorToHex(color) {
|
|
27
|
+
return NOTION_COLOR_MAP[color] ?? NOTION_COLOR_MAP.default;
|
|
28
|
+
}
|
|
29
|
+
// ============================================
|
|
30
|
+
// Client
|
|
31
|
+
// ============================================
|
|
32
|
+
export class NotionApiClient {
|
|
33
|
+
token;
|
|
34
|
+
lastRequestTime = 0;
|
|
35
|
+
constructor(token) {
|
|
36
|
+
this.token = token;
|
|
37
|
+
}
|
|
38
|
+
/** Test that the token is valid. Throws if not. */
|
|
39
|
+
async validateToken() {
|
|
40
|
+
// GET /v1/users/me works as a token validation check
|
|
41
|
+
await this.request('GET', '/users/me');
|
|
42
|
+
}
|
|
43
|
+
/** List all workspace users. */
|
|
44
|
+
async listUsers() {
|
|
45
|
+
const users = [];
|
|
46
|
+
let cursor = null;
|
|
47
|
+
do {
|
|
48
|
+
const params = new URLSearchParams();
|
|
49
|
+
if (cursor)
|
|
50
|
+
params.set('start_cursor', cursor);
|
|
51
|
+
params.set('page_size', '100');
|
|
52
|
+
const response = await this.request('GET', `/users?${params}`);
|
|
53
|
+
users.push(...response.results);
|
|
54
|
+
cursor = response.has_more ? response.next_cursor : null;
|
|
55
|
+
} while (cursor);
|
|
56
|
+
return users;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Search for all pages in the workspace.
|
|
60
|
+
* Sorted by last_edited_time descending for incremental sync support.
|
|
61
|
+
*/
|
|
62
|
+
async searchPages(options) {
|
|
63
|
+
const pages = [];
|
|
64
|
+
let cursor = null;
|
|
65
|
+
do {
|
|
66
|
+
const body = {
|
|
67
|
+
filter: { value: 'page', property: 'object' },
|
|
68
|
+
page_size: 100,
|
|
69
|
+
};
|
|
70
|
+
if (options?.sortByLastEdited) {
|
|
71
|
+
body.sort = { timestamp: 'last_edited_time', direction: 'descending' };
|
|
72
|
+
}
|
|
73
|
+
if (cursor)
|
|
74
|
+
body.start_cursor = cursor;
|
|
75
|
+
const response = await this.request('POST', '/search', body);
|
|
76
|
+
pages.push(...response.results);
|
|
77
|
+
cursor = response.has_more ? response.next_cursor : null;
|
|
78
|
+
} while (cursor);
|
|
79
|
+
return pages;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Search for all databases in the workspace.
|
|
83
|
+
*/
|
|
84
|
+
async searchDatabases() {
|
|
85
|
+
const databases = [];
|
|
86
|
+
let cursor = null;
|
|
87
|
+
do {
|
|
88
|
+
const body = {
|
|
89
|
+
filter: { value: 'database', property: 'object' },
|
|
90
|
+
page_size: 100,
|
|
91
|
+
};
|
|
92
|
+
if (cursor)
|
|
93
|
+
body.start_cursor = cursor;
|
|
94
|
+
const response = await this.request('POST', '/search', body);
|
|
95
|
+
databases.push(...response.results);
|
|
96
|
+
cursor = response.has_more ? response.next_cursor : null;
|
|
97
|
+
} while (cursor);
|
|
98
|
+
return databases;
|
|
99
|
+
}
|
|
100
|
+
/** Get a database schema by ID. */
|
|
101
|
+
async getDatabase(databaseId) {
|
|
102
|
+
return this.request('GET', `/databases/${databaseId}`);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Query all entries (pages) in a database.
|
|
106
|
+
*/
|
|
107
|
+
async queryDatabase(databaseId) {
|
|
108
|
+
const pages = [];
|
|
109
|
+
let cursor = null;
|
|
110
|
+
do {
|
|
111
|
+
const body = { page_size: 100 };
|
|
112
|
+
if (cursor)
|
|
113
|
+
body.start_cursor = cursor;
|
|
114
|
+
const response = await this.request('POST', `/databases/${databaseId}/query`, body);
|
|
115
|
+
pages.push(...response.results);
|
|
116
|
+
cursor = response.has_more ? response.next_cursor : null;
|
|
117
|
+
} while (cursor);
|
|
118
|
+
return pages;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Get all child blocks of a block (page or another block).
|
|
122
|
+
* Automatically paginates through all results.
|
|
123
|
+
*/
|
|
124
|
+
async getBlockChildren(blockId) {
|
|
125
|
+
const blocks = [];
|
|
126
|
+
let cursor = null;
|
|
127
|
+
do {
|
|
128
|
+
const params = new URLSearchParams({ page_size: '100' });
|
|
129
|
+
if (cursor)
|
|
130
|
+
params.set('start_cursor', cursor);
|
|
131
|
+
const response = await this.request('GET', `/blocks/${blockId}/children?${params}`);
|
|
132
|
+
blocks.push(...response.results);
|
|
133
|
+
cursor = response.has_more ? response.next_cursor : null;
|
|
134
|
+
} while (cursor);
|
|
135
|
+
return blocks;
|
|
136
|
+
}
|
|
137
|
+
/** Get a single page by ID. */
|
|
138
|
+
async getPage(pageId) {
|
|
139
|
+
return this.request('GET', `/pages/${pageId}`);
|
|
140
|
+
}
|
|
141
|
+
// ============================================
|
|
142
|
+
// Internal: HTTP with rate limiting + retry
|
|
143
|
+
// ============================================
|
|
144
|
+
async request(method, path, body) {
|
|
145
|
+
await this.rateLimit();
|
|
146
|
+
let lastError = null;
|
|
147
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
148
|
+
const headers = {
|
|
149
|
+
Authorization: `Bearer ${this.token}`,
|
|
150
|
+
'Notion-Version': NOTION_API_VERSION,
|
|
151
|
+
};
|
|
152
|
+
if (body)
|
|
153
|
+
headers['Content-Type'] = 'application/json';
|
|
154
|
+
const response = await fetch(`${NOTION_API_BASE}${path}`, {
|
|
155
|
+
method,
|
|
156
|
+
headers,
|
|
157
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
158
|
+
});
|
|
159
|
+
if (response.ok) {
|
|
160
|
+
return (await response.json());
|
|
161
|
+
}
|
|
162
|
+
if (response.status === 429) {
|
|
163
|
+
const retryAfter = parseInt(response.headers.get('Retry-After') || '1', 10);
|
|
164
|
+
const delay = Math.max(retryAfter * 1000, 1000) * (attempt + 1);
|
|
165
|
+
console.warn(`Rate limited. Retrying in ${delay}ms (attempt ${attempt + 1}/${MAX_RETRIES})`);
|
|
166
|
+
await sleep(delay);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const errorBody = await response.text();
|
|
170
|
+
lastError = new Error(`Notion API error ${response.status}: ${errorBody}`);
|
|
171
|
+
// Don't retry on client errors (except 429 handled above)
|
|
172
|
+
if (response.status >= 400 && response.status < 500) {
|
|
173
|
+
throw lastError;
|
|
174
|
+
}
|
|
175
|
+
// Retry on server errors
|
|
176
|
+
if (attempt < MAX_RETRIES) {
|
|
177
|
+
const delay = 1000 * (attempt + 1);
|
|
178
|
+
console.warn(`Server error ${response.status}. Retrying in ${delay}ms`);
|
|
179
|
+
await sleep(delay);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
throw lastError ?? new Error('Request failed after all retries');
|
|
184
|
+
}
|
|
185
|
+
async rateLimit() {
|
|
186
|
+
const now = Date.now();
|
|
187
|
+
const elapsed = now - this.lastRequestTime;
|
|
188
|
+
if (elapsed < MIN_REQUEST_INTERVAL_MS) {
|
|
189
|
+
await sleep(MIN_REQUEST_INTERVAL_MS - elapsed);
|
|
190
|
+
}
|
|
191
|
+
this.lastRequestTime = Date.now();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function sleep(ms) {
|
|
195
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
196
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converts Notion blocks to Moxn KB ContentBlocks and sections.
|
|
3
|
+
*
|
|
4
|
+
* Section boundaries are created at H2 headings.
|
|
5
|
+
* Rich text is converted to markdown.
|
|
6
|
+
*/
|
|
7
|
+
import type { SectionInput } from '../types.js';
|
|
8
|
+
import type { NotionBlock, NotionRichText, NotionApiClient, NotionPage } from './notion-api.js';
|
|
9
|
+
/** Map of page IDs to their KB paths (for cross-link resolution). */
|
|
10
|
+
export type PagePathMap = Map<string, string>;
|
|
11
|
+
/**
|
|
12
|
+
* Convert a page's blocks into Moxn sections.
|
|
13
|
+
*
|
|
14
|
+
* H2 headings create section boundaries.
|
|
15
|
+
* Content before the first H2 goes into an "Introduction" section.
|
|
16
|
+
*/
|
|
17
|
+
export declare function blocksToSections(blocks: NotionBlock[], client: NotionApiClient, pagePathMap: PagePathMap, options?: {
|
|
18
|
+
/** Track synced block IDs to detect cycles. */
|
|
19
|
+
visitedSyncedBlocks?: Set<string>;
|
|
20
|
+
}): Promise<SectionInput[]>;
|
|
21
|
+
/** Convert rich text array to markdown string. */
|
|
22
|
+
export declare function richTextToMarkdown(richText: NotionRichText[]): string;
|
|
23
|
+
/** Convert rich text array to plain text (no markdown formatting). */
|
|
24
|
+
export declare function richTextToPlain(richText: NotionRichText[]): string;
|
|
25
|
+
/** Normalize Notion ID: remove dashes for consistent lookup. */
|
|
26
|
+
export declare function normalizeId(id: string): string;
|
|
27
|
+
/**
|
|
28
|
+
* Extract the page title from Notion page properties.
|
|
29
|
+
*/
|
|
30
|
+
export declare function getPageTitle(page: NotionPage): string;
|