@moxn/kb-migrate 0.4.38 → 0.4.40

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,6 +1,7 @@
1
1
  /**
2
2
  * API client for Moxn KB
3
3
  */
4
+ /// <reference types="node" resolution-mode="require"/>
4
5
  import type { ExtractedDocument, MigrationOptions, MigrationResult, DocumentListItem, DocumentDetail, ExportOptions } from './types.js';
5
6
  import type { DateFilter } from './date-filter.js';
6
7
  /**
@@ -27,6 +28,11 @@ export interface ImportMarkdownResult {
27
28
  name?: string;
28
29
  /** True when a front-matter block was present but NOT applied (no name → body-only import). */
29
30
  frontMatterIgnored?: boolean;
31
+ /**
32
+ * Set when strict YAML parsing of the front-matter block failed, even if the
33
+ * lenient fallback recovered the name. Carries the js-yaml error message.
34
+ */
35
+ frontMatterError?: string;
30
36
  }
31
37
  export declare class MoxnClient {
32
38
  private apiUrl;
@@ -100,13 +100,16 @@ export async function runLocalGrammarMigration(source, options) {
100
100
  const client = new MoxnClient(options);
101
101
  let processed = 0;
102
102
  let consecutiveFailures = 0;
103
+ let fmIgnoredOrDegraded = 0;
103
104
  const MAX_CONSECUTIVE_FAILURES = 10;
104
105
  for await (const file of source.extractGrammarFiles()) {
105
106
  processed++;
106
107
  const progress = totalCount ? ` (${processed}/${totalCount})` : '';
107
108
  console.log(`Processing: ${file.sourcePath}${progress}`);
108
- const result = await migrateGrammarFile(client, file, options);
109
+ const { result, hadFmWarning } = await migrateGrammarFile(client, file, options);
109
110
  results.push(result);
111
+ if (hadFmWarning)
112
+ fmIgnoredOrDegraded++;
110
113
  if (result.status === 'failed') {
111
114
  consecutiveFailures++;
112
115
  }
@@ -138,6 +141,7 @@ export async function runLocalGrammarMigration(source, options) {
138
141
  skipped: results.filter((r) => r.status === 'skipped').length,
139
142
  failed: results.filter((r) => r.status === 'failed').length,
140
143
  duration: Date.now() - startTime,
144
+ ...(fmIgnoredOrDegraded > 0 ? { fmIgnoredOrDegraded } : {}),
141
145
  };
142
146
  return {
143
147
  timestamp: new Date().toISOString(),
@@ -155,10 +159,13 @@ async function migrateGrammarFile(client, file, options) {
155
159
  const documentPath = joinKbPath(options.basePath, file.kbPath);
156
160
  if (options.dryRun) {
157
161
  return {
158
- sourcePath: file.sourcePath,
159
- documentPath,
160
- status: 'skipped',
161
- duration: Date.now() - startTime,
162
+ result: {
163
+ sourcePath: file.sourcePath,
164
+ documentPath,
165
+ status: 'skipped',
166
+ duration: Date.now() - startTime,
167
+ },
168
+ hadFmWarning: false,
162
169
  };
163
170
  }
164
171
  try {
@@ -175,11 +182,18 @@ async function migrateGrammarFile(client, file, options) {
175
182
  onConflict: options.onConflict,
176
183
  });
177
184
  // Surface a non-fatal warning when the file carried a front-matter block
178
- // that wasn't applied (no `name` → body-only import): tags/description/etc.
179
- // declared in it were ignored.
185
+ // that wasn't applied (no `name` → body-only import), OR when strict YAML
186
+ // parsing failed (even if the lenient fallback recovered the name, some
187
+ // metadata like tags/properties may have been lost).
188
+ let hadFmWarning = false;
180
189
  if (res.frontMatterIgnored) {
190
+ hadFmWarning = true;
181
191
  console.warn(` ⚠ front-matter present but not applied (no \`name\`) — imported body only: ${file.sourcePath}`);
182
192
  }
193
+ else if (res.frontMatterError) {
194
+ hadFmWarning = true;
195
+ console.warn(` ⚠ front-matter YAML parse error (tags/properties may be missing): ${res.frontMatterError} — ${file.sourcePath}`);
196
+ }
183
197
  // Map import_markdown outcome → migration status.
184
198
  const status = res.outcome === 'created'
185
199
  ? 'created'
@@ -187,20 +201,26 @@ async function migrateGrammarFile(client, file, options) {
187
201
  ? 'updated'
188
202
  : 'skipped';
189
203
  return {
190
- sourcePath: file.sourcePath,
191
- documentPath: res.path || documentPath,
192
- status,
193
- documentId: res.id,
194
- duration: Date.now() - startTime,
204
+ result: {
205
+ sourcePath: file.sourcePath,
206
+ documentPath: res.path || documentPath,
207
+ status,
208
+ documentId: res.id,
209
+ duration: Date.now() - startTime,
210
+ },
211
+ hadFmWarning,
195
212
  };
196
213
  }
197
214
  catch (error) {
198
215
  return {
199
- sourcePath: file.sourcePath,
200
- documentPath,
201
- status: 'failed',
202
- error: error instanceof Error ? error.message : 'Unknown error',
203
- duration: Date.now() - startTime,
216
+ result: {
217
+ sourcePath: file.sourcePath,
218
+ documentPath,
219
+ status: 'failed',
220
+ error: error instanceof Error ? error.message : 'Unknown error',
221
+ duration: Date.now() - startTime,
222
+ },
223
+ hadFmWarning: false,
204
224
  };
205
225
  }
206
226
  }
@@ -1,5 +1,9 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { joinKbPath } from './import-local.js';
1
+ import { describe, it, expect, vi, afterEach, beforeEach, } from 'vitest';
2
+ import { joinKbPath, runLocalGrammarMigration } from './import-local.js';
3
+ // Mock fs/promises at module level so readFile is consistently stubable.
4
+ vi.mock('fs/promises', () => ({
5
+ readFile: vi.fn().mockResolvedValue('---\nname: test-doc\n---\n\n## Body\n\ncontent'),
6
+ }));
3
7
  describe('joinKbPath', () => {
4
8
  it('prefixes the relative KB path under a base path', () => {
5
9
  expect(joinKbPath('/imported', 'subdir/doc')).toBe('/imported/subdir/doc');
@@ -17,3 +21,99 @@ describe('joinKbPath', () => {
17
21
  expect(joinKbPath('base', 'doc')).toBe('/base/doc');
18
22
  });
19
23
  });
24
+ // ─────────────────────────────────────────────────────────────────────────────
25
+ // FM warning + summary count tests
26
+ // ─────────────────────────────────────────────────────────────────────────────
27
+ const BASE_OPTIONS = {
28
+ apiUrl: 'http://test-api',
29
+ apiKey: 'test-key',
30
+ basePath: '/imported',
31
+ onConflict: 'update',
32
+ dryRun: false,
33
+ };
34
+ function mockResponse(status, body) {
35
+ return {
36
+ ok: status >= 200 && status < 300,
37
+ status,
38
+ json: async () => body,
39
+ };
40
+ }
41
+ function fakeSource(kbPath = 'my-doc.md') {
42
+ return {
43
+ sourceType: 'local',
44
+ sourceLocation: '/fake',
45
+ validate: async () => { },
46
+ getDocumentCount: async () => 1,
47
+ async *extractGrammarFiles() {
48
+ yield {
49
+ fullPath: '/fake/my-doc.md',
50
+ sourcePath: 'my-doc.md',
51
+ kbPath,
52
+ };
53
+ },
54
+ };
55
+ }
56
+ describe('runLocalGrammarMigration — FM warning surface + summary count', () => {
57
+ let warnSpy;
58
+ let logSpy;
59
+ let fetchSpy;
60
+ beforeEach(() => {
61
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
62
+ logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
63
+ fetchSpy = vi.spyOn(globalThis, 'fetch');
64
+ });
65
+ afterEach(() => {
66
+ vi.restoreAllMocks();
67
+ });
68
+ it('includes frontMatterError text in the console warning when server returns it', async () => {
69
+ fetchSpy.mockResolvedValue(mockResponse(200, {
70
+ result: {
71
+ id: 'doc-1',
72
+ path: '/imported/test',
73
+ outcome: 'created',
74
+ existed: false,
75
+ name: 'test',
76
+ frontMatterIgnored: false,
77
+ frontMatterError: 'strict YAML parse failed (bad indentation of a mapping entry); recovered name/description via lenient fallback',
78
+ },
79
+ }));
80
+ const log = await runLocalGrammarMigration(fakeSource(), BASE_OPTIONS);
81
+ // Warning must be emitted and include the frontMatterError message
82
+ expect(warnSpy).toHaveBeenCalledOnce();
83
+ const warnMsg = String(warnSpy.mock.calls[0][0]);
84
+ expect(warnMsg).toMatch(/YAML parse error|frontMatterError|bad indentation/i);
85
+ // Summary must carry the count
86
+ expect(log.summary.fmIgnoredOrDegraded).toBe(1);
87
+ void logSpy; // suppress unused warning
88
+ });
89
+ it('includes frontMatterIgnored warning when server returns frontMatterIgnored (no frontMatterError)', async () => {
90
+ fetchSpy.mockResolvedValue(mockResponse(200, {
91
+ result: {
92
+ id: 'doc-2',
93
+ path: '/imported/my-doc',
94
+ outcome: 'created',
95
+ existed: false,
96
+ name: 'my-doc',
97
+ frontMatterIgnored: true,
98
+ },
99
+ }));
100
+ const log = await runLocalGrammarMigration(fakeSource(), BASE_OPTIONS);
101
+ expect(warnSpy).toHaveBeenCalledOnce();
102
+ expect(log.summary.fmIgnoredOrDegraded).toBe(1);
103
+ });
104
+ it('fmIgnoredOrDegraded is absent from summary when no FM issues occurred', async () => {
105
+ fetchSpy.mockResolvedValue(mockResponse(200, {
106
+ result: {
107
+ id: 'doc-3',
108
+ path: '/imported/clean',
109
+ outcome: 'created',
110
+ existed: false,
111
+ name: 'clean',
112
+ frontMatterIgnored: false,
113
+ },
114
+ }));
115
+ const log = await runLocalGrammarMigration(fakeSource(), BASE_OPTIONS);
116
+ expect(warnSpy).not.toHaveBeenCalled();
117
+ expect(log.summary.fmIgnoredOrDegraded).toBeUndefined();
118
+ });
119
+ });
package/dist/index.js CHANGED
@@ -142,6 +142,9 @@ function printSummary(log) {
142
142
  console.log(`Updated: ${log.summary.updated}`);
143
143
  console.log(`Skipped: ${log.summary.skipped}`);
144
144
  console.log(`Failed: ${log.summary.failed}`);
145
+ if (log.summary.fmIgnoredOrDegraded) {
146
+ console.log(`FM ignored: ${log.summary.fmIgnoredOrDegraded} (see warnings above)`);
147
+ }
145
148
  if (log.options.dryRun) {
146
149
  console.log('\n(Dry run - no changes made)');
147
150
  }
@@ -7,7 +7,9 @@ import * as fs from 'fs/promises';
7
7
  import * as fsSync from 'fs';
8
8
  import * as path from 'path';
9
9
  import { glob } from 'glob';
10
- import { unified } from 'unified';
10
+ // unified@9 is CJS with a default-function export — a named import fails both
11
+ // tsc (TS2595 under esModuleInterop) and runtime ESM loading under tsx.
12
+ import unified from 'unified';
11
13
  import remarkParse from 'remark-parse';
12
14
  import { MigrationSource } from './base.js';
13
15
  /**
@@ -323,7 +323,7 @@ function convertImage(block) {
323
323
  blockType: 'image',
324
324
  type: 'url',
325
325
  url: img.image.external.url,
326
- mediaType: 'image/png', // Generic — Moxn will detect actual type
326
+ mediaType: 'image/png',
327
327
  alt: caption || undefined,
328
328
  },
329
329
  ];
@@ -150,7 +150,7 @@ export declare function slugify(title: string): string;
150
150
  * so a second same-titled sibling was rejected by that assert (audit finding D).
151
151
  * Mutates `siblingSlugCounts` (keyed on the BASE slug) the way the builder expects.
152
152
  */
153
- export declare function disambiguateSibling(title: string, siblingSlugCounts: Map<string, number>): {
153
+ export declare function disambiguateSibling(title: string, siblingSlugCounts: Map<string, number>, slugifyFn?: (s: string) => string): {
154
154
  name: string;
155
155
  slug: string;
156
156
  };
@@ -13,7 +13,7 @@ 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
+ const MAX_DOCUMENT_COUNT = 10000;
17
17
  // ============================================
18
18
  // Source
19
19
  // ============================================
@@ -632,12 +632,12 @@ export function slugify(title) {
632
632
  * so a second same-titled sibling was rejected by that assert (audit finding D).
633
633
  * Mutates `siblingSlugCounts` (keyed on the BASE slug) the way the builder expects.
634
634
  */
635
- export function disambiguateSibling(title, siblingSlugCounts) {
636
- const baseSlug = slugify(title);
635
+ export function disambiguateSibling(title, siblingSlugCounts, slugifyFn = slugify) {
636
+ const baseSlug = slugifyFn(title);
637
637
  const existing = siblingSlugCounts.get(baseSlug) ?? 0;
638
638
  siblingSlugCounts.set(baseSlug, existing + 1);
639
639
  if (existing === 0)
640
640
  return { name: title, slug: baseSlug };
641
641
  const name = `${title} ${existing + 1}`;
642
- return { name, slug: slugify(name) };
642
+ return { name, slug: slugifyFn(name) };
643
643
  }
@@ -68,3 +68,16 @@ describe('disambiguateSibling (D — dedup name AND path together)', () => {
68
68
  expect(disambiguateSibling('Beta', counts).slug).toBe('beta');
69
69
  });
70
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
+ });
@@ -6,6 +6,7 @@
6
6
  * are simple enough that a hand-rolled client is cheaper than the SDK and
7
7
  * gives us precise control over 429 handling.
8
8
  */
9
+ /// <reference types="node" resolution-mode="require"/>
9
10
  import type { OneNoteNotebook, OneNotePage, OneNoteSection, OneNoteSectionGroup } from './types.js';
10
11
  export interface OneNoteApiClientOptions {
11
12
  /** Alternate base URL — for testing only. */
@@ -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/dist/types.d.ts CHANGED
@@ -176,6 +176,12 @@ export interface MigrationLog {
176
176
  discovered?: number;
177
177
  /** Pages skipped during extraction (empty, no content, errors). */
178
178
  skippedDuringExtraction?: number;
179
+ /**
180
+ * Number of files where front-matter was ignored (no name) or degraded
181
+ * (strict YAML failed but lenient fallback recovered a name). Non-zero means
182
+ * some files lost tags/description/properties declared in the front-matter.
183
+ */
184
+ fmIgnoredOrDegraded?: number;
179
185
  };
180
186
  }
181
187
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moxn/kb-migrate",
3
- "version": "0.4.38",
3
+ "version": "0.4.40",
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",