@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 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
+ });
@@ -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
- const MAX_DOCUMENT_COUNT = 10000;
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
- const processedSections = await this.downloadSectionMedia(sections);
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.1",
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"