@moxn/kb-migrate 0.4.37 → 0.4.39

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
@@ -93,6 +93,22 @@ export class MoxnClient {
93
93
  };
94
94
  }
95
95
  catch (updateError) {
96
+ // Re-importing BYTE-IDENTICAL content makes the server reject the
97
+ // empty commit ("No changes to commit"). For an importer that's a
98
+ // NO-OP, not a failure — report a successful (no-op) update, matching
99
+ // the grammar path's idempotent re-import. (Audit finding B.)
100
+ const msg = updateError instanceof Error ? updateError.message : '';
101
+ if (msg.includes('No changes to commit')) {
102
+ return {
103
+ sourcePath: doc.sourcePath,
104
+ documentPath,
105
+ status: 'updated',
106
+ documentId: error.documentId,
107
+ branchId: error.branchId,
108
+ sourcePageId: doc.metadata?.notionPageId,
109
+ duration: Date.now() - startTime,
110
+ };
111
+ }
96
112
  return {
97
113
  sourcePath: doc.sourcePath,
98
114
  documentPath,
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { MoxnClient } from './client.js';
3
+ /** Minimal fetch Response stand-in (client reads .ok/.status/.json()). */
4
+ function mockResponse(status, body) {
5
+ return {
6
+ ok: status >= 200 && status < 300,
7
+ status,
8
+ json: async () => body,
9
+ };
10
+ }
11
+ const doc = {
12
+ relativePath: 'runbook',
13
+ name: 'Runbook',
14
+ sections: [{ name: 'Steps', content: [{ blockType: 'text', text: 'body' }] }],
15
+ sourcePath: 'notion://x',
16
+ metadata: { notionPageId: 'p1' },
17
+ };
18
+ function client() {
19
+ return new MoxnClient({
20
+ apiUrl: 'http://test',
21
+ apiKey: 'k',
22
+ basePath: '/',
23
+ onConflict: 'update',
24
+ dryRun: false,
25
+ });
26
+ }
27
+ afterEach(() => vi.restoreAllMocks());
28
+ describe('migrateDocument — finding B: unchanged re-import is a no-op, not a failure', () => {
29
+ it("treats the server's 'No changes to commit' on update as a no-op success", async () => {
30
+ // create → 409 (path exists, id recovered) → update → 400 No-changes.
31
+ vi.spyOn(globalThis, 'fetch').mockImplementation((async (url, init) => {
32
+ const u = String(url);
33
+ const method = (init?.method ?? 'GET').toUpperCase();
34
+ if (method === 'POST' && u.endsWith('/api/v1/kb/documents')) {
35
+ return mockResponse(409, {
36
+ error: 'exists',
37
+ documentId: 'doc-1',
38
+ branchId: 'br-1',
39
+ });
40
+ }
41
+ if (method === 'PUT' && u.includes('/api/v1/kb/documents/')) {
42
+ return mockResponse(400, {
43
+ error: 'No changes to commit — working hash equals head body hash',
44
+ });
45
+ }
46
+ return mockResponse(200, {});
47
+ }));
48
+ const res = await client().migrateDocument(doc, '/', 'update', false);
49
+ expect(res.status).toBe('updated'); // NOT 'failed'
50
+ expect(res.documentId).toBe('doc-1');
51
+ });
52
+ it('still reports a genuine update error as failed', async () => {
53
+ vi.spyOn(globalThis, 'fetch').mockImplementation((async (url, init) => {
54
+ const u = String(url);
55
+ const method = (init?.method ?? 'GET').toUpperCase();
56
+ if (method === 'POST' && u.endsWith('/api/v1/kb/documents')) {
57
+ return mockResponse(409, {
58
+ error: 'exists',
59
+ documentId: 'doc-1',
60
+ branchId: 'br-1',
61
+ });
62
+ }
63
+ if (method === 'PUT')
64
+ return mockResponse(500, { error: 'boom' });
65
+ return mockResponse(200, {});
66
+ }));
67
+ const res = await client().migrateDocument(doc, '/', 'update', false);
68
+ expect(res.status).toBe('failed');
69
+ });
70
+ });
@@ -23,6 +23,8 @@ export interface NotionSourceConfig extends SourceConfig {
23
23
  export interface PageTreeNode {
24
24
  page: NotionPage;
25
25
  title: string;
26
+ /** Disambiguated document name — `slugify(name) === slug` (finding D). May differ from `title` for same-titled siblings. */
27
+ name: string;
26
28
  slug: string;
27
29
  children: PageTreeNode[];
28
30
  kbPath: string;
@@ -50,6 +52,7 @@ export declare class NotionSource extends MigrationSource<NotionSourceConfig> {
50
52
  private allPages;
51
53
  private pagePathMap;
52
54
  private pageTitleMap;
55
+ private pageNameMap;
53
56
  private databases;
54
57
  private databaseEntryPageIds;
55
58
  private _documentCount;
@@ -113,6 +116,8 @@ export interface PageTreeResult {
113
116
  allPages: PageTreeNode[];
114
117
  pagePathMap: PagePathMap;
115
118
  pageTitleMap: Map<string, string>;
119
+ /** nid → disambiguated document name (parallel to pagePathMap; finding D). */
120
+ pageNameMap: Map<string, string>;
116
121
  databaseEntryPageIds: Set<string>;
117
122
  }
118
123
  /**
@@ -136,3 +141,16 @@ export declare function buildPageTree(pages: NotionPage[], options?: BuildPageTr
136
141
  * Falls back to "untitled" when the result is empty (e.g. emoji-only or non-Latin titles).
137
142
  */
138
143
  export declare function slugify(title: string): string;
144
+ /**
145
+ * Disambiguate a sibling page whose slug collides with an earlier sibling,
146
+ * suffixing BOTH the name and the slug so the bijective invariant
147
+ * `slugify(name) === slug` holds — the server's `createDocumentWithSections`
148
+ * hard-asserts it (the #177 name/path cutover). The legacy tree builder
149
+ * suffixed only the PATH (`notes` → `notes-2`) while the name stayed `Notes`,
150
+ * so a second same-titled sibling was rejected by that assert (audit finding D).
151
+ * Mutates `siblingSlugCounts` (keyed on the BASE slug) the way the builder expects.
152
+ */
153
+ export declare function disambiguateSibling(title: string, siblingSlugCounts: Map<string, number>, slugifyFn?: (s: string) => string): {
154
+ name: string;
155
+ slug: string;
156
+ };
@@ -25,6 +25,7 @@ export class NotionSource extends MigrationSource {
25
25
  allPages = []; // flat list, depth-first order
26
26
  pagePathMap = new Map();
27
27
  pageTitleMap = new Map();
28
+ pageNameMap = new Map();
28
29
  databases = [];
29
30
  databaseEntryPageIds = new Set();
30
31
  _documentCount = 0;
@@ -266,6 +267,7 @@ export class NotionSource extends MigrationSource {
266
267
  this.allPages = result.allPages;
267
268
  this.pagePathMap = result.pagePathMap;
268
269
  this.pageTitleMap = result.pageTitleMap;
270
+ this.pageNameMap = result.pageNameMap;
269
271
  this.databaseEntryPageIds = result.databaseEntryPageIds;
270
272
  }
271
273
  // ============================================
@@ -326,7 +328,7 @@ export class NotionSource extends MigrationSource {
326
328
  }
327
329
  return {
328
330
  relativePath: node.kbPath,
329
- name: node.title,
331
+ name: node.name,
330
332
  sections,
331
333
  sourcePath: `notion://${node.page.id}`,
332
334
  references: references.length > 0 ? references : undefined,
@@ -373,7 +375,7 @@ export class NotionSource extends MigrationSource {
373
375
  const kbPath = this.pagePathMap.get(nid) ?? slug;
374
376
  return {
375
377
  relativePath: kbPath,
376
- name: title,
378
+ name: this.pageNameMap.get(nid) ?? title,
377
379
  sections: processedSections.length > 0
378
380
  ? processedSections
379
381
  : [
@@ -421,6 +423,7 @@ export class NotionSource extends MigrationSource {
421
423
  export function buildPageTree(pages, options) {
422
424
  const pagePathMap = new Map();
423
425
  const pageTitleMap = new Map();
426
+ const pageNameMap = new Map();
424
427
  const databaseEntryPageIds = new Set();
425
428
  const tree = [];
426
429
  const allPages = [];
@@ -530,18 +533,16 @@ export function buildPageTree(pages, options) {
530
533
  return null;
531
534
  }
532
535
  const title = getPageTitle(page);
533
- let slug = slugify(title);
534
- // Deduplicate sibling slugs
535
- const existing = siblingSlugCounts.get(slug) ?? 0;
536
- siblingSlugCounts.set(slug, existing + 1);
537
- if (existing > 0) {
538
- slug = `${slug}-${existing + 1}`;
539
- }
536
+ // Dedup sibling slugs — suffix BOTH name and slug so slugify(name) === slug
537
+ // (the server's hard bijection assert; audit finding D). Legacy code
538
+ // suffixed only the path, so a same-titled sibling was rejected.
539
+ const { name, slug } = disambiguateSibling(title, siblingSlugCounts);
540
540
  const kbPath = parentPath ? `${parentPath}/${slug}` : slug;
541
541
  const isDatabaseEntry = databaseEntryPageIds.has(nid);
542
542
  const node = {
543
543
  page,
544
544
  title,
545
+ name,
545
546
  slug,
546
547
  kbPath,
547
548
  isDatabaseEntry,
@@ -552,6 +553,7 @@ export function buildPageTree(pages, options) {
552
553
  // Register in path map and title map
553
554
  pagePathMap.set(nid, kbPath);
554
555
  pageTitleMap.set(nid, title);
556
+ pageNameMap.set(nid, name);
555
557
  // Process children
556
558
  const childPages = childrenByParent.get(nid) ?? [];
557
559
  const childSlugCounts = new Map();
@@ -579,7 +581,14 @@ export function buildPageTree(pages, options) {
579
581
  }
580
582
  };
581
583
  flatten(tree);
582
- return { tree, allPages, pagePathMap, pageTitleMap, databaseEntryPageIds };
584
+ return {
585
+ tree,
586
+ allPages,
587
+ pagePathMap,
588
+ pageTitleMap,
589
+ pageNameMap,
590
+ databaseEntryPageIds,
591
+ };
583
592
  }
584
593
  /** Extract the parent page ID from a Notion page's parent field. */
585
594
  function getParentPageId(page) {
@@ -614,3 +623,21 @@ export function slugify(title) {
614
623
  .replace(/^-+|-+$/g, '');
615
624
  return cleaned.length > 0 ? cleaned : 'untitled';
616
625
  }
626
+ /**
627
+ * Disambiguate a sibling page whose slug collides with an earlier sibling,
628
+ * suffixing BOTH the name and the slug so the bijective invariant
629
+ * `slugify(name) === slug` holds — the server's `createDocumentWithSections`
630
+ * hard-asserts it (the #177 name/path cutover). The legacy tree builder
631
+ * suffixed only the PATH (`notes` → `notes-2`) while the name stayed `Notes`,
632
+ * so a second same-titled sibling was rejected by that assert (audit finding D).
633
+ * Mutates `siblingSlugCounts` (keyed on the BASE slug) the way the builder expects.
634
+ */
635
+ export function disambiguateSibling(title, siblingSlugCounts, slugifyFn = slugify) {
636
+ const baseSlug = slugifyFn(title);
637
+ const existing = siblingSlugCounts.get(baseSlug) ?? 0;
638
+ siblingSlugCounts.set(baseSlug, existing + 1);
639
+ if (existing === 0)
640
+ return { name: title, slug: baseSlug };
641
+ const name = `${title} ${existing + 1}`;
642
+ return { name, slug: slugifyFn(name) };
643
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { slugify } from './notion.js';
2
+ import { slugify, disambiguateSibling } from './notion.js';
3
3
  describe('slugify (notion)', () => {
4
4
  it('lowercases', () => {
5
5
  expect(slugify('MyDoc')).toBe('mydoc');
@@ -43,3 +43,41 @@ describe('slugify (notion)', () => {
43
43
  expect(slugify('🎉')).toBe('untitled');
44
44
  });
45
45
  });
46
+ describe('disambiguateSibling (D — dedup name AND path together)', () => {
47
+ it('returns the title unchanged for the first occurrence', () => {
48
+ const counts = new Map();
49
+ expect(disambiguateSibling('Notes', counts)).toEqual({
50
+ name: 'Notes',
51
+ slug: 'notes',
52
+ });
53
+ });
54
+ it('suffixes BOTH name and slug for siblings, keeping the bijection', () => {
55
+ const counts = new Map();
56
+ disambiguateSibling('Notes', counts); // first
57
+ const second = disambiguateSibling('Notes', counts);
58
+ const third = disambiguateSibling('Notes', counts);
59
+ expect(second).toEqual({ name: 'Notes 2', slug: 'notes-2' });
60
+ expect(third).toEqual({ name: 'Notes 3', slug: 'notes-3' });
61
+ // The invariant the server's assertNamePathConsistent enforces:
62
+ expect(slugify(second.name)).toBe(second.slug);
63
+ expect(slugify(third.name)).toBe(third.slug);
64
+ });
65
+ it('independent base titles do not collide in the counter', () => {
66
+ const counts = new Map();
67
+ expect(disambiguateSibling('Alpha', counts).slug).toBe('alpha');
68
+ expect(disambiguateSibling('Beta', counts).slug).toBe('beta');
69
+ });
70
+ });
71
+ describe('disambiguateSibling — custom slugify (OneNote uses slugifyPathSegment)', () => {
72
+ const upperSlug = (s) => s.toUpperCase().replace(/ /g, '_');
73
+ it('derives the slug with the provided function, keeping the bijection', () => {
74
+ const counts = new Map();
75
+ expect(disambiguateSibling('My Page', counts, upperSlug)).toEqual({
76
+ name: 'My Page',
77
+ slug: 'MY_PAGE',
78
+ });
79
+ const second = disambiguateSibling('My Page', counts, upperSlug);
80
+ expect(second).toEqual({ name: 'My Page 2', slug: 'MY_PAGE_2' });
81
+ expect(upperSlug(second.name)).toBe(second.slug);
82
+ });
83
+ });
@@ -11,6 +11,7 @@
11
11
  * own ID or any ancestor's ID is in the set. If the set is empty, every
12
12
  * page is included.
13
13
  */
14
+ import { disambiguateSibling } from '../notion.js';
14
15
  const MAX_GROUP_DEPTH = 4;
15
16
  /**
16
17
  * Normalize any human text into a ltree-safe slug segment.
@@ -96,12 +97,22 @@ async function walkSection(client, section, pathSegs, ctx, ancestorSelected, sel
96
97
  createdBefore: opts.createdBefore,
97
98
  });
98
99
  const sectionSeg = slugifyPathSegment(section.displayName);
100
+ // Dedup same-titled sibling pages within a section — suffix the title AND the
101
+ // path segment together so slugify(name) === pathTail (the server's hard
102
+ // bijection assert) and two pages never collide on one path. Without this,
103
+ // two "Untitled" pages produce the same kbPath → the second silently clobbers
104
+ // or skips the first on import. (Notion finding D; OneNote had no dedup at all.)
105
+ const pageSlugCounts = new Map();
99
106
  for (const page of pages) {
100
107
  const pageSelected = ancestorSelected || (!!sel && sel.has(page.id));
101
108
  if (!pageSelected)
102
109
  continue;
103
- const pageSeg = slugifyPathSegment(page.title ?? 'untitled');
104
- out.push(toDiscovered(page, section, [...pathSegs, sectionSeg, pageSeg], ctx));
110
+ const { name: dedupTitle, slug: pageSeg } = disambiguateSibling(page.title ?? 'untitled', pageSlugCounts, slugifyPathSegment);
111
+ out.push(toDiscovered({ ...page, title: dedupTitle }, section, [
112
+ ...pathSegs,
113
+ sectionSeg,
114
+ pageSeg,
115
+ ], ctx));
105
116
  }
106
117
  }
107
118
  function toDiscovered(page, section, segs, ctx) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moxn/kb-migrate",
3
- "version": "0.4.37",
3
+ "version": "0.4.39",
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",