@moxn/kb-migrate 0.4.23 → 0.4.24

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.js CHANGED
@@ -299,7 +299,12 @@ export class MoxnClient {
299
299
  error.branchId = body.branchId;
300
300
  throw error;
301
301
  }
302
- throw new Error(body.error || `API error: ${response.status}`);
302
+ const message = body.details
303
+ ? `API error ${response.status}: ${body.details}`
304
+ : body.error
305
+ ? `API error ${response.status}: ${body.error}`
306
+ : `API error: ${response.status}`;
307
+ throw new Error(message);
303
308
  }
304
309
  return response.json();
305
310
  }
@@ -314,7 +319,12 @@ export class MoxnClient {
314
319
  });
315
320
  if (!response.ok) {
316
321
  const body = await response.json().catch(() => ({}));
317
- throw new Error(body.error || `API error: ${response.status}`);
322
+ const message = body.details
323
+ ? `API error ${response.status}: ${body.details}`
324
+ : body.error
325
+ ? `API error ${response.status}: ${body.error}`
326
+ : `API error: ${response.status}`;
327
+ throw new Error(message);
318
328
  }
319
329
  return response.json();
320
330
  }
package/dist/index.js CHANGED
@@ -27,21 +27,28 @@ async function runMigration(source, options) {
27
27
  const results = [];
28
28
  // Validate source
29
29
  await source.validate();
30
- // Get document count for progress
30
+ // Get document count for progress (this is an upper bound — some pages
31
+ // may be skipped during extraction if they are empty or have errors)
31
32
  const totalCount = await source.getDocumentCount();
32
- if (totalCount !== undefined) {
33
- console.log(`Found ${totalCount} documents to migrate`);
34
- }
35
33
  // Create client
36
34
  const client = new MoxnClient(options);
37
35
  // Process documents
38
36
  let processed = 0;
37
+ let consecutiveFailures = 0;
38
+ const MAX_CONSECUTIVE_FAILURES = 10;
39
39
  for await (const doc of source.extract()) {
40
40
  processed++;
41
41
  const progress = totalCount ? ` (${processed}/${totalCount})` : '';
42
42
  console.log(`Processing: ${doc.sourcePath}${progress}`);
43
43
  const result = await client.migrateDocument(doc, options.basePath, options.onConflict, options.dryRun);
44
44
  results.push(result);
45
+ // Track consecutive failures for circuit breaker
46
+ if (result.status === 'failed') {
47
+ consecutiveFailures++;
48
+ }
49
+ else {
50
+ consecutiveFailures = 0;
51
+ }
45
52
  // Log result
46
53
  const statusIcon = {
47
54
  created: '\u2713',
@@ -53,6 +60,13 @@ async function runMigration(source, options) {
53
60
  if (result.error) {
54
61
  console.log(` Error: ${result.error}`);
55
62
  }
63
+ // Circuit breaker: abort after too many consecutive failures
64
+ if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
65
+ console.error(`\nAborting: ${MAX_CONSECUTIVE_FAILURES} consecutive failures. ` +
66
+ 'Last error: ' + (result.error || 'unknown') + '\n' +
67
+ 'Fix the underlying issue and retry. Remaining documents will be skipped.');
68
+ break;
69
+ }
56
70
  }
57
71
  // Build summary
58
72
  const summary = {
@@ -8,6 +8,8 @@ import type { SectionInput } from '../types.js';
8
8
  import type { NotionBlock, NotionRichText, NotionApiClient, NotionPage } from './notion-api.js';
9
9
  /** Map of page IDs to their KB paths (for cross-link resolution). */
10
10
  export type PagePathMap = Map<string, string>;
11
+ /** Tracks unsupported Notion block types encountered during conversion. */
12
+ export type UnsupportedBlockTracker = Map<string, number>;
11
13
  /**
12
14
  * Convert a page's blocks into Moxn sections.
13
15
  *
@@ -19,6 +21,8 @@ export declare function blocksToSections(blocks: NotionBlock[], client: NotionAp
19
21
  visitedSyncedBlocks?: Set<string>;
20
22
  /** Map of Notion database IDs to KB database IDs (for child_database blocks). */
21
23
  databaseIdMap?: Map<string, string>;
24
+ /** Accumulates counts of unsupported block types (caller-owned). */
25
+ unsupportedBlocks?: UnsupportedBlockTracker;
22
26
  }): Promise<SectionInput[]>;
23
27
  /** Convert rich text array to markdown string. */
24
28
  export declare function richTextToMarkdown(richText: NotionRichText[]): string;
@@ -16,6 +16,7 @@
16
16
  export async function blocksToSections(blocks, client, pagePathMap, options) {
17
17
  const visitedSyncedBlocks = options?.visitedSyncedBlocks ?? new Set();
18
18
  const databaseIdMap = options?.databaseIdMap;
19
+ const unsupportedBlocks = options?.unsupportedBlocks;
19
20
  const sections = [];
20
21
  let currentSectionName = 'Introduction';
21
22
  let currentBlocks = [];
@@ -35,7 +36,7 @@ export async function blocksToSections(blocks, client, pagePathMap, options) {
35
36
  continue;
36
37
  }
37
38
  // Convert block to content blocks
38
- const converted = await convertBlock(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap);
39
+ const converted = await convertBlock(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks);
39
40
  currentBlocks.push(...converted);
40
41
  }
41
42
  // Flush last section
@@ -51,7 +52,7 @@ export async function blocksToSections(blocks, client, pagePathMap, options) {
51
52
  // ============================================
52
53
  // Block conversion
53
54
  // ============================================
54
- async function convertBlock(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap) {
55
+ async function convertBlock(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks) {
55
56
  const results = [];
56
57
  switch (block.type) {
57
58
  case 'paragraph':
@@ -81,10 +82,10 @@ async function convertBlock(block, client, pagePathMap, visitedSyncedBlocks, dat
81
82
  results.push(...convertToDo(block));
82
83
  break;
83
84
  case 'quote':
84
- results.push(...(await convertQuote(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap)));
85
+ results.push(...(await convertQuote(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks)));
85
86
  break;
86
87
  case 'callout':
87
- results.push(...(await convertCallout(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap)));
88
+ results.push(...(await convertCallout(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks)));
88
89
  break;
89
90
  case 'divider':
90
91
  results.push(textBlock('---'));
@@ -93,7 +94,7 @@ async function convertBlock(block, client, pagePathMap, visitedSyncedBlocks, dat
93
94
  results.push(...(await convertTable(block, client)));
94
95
  break;
95
96
  case 'toggle':
96
- results.push(...(await convertToggle(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap)));
97
+ results.push(...(await convertToggle(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks)));
97
98
  break;
98
99
  case 'bookmark':
99
100
  results.push(...convertBookmark(block));
@@ -122,10 +123,10 @@ async function convertBlock(block, client, pagePathMap, visitedSyncedBlocks, dat
122
123
  results.push(...convertChildDatabase(block, databaseIdMap));
123
124
  break;
124
125
  case 'synced_block':
125
- results.push(...(await convertSyncedBlock(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap)));
126
+ results.push(...(await convertSyncedBlock(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks)));
126
127
  break;
127
128
  case 'column_list':
128
- results.push(...(await convertColumnList(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap)));
129
+ results.push(...(await convertColumnList(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks)));
129
130
  break;
130
131
  case 'column':
131
132
  // Columns are handled by column_list
@@ -146,7 +147,10 @@ async function convertBlock(block, client, pagePathMap, visitedSyncedBlocks, dat
146
147
  results.push(...convertLinkPreview(block));
147
148
  break;
148
149
  default:
149
- // Unknown block type — log and skip
150
+ // Unknown block type — track and skip
151
+ if (unsupportedBlocks) {
152
+ unsupportedBlocks.set(block.type, (unsupportedBlocks.get(block.type) ?? 0) + 1);
153
+ }
150
154
  console.warn(` Skipping unsupported Notion block type: ${block.type}`);
151
155
  break;
152
156
  }
@@ -165,7 +169,7 @@ async function convertBlock(block, client, pagePathMap, visitedSyncedBlocks, dat
165
169
  ? ' '
166
170
  : undefined;
167
171
  const children = await client.getBlockChildren(block.id);
168
- const childBlocks = await convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, indent, databaseIdMap);
172
+ const childBlocks = await convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, indent, databaseIdMap, unsupportedBlocks);
169
173
  results.push(...childBlocks);
170
174
  }
171
175
  return results;
@@ -211,7 +215,7 @@ function convertToDo(block) {
211
215
  const text = richTextToMarkdown(td.to_do.rich_text);
212
216
  return [textBlock(`${checkbox} ${text}`)];
213
217
  }
214
- async function convertQuote(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap) {
218
+ async function convertQuote(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks) {
215
219
  const q = block;
216
220
  const text = richTextToMarkdown(q.quote.rich_text);
217
221
  if (!text && !block.has_children)
@@ -225,12 +229,12 @@ async function convertQuote(block, client, pagePathMap, visitedSyncedBlocks, dat
225
229
  const results = quoted ? [textBlock(quoted)] : [];
226
230
  if (block.has_children) {
227
231
  const children = await client.getBlockChildren(block.id);
228
- const childBlocks = await convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, '> ', databaseIdMap);
232
+ const childBlocks = await convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, '> ', databaseIdMap, unsupportedBlocks);
229
233
  results.push(...childBlocks);
230
234
  }
231
235
  return results;
232
236
  }
233
- async function convertCallout(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap) {
237
+ async function convertCallout(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks) {
234
238
  const c = block;
235
239
  const text = richTextToMarkdown(c.callout.rich_text);
236
240
  const emoji = c.callout.icon?.emoji ?? '';
@@ -242,7 +246,7 @@ async function convertCallout(block, client, pagePathMap, visitedSyncedBlocks, d
242
246
  const results = [textBlock(quoted)];
243
247
  if (block.has_children) {
244
248
  const children = await client.getBlockChildren(block.id);
245
- const childBlocks = await convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, '> ', databaseIdMap);
249
+ const childBlocks = await convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, '> ', databaseIdMap, unsupportedBlocks);
246
250
  results.push(...childBlocks);
247
251
  }
248
252
  return results;
@@ -271,13 +275,13 @@ async function convertTable(block, client) {
271
275
  }
272
276
  return [textBlock(lines.join('\n'))];
273
277
  }
274
- async function convertToggle(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap) {
278
+ async function convertToggle(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks) {
275
279
  const t = block;
276
280
  const header = richTextToMarkdown(t.toggle.rich_text);
277
281
  const results = [textBlock(`**${header}**`)];
278
282
  if (block.has_children) {
279
283
  const children = await client.getBlockChildren(block.id);
280
- const childBlocks = await convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, '> ', databaseIdMap);
284
+ const childBlocks = await convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, '> ', databaseIdMap, unsupportedBlocks);
281
285
  results.push(...childBlocks);
282
286
  }
283
287
  return results;
@@ -402,7 +406,7 @@ function convertLinkToPage(block, pagePathMap) {
402
406
  // Target not in import set
403
407
  return [textBlock(`*(Link to Notion page: ${targetId})*`)];
404
408
  }
405
- async function convertSyncedBlock(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap) {
409
+ async function convertSyncedBlock(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks) {
406
410
  const sb = block;
407
411
  // Get the source block ID (either this block or the original)
408
412
  const sourceId = sb.synced_block.synced_from?.block_id ?? block.id;
@@ -413,19 +417,19 @@ async function convertSyncedBlock(block, client, pagePathMap, visitedSyncedBlock
413
417
  visitedSyncedBlocks.add(sourceId);
414
418
  try {
415
419
  const children = await client.getBlockChildren(sourceId);
416
- return await convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, undefined, databaseIdMap);
420
+ return await convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, undefined, databaseIdMap, unsupportedBlocks);
417
421
  }
418
422
  finally {
419
423
  visitedSyncedBlocks.delete(sourceId);
420
424
  }
421
425
  }
422
- async function convertColumnList(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap) {
426
+ async function convertColumnList(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks) {
423
427
  const children = await client.getBlockChildren(block.id);
424
428
  const results = [];
425
429
  for (const column of children) {
426
430
  if (column.type === 'column' && column.has_children) {
427
431
  const columnChildren = await client.getBlockChildren(column.id);
428
- const converted = await convertAndMergeChildren(columnChildren, client, pagePathMap, visitedSyncedBlocks, undefined, databaseIdMap);
432
+ const converted = await convertAndMergeChildren(columnChildren, client, pagePathMap, visitedSyncedBlocks, undefined, databaseIdMap, unsupportedBlocks);
429
433
  results.push(...converted);
430
434
  }
431
435
  }
@@ -574,10 +578,10 @@ function mergeConsecutiveListBlocks(blocks) {
574
578
  /**
575
579
  * Convert children blocks, merge consecutive list items, and optionally indent.
576
580
  */
577
- async function convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, indent, databaseIdMap) {
581
+ async function convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, indent, databaseIdMap, unsupportedBlocks) {
578
582
  const blocks = [];
579
583
  for (const child of children) {
580
- const converted = await convertBlock(child, client, pagePathMap, visitedSyncedBlocks, databaseIdMap);
584
+ const converted = await convertBlock(child, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks);
581
585
  blocks.push(...converted);
582
586
  }
583
587
  const merged = mergeConsecutiveListBlocks(blocks);
@@ -52,6 +52,11 @@ export declare class NotionSource extends MigrationSource<NotionSourceConfig> {
52
52
  private databaseEntryPageIds;
53
53
  private _documentCount;
54
54
  private _validated;
55
+ private _skippedEmpty;
56
+ private _skippedNoContent;
57
+ private _extractionErrors;
58
+ private _syntheticDatabaseNodes;
59
+ private _unsupportedBlocks;
55
60
  /** Map of Notion database ID → KB database ID, set before extraction. */
56
61
  private _databaseIdMap;
57
62
  constructor(config: NotionSourceConfig);
@@ -28,6 +28,12 @@ export class NotionSource extends MigrationSource {
28
28
  databaseEntryPageIds = new Set();
29
29
  _documentCount = 0;
30
30
  _validated = false;
31
+ // Tracking for extraction-time skips (populated during extract())
32
+ _skippedEmpty = [];
33
+ _skippedNoContent = [];
34
+ _extractionErrors = [];
35
+ _syntheticDatabaseNodes = 0;
36
+ _unsupportedBlocks = new Map();
31
37
  /** Map of Notion database ID → KB database ID, set before extraction. */
32
38
  _databaseIdMap = new Map();
33
39
  constructor(config) {
@@ -89,13 +95,18 @@ export class NotionSource extends MigrationSource {
89
95
  // 5. Process databases — identify entries and build schemas
90
96
  await this.processDatabases(allNotionDatabases);
91
97
  // 6. Count and validate (exclude synthetic database container nodes)
92
- this._documentCount = this.allPages.filter((n) => !n.isDatabaseNode).length;
98
+ this._syntheticDatabaseNodes = this.allPages.filter((n) => n.isDatabaseNode).length;
99
+ const pagesInTree = this.allPages.filter((n) => !n.isDatabaseNode).length;
100
+ const databaseEntriesInTree = this.allPages.filter((n) => n.isDatabaseEntry && !n.isDatabaseNode).length;
101
+ this._documentCount = pagesInTree;
93
102
  // Add database-only entries (pages that are in databases but not in page tree)
103
+ let databaseEntriesOutsideTree = 0;
94
104
  for (const dbInfo of this.databases) {
95
105
  for (const entry of dbInfo.entries) {
96
106
  const nid = normalizeId(entry.id);
97
107
  if (!this.pagePathMap.has(nid)) {
98
108
  this._documentCount++;
109
+ databaseEntriesOutsideTree++;
99
110
  }
100
111
  }
101
112
  }
@@ -103,7 +114,12 @@ export class NotionSource extends MigrationSource {
103
114
  throw new Error(`Workspace has ${this._documentCount} documents, exceeding the ${MAX_DOCUMENT_COUNT} limit. ` +
104
115
  'Use --root-page-id to import a subtree.');
105
116
  }
106
- console.log(` ${this.allPages.length} pages + ${this.databases.length} databases ready for import.`);
117
+ // Log clear breakdown of what was discovered
118
+ const standalonePages = pagesInTree - databaseEntriesInTree;
119
+ const totalDbEntries = databaseEntriesInTree + databaseEntriesOutsideTree;
120
+ console.log(` ${this._documentCount} documents to import: ` +
121
+ `${standalonePages} pages + ${totalDbEntries} database entries ` +
122
+ `(across ${this.databases.length} databases)`);
107
123
  // 7. Initialize media downloader
108
124
  await this.mediaDownloader.init();
109
125
  this._validated = true;
@@ -135,6 +151,12 @@ export class NotionSource extends MigrationSource {
135
151
  // Pass 2: Extraction
136
152
  // ============================================
137
153
  async *extract() {
154
+ // Reset extraction tracking
155
+ this._skippedEmpty = [];
156
+ this._skippedNoContent = [];
157
+ this._extractionErrors = [];
158
+ this._unsupportedBlocks = new Map();
159
+ let yielded = 0;
138
160
  // Walk pages depth-first
139
161
  for (const node of this.allPages) {
140
162
  // Skip database entries that will be created during database import
@@ -145,8 +167,10 @@ export class NotionSource extends MigrationSource {
145
167
  if (node.isDatabaseNode)
146
168
  continue;
147
169
  const doc = await this.extractPage(node);
148
- if (doc)
170
+ if (doc) {
171
+ yielded++;
149
172
  yield doc;
173
+ }
150
174
  }
151
175
  // Extract database-only entries (pages not in page tree)
152
176
  for (const dbInfo of this.databases) {
@@ -162,9 +186,42 @@ export class NotionSource extends MigrationSource {
162
186
  }
163
187
  }
164
188
  const doc = await this.extractDatabaseEntry(entry, dbInfo);
165
- if (doc)
189
+ if (doc) {
190
+ yielded++;
166
191
  yield doc;
192
+ }
193
+ }
194
+ }
195
+ // Log extraction summary so user can reconcile counts
196
+ const totalSkipped = this._skippedEmpty.length +
197
+ this._skippedNoContent.length +
198
+ this._extractionErrors.length;
199
+ console.log(`\nExtraction complete: ${yielded} of ${this._documentCount} documents extracted`);
200
+ if (totalSkipped > 0) {
201
+ console.log(` ${totalSkipped} pages skipped:`);
202
+ if (this._skippedEmpty.length > 0) {
203
+ console.log(` Empty pages (no blocks): ${this._skippedEmpty.map((t) => `"${t}"`).join(', ')}`);
204
+ }
205
+ if (this._skippedNoContent.length > 0) {
206
+ console.log(` No extractable content: ${this._skippedNoContent.map((t) => `"${t}"`).join(', ')}`);
167
207
  }
208
+ if (this._extractionErrors.length > 0) {
209
+ console.log(` Extraction errors: ${this._extractionErrors.map((t) => `"${t}"`).join(', ')}`);
210
+ }
211
+ }
212
+ // Report unsupported block types
213
+ if (this._unsupportedBlocks.size > 0) {
214
+ const sorted = [...this._unsupportedBlocks.entries()].sort((a, b) => b[1] - a[1]);
215
+ const totalUnsupported = sorted.reduce((sum, [, count]) => sum + count, 0);
216
+ console.log(` ${totalUnsupported} unsupported block(s) across ${sorted.length} type(s):`);
217
+ for (const [type, count] of sorted) {
218
+ console.log(` ${type}: ${count}`);
219
+ }
220
+ }
221
+ // Warn if counts don't reconcile (helps catch counting bugs)
222
+ const expectedYield = this._documentCount - totalSkipped;
223
+ if (yielded !== expectedYield) {
224
+ console.log(` ⚠ Count mismatch: expected ${expectedYield} (${this._documentCount} - ${totalSkipped} skipped) but extracted ${yielded}`);
168
225
  }
169
226
  }
170
227
  /** Clean up temp files after migration completes. */
@@ -224,11 +281,12 @@ export class NotionSource extends MigrationSource {
224
281
  try {
225
282
  const blocks = await this.client.getBlockChildren(node.page.id);
226
283
  if (blocks.length === 0) {
227
- console.log(` Skipping empty page: ${node.title}`);
284
+ this._skippedEmpty.push(node.title);
228
285
  return null;
229
286
  }
230
287
  let sections = await blocksToSections(blocks, this.client, this.pagePathMap, {
231
288
  databaseIdMap: this._databaseIdMap.size > 0 ? this._databaseIdMap : undefined,
289
+ unsupportedBlocks: this._unsupportedBlocks,
232
290
  });
233
291
  // Download Notion-hosted media files
234
292
  sections = await this.downloadSectionMedia(sections);
@@ -236,7 +294,7 @@ export class NotionSource extends MigrationSource {
236
294
  const { sections: resolvedSections, references } = resolveNotionReferences(sections, { notionIdToKbPath: this.pagePathMap });
237
295
  sections = resolvedSections;
238
296
  if (sections.length === 0) {
239
- console.log(` Skipping page with no content: ${node.title}`);
297
+ this._skippedNoContent.push(node.title);
240
298
  return null;
241
299
  }
242
300
  return {
@@ -252,6 +310,7 @@ export class NotionSource extends MigrationSource {
252
310
  };
253
311
  }
254
312
  catch (error) {
313
+ this._extractionErrors.push(node.title);
255
314
  console.error(` Error extracting page "${node.title}": ${error instanceof Error ? error.message : error}`);
256
315
  return null;
257
316
  }
@@ -273,6 +332,7 @@ export class NotionSource extends MigrationSource {
273
332
  if (blocks.length > 0) {
274
333
  const contentSections = await blocksToSections(blocks, this.client, this.pagePathMap, {
275
334
  databaseIdMap: this._databaseIdMap.size > 0 ? this._databaseIdMap : undefined,
335
+ unsupportedBlocks: this._unsupportedBlocks,
276
336
  });
277
337
  sections.push(...contentSections);
278
338
  }
@@ -303,7 +363,9 @@ export class NotionSource extends MigrationSource {
303
363
  };
304
364
  }
305
365
  catch (error) {
306
- console.error(` Error extracting database entry: ${error instanceof Error ? error.message : error}`);
366
+ const entryTitle = getPageTitle(entry) || `DB entry ${entry.id}`;
367
+ this._extractionErrors.push(entryTitle);
368
+ console.error(` Error extracting database entry "${entryTitle}": ${error instanceof Error ? error.message : error}`);
307
369
  return null;
308
370
  }
309
371
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moxn/kb-migrate",
3
- "version": "0.4.23",
3
+ "version": "0.4.24",
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",