@moxn/kb-migrate 0.4.1 → 0.4.2
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 +4 -1
- package/dist/sources/notion-blocks.test.d.ts +1 -0
- package/dist/sources/notion-blocks.test.js +197 -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 +141 -0
- package/dist/sources/notion-references.test.d.ts +1 -0
- package/dist/sources/notion-references.test.js +405 -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
|
];
|
|
@@ -441,6 +441,9 @@ export function richTextToMarkdown(richText) {
|
|
|
441
441
|
else if (rt.mention.type === 'page' && rt.mention.page) {
|
|
442
442
|
text = `[${text}](notion://${rt.mention.page.id})`;
|
|
443
443
|
}
|
|
444
|
+
else if (rt.mention.type === 'database' && rt.mention.database) {
|
|
445
|
+
text = `[${text}](notion://${rt.mention.database.id})`;
|
|
446
|
+
}
|
|
444
447
|
else if (rt.mention.type === 'date' && rt.mention.date) {
|
|
445
448
|
text = rt.mention.date.start;
|
|
446
449
|
if (rt.mention.date.end)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,197 @@
|
|
|
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 the href (href takes precedence)', () => {
|
|
49
|
+
// When href is set on the rich text, line 641 handles it BEFORE the mention check
|
|
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
|
+
// href takes precedence — produces a notion.so link (resolved in post-processing)
|
|
69
|
+
expect(result).toBe('[My Database](https://www.notion.so/workspace/abc123de-f456-abc1-23de-f456abc123de)');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
describe('page mentions', () => {
|
|
73
|
+
it('renders page mention without href as notion:// link', () => {
|
|
74
|
+
const rt = {
|
|
75
|
+
type: 'mention',
|
|
76
|
+
plain_text: 'My Page',
|
|
77
|
+
annotations: {
|
|
78
|
+
bold: false,
|
|
79
|
+
italic: false,
|
|
80
|
+
strikethrough: false,
|
|
81
|
+
underline: false,
|
|
82
|
+
code: false,
|
|
83
|
+
color: 'default',
|
|
84
|
+
},
|
|
85
|
+
href: null,
|
|
86
|
+
mention: {
|
|
87
|
+
type: 'page',
|
|
88
|
+
page: { id: 'abc123de-f456-abc1-23de-f456abc123de' },
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
const result = richTextToMarkdown([rt]);
|
|
92
|
+
expect(result).toBe('[My Page](notion://abc123de-f456-abc1-23de-f456abc123de)');
|
|
93
|
+
});
|
|
94
|
+
it('renders page mention with href using the href', () => {
|
|
95
|
+
const rt = {
|
|
96
|
+
type: 'mention',
|
|
97
|
+
plain_text: 'My Page',
|
|
98
|
+
annotations: {
|
|
99
|
+
bold: false,
|
|
100
|
+
italic: false,
|
|
101
|
+
strikethrough: false,
|
|
102
|
+
underline: false,
|
|
103
|
+
code: false,
|
|
104
|
+
color: 'default',
|
|
105
|
+
},
|
|
106
|
+
href: 'https://www.notion.so/workspace/abc123de-f456-abc1-23de-f456abc123de',
|
|
107
|
+
mention: {
|
|
108
|
+
type: 'page',
|
|
109
|
+
page: { id: 'abc123de-f456-abc1-23de-f456abc123de' },
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
const result = richTextToMarkdown([rt]);
|
|
113
|
+
expect(result).toBe('[My Page](https://www.notion.so/workspace/abc123de-f456-abc1-23de-f456abc123de)');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
describe('other mention types preserved', () => {
|
|
117
|
+
it('renders user mentions as @name', () => {
|
|
118
|
+
const rt = {
|
|
119
|
+
type: 'mention',
|
|
120
|
+
plain_text: 'John',
|
|
121
|
+
annotations: {
|
|
122
|
+
bold: false,
|
|
123
|
+
italic: false,
|
|
124
|
+
strikethrough: false,
|
|
125
|
+
underline: false,
|
|
126
|
+
code: false,
|
|
127
|
+
color: 'default',
|
|
128
|
+
},
|
|
129
|
+
href: null,
|
|
130
|
+
mention: {
|
|
131
|
+
type: 'user',
|
|
132
|
+
user: { id: 'user-123', name: 'John' },
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
const result = richTextToMarkdown([rt]);
|
|
136
|
+
expect(result).toBe('@John');
|
|
137
|
+
});
|
|
138
|
+
it('renders date mentions', () => {
|
|
139
|
+
const rt = {
|
|
140
|
+
type: 'mention',
|
|
141
|
+
plain_text: '2024-01-15',
|
|
142
|
+
annotations: {
|
|
143
|
+
bold: false,
|
|
144
|
+
italic: false,
|
|
145
|
+
strikethrough: false,
|
|
146
|
+
underline: false,
|
|
147
|
+
code: false,
|
|
148
|
+
color: 'default',
|
|
149
|
+
},
|
|
150
|
+
href: null,
|
|
151
|
+
mention: {
|
|
152
|
+
type: 'date',
|
|
153
|
+
date: { start: '2024-01-15', end: '2024-01-20' },
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
const result = richTextToMarkdown([rt]);
|
|
157
|
+
expect(result).toBe('2024-01-15 → 2024-01-20');
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
describe('annotations on mentions', () => {
|
|
161
|
+
it('applies bold to database mention', () => {
|
|
162
|
+
const rt = {
|
|
163
|
+
type: 'mention',
|
|
164
|
+
plain_text: 'DB',
|
|
165
|
+
annotations: {
|
|
166
|
+
bold: true,
|
|
167
|
+
italic: false,
|
|
168
|
+
strikethrough: false,
|
|
169
|
+
underline: false,
|
|
170
|
+
code: false,
|
|
171
|
+
color: 'default',
|
|
172
|
+
},
|
|
173
|
+
href: null,
|
|
174
|
+
mention: {
|
|
175
|
+
type: 'database',
|
|
176
|
+
database: { id: 'aaaa1111bbbb2222cccc3333dddd4444' },
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
const result = richTextToMarkdown([rt]);
|
|
180
|
+
expect(result).toBe('[**DB**](notion://aaaa1111bbbb2222cccc3333dddd4444)');
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
// ============================================
|
|
185
|
+
// normalizeId
|
|
186
|
+
// ============================================
|
|
187
|
+
describe('normalizeId', () => {
|
|
188
|
+
it('removes dashes from UUID', () => {
|
|
189
|
+
expect(normalizeId('abc123de-f456-abc1-23de-f456abc123de')).toBe('abc123def456abc123def456abc123de');
|
|
190
|
+
});
|
|
191
|
+
it('returns already-normalized ID unchanged', () => {
|
|
192
|
+
expect(normalizeId('abc123def456abc123def456abc123de')).toBe('abc123def456abc123def456abc123de');
|
|
193
|
+
});
|
|
194
|
+
it('handles empty string', () => {
|
|
195
|
+
expect(normalizeId('')).toBe('');
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -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,141 @@
|
|
|
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
|
+
// 3. Unresolved placeholders from link_to_page conversion
|
|
22
|
+
const LINK_PLACEHOLDER_RE = /\*\(Link to Notion page: ([a-f0-9-]{32,36})\)\*/g;
|
|
23
|
+
// 4. Relation property markers
|
|
24
|
+
const RELATION_REF_RE = /notion-ref:([a-f0-9]{32})/g;
|
|
25
|
+
// ============================================
|
|
26
|
+
// Main function
|
|
27
|
+
// ============================================
|
|
28
|
+
/**
|
|
29
|
+
* Resolve Notion cross-references in section content and extract reference metadata.
|
|
30
|
+
*
|
|
31
|
+
* Processes all TextBlock content, replacing Notion URLs/IDs with KB paths
|
|
32
|
+
* where a mapping exists. Returns both the resolved sections and a list of
|
|
33
|
+
* extracted references for KB Reference object creation.
|
|
34
|
+
*/
|
|
35
|
+
export function resolveNotionReferences(sections, mapping) {
|
|
36
|
+
const references = [];
|
|
37
|
+
const resolvedSections = sections.map((section, sectionIndex) => {
|
|
38
|
+
const resolvedContent = section.content.map((block) => {
|
|
39
|
+
if (block.blockType !== 'text')
|
|
40
|
+
return block;
|
|
41
|
+
const textBlock = block;
|
|
42
|
+
let text = textBlock.text;
|
|
43
|
+
// Pass 1: notion:// protocol links
|
|
44
|
+
text = text.replace(NOTION_PROTOCOL_RE, (_match, displayText, rawId) => {
|
|
45
|
+
const nid = normalizeId(rawId);
|
|
46
|
+
const kbPath = mapping.notionIdToKbPath.get(nid);
|
|
47
|
+
if (kbPath) {
|
|
48
|
+
references.push({
|
|
49
|
+
sectionIndex,
|
|
50
|
+
targetNotionId: nid,
|
|
51
|
+
targetKbPath: kbPath,
|
|
52
|
+
displayText: displayText || kbPath,
|
|
53
|
+
});
|
|
54
|
+
return `[${displayText || kbPath}](${kbPath})`;
|
|
55
|
+
}
|
|
56
|
+
// Leave as notion:// for future re-resolution
|
|
57
|
+
return `[${displayText}](notion://${nid})`;
|
|
58
|
+
});
|
|
59
|
+
// Pass 2: Notion web URLs (with dashes in UUID)
|
|
60
|
+
text = text.replace(NOTION_WEB_URL_RE, (_match, displayText, rawId) => {
|
|
61
|
+
const nid = normalizeId(rawId);
|
|
62
|
+
const kbPath = mapping.notionIdToKbPath.get(nid);
|
|
63
|
+
if (kbPath) {
|
|
64
|
+
references.push({
|
|
65
|
+
sectionIndex,
|
|
66
|
+
targetNotionId: nid,
|
|
67
|
+
targetKbPath: kbPath,
|
|
68
|
+
displayText: displayText || kbPath,
|
|
69
|
+
});
|
|
70
|
+
return `[${displayText || kbPath}](${kbPath})`;
|
|
71
|
+
}
|
|
72
|
+
// Unresolved — normalize to notion:// format
|
|
73
|
+
return `[${displayText}](notion://${nid})`;
|
|
74
|
+
});
|
|
75
|
+
// Pass 3: Notion web URLs (slug-embedded 32-char hex IDs)
|
|
76
|
+
text = text.replace(NOTION_WEB_URL_SLUG_RE, (_match, displayText, rawId) => {
|
|
77
|
+
const nid = normalizeId(rawId);
|
|
78
|
+
const kbPath = mapping.notionIdToKbPath.get(nid);
|
|
79
|
+
if (kbPath) {
|
|
80
|
+
references.push({
|
|
81
|
+
sectionIndex,
|
|
82
|
+
targetNotionId: nid,
|
|
83
|
+
targetKbPath: kbPath,
|
|
84
|
+
displayText: displayText || kbPath,
|
|
85
|
+
});
|
|
86
|
+
return `[${displayText || kbPath}](${kbPath})`;
|
|
87
|
+
}
|
|
88
|
+
return `[${displayText}](notion://${nid})`;
|
|
89
|
+
});
|
|
90
|
+
// Pass 4: Unresolved link_to_page placeholders
|
|
91
|
+
text = text.replace(LINK_PLACEHOLDER_RE, (_match, rawId) => {
|
|
92
|
+
const nid = normalizeId(rawId);
|
|
93
|
+
const kbPath = mapping.notionIdToKbPath.get(nid);
|
|
94
|
+
if (kbPath) {
|
|
95
|
+
references.push({
|
|
96
|
+
sectionIndex,
|
|
97
|
+
targetNotionId: nid,
|
|
98
|
+
targetKbPath: kbPath,
|
|
99
|
+
displayText: kbPath,
|
|
100
|
+
});
|
|
101
|
+
return `[${kbPath}](${kbPath})`;
|
|
102
|
+
}
|
|
103
|
+
// Leave as placeholder
|
|
104
|
+
return `*(Link to Notion page: ${rawId})*`;
|
|
105
|
+
});
|
|
106
|
+
// Pass 5: Relation property markers
|
|
107
|
+
text = text.replace(RELATION_REF_RE, (_match, rawId) => {
|
|
108
|
+
const nid = normalizeId(rawId);
|
|
109
|
+
const kbPath = mapping.notionIdToKbPath.get(nid);
|
|
110
|
+
if (kbPath) {
|
|
111
|
+
references.push({
|
|
112
|
+
sectionIndex,
|
|
113
|
+
targetNotionId: nid,
|
|
114
|
+
targetKbPath: kbPath,
|
|
115
|
+
displayText: kbPath,
|
|
116
|
+
});
|
|
117
|
+
return `[${kbPath}](${kbPath})`;
|
|
118
|
+
}
|
|
119
|
+
// Unresolved — keep the ID
|
|
120
|
+
return nid;
|
|
121
|
+
});
|
|
122
|
+
return { blockType: 'text', text };
|
|
123
|
+
});
|
|
124
|
+
return { name: section.name, content: resolvedContent };
|
|
125
|
+
});
|
|
126
|
+
return { sections: resolvedSections, references };
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Resolve relation property IDs directly (for use in property rendering).
|
|
130
|
+
* Replaces notion-ref:ID markers with KB path links or bare IDs.
|
|
131
|
+
*/
|
|
132
|
+
export function resolveRelationIds(value, mapping) {
|
|
133
|
+
return value.replace(RELATION_REF_RE, (_match, rawId) => {
|
|
134
|
+
const nid = normalizeId(rawId);
|
|
135
|
+
const kbPath = mapping.notionIdToKbPath.get(nid);
|
|
136
|
+
if (kbPath) {
|
|
137
|
+
return `[${kbPath}](${kbPath})`;
|
|
138
|
+
}
|
|
139
|
+
return nid;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,405 @@
|
|
|
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
|
+
});
|
|
124
|
+
describe('link_to_page placeholders', () => {
|
|
125
|
+
it('resolves placeholders to KB path links', () => {
|
|
126
|
+
const sections = [
|
|
127
|
+
textSection('Intro', '*(Link to Notion page: abc123def456abc123def456abc123de)*'),
|
|
128
|
+
];
|
|
129
|
+
const m = mapping({ abc123def456abc123def456abc123de: '/docs/linked-page' });
|
|
130
|
+
const { sections: resolved, references } = resolveNotionReferences(sections, m);
|
|
131
|
+
expect(getText(resolved[0])).toBe('[/docs/linked-page](/docs/linked-page)');
|
|
132
|
+
expect(references).toHaveLength(1);
|
|
133
|
+
expect(references[0].displayText).toBe('/docs/linked-page');
|
|
134
|
+
});
|
|
135
|
+
it('resolves placeholders with dashed UUIDs', () => {
|
|
136
|
+
const sections = [
|
|
137
|
+
textSection('Intro', '*(Link to Notion page: abc123de-f456-abc1-23de-f456abc123de)*'),
|
|
138
|
+
];
|
|
139
|
+
const m = mapping({ abc123def456abc123def456abc123de: '/docs/page' });
|
|
140
|
+
const { sections: resolved } = resolveNotionReferences(sections, m);
|
|
141
|
+
expect(getText(resolved[0])).toBe('[/docs/page](/docs/page)');
|
|
142
|
+
});
|
|
143
|
+
it('keeps unresolved placeholders unchanged', () => {
|
|
144
|
+
const sections = [
|
|
145
|
+
textSection('Intro', '*(Link to Notion page: abc123def456abc123def456abc123de)*'),
|
|
146
|
+
];
|
|
147
|
+
const m = mapping({});
|
|
148
|
+
const { sections: resolved } = resolveNotionReferences(sections, m);
|
|
149
|
+
expect(getText(resolved[0])).toBe('*(Link to Notion page: abc123def456abc123def456abc123de)*');
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
describe('relation property markers', () => {
|
|
153
|
+
it('resolves notion-ref: markers to KB path links', () => {
|
|
154
|
+
const sections = [
|
|
155
|
+
textSection('Properties', '| Related | notion-ref:abc123def456abc123def456abc123de |'),
|
|
156
|
+
];
|
|
157
|
+
const m = mapping({ abc123def456abc123def456abc123de: '/docs/related' });
|
|
158
|
+
const { sections: resolved, references } = resolveNotionReferences(sections, m);
|
|
159
|
+
expect(getText(resolved[0])).toBe('| Related | [/docs/related](/docs/related) |');
|
|
160
|
+
expect(references).toHaveLength(1);
|
|
161
|
+
});
|
|
162
|
+
it('resolves multiple comma-separated relation refs', () => {
|
|
163
|
+
const sections = [
|
|
164
|
+
textSection('Properties', 'notion-ref:aaaa1111bbbb2222cccc3333dddd4444, notion-ref:eeee5555ffff6666aaaa7777bbbb8888'),
|
|
165
|
+
];
|
|
166
|
+
const m = mapping({
|
|
167
|
+
aaaa1111bbbb2222cccc3333dddd4444: '/docs/page-a',
|
|
168
|
+
eeee5555ffff6666aaaa7777bbbb8888: '/docs/page-b',
|
|
169
|
+
});
|
|
170
|
+
const { sections: resolved, references } = resolveNotionReferences(sections, m);
|
|
171
|
+
expect(getText(resolved[0])).toBe('[/docs/page-a](/docs/page-a), [/docs/page-b](/docs/page-b)');
|
|
172
|
+
expect(references).toHaveLength(2);
|
|
173
|
+
});
|
|
174
|
+
it('keeps unresolved relation refs as bare IDs', () => {
|
|
175
|
+
const sections = [
|
|
176
|
+
textSection('Properties', 'notion-ref:aaaa1111bbbb2222cccc3333dddd4444'),
|
|
177
|
+
];
|
|
178
|
+
const m = mapping({});
|
|
179
|
+
const { sections: resolved } = resolveNotionReferences(sections, m);
|
|
180
|
+
expect(getText(resolved[0])).toBe('aaaa1111bbbb2222cccc3333dddd4444');
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
describe('multiple references', () => {
|
|
184
|
+
it('resolves multiple different reference types in one text block', () => {
|
|
185
|
+
const sections = [
|
|
186
|
+
textSection('Mixed', [
|
|
187
|
+
'See [Page A](notion://aaaa1111bbbb2222cccc3333dddd4444)',
|
|
188
|
+
'and *(Link to Notion page: eeee5555ffff6666aaaa7777bbbb8888)*',
|
|
189
|
+
'also notion-ref:1111222233334444555566667777aaaa',
|
|
190
|
+
].join(' ')),
|
|
191
|
+
];
|
|
192
|
+
const m = mapping({
|
|
193
|
+
aaaa1111bbbb2222cccc3333dddd4444: '/docs/a',
|
|
194
|
+
eeee5555ffff6666aaaa7777bbbb8888: '/docs/b',
|
|
195
|
+
'1111222233334444555566667777aaaa': '/docs/c',
|
|
196
|
+
});
|
|
197
|
+
const { sections: resolved, references } = resolveNotionReferences(sections, m);
|
|
198
|
+
const text = getText(resolved[0]);
|
|
199
|
+
expect(text).toContain('[Page A](/docs/a)');
|
|
200
|
+
expect(text).toContain('[/docs/b](/docs/b)');
|
|
201
|
+
expect(text).toContain('[/docs/c](/docs/c)');
|
|
202
|
+
expect(references).toHaveLength(3);
|
|
203
|
+
});
|
|
204
|
+
it('handles mixed resolved and unresolved references', () => {
|
|
205
|
+
const sections = [
|
|
206
|
+
textSection('Mixed', '[Known](notion://aaaa1111bbbb2222cccc3333dddd4444) and [Unknown](notion://ffffffffffffffffffffffffffffffff)'),
|
|
207
|
+
];
|
|
208
|
+
const m = mapping({ aaaa1111bbbb2222cccc3333dddd4444: '/docs/known' });
|
|
209
|
+
const { sections: resolved, references } = resolveNotionReferences(sections, m);
|
|
210
|
+
const text = getText(resolved[0]);
|
|
211
|
+
expect(text).toContain('[Known](/docs/known)');
|
|
212
|
+
expect(text).toContain('[Unknown](notion://ffffffffffffffffffffffffffffffff)');
|
|
213
|
+
expect(references).toHaveLength(1);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
describe('section indexing', () => {
|
|
217
|
+
it('tracks correct sectionIndex across multiple sections', () => {
|
|
218
|
+
const sections = [
|
|
219
|
+
textSection('Section 0', '[A](notion://aaaa1111bbbb2222cccc3333dddd4444)'),
|
|
220
|
+
textSection('Section 1', 'No references here'),
|
|
221
|
+
textSection('Section 2', '[B](notion://eeee5555ffff6666aaaa7777bbbb8888)'),
|
|
222
|
+
];
|
|
223
|
+
const m = mapping({
|
|
224
|
+
aaaa1111bbbb2222cccc3333dddd4444: '/docs/a',
|
|
225
|
+
eeee5555ffff6666aaaa7777bbbb8888: '/docs/b',
|
|
226
|
+
});
|
|
227
|
+
const { references } = resolveNotionReferences(sections, m);
|
|
228
|
+
expect(references).toHaveLength(2);
|
|
229
|
+
expect(references[0].sectionIndex).toBe(0);
|
|
230
|
+
expect(references[1].sectionIndex).toBe(2);
|
|
231
|
+
});
|
|
232
|
+
it('assigns sectionIndex based on section position, not block position', () => {
|
|
233
|
+
const sections = [
|
|
234
|
+
{
|
|
235
|
+
name: 'Multi-block',
|
|
236
|
+
content: [
|
|
237
|
+
{ blockType: 'text', text: 'No ref here' },
|
|
238
|
+
{
|
|
239
|
+
blockType: 'text',
|
|
240
|
+
text: '[A](notion://aaaa1111bbbb2222cccc3333dddd4444)',
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
},
|
|
244
|
+
];
|
|
245
|
+
const m = mapping({ aaaa1111bbbb2222cccc3333dddd4444: '/docs/a' });
|
|
246
|
+
const { references } = resolveNotionReferences(sections, m);
|
|
247
|
+
expect(references).toHaveLength(1);
|
|
248
|
+
expect(references[0].sectionIndex).toBe(0);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
describe('non-text blocks', () => {
|
|
252
|
+
it('passes image blocks through unchanged', () => {
|
|
253
|
+
const sections = [
|
|
254
|
+
{
|
|
255
|
+
name: 'Images',
|
|
256
|
+
content: [
|
|
257
|
+
{
|
|
258
|
+
blockType: 'image',
|
|
259
|
+
type: 'url',
|
|
260
|
+
url: 'https://example.com/img.png',
|
|
261
|
+
mediaType: 'image/png',
|
|
262
|
+
},
|
|
263
|
+
],
|
|
264
|
+
},
|
|
265
|
+
];
|
|
266
|
+
const m = mapping({ aaaa1111bbbb2222cccc3333dddd4444: '/docs/a' });
|
|
267
|
+
const { sections: resolved, references } = resolveNotionReferences(sections, m);
|
|
268
|
+
expect(resolved[0].content[0]).toEqual(sections[0].content[0]);
|
|
269
|
+
expect(references).toHaveLength(0);
|
|
270
|
+
});
|
|
271
|
+
it('handles mixed text and non-text blocks', () => {
|
|
272
|
+
const sections = [
|
|
273
|
+
{
|
|
274
|
+
name: 'Mixed',
|
|
275
|
+
content: [
|
|
276
|
+
{
|
|
277
|
+
blockType: 'text',
|
|
278
|
+
text: '[A](notion://aaaa1111bbbb2222cccc3333dddd4444)',
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
blockType: 'image',
|
|
282
|
+
type: 'url',
|
|
283
|
+
url: 'https://example.com/img.png',
|
|
284
|
+
mediaType: 'image/png',
|
|
285
|
+
},
|
|
286
|
+
{ blockType: 'text', text: 'Plain text, no refs' },
|
|
287
|
+
],
|
|
288
|
+
},
|
|
289
|
+
];
|
|
290
|
+
const m = mapping({ aaaa1111bbbb2222cccc3333dddd4444: '/docs/a' });
|
|
291
|
+
const { sections: resolved, references } = resolveNotionReferences(sections, m);
|
|
292
|
+
expect(getText(resolved[0], 0)).toBe('[A](/docs/a)');
|
|
293
|
+
expect(resolved[0].content[1].blockType).toBe('image');
|
|
294
|
+
expect(getText(resolved[0], 2)).toBe('Plain text, no refs');
|
|
295
|
+
expect(references).toHaveLength(1);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
describe('edge cases', () => {
|
|
299
|
+
it('handles empty sections array', () => {
|
|
300
|
+
const { sections, references } = resolveNotionReferences([], mapping({}));
|
|
301
|
+
expect(sections).toEqual([]);
|
|
302
|
+
expect(references).toEqual([]);
|
|
303
|
+
});
|
|
304
|
+
it('handles sections with empty content', () => {
|
|
305
|
+
const sections = [{ name: 'Empty', content: [] }];
|
|
306
|
+
const { sections: resolved, references } = resolveNotionReferences(sections, mapping({}));
|
|
307
|
+
expect(resolved).toHaveLength(1);
|
|
308
|
+
expect(resolved[0].content).toEqual([]);
|
|
309
|
+
expect(references).toEqual([]);
|
|
310
|
+
});
|
|
311
|
+
it('handles text blocks with no references', () => {
|
|
312
|
+
const sections = [
|
|
313
|
+
textSection('Plain', 'Just some normal text with [a link](https://example.com)'),
|
|
314
|
+
];
|
|
315
|
+
const m = mapping({ aaaa1111bbbb2222cccc3333dddd4444: '/docs/a' });
|
|
316
|
+
const { sections: resolved, references } = resolveNotionReferences(sections, m);
|
|
317
|
+
expect(getText(resolved[0])).toBe('Just some normal text with [a link](https://example.com)');
|
|
318
|
+
expect(references).toHaveLength(0);
|
|
319
|
+
});
|
|
320
|
+
it('preserves section names', () => {
|
|
321
|
+
const sections = [textSection('My Section Name', 'text')];
|
|
322
|
+
const { sections: resolved } = resolveNotionReferences(sections, mapping({}));
|
|
323
|
+
expect(resolved[0].name).toBe('My Section Name');
|
|
324
|
+
});
|
|
325
|
+
it('does not modify the original sections (immutability)', () => {
|
|
326
|
+
const original = [
|
|
327
|
+
textSection('Intro', '[A](notion://aaaa1111bbbb2222cccc3333dddd4444)'),
|
|
328
|
+
];
|
|
329
|
+
const originalText = getText(original[0]);
|
|
330
|
+
const m = mapping({ aaaa1111bbbb2222cccc3333dddd4444: '/docs/a' });
|
|
331
|
+
resolveNotionReferences(original, m);
|
|
332
|
+
// Original should be unchanged
|
|
333
|
+
expect(getText(original[0])).toBe(originalText);
|
|
334
|
+
});
|
|
335
|
+
it('handles duplicate references to the same target in one block', () => {
|
|
336
|
+
const id = 'aaaa1111bbbb2222cccc3333dddd4444';
|
|
337
|
+
const sections = [
|
|
338
|
+
textSection('Intro', `[First](notion://${id}) and [Second](notion://${id})`),
|
|
339
|
+
];
|
|
340
|
+
const m = mapping({ [id]: '/docs/target' });
|
|
341
|
+
const { sections: resolved, references } = resolveNotionReferences(sections, m);
|
|
342
|
+
expect(getText(resolved[0])).toBe('[First](/docs/target) and [Second](/docs/target)');
|
|
343
|
+
// Both occurrences are recorded as references
|
|
344
|
+
expect(references).toHaveLength(2);
|
|
345
|
+
expect(references[0].displayText).toBe('First');
|
|
346
|
+
expect(references[1].displayText).toBe('Second');
|
|
347
|
+
});
|
|
348
|
+
it('handles self-references (resolves in content, does not filter)', () => {
|
|
349
|
+
const id = 'aaaa1111bbbb2222cccc3333dddd4444';
|
|
350
|
+
const sections = [textSection('Self', `[Self Link](notion://${id})`)];
|
|
351
|
+
const m = mapping({ [id]: '/docs/self-page' });
|
|
352
|
+
const { sections: resolved, references } = resolveNotionReferences(sections, m);
|
|
353
|
+
expect(getText(resolved[0])).toBe('[Self Link](/docs/self-page)');
|
|
354
|
+
// Resolution function doesn't filter self-refs — that's the caller's job
|
|
355
|
+
expect(references).toHaveLength(1);
|
|
356
|
+
});
|
|
357
|
+
it('handles circular references (A→B and B→A)', () => {
|
|
358
|
+
const idA = 'aaaa1111bbbb2222cccc3333dddd4444';
|
|
359
|
+
const idB = 'eeee5555ffff6666aaaa7777bbbb8888';
|
|
360
|
+
const sections = [
|
|
361
|
+
textSection('Page A', `Link to [B](notion://${idB})`),
|
|
362
|
+
textSection('Page B', `Link to [A](notion://${idA})`),
|
|
363
|
+
];
|
|
364
|
+
const m = mapping({
|
|
365
|
+
[idA]: '/docs/page-a',
|
|
366
|
+
[idB]: '/docs/page-b',
|
|
367
|
+
});
|
|
368
|
+
const { sections: resolved, references } = resolveNotionReferences(sections, m);
|
|
369
|
+
expect(getText(resolved[0])).toBe('Link to [B](/docs/page-b)');
|
|
370
|
+
expect(getText(resolved[1])).toBe('Link to [A](/docs/page-a)');
|
|
371
|
+
expect(references).toHaveLength(2);
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
// ============================================
|
|
376
|
+
// resolveRelationIds
|
|
377
|
+
// ============================================
|
|
378
|
+
describe('resolveRelationIds', () => {
|
|
379
|
+
it('resolves a single notion-ref marker', () => {
|
|
380
|
+
const m = mapping({ aaaa1111bbbb2222cccc3333dddd4444: '/docs/target' });
|
|
381
|
+
const result = resolveRelationIds('notion-ref:aaaa1111bbbb2222cccc3333dddd4444', m);
|
|
382
|
+
expect(result).toBe('[/docs/target](/docs/target)');
|
|
383
|
+
});
|
|
384
|
+
it('resolves multiple comma-separated markers', () => {
|
|
385
|
+
const m = mapping({
|
|
386
|
+
aaaa1111bbbb2222cccc3333dddd4444: '/docs/a',
|
|
387
|
+
eeee5555ffff6666aaaa7777bbbb8888: '/docs/b',
|
|
388
|
+
});
|
|
389
|
+
const result = resolveRelationIds('notion-ref:aaaa1111bbbb2222cccc3333dddd4444, notion-ref:eeee5555ffff6666aaaa7777bbbb8888', m);
|
|
390
|
+
expect(result).toBe('[/docs/a](/docs/a), [/docs/b](/docs/b)');
|
|
391
|
+
});
|
|
392
|
+
it('returns bare ID for unresolved markers', () => {
|
|
393
|
+
const result = resolveRelationIds('notion-ref:aaaa1111bbbb2222cccc3333dddd4444', mapping({}));
|
|
394
|
+
expect(result).toBe('aaaa1111bbbb2222cccc3333dddd4444');
|
|
395
|
+
});
|
|
396
|
+
it('handles mix of resolved and unresolved', () => {
|
|
397
|
+
const m = mapping({ aaaa1111bbbb2222cccc3333dddd4444: '/docs/known' });
|
|
398
|
+
const result = resolveRelationIds('notion-ref:aaaa1111bbbb2222cccc3333dddd4444, notion-ref:ffffffffffffffffffffffffffffffff', m);
|
|
399
|
+
expect(result).toBe('[/docs/known](/docs/known), ffffffffffffffffffffffffffffffff');
|
|
400
|
+
});
|
|
401
|
+
it('returns input unchanged if no markers present', () => {
|
|
402
|
+
const result = resolveRelationIds('just plain text', mapping({}));
|
|
403
|
+
expect(result).toBe('just plain text');
|
|
404
|
+
});
|
|
405
|
+
});
|
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.2",
|
|
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"
|