@moxn/kb-migrate 0.4.32 → 0.4.34
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/sources/local.d.ts +15 -0
- package/dist/sources/local.js +32 -6
- package/dist/sources/local.test.d.ts +1 -0
- package/dist/sources/local.test.js +63 -0
- package/dist/sources/onenote/__tests__/onenote-html.test.js +3 -1
- package/dist/sources/onenote/onenote-api.d.ts +2 -0
- package/dist/sources/onenote/onenote-api.js +10 -2
- package/dist/sources/onenote/onenote-html.js +15 -5
- package/dist/sources/onenote/onenote-tree.d.ts +2 -0
- package/dist/sources/onenote/onenote-tree.js +3 -2
- package/package.json +1 -1
package/dist/sources/local.d.ts
CHANGED
|
@@ -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;
|
package/dist/sources/local.js
CHANGED
|
@@ -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
|
|
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
|
|
124
|
-
|
|
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}" (
|
|
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
|
+
});
|
|
@@ -105,7 +105,9 @@ describe('convertOneNoteHtmlToSections', () => {
|
|
|
105
105
|
const charts = result.sections[1];
|
|
106
106
|
const imageBlock = charts.content.find((b) => b.blockType === 'image');
|
|
107
107
|
expect(imageBlock).toBeDefined();
|
|
108
|
-
if (imageBlock &&
|
|
108
|
+
if (imageBlock &&
|
|
109
|
+
imageBlock.blockType === 'image' &&
|
|
110
|
+
imageBlock.type === 'storage') {
|
|
109
111
|
expect(imageBlock.key).toMatch(/kb\/onenote\/test\/abc-123\./);
|
|
110
112
|
expect(imageBlock.mediaType).toBe('image/png');
|
|
111
113
|
expect(imageBlock.alt).toBe('semis relative performance');
|
|
@@ -34,6 +34,8 @@ export declare class OneNoteApiClient {
|
|
|
34
34
|
listPages(sectionId: string, options?: {
|
|
35
35
|
modifiedAfter?: Date;
|
|
36
36
|
modifiedBefore?: Date;
|
|
37
|
+
createdAfter?: Date;
|
|
38
|
+
createdBefore?: Date;
|
|
37
39
|
}): Promise<OneNotePage[]>;
|
|
38
40
|
getPage(pageId: string): Promise<OneNotePage>;
|
|
39
41
|
/**
|
|
@@ -52,7 +52,15 @@ export class OneNoteApiClient {
|
|
|
52
52
|
if (options?.modifiedBefore) {
|
|
53
53
|
filters.push(`lastModifiedTime le ${options.modifiedBefore.toISOString()}`);
|
|
54
54
|
}
|
|
55
|
-
|
|
55
|
+
if (options?.createdAfter) {
|
|
56
|
+
filters.push(`createdTime ge ${options.createdAfter.toISOString()}`);
|
|
57
|
+
}
|
|
58
|
+
if (options?.createdBefore) {
|
|
59
|
+
filters.push(`createdTime le ${options.createdBefore.toISOString()}`);
|
|
60
|
+
}
|
|
61
|
+
const filter = filters.length
|
|
62
|
+
? `&$filter=${encodeURIComponent(filters.join(' and '))}`
|
|
63
|
+
: '';
|
|
56
64
|
return this.paginate(`${this.baseUrl}/${this.userSlug}/onenote/sections/${encodeURIComponent(sectionId)}/pages?$top=${DEFAULT_PAGE_SIZE}${filter}&${select.slice(1)}`);
|
|
57
65
|
}
|
|
58
66
|
async getPage(pageId) {
|
|
@@ -141,7 +149,7 @@ export class OneNoteApiClient {
|
|
|
141
149
|
const errorBody = await response.text().catch(() => '');
|
|
142
150
|
throw new Error(`Graph API ${response.status} on ${url}: ${errorBody.slice(0, 400)}`);
|
|
143
151
|
}
|
|
144
|
-
throw lastError ?? new Error(`Graph API request failed after ${MAX_RETRIES} retries`);
|
|
152
|
+
throw (lastError ?? new Error(`Graph API request failed after ${MAX_RETRIES} retries`));
|
|
145
153
|
}
|
|
146
154
|
}
|
|
147
155
|
function sleep(ms) {
|
|
@@ -453,9 +453,7 @@ function extractInlineMarkdown(node, ctx) {
|
|
|
453
453
|
function walkInline(node, refs, ctx) {
|
|
454
454
|
const tag = node.tagName?.toUpperCase();
|
|
455
455
|
const style = node.getAttribute('style') ?? '';
|
|
456
|
-
const children = node.childNodes
|
|
457
|
-
.map((c) => renderChild(c, refs, ctx))
|
|
458
|
-
.join('');
|
|
456
|
+
const children = node.childNodes.map((c) => renderChild(c, refs, ctx)).join('');
|
|
459
457
|
switch (tag) {
|
|
460
458
|
case 'B':
|
|
461
459
|
case 'STRONG':
|
|
@@ -593,10 +591,22 @@ function textBlock(text) {
|
|
|
593
591
|
return { blockType: 'text', text };
|
|
594
592
|
}
|
|
595
593
|
function block(b) {
|
|
596
|
-
return {
|
|
594
|
+
return {
|
|
595
|
+
blocks: [b],
|
|
596
|
+
refs: [],
|
|
597
|
+
mediaCount: 0,
|
|
598
|
+
skippedItemCount: 0,
|
|
599
|
+
skippedReasons: [],
|
|
600
|
+
};
|
|
597
601
|
}
|
|
598
602
|
function empty() {
|
|
599
|
-
return {
|
|
603
|
+
return {
|
|
604
|
+
blocks: [],
|
|
605
|
+
refs: [],
|
|
606
|
+
mediaCount: 0,
|
|
607
|
+
skippedItemCount: 0,
|
|
608
|
+
skippedReasons: [],
|
|
609
|
+
};
|
|
600
610
|
}
|
|
601
611
|
/**
|
|
602
612
|
* Collapse consecutive text blocks with two newlines between; drop empties.
|
|
@@ -92,6 +92,8 @@ async function walkSection(client, section, pathSegs, ctx, ancestorSelected, sel
|
|
|
92
92
|
const pages = await client.listPages(section.id, {
|
|
93
93
|
modifiedAfter: opts.modifiedAfter,
|
|
94
94
|
modifiedBefore: opts.modifiedBefore,
|
|
95
|
+
createdAfter: opts.createdAfter,
|
|
96
|
+
createdBefore: opts.createdBefore,
|
|
95
97
|
});
|
|
96
98
|
const sectionSeg = slugifyPathSegment(section.displayName);
|
|
97
99
|
for (const page of pages) {
|
|
@@ -117,8 +119,7 @@ function toDiscovered(page, section, segs, ctx) {
|
|
|
117
119
|
lastModifiedDateTime: page.lastModifiedTime,
|
|
118
120
|
createdByEmail: page.createdBy?.user?.email ?? page.createdBy?.user?.userPrincipalName,
|
|
119
121
|
createdByDisplayName: page.createdBy?.user?.displayName,
|
|
120
|
-
lastModifiedByEmail: page.lastModifiedBy?.user?.email ??
|
|
121
|
-
page.lastModifiedBy?.user?.userPrincipalName,
|
|
122
|
+
lastModifiedByEmail: page.lastModifiedBy?.user?.email ?? page.lastModifiedBy?.user?.userPrincipalName,
|
|
122
123
|
};
|
|
123
124
|
}
|
|
124
125
|
/**
|
package/package.json
CHANGED