@moxn/kb-migrate 0.4.33 → 0.4.35

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/index.js CHANGED
@@ -950,15 +950,9 @@ async function importNotionDatabase(client, dbImport, log, options, preCreatedDb
950
950
  }
951
951
  /**
952
952
  * Slugify a string for use as a tag path segment.
953
- * Similar to page slug but for the tag hierarchy.
953
+ * Delegates to the shared notion slugify so tags and page paths normalize identically.
954
954
  */
955
955
  function slugifyTagPath(s) {
956
- return (s
957
- .toLowerCase()
958
- .trim()
959
- .replace(/[^\w\s-]/g, '')
960
- .replace(/[\s_]+/g, '-')
961
- .replace(/-+/g, '-')
962
- .replace(/^-|-$/g, '') || 'untitled');
956
+ return slugify(s);
963
957
  }
964
958
  program.parse();
@@ -6,6 +6,21 @@
6
6
  import { MigrationSource, type SourceConfig } from './base.js';
7
7
  import type { ExtractedDocument } from '../types.js';
8
8
  import type { DateFilter } from '../date-filter.js';
9
+ /**
10
+ * Sanitize a single path segment for ltree compatibility.
11
+ * Server's validatePathForLtree only allows [a-zA-Z0-9-]. We:
12
+ * - normalize NFKD + strip combining marks so "résumé" → "resume"
13
+ * - replace anything not in [a-zA-Z0-9-] with "-"
14
+ * - collapse runs of "-" and trim leading/trailing "-"
15
+ * Falls back to "untitled" when the result is empty (e.g. "___.md").
16
+ */
17
+ export declare function sanitizePathSegment(segment: string): string;
18
+ /**
19
+ * Convert a relative file path (e.g. "subdir/My_Doc.v2.md") into a
20
+ * server-acceptable KB path (e.g. "subdir/My-Doc-v2"). Strips the file
21
+ * extension and sanitizes each path segment.
22
+ */
23
+ export declare function filePathToKbPath(relativePath: string): string;
9
24
  export interface LocalSourceConfig extends SourceConfig {
10
25
  /** Directory path to scan for documents */
11
26
  directory: string;
@@ -10,6 +10,33 @@ import { glob } from 'glob';
10
10
  import { unified } from 'unified';
11
11
  import remarkParse from 'remark-parse';
12
12
  import { MigrationSource } from './base.js';
13
+ /**
14
+ * Sanitize a single path segment for ltree compatibility.
15
+ * Server's validatePathForLtree only allows [a-zA-Z0-9-]. We:
16
+ * - normalize NFKD + strip combining marks so "résumé" → "resume"
17
+ * - replace anything not in [a-zA-Z0-9-] with "-"
18
+ * - collapse runs of "-" and trim leading/trailing "-"
19
+ * Falls back to "untitled" when the result is empty (e.g. "___.md").
20
+ */
21
+ export function sanitizePathSegment(segment) {
22
+ const cleaned = segment
23
+ .normalize('NFKD')
24
+ .replace(/[̀-ͯ]/g, '')
25
+ .replace(/[^a-zA-Z0-9-]/g, '-')
26
+ .replace(/-+/g, '-')
27
+ .replace(/^-+|-+$/g, '');
28
+ return cleaned.length > 0 ? cleaned : 'untitled';
29
+ }
30
+ /**
31
+ * Convert a relative file path (e.g. "subdir/My_Doc.v2.md") into a
32
+ * server-acceptable KB path (e.g. "subdir/My-Doc-v2"). Strips the file
33
+ * extension and sanitizes each path segment.
34
+ */
35
+ export function filePathToKbPath(relativePath) {
36
+ const parsed = path.parse(relativePath);
37
+ const dirParts = parsed.dir ? parsed.dir.split(path.sep) : [];
38
+ return [...dirParts, parsed.name].map(sanitizePathSegment).join('/');
39
+ }
13
40
  /**
14
41
  * Local filesystem migration source
15
42
  */
@@ -94,9 +121,7 @@ export class LocalSource extends MigrationSource {
94
121
  // Detect KB path collisions (e.g., doc.md and doc.mdx both map to /doc)
95
122
  const pathToFiles = new Map();
96
123
  for (const file of uniqueFiles) {
97
- const parsed = path.parse(file);
98
- const dirParts = parsed.dir ? parsed.dir.split(path.sep) : [];
99
- const kbPath = [...dirParts, parsed.name].join('/').replace(/ /g, '-');
124
+ const kbPath = filePathToKbPath(file);
100
125
  const existing = pathToFiles.get(kbPath) || [];
101
126
  existing.push(file);
102
127
  pathToFiles.set(kbPath, existing);
@@ -120,10 +145,11 @@ export class LocalSource extends MigrationSource {
120
145
  const parsed = path.parse(relativePath);
121
146
  const dirParts = parsed.dir ? parsed.dir.split(path.sep) : [];
122
147
  const rawPath = [...dirParts, parsed.name].join('/');
123
- // Sanitize: replace spaces with hyphens (server rejects paths with spaces)
124
- const docPath = rawPath.replace(/ /g, '-');
148
+ // Sanitize each segment: server's ltree validation only allows [a-zA-Z0-9-].
149
+ // Spaces, underscores, dots, and other special chars are replaced with hyphens.
150
+ const docPath = filePathToKbPath(relativePath);
125
151
  if (docPath !== rawPath) {
126
- console.warn(` ⚠ Path sanitized: "${rawPath}" → "${docPath}" (spaces replaced with hyphens)`);
152
+ console.warn(` ⚠ Path sanitized: "${rawPath}" → "${docPath}" (unsupported chars replaced with hyphens)`);
127
153
  }
128
154
  // Derive name from filename
129
155
  const name = parsed.name
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { sanitizePathSegment, filePathToKbPath } from './local.js';
3
+ describe('sanitizePathSegment', () => {
4
+ it('replaces spaces with hyphens', () => {
5
+ expect(sanitizePathSegment('My Doc')).toBe('My-Doc');
6
+ });
7
+ it('replaces underscores with hyphens (ltree forbids _)', () => {
8
+ expect(sanitizePathSegment('my_doc')).toBe('my-doc');
9
+ });
10
+ it('replaces periods with hyphens (ltree forbids .)', () => {
11
+ expect(sanitizePathSegment('my.doc.v2')).toBe('my-doc-v2');
12
+ });
13
+ it('handles mixed underscores, periods, and spaces', () => {
14
+ expect(sanitizePathSegment('My_Doc.v2 final')).toBe('My-Doc-v2-final');
15
+ });
16
+ it('collapses runs of separators', () => {
17
+ expect(sanitizePathSegment('foo___bar...baz')).toBe('foo-bar-baz');
18
+ });
19
+ it('replaces other special characters with hyphens', () => {
20
+ expect(sanitizePathSegment('foo@bar!')).toBe('foo-bar');
21
+ });
22
+ it('handles parentheses, ampersands, apostrophes', () => {
23
+ expect(sanitizePathSegment("Q3 Plan (final)")).toBe('Q3-Plan-final');
24
+ expect(sanitizePathSegment("Tom's & Jerry's")).toBe('Tom-s-Jerry-s');
25
+ });
26
+ it('strips accents via NFKD normalization', () => {
27
+ expect(sanitizePathSegment('résumé')).toBe('resume');
28
+ expect(sanitizePathSegment('café')).toBe('cafe');
29
+ });
30
+ it('replaces unsupported scripts with hyphens', () => {
31
+ expect(sanitizePathSegment('файл')).toBe('untitled');
32
+ });
33
+ it('trims leading/trailing hyphens', () => {
34
+ expect(sanitizePathSegment('_foo_')).toBe('foo');
35
+ });
36
+ it('preserves case', () => {
37
+ expect(sanitizePathSegment('MyDoc')).toBe('MyDoc');
38
+ });
39
+ it('falls back to "untitled" for empty result', () => {
40
+ expect(sanitizePathSegment('___')).toBe('untitled');
41
+ expect(sanitizePathSegment('!!!')).toBe('untitled');
42
+ });
43
+ });
44
+ describe('filePathToKbPath', () => {
45
+ it('strips file extension', () => {
46
+ expect(filePathToKbPath('readme.md')).toBe('readme');
47
+ });
48
+ it('sanitizes underscores in filename', () => {
49
+ expect(filePathToKbPath('my_notes.md')).toBe('my-notes');
50
+ });
51
+ it('sanitizes periods in filename', () => {
52
+ expect(filePathToKbPath('release.v2.md')).toBe('release-v2');
53
+ });
54
+ it('sanitizes each directory segment', () => {
55
+ expect(filePathToKbPath('eng_docs/my.spec/readme.md')).toBe('eng-docs/my-spec/readme');
56
+ });
57
+ it('handles paths with both underscores and periods together', () => {
58
+ expect(filePathToKbPath('release_notes/v1.2.md')).toBe('release-notes/v1-2');
59
+ });
60
+ it('handles parens and special chars in filename', () => {
61
+ expect(filePathToKbPath('Q3 Plan (final).md')).toBe('Q3-Plan-final');
62
+ });
63
+ });
@@ -127,6 +127,12 @@ export interface PageTreeResult {
127
127
  export declare function buildPageTree(pages: NotionPage[], options?: BuildPageTreeOptions): PageTreeResult;
128
128
  /**
129
129
  * Slugify a page title for use as a KB path segment.
130
- * Lowercase, spaces→hyphens, strip special chars, ltree-compatible.
130
+ *
131
+ * Server's validatePathForLtree only allows [a-zA-Z0-9-]. We:
132
+ * - lowercase
133
+ * - normalize NFKD + strip combining marks so "résumé" → "resume"
134
+ * - replace anything not in [a-z0-9-] with "-"
135
+ * - collapse runs of "-" and trim leading/trailing "-"
136
+ * Falls back to "untitled" when the result is empty (e.g. emoji-only or non-Latin titles).
131
137
  */
132
138
  export declare function slugify(title: string): string;
@@ -596,15 +596,21 @@ function getParentPageId(page) {
596
596
  // ============================================
597
597
  /**
598
598
  * Slugify a page title for use as a KB path segment.
599
- * Lowercase, spaces→hyphens, strip special chars, ltree-compatible.
599
+ *
600
+ * Server's validatePathForLtree only allows [a-zA-Z0-9-]. We:
601
+ * - lowercase
602
+ * - normalize NFKD + strip combining marks so "résumé" → "resume"
603
+ * - replace anything not in [a-z0-9-] with "-"
604
+ * - collapse runs of "-" and trim leading/trailing "-"
605
+ * Falls back to "untitled" when the result is empty (e.g. emoji-only or non-Latin titles).
600
606
  */
601
607
  export function slugify(title) {
602
- return (title
608
+ const cleaned = title
609
+ .normalize('NFKD')
610
+ .replace(/[̀-ͯ]/g, '')
603
611
  .toLowerCase()
604
- .trim()
605
- .replace(/[^\w\s-]/g, '') // Remove special chars (keep word chars, spaces, hyphens)
606
- .replace(/[\s_]+/g, '-') // Spaces and underscores → hyphens (ltree forbids _ and .)
607
- .replace(/-+/g, '-') // Collapse multiple hyphens
608
- .replace(/^-|-$/g, '') || // Trim leading/trailing hyphens
609
- 'untitled');
612
+ .replace(/[^a-z0-9-]/g, '-')
613
+ .replace(/-+/g, '-')
614
+ .replace(/^-+|-+$/g, '');
615
+ return cleaned.length > 0 ? cleaned : 'untitled';
610
616
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { slugify } from './notion.js';
3
+ describe('slugify (notion)', () => {
4
+ it('lowercases', () => {
5
+ expect(slugify('MyDoc')).toBe('mydoc');
6
+ });
7
+ it('replaces spaces with hyphens', () => {
8
+ expect(slugify('My Doc')).toBe('my-doc');
9
+ });
10
+ it('replaces underscores with hyphens (ltree forbids _)', () => {
11
+ expect(slugify('my_doc')).toBe('my-doc');
12
+ });
13
+ it('replaces periods with hyphens (ltree forbids .)', () => {
14
+ expect(slugify('release.v2')).toBe('release-v2');
15
+ });
16
+ it('handles parens, ampersands, apostrophes', () => {
17
+ expect(slugify('Q3 Plan (final)')).toBe('q3-plan-final');
18
+ expect(slugify("Tom's & Jerry's")).toBe('tom-s-jerry-s');
19
+ });
20
+ it('strips accents via NFKD normalization', () => {
21
+ expect(slugify('résumé')).toBe('resume');
22
+ expect(slugify('café')).toBe('cafe');
23
+ expect(slugify('naïve')).toBe('naive');
24
+ });
25
+ it('replaces non-Latin scripts with hyphens', () => {
26
+ expect(slugify('файл')).toBe('untitled');
27
+ expect(slugify('日本語')).toBe('untitled');
28
+ });
29
+ it('preserves digits', () => {
30
+ expect(slugify('Q3-2024')).toBe('q3-2024');
31
+ });
32
+ it('collapses runs of separators', () => {
33
+ expect(slugify('foo___bar...baz')).toBe('foo-bar-baz');
34
+ });
35
+ it('trims leading/trailing hyphens', () => {
36
+ expect(slugify('-foo-')).toBe('foo');
37
+ expect(slugify(' hello ')).toBe('hello');
38
+ });
39
+ it('falls back to "untitled" for empty result', () => {
40
+ expect(slugify('')).toBe('untitled');
41
+ expect(slugify('___')).toBe('untitled');
42
+ expect(slugify('!!!')).toBe('untitled');
43
+ expect(slugify('🎉')).toBe('untitled');
44
+ });
45
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moxn/kb-migrate",
3
- "version": "0.4.33",
3
+ "version": "0.4.35",
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",