@moxn/kb-migrate 0.4.1 → 0.4.3
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 +0 -1
- package/dist/sources/notion-blocks.js +10 -7
- package/dist/sources/notion-blocks.test.d.ts +1 -0
- package/dist/sources/notion-blocks.test.js +196 -0
- package/dist/sources/notion-databases.js +2 -2
- package/dist/sources/notion-databases.test.d.ts +1 -0
- package/dist/sources/notion-databases.test.js +115 -0
- package/dist/sources/notion-references.d.ts +36 -0
- package/dist/sources/notion-references.js +159 -0
- package/dist/sources/notion-references.test.d.ts +1 -0
- package/dist/sources/notion-references.test.js +423 -0
- package/dist/sources/notion.js +11 -2
- package/dist/types.d.ts +12 -0
- package/package.json +9 -2
package/dist/client.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* API client for Moxn KB
|
|
3
3
|
*/
|
|
4
|
-
/// <reference types="node" resolution-mode="require"/>
|
|
5
4
|
import type { ExtractedDocument, MigrationOptions, MigrationResult, DocumentListItem, DocumentDetail, ExportOptions } from './types.js';
|
|
6
5
|
export declare class MoxnClient {
|
|
7
6
|
private apiUrl;
|
|
@@ -287,7 +287,7 @@ function convertImage(block) {
|
|
|
287
287
|
blockType: 'image',
|
|
288
288
|
type: 'url',
|
|
289
289
|
url: img.image.external.url,
|
|
290
|
-
mediaType: 'image/png',
|
|
290
|
+
mediaType: 'image/png', // Generic — Moxn will detect actual type
|
|
291
291
|
alt: caption || undefined,
|
|
292
292
|
},
|
|
293
293
|
];
|
|
@@ -429,24 +429,27 @@ export function richTextToMarkdown(richText) {
|
|
|
429
429
|
text = '*' + text + '*';
|
|
430
430
|
if (rt.annotations.strikethrough)
|
|
431
431
|
text = '~~' + text + '~~';
|
|
432
|
-
//
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
}
|
|
436
|
-
else if (rt.type === 'mention' && rt.mention) {
|
|
437
|
-
// Handle mention types
|
|
432
|
+
// Handle mentions BEFORE href — Notion sets href on mentions too
|
|
433
|
+
// (as a web URL), but we want notion:// protocol for resolution
|
|
434
|
+
if (rt.type === 'mention' && rt.mention) {
|
|
438
435
|
if (rt.mention.type === 'user' && rt.mention.user?.name) {
|
|
439
436
|
text = `@${rt.mention.user.name}`;
|
|
440
437
|
}
|
|
441
438
|
else if (rt.mention.type === 'page' && rt.mention.page) {
|
|
442
439
|
text = `[${text}](notion://${rt.mention.page.id})`;
|
|
443
440
|
}
|
|
441
|
+
else if (rt.mention.type === 'database' && rt.mention.database) {
|
|
442
|
+
text = `[${text}](notion://${rt.mention.database.id})`;
|
|
443
|
+
}
|
|
444
444
|
else if (rt.mention.type === 'date' && rt.mention.date) {
|
|
445
445
|
text = rt.mention.date.start;
|
|
446
446
|
if (rt.mention.date.end)
|
|
447
447
|
text += ` → ${rt.mention.date.end}`;
|
|
448
448
|
}
|
|
449
449
|
}
|
|
450
|
+
else if (rt.href) {
|
|
451
|
+
text = `[${text}](${rt.href})`;
|
|
452
|
+
}
|
|
450
453
|
else if (rt.type === 'equation' && rt.equation) {
|
|
451
454
|
text = `$${rt.equation.expression}$`;
|
|
452
455
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { richTextToMarkdown, normalizeId } from './notion-blocks.js';
|
|
3
|
+
// ============================================
|
|
4
|
+
// Helper to build NotionRichText objects
|
|
5
|
+
// ============================================
|
|
6
|
+
function plainText(text, overrides) {
|
|
7
|
+
return {
|
|
8
|
+
type: 'text',
|
|
9
|
+
plain_text: text,
|
|
10
|
+
annotations: {
|
|
11
|
+
bold: false,
|
|
12
|
+
italic: false,
|
|
13
|
+
strikethrough: false,
|
|
14
|
+
underline: false,
|
|
15
|
+
code: false,
|
|
16
|
+
color: 'default',
|
|
17
|
+
},
|
|
18
|
+
href: null,
|
|
19
|
+
...overrides,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
// ============================================
|
|
23
|
+
// Database mention handling
|
|
24
|
+
// ============================================
|
|
25
|
+
describe('richTextToMarkdown', () => {
|
|
26
|
+
describe('database mentions', () => {
|
|
27
|
+
it('renders database mention as notion:// link', () => {
|
|
28
|
+
const rt = {
|
|
29
|
+
type: 'mention',
|
|
30
|
+
plain_text: 'My Database',
|
|
31
|
+
annotations: {
|
|
32
|
+
bold: false,
|
|
33
|
+
italic: false,
|
|
34
|
+
strikethrough: false,
|
|
35
|
+
underline: false,
|
|
36
|
+
code: false,
|
|
37
|
+
color: 'default',
|
|
38
|
+
},
|
|
39
|
+
href: null,
|
|
40
|
+
mention: {
|
|
41
|
+
type: 'database',
|
|
42
|
+
database: { id: 'abc123de-f456-abc1-23de-f456abc123de' },
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
const result = richTextToMarkdown([rt]);
|
|
46
|
+
expect(result).toBe('[My Database](notion://abc123de-f456-abc1-23de-f456abc123de)');
|
|
47
|
+
});
|
|
48
|
+
it('renders database mention with href using notion:// (mention takes precedence over href)', () => {
|
|
49
|
+
// Mentions should always use notion:// protocol, even when href is set
|
|
50
|
+
const rt = {
|
|
51
|
+
type: 'mention',
|
|
52
|
+
plain_text: 'My Database',
|
|
53
|
+
annotations: {
|
|
54
|
+
bold: false,
|
|
55
|
+
italic: false,
|
|
56
|
+
strikethrough: false,
|
|
57
|
+
underline: false,
|
|
58
|
+
code: false,
|
|
59
|
+
color: 'default',
|
|
60
|
+
},
|
|
61
|
+
href: 'https://www.notion.so/workspace/abc123de-f456-abc1-23de-f456abc123de',
|
|
62
|
+
mention: {
|
|
63
|
+
type: 'database',
|
|
64
|
+
database: { id: 'abc123de-f456-abc1-23de-f456abc123de' },
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
const result = richTextToMarkdown([rt]);
|
|
68
|
+
expect(result).toBe('[My Database](notion://abc123de-f456-abc1-23de-f456abc123de)');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
describe('page mentions', () => {
|
|
72
|
+
it('renders page mention without href as notion:// link', () => {
|
|
73
|
+
const rt = {
|
|
74
|
+
type: 'mention',
|
|
75
|
+
plain_text: 'My Page',
|
|
76
|
+
annotations: {
|
|
77
|
+
bold: false,
|
|
78
|
+
italic: false,
|
|
79
|
+
strikethrough: false,
|
|
80
|
+
underline: false,
|
|
81
|
+
code: false,
|
|
82
|
+
color: 'default',
|
|
83
|
+
},
|
|
84
|
+
href: null,
|
|
85
|
+
mention: {
|
|
86
|
+
type: 'page',
|
|
87
|
+
page: { id: 'abc123de-f456-abc1-23de-f456abc123de' },
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
const result = richTextToMarkdown([rt]);
|
|
91
|
+
expect(result).toBe('[My Page](notion://abc123de-f456-abc1-23de-f456abc123de)');
|
|
92
|
+
});
|
|
93
|
+
it('renders page mention with href using notion:// (mention takes precedence over href)', () => {
|
|
94
|
+
const rt = {
|
|
95
|
+
type: 'mention',
|
|
96
|
+
plain_text: 'My Page',
|
|
97
|
+
annotations: {
|
|
98
|
+
bold: false,
|
|
99
|
+
italic: false,
|
|
100
|
+
strikethrough: false,
|
|
101
|
+
underline: false,
|
|
102
|
+
code: false,
|
|
103
|
+
color: 'default',
|
|
104
|
+
},
|
|
105
|
+
href: 'https://www.notion.so/workspace/abc123de-f456-abc1-23de-f456abc123de',
|
|
106
|
+
mention: {
|
|
107
|
+
type: 'page',
|
|
108
|
+
page: { id: 'abc123de-f456-abc1-23de-f456abc123de' },
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
const result = richTextToMarkdown([rt]);
|
|
112
|
+
expect(result).toBe('[My Page](notion://abc123de-f456-abc1-23de-f456abc123de)');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
describe('other mention types preserved', () => {
|
|
116
|
+
it('renders user mentions as @name', () => {
|
|
117
|
+
const rt = {
|
|
118
|
+
type: 'mention',
|
|
119
|
+
plain_text: 'John',
|
|
120
|
+
annotations: {
|
|
121
|
+
bold: false,
|
|
122
|
+
italic: false,
|
|
123
|
+
strikethrough: false,
|
|
124
|
+
underline: false,
|
|
125
|
+
code: false,
|
|
126
|
+
color: 'default',
|
|
127
|
+
},
|
|
128
|
+
href: null,
|
|
129
|
+
mention: {
|
|
130
|
+
type: 'user',
|
|
131
|
+
user: { id: 'user-123', name: 'John' },
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
const result = richTextToMarkdown([rt]);
|
|
135
|
+
expect(result).toBe('@John');
|
|
136
|
+
});
|
|
137
|
+
it('renders date mentions', () => {
|
|
138
|
+
const rt = {
|
|
139
|
+
type: 'mention',
|
|
140
|
+
plain_text: '2024-01-15',
|
|
141
|
+
annotations: {
|
|
142
|
+
bold: false,
|
|
143
|
+
italic: false,
|
|
144
|
+
strikethrough: false,
|
|
145
|
+
underline: false,
|
|
146
|
+
code: false,
|
|
147
|
+
color: 'default',
|
|
148
|
+
},
|
|
149
|
+
href: null,
|
|
150
|
+
mention: {
|
|
151
|
+
type: 'date',
|
|
152
|
+
date: { start: '2024-01-15', end: '2024-01-20' },
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
const result = richTextToMarkdown([rt]);
|
|
156
|
+
expect(result).toBe('2024-01-15 → 2024-01-20');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
describe('annotations on mentions', () => {
|
|
160
|
+
it('applies bold to database mention', () => {
|
|
161
|
+
const rt = {
|
|
162
|
+
type: 'mention',
|
|
163
|
+
plain_text: 'DB',
|
|
164
|
+
annotations: {
|
|
165
|
+
bold: true,
|
|
166
|
+
italic: false,
|
|
167
|
+
strikethrough: false,
|
|
168
|
+
underline: false,
|
|
169
|
+
code: false,
|
|
170
|
+
color: 'default',
|
|
171
|
+
},
|
|
172
|
+
href: null,
|
|
173
|
+
mention: {
|
|
174
|
+
type: 'database',
|
|
175
|
+
database: { id: 'aaaa1111bbbb2222cccc3333dddd4444' },
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
const result = richTextToMarkdown([rt]);
|
|
179
|
+
expect(result).toBe('[**DB**](notion://aaaa1111bbbb2222cccc3333dddd4444)');
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
// ============================================
|
|
184
|
+
// normalizeId
|
|
185
|
+
// ============================================
|
|
186
|
+
describe('normalizeId', () => {
|
|
187
|
+
it('removes dashes from UUID', () => {
|
|
188
|
+
expect(normalizeId('abc123de-f456-abc1-23de-f456abc123de')).toBe('abc123def456abc123def456abc123de');
|
|
189
|
+
});
|
|
190
|
+
it('returns already-normalized ID unchanged', () => {
|
|
191
|
+
expect(normalizeId('abc123def456abc123def456abc123de')).toBe('abc123def456abc123def456abc123de');
|
|
192
|
+
});
|
|
193
|
+
it('handles empty string', () => {
|
|
194
|
+
expect(normalizeId('')).toBe('');
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* - title → document name (not a column)
|
|
7
7
|
* - All other property types → rendered as markdown in a "Properties" section
|
|
8
8
|
*/
|
|
9
|
-
import { richTextToPlain, richTextToMarkdown, getPageTitle } from './notion-blocks.js';
|
|
9
|
+
import { richTextToPlain, richTextToMarkdown, getPageTitle, normalizeId, } from './notion-blocks.js';
|
|
10
10
|
// ============================================
|
|
11
11
|
// Schema Parsing
|
|
12
12
|
// ============================================
|
|
@@ -208,7 +208,7 @@ function renderPropertyValue(prop) {
|
|
|
208
208
|
case 'relation':
|
|
209
209
|
if (!prop.relation || prop.relation.length === 0)
|
|
210
210
|
return null;
|
|
211
|
-
return prop.relation.map((r) => r.id).join(', ');
|
|
211
|
+
return prop.relation.map((r) => `notion-ref:${normalizeId(r.id)}`).join(', ');
|
|
212
212
|
case 'formula':
|
|
213
213
|
if (!prop.formula)
|
|
214
214
|
return null;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseEntryValues, renderPropertiesSection } from './notion-databases.js';
|
|
3
|
+
// ============================================
|
|
4
|
+
// Helpers
|
|
5
|
+
// ============================================
|
|
6
|
+
function makeEntryPage(properties) {
|
|
7
|
+
return {
|
|
8
|
+
id: 'page-123',
|
|
9
|
+
object: 'page',
|
|
10
|
+
parent: { type: 'database_id', database_id: 'db-123' },
|
|
11
|
+
created_time: '2024-01-01T00:00:00.000Z',
|
|
12
|
+
last_edited_time: '2024-01-01T00:00:00.000Z',
|
|
13
|
+
archived: false,
|
|
14
|
+
url: 'https://notion.so/page-123',
|
|
15
|
+
properties: {
|
|
16
|
+
Name: {
|
|
17
|
+
id: 'title-id',
|
|
18
|
+
type: 'title',
|
|
19
|
+
title: [
|
|
20
|
+
{
|
|
21
|
+
type: 'text',
|
|
22
|
+
plain_text: 'Test Entry',
|
|
23
|
+
annotations: {
|
|
24
|
+
bold: false,
|
|
25
|
+
italic: false,
|
|
26
|
+
strikethrough: false,
|
|
27
|
+
underline: false,
|
|
28
|
+
code: false,
|
|
29
|
+
color: 'default',
|
|
30
|
+
},
|
|
31
|
+
href: null,
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
...properties,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function makeSchema(unmappedColumns) {
|
|
40
|
+
return {
|
|
41
|
+
name: 'Test DB',
|
|
42
|
+
description: '',
|
|
43
|
+
mappedColumns: [],
|
|
44
|
+
unmappedColumns: unmappedColumns.map((name) => ({
|
|
45
|
+
notionPropertyId: `prop-${name}`,
|
|
46
|
+
notionPropertyName: name,
|
|
47
|
+
notionType: 'relation',
|
|
48
|
+
})),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// ============================================
|
|
52
|
+
// Relation property rendering
|
|
53
|
+
// ============================================
|
|
54
|
+
describe('relation property rendering', () => {
|
|
55
|
+
it('renders relation IDs with notion-ref: prefix and normalized IDs', () => {
|
|
56
|
+
const page = makeEntryPage({
|
|
57
|
+
Related: {
|
|
58
|
+
id: 'rel-id',
|
|
59
|
+
type: 'relation',
|
|
60
|
+
relation: [
|
|
61
|
+
{ id: 'abc123de-f456-abc1-23de-f456abc123de' },
|
|
62
|
+
{ id: 'eeee5555-ffff-6666-aaaa-7777bbbb8888' },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
const schema = makeSchema(['Related']);
|
|
67
|
+
const values = parseEntryValues(page, schema);
|
|
68
|
+
const relatedValue = values.textValues.get('Related');
|
|
69
|
+
expect(relatedValue).toBe('notion-ref:abc123def456abc123def456abc123de, notion-ref:eeee5555ffff6666aaaa7777bbbb8888');
|
|
70
|
+
});
|
|
71
|
+
it('returns null for empty relation array', () => {
|
|
72
|
+
const page = makeEntryPage({
|
|
73
|
+
Related: {
|
|
74
|
+
id: 'rel-id',
|
|
75
|
+
type: 'relation',
|
|
76
|
+
relation: [],
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
const schema = makeSchema(['Related']);
|
|
80
|
+
const values = parseEntryValues(page, schema);
|
|
81
|
+
expect(values.textValues.has('Related')).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
it('renders single relation with notion-ref: prefix', () => {
|
|
84
|
+
const page = makeEntryPage({
|
|
85
|
+
Parent: {
|
|
86
|
+
id: 'rel-id',
|
|
87
|
+
type: 'relation',
|
|
88
|
+
relation: [{ id: 'aaaa1111bbbb2222cccc3333dddd4444' }],
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
const schema = makeSchema(['Parent']);
|
|
92
|
+
const values = parseEntryValues(page, schema);
|
|
93
|
+
const parentValue = values.textValues.get('Parent');
|
|
94
|
+
// Already normalized (no dashes) — should still get prefix
|
|
95
|
+
expect(parentValue).toBe('notion-ref:aaaa1111bbbb2222cccc3333dddd4444');
|
|
96
|
+
});
|
|
97
|
+
it('renders relation refs in properties section markdown table', () => {
|
|
98
|
+
const page = makeEntryPage({
|
|
99
|
+
Related: {
|
|
100
|
+
id: 'rel-id',
|
|
101
|
+
type: 'relation',
|
|
102
|
+
relation: [{ id: 'abc123de-f456-abc1-23de-f456abc123de' }],
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
const schema = makeSchema(['Related']);
|
|
106
|
+
const values = parseEntryValues(page, schema);
|
|
107
|
+
const section = renderPropertiesSection(values);
|
|
108
|
+
expect(section).not.toBeNull();
|
|
109
|
+
const textBlock = section.content[0];
|
|
110
|
+
expect(textBlock.blockType).toBe('text');
|
|
111
|
+
const text = textBlock.text;
|
|
112
|
+
expect(text).toContain('notion-ref:abc123def456abc123def456abc123de');
|
|
113
|
+
expect(text).toContain('| Related |');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-processing pass to resolve Notion cross-references in imported content.
|
|
3
|
+
*
|
|
4
|
+
* Instead of threading a pagePathMap through every richTextToMarkdown() call site,
|
|
5
|
+
* this module operates on the final TextBlock strings, catching ALL reference patterns
|
|
6
|
+
* regardless of their source (paragraph, heading, list, quote, callout, table, etc.).
|
|
7
|
+
*
|
|
8
|
+
* Also extracts reference metadata for KB Reference object creation.
|
|
9
|
+
*/
|
|
10
|
+
import type { SectionInput } from '../types.js';
|
|
11
|
+
export interface NotionIdMapping {
|
|
12
|
+
notionIdToKbPath: Map<string, string>;
|
|
13
|
+
}
|
|
14
|
+
export interface ExtractedReference {
|
|
15
|
+
sectionIndex: number;
|
|
16
|
+
targetNotionId: string;
|
|
17
|
+
targetKbPath: string | null;
|
|
18
|
+
displayText: string;
|
|
19
|
+
}
|
|
20
|
+
export interface ResolvedSections {
|
|
21
|
+
sections: SectionInput[];
|
|
22
|
+
references: ExtractedReference[];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Resolve Notion cross-references in section content and extract reference metadata.
|
|
26
|
+
*
|
|
27
|
+
* Processes all TextBlock content, replacing Notion URLs/IDs with KB paths
|
|
28
|
+
* where a mapping exists. Returns both the resolved sections and a list of
|
|
29
|
+
* extracted references for KB Reference object creation.
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolveNotionReferences(sections: SectionInput[], mapping: NotionIdMapping): ResolvedSections;
|
|
32
|
+
/**
|
|
33
|
+
* Resolve relation property IDs directly (for use in property rendering).
|
|
34
|
+
* Replaces notion-ref:ID markers with KB path links or bare IDs.
|
|
35
|
+
*/
|
|
36
|
+
export declare function resolveRelationIds(value: string, mapping: NotionIdMapping): string;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-processing pass to resolve Notion cross-references in imported content.
|
|
3
|
+
*
|
|
4
|
+
* Instead of threading a pagePathMap through every richTextToMarkdown() call site,
|
|
5
|
+
* this module operates on the final TextBlock strings, catching ALL reference patterns
|
|
6
|
+
* regardless of their source (paragraph, heading, list, quote, callout, table, etc.).
|
|
7
|
+
*
|
|
8
|
+
* Also extracts reference metadata for KB Reference object creation.
|
|
9
|
+
*/
|
|
10
|
+
import { normalizeId } from './notion-blocks.js';
|
|
11
|
+
// ============================================
|
|
12
|
+
// Patterns
|
|
13
|
+
// ============================================
|
|
14
|
+
// 1. notion:// links: [text](notion://abc123...)
|
|
15
|
+
const NOTION_PROTOCOL_RE = /\[([^\]]*)\]\(notion:\/\/([a-f0-9-]{32,36})\)/g;
|
|
16
|
+
// 2. Notion web URLs: [text](https://www.notion.so/workspace/page-id?query)
|
|
17
|
+
// The ID can appear with or without dashes. It may also appear as a slug suffix (last 32 hex chars).
|
|
18
|
+
const NOTION_WEB_URL_RE = /\[([^\]]*)\]\(https?:\/\/(?:www\.)?notion\.so\/(?:[a-zA-Z0-9_-]+\/)*([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})(?:[?#][^)]*)?(?:\))/g;
|
|
19
|
+
// Fallback for IDs without dashes embedded in slug URLs (e.g., ...Page-Title-abc123def456...)
|
|
20
|
+
const NOTION_WEB_URL_SLUG_RE = /\[([^\]]*)\]\(https?:\/\/(?:www\.)?notion\.so\/(?:[a-zA-Z0-9_-]+\/)*[a-zA-Z0-9-]+-([a-f0-9]{32})(?:[?#][^)]*)?\)/g;
|
|
21
|
+
// Bare 32-char hex on notion.so (e.g., https://www.notion.so/3004bf42cfeb818ebe8fe2f4befdad52)
|
|
22
|
+
// Catches mention hrefs that leak through as web URLs
|
|
23
|
+
const NOTION_WEB_URL_BARE_RE = /\[([^\]]*)\]\(https?:\/\/(?:www\.)?notion\.so\/(?:[a-zA-Z0-9_-]+\/)*([a-f0-9]{32})(?:[?#][^)]*)?\)/g;
|
|
24
|
+
// 3. Unresolved placeholders from link_to_page conversion
|
|
25
|
+
const LINK_PLACEHOLDER_RE = /\*\(Link to Notion page: ([a-f0-9-]{32,36})\)\*/g;
|
|
26
|
+
// 4. Relation property markers
|
|
27
|
+
const RELATION_REF_RE = /notion-ref:([a-f0-9]{32})/g;
|
|
28
|
+
// ============================================
|
|
29
|
+
// Main function
|
|
30
|
+
// ============================================
|
|
31
|
+
/**
|
|
32
|
+
* Resolve Notion cross-references in section content and extract reference metadata.
|
|
33
|
+
*
|
|
34
|
+
* Processes all TextBlock content, replacing Notion URLs/IDs with KB paths
|
|
35
|
+
* where a mapping exists. Returns both the resolved sections and a list of
|
|
36
|
+
* extracted references for KB Reference object creation.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveNotionReferences(sections, mapping) {
|
|
39
|
+
const references = [];
|
|
40
|
+
const resolvedSections = sections.map((section, sectionIndex) => {
|
|
41
|
+
const resolvedContent = section.content.map((block) => {
|
|
42
|
+
if (block.blockType !== 'text')
|
|
43
|
+
return block;
|
|
44
|
+
const textBlock = block;
|
|
45
|
+
let text = textBlock.text;
|
|
46
|
+
// Pass 1: notion:// protocol links
|
|
47
|
+
text = text.replace(NOTION_PROTOCOL_RE, (_match, displayText, rawId) => {
|
|
48
|
+
const nid = normalizeId(rawId);
|
|
49
|
+
const kbPath = mapping.notionIdToKbPath.get(nid);
|
|
50
|
+
if (kbPath) {
|
|
51
|
+
references.push({
|
|
52
|
+
sectionIndex,
|
|
53
|
+
targetNotionId: nid,
|
|
54
|
+
targetKbPath: kbPath,
|
|
55
|
+
displayText: displayText || kbPath,
|
|
56
|
+
});
|
|
57
|
+
return `[${displayText || kbPath}](${kbPath})`;
|
|
58
|
+
}
|
|
59
|
+
// Leave as notion:// for future re-resolution
|
|
60
|
+
return `[${displayText}](notion://${nid})`;
|
|
61
|
+
});
|
|
62
|
+
// Pass 2: Notion web URLs (with dashes in UUID)
|
|
63
|
+
text = text.replace(NOTION_WEB_URL_RE, (_match, displayText, rawId) => {
|
|
64
|
+
const nid = normalizeId(rawId);
|
|
65
|
+
const kbPath = mapping.notionIdToKbPath.get(nid);
|
|
66
|
+
if (kbPath) {
|
|
67
|
+
references.push({
|
|
68
|
+
sectionIndex,
|
|
69
|
+
targetNotionId: nid,
|
|
70
|
+
targetKbPath: kbPath,
|
|
71
|
+
displayText: displayText || kbPath,
|
|
72
|
+
});
|
|
73
|
+
return `[${displayText || kbPath}](${kbPath})`;
|
|
74
|
+
}
|
|
75
|
+
// Unresolved — normalize to notion:// format
|
|
76
|
+
return `[${displayText}](notion://${nid})`;
|
|
77
|
+
});
|
|
78
|
+
// Pass 3: Notion web URLs (slug-embedded 32-char hex IDs)
|
|
79
|
+
text = text.replace(NOTION_WEB_URL_SLUG_RE, (_match, displayText, rawId) => {
|
|
80
|
+
const nid = normalizeId(rawId);
|
|
81
|
+
const kbPath = mapping.notionIdToKbPath.get(nid);
|
|
82
|
+
if (kbPath) {
|
|
83
|
+
references.push({
|
|
84
|
+
sectionIndex,
|
|
85
|
+
targetNotionId: nid,
|
|
86
|
+
targetKbPath: kbPath,
|
|
87
|
+
displayText: displayText || kbPath,
|
|
88
|
+
});
|
|
89
|
+
return `[${displayText || kbPath}](${kbPath})`;
|
|
90
|
+
}
|
|
91
|
+
return `[${displayText}](notion://${nid})`;
|
|
92
|
+
});
|
|
93
|
+
// Pass 3b: Bare 32-char hex Notion URLs (safety net for mention hrefs)
|
|
94
|
+
text = text.replace(NOTION_WEB_URL_BARE_RE, (_match, displayText, rawId) => {
|
|
95
|
+
const nid = normalizeId(rawId);
|
|
96
|
+
const kbPath = mapping.notionIdToKbPath.get(nid);
|
|
97
|
+
if (kbPath) {
|
|
98
|
+
references.push({
|
|
99
|
+
sectionIndex,
|
|
100
|
+
targetNotionId: nid,
|
|
101
|
+
targetKbPath: kbPath,
|
|
102
|
+
displayText: displayText || kbPath,
|
|
103
|
+
});
|
|
104
|
+
return `[${displayText || kbPath}](${kbPath})`;
|
|
105
|
+
}
|
|
106
|
+
return `[${displayText}](notion://${nid})`;
|
|
107
|
+
});
|
|
108
|
+
// Pass 4: Unresolved link_to_page placeholders
|
|
109
|
+
text = text.replace(LINK_PLACEHOLDER_RE, (_match, rawId) => {
|
|
110
|
+
const nid = normalizeId(rawId);
|
|
111
|
+
const kbPath = mapping.notionIdToKbPath.get(nid);
|
|
112
|
+
if (kbPath) {
|
|
113
|
+
references.push({
|
|
114
|
+
sectionIndex,
|
|
115
|
+
targetNotionId: nid,
|
|
116
|
+
targetKbPath: kbPath,
|
|
117
|
+
displayText: kbPath,
|
|
118
|
+
});
|
|
119
|
+
return `[${kbPath}](${kbPath})`;
|
|
120
|
+
}
|
|
121
|
+
// Leave as placeholder
|
|
122
|
+
return `*(Link to Notion page: ${rawId})*`;
|
|
123
|
+
});
|
|
124
|
+
// Pass 5: Relation property markers
|
|
125
|
+
text = text.replace(RELATION_REF_RE, (_match, rawId) => {
|
|
126
|
+
const nid = normalizeId(rawId);
|
|
127
|
+
const kbPath = mapping.notionIdToKbPath.get(nid);
|
|
128
|
+
if (kbPath) {
|
|
129
|
+
references.push({
|
|
130
|
+
sectionIndex,
|
|
131
|
+
targetNotionId: nid,
|
|
132
|
+
targetKbPath: kbPath,
|
|
133
|
+
displayText: kbPath,
|
|
134
|
+
});
|
|
135
|
+
return `[${kbPath}](${kbPath})`;
|
|
136
|
+
}
|
|
137
|
+
// Unresolved — keep the ID
|
|
138
|
+
return nid;
|
|
139
|
+
});
|
|
140
|
+
return { blockType: 'text', text };
|
|
141
|
+
});
|
|
142
|
+
return { name: section.name, content: resolvedContent };
|
|
143
|
+
});
|
|
144
|
+
return { sections: resolvedSections, references };
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Resolve relation property IDs directly (for use in property rendering).
|
|
148
|
+
* Replaces notion-ref:ID markers with KB path links or bare IDs.
|
|
149
|
+
*/
|
|
150
|
+
export function resolveRelationIds(value, mapping) {
|
|
151
|
+
return value.replace(RELATION_REF_RE, (_match, rawId) => {
|
|
152
|
+
const nid = normalizeId(rawId);
|
|
153
|
+
const kbPath = mapping.notionIdToKbPath.get(nid);
|
|
154
|
+
if (kbPath) {
|
|
155
|
+
return `[${kbPath}](${kbPath})`;
|
|
156
|
+
}
|
|
157
|
+
return nid;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { resolveNotionReferences, resolveRelationIds, } from './notion-references.js';
|
|
3
|
+
// ============================================
|
|
4
|
+
// Helpers
|
|
5
|
+
// ============================================
|
|
6
|
+
function textSection(name, ...texts) {
|
|
7
|
+
return {
|
|
8
|
+
name,
|
|
9
|
+
content: texts.map((t) => ({ blockType: 'text', text: t })),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
function mapping(entries) {
|
|
13
|
+
return {
|
|
14
|
+
notionIdToKbPath: new Map(Object.entries(entries)),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function getText(section, blockIndex = 0) {
|
|
18
|
+
const block = section.content[blockIndex];
|
|
19
|
+
if (block.blockType !== 'text')
|
|
20
|
+
throw new Error('Expected text block');
|
|
21
|
+
return block.text;
|
|
22
|
+
}
|
|
23
|
+
// ============================================
|
|
24
|
+
// resolveNotionReferences
|
|
25
|
+
// ============================================
|
|
26
|
+
describe('resolveNotionReferences', () => {
|
|
27
|
+
describe('notion:// protocol links', () => {
|
|
28
|
+
it('resolves a notion:// link when ID is in the mapping', () => {
|
|
29
|
+
const sections = [
|
|
30
|
+
textSection('Intro', 'See [My Page](notion://abc123def456abc123def456abc123de)'),
|
|
31
|
+
];
|
|
32
|
+
const m = mapping({ abc123def456abc123def456abc123de: '/docs/my-page' });
|
|
33
|
+
const { sections: resolved, references } = resolveNotionReferences(sections, m);
|
|
34
|
+
expect(getText(resolved[0])).toBe('See [My Page](/docs/my-page)');
|
|
35
|
+
expect(references).toHaveLength(1);
|
|
36
|
+
expect(references[0]).toEqual({
|
|
37
|
+
sectionIndex: 0,
|
|
38
|
+
targetNotionId: 'abc123def456abc123def456abc123de',
|
|
39
|
+
targetKbPath: '/docs/my-page',
|
|
40
|
+
displayText: 'My Page',
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
it('normalizes IDs with dashes before lookup', () => {
|
|
44
|
+
const sections = [
|
|
45
|
+
textSection('Intro', 'See [Page](notion://abc123de-f456-abc1-23de-f456abc123de)'),
|
|
46
|
+
];
|
|
47
|
+
const m = mapping({ abc123def456abc123def456abc123de: '/docs/page' });
|
|
48
|
+
const { sections: resolved } = resolveNotionReferences(sections, m);
|
|
49
|
+
expect(getText(resolved[0])).toBe('See [Page](/docs/page)');
|
|
50
|
+
});
|
|
51
|
+
it('keeps unresolved notion:// links with normalized ID', () => {
|
|
52
|
+
const sections = [
|
|
53
|
+
textSection('Intro', 'See [Gone](notion://aaaa1111bbbb2222cccc3333dddd4444)'),
|
|
54
|
+
];
|
|
55
|
+
const m = mapping({});
|
|
56
|
+
const { sections: resolved, references } = resolveNotionReferences(sections, m);
|
|
57
|
+
expect(getText(resolved[0])).toBe('See [Gone](notion://aaaa1111bbbb2222cccc3333dddd4444)');
|
|
58
|
+
expect(references).toHaveLength(0);
|
|
59
|
+
});
|
|
60
|
+
it('uses KB path as display text when original display text is empty', () => {
|
|
61
|
+
const sections = [
|
|
62
|
+
textSection('Intro', 'See [](notion://abc123def456abc123def456abc123de)'),
|
|
63
|
+
];
|
|
64
|
+
const m = mapping({ abc123def456abc123def456abc123de: '/docs/page' });
|
|
65
|
+
const { sections: resolved, references } = resolveNotionReferences(sections, m);
|
|
66
|
+
expect(getText(resolved[0])).toBe('See [/docs/page](/docs/page)');
|
|
67
|
+
expect(references[0].displayText).toBe('/docs/page');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe('Notion web URLs', () => {
|
|
71
|
+
it('resolves https://www.notion.so/ URLs with UUID', () => {
|
|
72
|
+
const uuid = 'abc123de-f456-abc1-23de-f456abc123de';
|
|
73
|
+
const sections = [
|
|
74
|
+
textSection('Intro', `Check [this](https://www.notion.so/workspace/${uuid})`),
|
|
75
|
+
];
|
|
76
|
+
const m = mapping({ abc123def456abc123def456abc123de: '/docs/target' });
|
|
77
|
+
const { sections: resolved, references } = resolveNotionReferences(sections, m);
|
|
78
|
+
expect(getText(resolved[0])).toBe('Check [this](/docs/target)');
|
|
79
|
+
expect(references).toHaveLength(1);
|
|
80
|
+
});
|
|
81
|
+
it('resolves http:// variant (no www)', () => {
|
|
82
|
+
const uuid = 'abc123de-f456-abc1-23de-f456abc123de';
|
|
83
|
+
const sections = [textSection('Intro', `See [link](http://notion.so/${uuid})`)];
|
|
84
|
+
const m = mapping({ abc123def456abc123def456abc123de: '/docs/target' });
|
|
85
|
+
const { sections: resolved } = resolveNotionReferences(sections, m);
|
|
86
|
+
expect(getText(resolved[0])).toBe('See [link](/docs/target)');
|
|
87
|
+
});
|
|
88
|
+
it('handles URLs with query params and hash', () => {
|
|
89
|
+
const uuid = 'abc123de-f456-abc1-23de-f456abc123de';
|
|
90
|
+
const sections = [
|
|
91
|
+
textSection('Intro', `See [link](https://www.notion.so/workspace/${uuid}?pvs=4#section)`),
|
|
92
|
+
];
|
|
93
|
+
const m = mapping({ abc123def456abc123def456abc123de: '/docs/target' });
|
|
94
|
+
const { sections: resolved } = resolveNotionReferences(sections, m);
|
|
95
|
+
expect(getText(resolved[0])).toBe('See [link](/docs/target)');
|
|
96
|
+
});
|
|
97
|
+
it('handles URLs with workspace slug prefix', () => {
|
|
98
|
+
const uuid = 'abc123de-f456-abc1-23de-f456abc123de';
|
|
99
|
+
const sections = [
|
|
100
|
+
textSection('Intro', `See [link](https://www.notion.so/my-workspace/${uuid})`),
|
|
101
|
+
];
|
|
102
|
+
const m = mapping({ abc123def456abc123def456abc123de: '/docs/target' });
|
|
103
|
+
const { sections: resolved } = resolveNotionReferences(sections, m);
|
|
104
|
+
expect(getText(resolved[0])).toBe('See [link](/docs/target)');
|
|
105
|
+
});
|
|
106
|
+
it('normalizes unresolved web URLs to notion:// format', () => {
|
|
107
|
+
const uuid = 'aaaa1111-bbbb-2222-cccc-3333dddd4444';
|
|
108
|
+
const sections = [
|
|
109
|
+
textSection('Intro', `See [link](https://www.notion.so/workspace/${uuid})`),
|
|
110
|
+
];
|
|
111
|
+
const m = mapping({});
|
|
112
|
+
const { sections: resolved } = resolveNotionReferences(sections, m);
|
|
113
|
+
expect(getText(resolved[0])).toBe('See [link](notion://aaaa1111bbbb2222cccc3333dddd4444)');
|
|
114
|
+
});
|
|
115
|
+
it('resolves slug-embedded 32-char hex IDs', () => {
|
|
116
|
+
const sections = [
|
|
117
|
+
textSection('Intro', 'See [link](https://notion.so/My-Page-Title-abc123def456abc123def456abc123de)'),
|
|
118
|
+
];
|
|
119
|
+
const m = mapping({ abc123def456abc123def456abc123de: '/docs/my-page' });
|
|
120
|
+
const { sections: resolved } = resolveNotionReferences(sections, m);
|
|
121
|
+
expect(getText(resolved[0])).toBe('See [link](/docs/my-page)');
|
|
122
|
+
});
|
|
123
|
+
it('resolves bare 32-char hex Notion URLs (no slug prefix)', () => {
|
|
124
|
+
const sections = [
|
|
125
|
+
textSection('Intro', 'See [Dev Setup](https://www.notion.so/3004bf42cfeb818ebe8fe2f4befdad52)'),
|
|
126
|
+
];
|
|
127
|
+
const m = mapping({ '3004bf42cfeb818ebe8fe2f4befdad52': '/docs/dev-setup' });
|
|
128
|
+
const { sections: resolved, references } = resolveNotionReferences(sections, m);
|
|
129
|
+
expect(getText(resolved[0])).toBe('See [Dev Setup](/docs/dev-setup)');
|
|
130
|
+
expect(references).toHaveLength(1);
|
|
131
|
+
expect(references[0].targetNotionId).toBe('3004bf42cfeb818ebe8fe2f4befdad52');
|
|
132
|
+
});
|
|
133
|
+
it('normalizes unresolved bare hex Notion URLs to notion://', () => {
|
|
134
|
+
const sections = [
|
|
135
|
+
textSection('Intro', 'See [page](https://www.notion.so/aabbccdd11223344aabbccdd11223344)'),
|
|
136
|
+
];
|
|
137
|
+
const m = mapping({});
|
|
138
|
+
const { sections: resolved } = resolveNotionReferences(sections, m);
|
|
139
|
+
expect(getText(resolved[0])).toBe('See [page](notion://aabbccdd11223344aabbccdd11223344)');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
describe('link_to_page placeholders', () => {
|
|
143
|
+
it('resolves placeholders to KB path links', () => {
|
|
144
|
+
const sections = [
|
|
145
|
+
textSection('Intro', '*(Link to Notion page: abc123def456abc123def456abc123de)*'),
|
|
146
|
+
];
|
|
147
|
+
const m = mapping({ abc123def456abc123def456abc123de: '/docs/linked-page' });
|
|
148
|
+
const { sections: resolved, references } = resolveNotionReferences(sections, m);
|
|
149
|
+
expect(getText(resolved[0])).toBe('[/docs/linked-page](/docs/linked-page)');
|
|
150
|
+
expect(references).toHaveLength(1);
|
|
151
|
+
expect(references[0].displayText).toBe('/docs/linked-page');
|
|
152
|
+
});
|
|
153
|
+
it('resolves placeholders with dashed UUIDs', () => {
|
|
154
|
+
const sections = [
|
|
155
|
+
textSection('Intro', '*(Link to Notion page: abc123de-f456-abc1-23de-f456abc123de)*'),
|
|
156
|
+
];
|
|
157
|
+
const m = mapping({ abc123def456abc123def456abc123de: '/docs/page' });
|
|
158
|
+
const { sections: resolved } = resolveNotionReferences(sections, m);
|
|
159
|
+
expect(getText(resolved[0])).toBe('[/docs/page](/docs/page)');
|
|
160
|
+
});
|
|
161
|
+
it('keeps unresolved placeholders unchanged', () => {
|
|
162
|
+
const sections = [
|
|
163
|
+
textSection('Intro', '*(Link to Notion page: abc123def456abc123def456abc123de)*'),
|
|
164
|
+
];
|
|
165
|
+
const m = mapping({});
|
|
166
|
+
const { sections: resolved } = resolveNotionReferences(sections, m);
|
|
167
|
+
expect(getText(resolved[0])).toBe('*(Link to Notion page: abc123def456abc123def456abc123de)*');
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
describe('relation property markers', () => {
|
|
171
|
+
it('resolves notion-ref: markers to KB path links', () => {
|
|
172
|
+
const sections = [
|
|
173
|
+
textSection('Properties', '| Related | notion-ref:abc123def456abc123def456abc123de |'),
|
|
174
|
+
];
|
|
175
|
+
const m = mapping({ abc123def456abc123def456abc123de: '/docs/related' });
|
|
176
|
+
const { sections: resolved, references } = resolveNotionReferences(sections, m);
|
|
177
|
+
expect(getText(resolved[0])).toBe('| Related | [/docs/related](/docs/related) |');
|
|
178
|
+
expect(references).toHaveLength(1);
|
|
179
|
+
});
|
|
180
|
+
it('resolves multiple comma-separated relation refs', () => {
|
|
181
|
+
const sections = [
|
|
182
|
+
textSection('Properties', 'notion-ref:aaaa1111bbbb2222cccc3333dddd4444, notion-ref:eeee5555ffff6666aaaa7777bbbb8888'),
|
|
183
|
+
];
|
|
184
|
+
const m = mapping({
|
|
185
|
+
aaaa1111bbbb2222cccc3333dddd4444: '/docs/page-a',
|
|
186
|
+
eeee5555ffff6666aaaa7777bbbb8888: '/docs/page-b',
|
|
187
|
+
});
|
|
188
|
+
const { sections: resolved, references } = resolveNotionReferences(sections, m);
|
|
189
|
+
expect(getText(resolved[0])).toBe('[/docs/page-a](/docs/page-a), [/docs/page-b](/docs/page-b)');
|
|
190
|
+
expect(references).toHaveLength(2);
|
|
191
|
+
});
|
|
192
|
+
it('keeps unresolved relation refs as bare IDs', () => {
|
|
193
|
+
const sections = [
|
|
194
|
+
textSection('Properties', 'notion-ref:aaaa1111bbbb2222cccc3333dddd4444'),
|
|
195
|
+
];
|
|
196
|
+
const m = mapping({});
|
|
197
|
+
const { sections: resolved } = resolveNotionReferences(sections, m);
|
|
198
|
+
expect(getText(resolved[0])).toBe('aaaa1111bbbb2222cccc3333dddd4444');
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
describe('multiple references', () => {
|
|
202
|
+
it('resolves multiple different reference types in one text block', () => {
|
|
203
|
+
const sections = [
|
|
204
|
+
textSection('Mixed', [
|
|
205
|
+
'See [Page A](notion://aaaa1111bbbb2222cccc3333dddd4444)',
|
|
206
|
+
'and *(Link to Notion page: eeee5555ffff6666aaaa7777bbbb8888)*',
|
|
207
|
+
'also notion-ref:1111222233334444555566667777aaaa',
|
|
208
|
+
].join(' ')),
|
|
209
|
+
];
|
|
210
|
+
const m = mapping({
|
|
211
|
+
aaaa1111bbbb2222cccc3333dddd4444: '/docs/a',
|
|
212
|
+
eeee5555ffff6666aaaa7777bbbb8888: '/docs/b',
|
|
213
|
+
'1111222233334444555566667777aaaa': '/docs/c',
|
|
214
|
+
});
|
|
215
|
+
const { sections: resolved, references } = resolveNotionReferences(sections, m);
|
|
216
|
+
const text = getText(resolved[0]);
|
|
217
|
+
expect(text).toContain('[Page A](/docs/a)');
|
|
218
|
+
expect(text).toContain('[/docs/b](/docs/b)');
|
|
219
|
+
expect(text).toContain('[/docs/c](/docs/c)');
|
|
220
|
+
expect(references).toHaveLength(3);
|
|
221
|
+
});
|
|
222
|
+
it('handles mixed resolved and unresolved references', () => {
|
|
223
|
+
const sections = [
|
|
224
|
+
textSection('Mixed', '[Known](notion://aaaa1111bbbb2222cccc3333dddd4444) and [Unknown](notion://ffffffffffffffffffffffffffffffff)'),
|
|
225
|
+
];
|
|
226
|
+
const m = mapping({ aaaa1111bbbb2222cccc3333dddd4444: '/docs/known' });
|
|
227
|
+
const { sections: resolved, references } = resolveNotionReferences(sections, m);
|
|
228
|
+
const text = getText(resolved[0]);
|
|
229
|
+
expect(text).toContain('[Known](/docs/known)');
|
|
230
|
+
expect(text).toContain('[Unknown](notion://ffffffffffffffffffffffffffffffff)');
|
|
231
|
+
expect(references).toHaveLength(1);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
describe('section indexing', () => {
|
|
235
|
+
it('tracks correct sectionIndex across multiple sections', () => {
|
|
236
|
+
const sections = [
|
|
237
|
+
textSection('Section 0', '[A](notion://aaaa1111bbbb2222cccc3333dddd4444)'),
|
|
238
|
+
textSection('Section 1', 'No references here'),
|
|
239
|
+
textSection('Section 2', '[B](notion://eeee5555ffff6666aaaa7777bbbb8888)'),
|
|
240
|
+
];
|
|
241
|
+
const m = mapping({
|
|
242
|
+
aaaa1111bbbb2222cccc3333dddd4444: '/docs/a',
|
|
243
|
+
eeee5555ffff6666aaaa7777bbbb8888: '/docs/b',
|
|
244
|
+
});
|
|
245
|
+
const { references } = resolveNotionReferences(sections, m);
|
|
246
|
+
expect(references).toHaveLength(2);
|
|
247
|
+
expect(references[0].sectionIndex).toBe(0);
|
|
248
|
+
expect(references[1].sectionIndex).toBe(2);
|
|
249
|
+
});
|
|
250
|
+
it('assigns sectionIndex based on section position, not block position', () => {
|
|
251
|
+
const sections = [
|
|
252
|
+
{
|
|
253
|
+
name: 'Multi-block',
|
|
254
|
+
content: [
|
|
255
|
+
{ blockType: 'text', text: 'No ref here' },
|
|
256
|
+
{
|
|
257
|
+
blockType: 'text',
|
|
258
|
+
text: '[A](notion://aaaa1111bbbb2222cccc3333dddd4444)',
|
|
259
|
+
},
|
|
260
|
+
],
|
|
261
|
+
},
|
|
262
|
+
];
|
|
263
|
+
const m = mapping({ aaaa1111bbbb2222cccc3333dddd4444: '/docs/a' });
|
|
264
|
+
const { references } = resolveNotionReferences(sections, m);
|
|
265
|
+
expect(references).toHaveLength(1);
|
|
266
|
+
expect(references[0].sectionIndex).toBe(0);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
describe('non-text blocks', () => {
|
|
270
|
+
it('passes image blocks through unchanged', () => {
|
|
271
|
+
const sections = [
|
|
272
|
+
{
|
|
273
|
+
name: 'Images',
|
|
274
|
+
content: [
|
|
275
|
+
{
|
|
276
|
+
blockType: 'image',
|
|
277
|
+
type: 'url',
|
|
278
|
+
url: 'https://example.com/img.png',
|
|
279
|
+
mediaType: 'image/png',
|
|
280
|
+
},
|
|
281
|
+
],
|
|
282
|
+
},
|
|
283
|
+
];
|
|
284
|
+
const m = mapping({ aaaa1111bbbb2222cccc3333dddd4444: '/docs/a' });
|
|
285
|
+
const { sections: resolved, references } = resolveNotionReferences(sections, m);
|
|
286
|
+
expect(resolved[0].content[0]).toEqual(sections[0].content[0]);
|
|
287
|
+
expect(references).toHaveLength(0);
|
|
288
|
+
});
|
|
289
|
+
it('handles mixed text and non-text blocks', () => {
|
|
290
|
+
const sections = [
|
|
291
|
+
{
|
|
292
|
+
name: 'Mixed',
|
|
293
|
+
content: [
|
|
294
|
+
{
|
|
295
|
+
blockType: 'text',
|
|
296
|
+
text: '[A](notion://aaaa1111bbbb2222cccc3333dddd4444)',
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
blockType: 'image',
|
|
300
|
+
type: 'url',
|
|
301
|
+
url: 'https://example.com/img.png',
|
|
302
|
+
mediaType: 'image/png',
|
|
303
|
+
},
|
|
304
|
+
{ blockType: 'text', text: 'Plain text, no refs' },
|
|
305
|
+
],
|
|
306
|
+
},
|
|
307
|
+
];
|
|
308
|
+
const m = mapping({ aaaa1111bbbb2222cccc3333dddd4444: '/docs/a' });
|
|
309
|
+
const { sections: resolved, references } = resolveNotionReferences(sections, m);
|
|
310
|
+
expect(getText(resolved[0], 0)).toBe('[A](/docs/a)');
|
|
311
|
+
expect(resolved[0].content[1].blockType).toBe('image');
|
|
312
|
+
expect(getText(resolved[0], 2)).toBe('Plain text, no refs');
|
|
313
|
+
expect(references).toHaveLength(1);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
describe('edge cases', () => {
|
|
317
|
+
it('handles empty sections array', () => {
|
|
318
|
+
const { sections, references } = resolveNotionReferences([], mapping({}));
|
|
319
|
+
expect(sections).toEqual([]);
|
|
320
|
+
expect(references).toEqual([]);
|
|
321
|
+
});
|
|
322
|
+
it('handles sections with empty content', () => {
|
|
323
|
+
const sections = [{ name: 'Empty', content: [] }];
|
|
324
|
+
const { sections: resolved, references } = resolveNotionReferences(sections, mapping({}));
|
|
325
|
+
expect(resolved).toHaveLength(1);
|
|
326
|
+
expect(resolved[0].content).toEqual([]);
|
|
327
|
+
expect(references).toEqual([]);
|
|
328
|
+
});
|
|
329
|
+
it('handles text blocks with no references', () => {
|
|
330
|
+
const sections = [
|
|
331
|
+
textSection('Plain', 'Just some normal text with [a link](https://example.com)'),
|
|
332
|
+
];
|
|
333
|
+
const m = mapping({ aaaa1111bbbb2222cccc3333dddd4444: '/docs/a' });
|
|
334
|
+
const { sections: resolved, references } = resolveNotionReferences(sections, m);
|
|
335
|
+
expect(getText(resolved[0])).toBe('Just some normal text with [a link](https://example.com)');
|
|
336
|
+
expect(references).toHaveLength(0);
|
|
337
|
+
});
|
|
338
|
+
it('preserves section names', () => {
|
|
339
|
+
const sections = [textSection('My Section Name', 'text')];
|
|
340
|
+
const { sections: resolved } = resolveNotionReferences(sections, mapping({}));
|
|
341
|
+
expect(resolved[0].name).toBe('My Section Name');
|
|
342
|
+
});
|
|
343
|
+
it('does not modify the original sections (immutability)', () => {
|
|
344
|
+
const original = [
|
|
345
|
+
textSection('Intro', '[A](notion://aaaa1111bbbb2222cccc3333dddd4444)'),
|
|
346
|
+
];
|
|
347
|
+
const originalText = getText(original[0]);
|
|
348
|
+
const m = mapping({ aaaa1111bbbb2222cccc3333dddd4444: '/docs/a' });
|
|
349
|
+
resolveNotionReferences(original, m);
|
|
350
|
+
// Original should be unchanged
|
|
351
|
+
expect(getText(original[0])).toBe(originalText);
|
|
352
|
+
});
|
|
353
|
+
it('handles duplicate references to the same target in one block', () => {
|
|
354
|
+
const id = 'aaaa1111bbbb2222cccc3333dddd4444';
|
|
355
|
+
const sections = [
|
|
356
|
+
textSection('Intro', `[First](notion://${id}) and [Second](notion://${id})`),
|
|
357
|
+
];
|
|
358
|
+
const m = mapping({ [id]: '/docs/target' });
|
|
359
|
+
const { sections: resolved, references } = resolveNotionReferences(sections, m);
|
|
360
|
+
expect(getText(resolved[0])).toBe('[First](/docs/target) and [Second](/docs/target)');
|
|
361
|
+
// Both occurrences are recorded as references
|
|
362
|
+
expect(references).toHaveLength(2);
|
|
363
|
+
expect(references[0].displayText).toBe('First');
|
|
364
|
+
expect(references[1].displayText).toBe('Second');
|
|
365
|
+
});
|
|
366
|
+
it('handles self-references (resolves in content, does not filter)', () => {
|
|
367
|
+
const id = 'aaaa1111bbbb2222cccc3333dddd4444';
|
|
368
|
+
const sections = [textSection('Self', `[Self Link](notion://${id})`)];
|
|
369
|
+
const m = mapping({ [id]: '/docs/self-page' });
|
|
370
|
+
const { sections: resolved, references } = resolveNotionReferences(sections, m);
|
|
371
|
+
expect(getText(resolved[0])).toBe('[Self Link](/docs/self-page)');
|
|
372
|
+
// Resolution function doesn't filter self-refs — that's the caller's job
|
|
373
|
+
expect(references).toHaveLength(1);
|
|
374
|
+
});
|
|
375
|
+
it('handles circular references (A→B and B→A)', () => {
|
|
376
|
+
const idA = 'aaaa1111bbbb2222cccc3333dddd4444';
|
|
377
|
+
const idB = 'eeee5555ffff6666aaaa7777bbbb8888';
|
|
378
|
+
const sections = [
|
|
379
|
+
textSection('Page A', `Link to [B](notion://${idB})`),
|
|
380
|
+
textSection('Page B', `Link to [A](notion://${idA})`),
|
|
381
|
+
];
|
|
382
|
+
const m = mapping({
|
|
383
|
+
[idA]: '/docs/page-a',
|
|
384
|
+
[idB]: '/docs/page-b',
|
|
385
|
+
});
|
|
386
|
+
const { sections: resolved, references } = resolveNotionReferences(sections, m);
|
|
387
|
+
expect(getText(resolved[0])).toBe('Link to [B](/docs/page-b)');
|
|
388
|
+
expect(getText(resolved[1])).toBe('Link to [A](/docs/page-a)');
|
|
389
|
+
expect(references).toHaveLength(2);
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
// ============================================
|
|
394
|
+
// resolveRelationIds
|
|
395
|
+
// ============================================
|
|
396
|
+
describe('resolveRelationIds', () => {
|
|
397
|
+
it('resolves a single notion-ref marker', () => {
|
|
398
|
+
const m = mapping({ aaaa1111bbbb2222cccc3333dddd4444: '/docs/target' });
|
|
399
|
+
const result = resolveRelationIds('notion-ref:aaaa1111bbbb2222cccc3333dddd4444', m);
|
|
400
|
+
expect(result).toBe('[/docs/target](/docs/target)');
|
|
401
|
+
});
|
|
402
|
+
it('resolves multiple comma-separated markers', () => {
|
|
403
|
+
const m = mapping({
|
|
404
|
+
aaaa1111bbbb2222cccc3333dddd4444: '/docs/a',
|
|
405
|
+
eeee5555ffff6666aaaa7777bbbb8888: '/docs/b',
|
|
406
|
+
});
|
|
407
|
+
const result = resolveRelationIds('notion-ref:aaaa1111bbbb2222cccc3333dddd4444, notion-ref:eeee5555ffff6666aaaa7777bbbb8888', m);
|
|
408
|
+
expect(result).toBe('[/docs/a](/docs/a), [/docs/b](/docs/b)');
|
|
409
|
+
});
|
|
410
|
+
it('returns bare ID for unresolved markers', () => {
|
|
411
|
+
const result = resolveRelationIds('notion-ref:aaaa1111bbbb2222cccc3333dddd4444', mapping({}));
|
|
412
|
+
expect(result).toBe('aaaa1111bbbb2222cccc3333dddd4444');
|
|
413
|
+
});
|
|
414
|
+
it('handles mix of resolved and unresolved', () => {
|
|
415
|
+
const m = mapping({ aaaa1111bbbb2222cccc3333dddd4444: '/docs/known' });
|
|
416
|
+
const result = resolveRelationIds('notion-ref:aaaa1111bbbb2222cccc3333dddd4444, notion-ref:ffffffffffffffffffffffffffffffff', m);
|
|
417
|
+
expect(result).toBe('[/docs/known](/docs/known), ffffffffffffffffffffffffffffffff');
|
|
418
|
+
});
|
|
419
|
+
it('returns input unchanged if no markers present', () => {
|
|
420
|
+
const result = resolveRelationIds('just plain text', mapping({}));
|
|
421
|
+
expect(result).toBe('just plain text');
|
|
422
|
+
});
|
|
423
|
+
});
|
package/dist/sources/notion.js
CHANGED
|
@@ -12,7 +12,8 @@ import { NotionApiClient } from './notion-api.js';
|
|
|
12
12
|
import { blocksToSections, getPageTitle, normalizeId, } from './notion-blocks.js';
|
|
13
13
|
import { NotionMediaDownloader } from './notion-media.js';
|
|
14
14
|
import { parseDatabaseSchema, parseEntryValues, renderPropertiesSection, } from './notion-databases.js';
|
|
15
|
-
|
|
15
|
+
import { resolveNotionReferences } from './notion-references.js';
|
|
16
|
+
const MAX_DOCUMENT_COUNT = 10_000;
|
|
16
17
|
// ============================================
|
|
17
18
|
// Source
|
|
18
19
|
// ============================================
|
|
@@ -302,6 +303,9 @@ export class NotionSource extends MigrationSource {
|
|
|
302
303
|
let sections = await blocksToSections(blocks, this.client, this.pagePathMap);
|
|
303
304
|
// Download Notion-hosted media files
|
|
304
305
|
sections = await this.downloadSectionMedia(sections);
|
|
306
|
+
// Resolve cross-references
|
|
307
|
+
const { sections: resolvedSections, references } = resolveNotionReferences(sections, { notionIdToKbPath: this.pagePathMap });
|
|
308
|
+
sections = resolvedSections;
|
|
305
309
|
if (sections.length === 0) {
|
|
306
310
|
console.log(` Skipping page with no content: ${node.title}`);
|
|
307
311
|
return null;
|
|
@@ -311,6 +315,7 @@ export class NotionSource extends MigrationSource {
|
|
|
311
315
|
name: node.title,
|
|
312
316
|
sections,
|
|
313
317
|
sourcePath: `notion://${node.page.id}`,
|
|
318
|
+
references: references.length > 0 ? references : undefined,
|
|
314
319
|
};
|
|
315
320
|
}
|
|
316
321
|
catch (error) {
|
|
@@ -337,7 +342,10 @@ export class NotionSource extends MigrationSource {
|
|
|
337
342
|
sections.push(...contentSections);
|
|
338
343
|
}
|
|
339
344
|
// Download media
|
|
340
|
-
|
|
345
|
+
let processedSections = await this.downloadSectionMedia(sections);
|
|
346
|
+
// Resolve cross-references
|
|
347
|
+
const { sections: resolvedSections, references } = resolveNotionReferences(processedSections, { notionIdToKbPath: this.pagePathMap });
|
|
348
|
+
processedSections = resolvedSections;
|
|
341
349
|
const nid = normalizeId(entry.id);
|
|
342
350
|
const kbPath = this.pagePathMap.get(nid) ?? slug;
|
|
343
351
|
return {
|
|
@@ -352,6 +360,7 @@ export class NotionSource extends MigrationSource {
|
|
|
352
360
|
},
|
|
353
361
|
],
|
|
354
362
|
sourcePath: `notion://${entry.id}`,
|
|
363
|
+
references: references.length > 0 ? references : undefined,
|
|
355
364
|
};
|
|
356
365
|
}
|
|
357
366
|
catch (error) {
|
package/dist/types.d.ts
CHANGED
|
@@ -72,6 +72,16 @@ export interface SectionInput {
|
|
|
72
72
|
name: string;
|
|
73
73
|
content: ContentBlock[];
|
|
74
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* A cross-reference discovered during Notion import resolution.
|
|
77
|
+
* Re-exported from notion-references for convenience.
|
|
78
|
+
*/
|
|
79
|
+
export interface ExtractedReference {
|
|
80
|
+
sectionIndex: number;
|
|
81
|
+
targetNotionId: string;
|
|
82
|
+
targetKbPath: string | null;
|
|
83
|
+
displayText: string;
|
|
84
|
+
}
|
|
75
85
|
/**
|
|
76
86
|
* A document extracted from a source
|
|
77
87
|
*/
|
|
@@ -86,6 +96,8 @@ export interface ExtractedDocument {
|
|
|
86
96
|
sections: SectionInput[];
|
|
87
97
|
/** Original source path/identifier for logging */
|
|
88
98
|
sourcePath: string;
|
|
99
|
+
/** Cross-references discovered during resolution */
|
|
100
|
+
references?: ExtractedReference[];
|
|
89
101
|
}
|
|
90
102
|
/**
|
|
91
103
|
* Status of a single document migration
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moxn/kb-migrate",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "Migration tool for importing documents into Moxn Knowledge Base from local files, Notion, Google Docs, and more",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -26,6 +26,10 @@
|
|
|
26
26
|
"types": "./dist/sources/notion-databases.d.ts",
|
|
27
27
|
"default": "./dist/sources/notion-databases.js"
|
|
28
28
|
},
|
|
29
|
+
"./sources/notion-references": {
|
|
30
|
+
"types": "./dist/sources/notion-references.d.ts",
|
|
31
|
+
"default": "./dist/sources/notion-references.js"
|
|
32
|
+
},
|
|
29
33
|
"./sources/notion-media": {
|
|
30
34
|
"types": "./dist/sources/notion-media.d.ts",
|
|
31
35
|
"default": "./dist/sources/notion-media.js"
|
|
@@ -53,6 +57,8 @@
|
|
|
53
57
|
"scripts": {
|
|
54
58
|
"build": "tsc",
|
|
55
59
|
"dev": "tsc --watch",
|
|
60
|
+
"test": "vitest run",
|
|
61
|
+
"test:watch": "vitest",
|
|
56
62
|
"prepublishOnly": "npm run build"
|
|
57
63
|
},
|
|
58
64
|
"dependencies": {
|
|
@@ -63,7 +69,8 @@
|
|
|
63
69
|
},
|
|
64
70
|
"devDependencies": {
|
|
65
71
|
"@types/node": "^20.0.0",
|
|
66
|
-
"typescript": "^5.0.0"
|
|
72
|
+
"typescript": "^5.0.0",
|
|
73
|
+
"vitest": "^4.0.18"
|
|
67
74
|
},
|
|
68
75
|
"engines": {
|
|
69
76
|
"node": ">=18.0.0"
|