@moxn/kb-migrate 0.4.29 → 0.4.31

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.
@@ -0,0 +1,619 @@
1
+ /**
2
+ * Convert OneNote page HTML into Moxn SectionInput[].
3
+ *
4
+ * Contract:
5
+ * - Input: a page's HTML body as returned by GET /me/onenote/pages/{id}/content
6
+ * (with ?includeIDs=true, so each element has a stable data-id).
7
+ * - Output: zero or more SectionInput. Content before the first <h2> lands
8
+ * in an "Introduction" section; each <h2> starts a new section.
9
+ *
10
+ * This is the highest-risk part of the import — OneNote HTML is idiosyncratic
11
+ * (absolute positioning, data-tag semantics, citation footers). The function
12
+ * is pure: no network, no filesystem. It consumes an ImageResolver callback
13
+ * that the fan-out task wires to a Supabase-upload pipeline.
14
+ *
15
+ * Images: the converter does NOT fetch images. It calls `resolveMedia` for
16
+ * every <img>/<object> and expects back a storage key. The resolver is free
17
+ * to return null to indicate "skip this embed".
18
+ */
19
+ import { parse, NodeType } from 'node-html-parser';
20
+ const INTRODUCTION_SECTION = 'Introduction';
21
+ /** Map from OneNote note-tag semantic → emoji prefix. `to-do` is special-cased. */
22
+ const NOTE_TAG_EMOJI = {
23
+ important: '⭐',
24
+ question: '❓',
25
+ definition: '📖',
26
+ highlight: '🖍️',
27
+ 'contact-information': '📇',
28
+ address: '📍',
29
+ 'phone-number': '📞',
30
+ 'web-site-to-visit': '🔗',
31
+ idea: '💡',
32
+ password: '🔑',
33
+ critical: '🚨',
34
+ 'project-a': '🅰️',
35
+ 'project-b': '🅱️',
36
+ 'remember-for-later': '🧠',
37
+ 'movie-to-see': '🎬',
38
+ 'book-to-read': '📚',
39
+ 'music-to-listen-to': '🎵',
40
+ 'source-for-article': '📰',
41
+ 'remember-for-blog': '🖋️',
42
+ discuss: '💬',
43
+ 'send-in-email': '📧',
44
+ schedule: '📅',
45
+ 'call-back': '📞',
46
+ 'to-do-priority-1': '❗',
47
+ 'to-do-priority-2': '❕',
48
+ client: '👤',
49
+ };
50
+ export async function convertOneNoteHtmlToSections(html, options) {
51
+ // Extract title and strip it from the body.
52
+ const root = parse(html, {
53
+ lowerCaseTagName: false,
54
+ comment: false,
55
+ });
56
+ const title = extractTitle(root, options.titleOverride);
57
+ const body = resolveBodyRoot(root);
58
+ const flatTopLevel = flattenForReadingOrder(body);
59
+ const sections = [];
60
+ let currentName = INTRODUCTION_SECTION;
61
+ let currentBlocks = [];
62
+ /**
63
+ * True once we've seen the first H2. Before that, an "Introduction" with
64
+ * no content is dropped (matches Notion's behavior). After the first H2,
65
+ * every H2 pushes a section even if empty — the user's structure survives
66
+ * even when media resolution fails.
67
+ */
68
+ let headingSeen = false;
69
+ const refs = [];
70
+ const skippedReasons = [];
71
+ let mediaCount = 0;
72
+ let skippedItemCount = 0;
73
+ const flushSection = (preserveEmpty) => {
74
+ if (preserveEmpty || currentBlocks.length > 0) {
75
+ sections.push({ name: currentName, content: compact(currentBlocks) });
76
+ }
77
+ currentBlocks = [];
78
+ };
79
+ for (const node of flatTopLevel) {
80
+ if (isCitationFooter(node)) {
81
+ if (options.dropCitations)
82
+ continue;
83
+ const text = node.text.trim();
84
+ if (text) {
85
+ currentBlocks.push(textBlock('> ' + text));
86
+ }
87
+ continue;
88
+ }
89
+ if (isH1(node) && currentBlocks.length === 0 && sections.length === 0) {
90
+ // Leading H1 usually echoes the page title; skip.
91
+ continue;
92
+ }
93
+ if (isH2(node)) {
94
+ // First H2: flush Introduction only if it has content (no empty header).
95
+ // Subsequent H2s: flush even if empty so structure is preserved.
96
+ flushSection(headingSeen);
97
+ currentName = node.text.trim() || 'Untitled';
98
+ headingSeen = true;
99
+ continue;
100
+ }
101
+ const sectionIndex = sections.length;
102
+ const result = await convertTopLevelNode(node, {
103
+ sectionIndex,
104
+ resolveMedia: options.resolveMedia,
105
+ });
106
+ currentBlocks.push(...result.blocks);
107
+ refs.push(...result.refs);
108
+ mediaCount += result.mediaCount;
109
+ skippedItemCount += result.skippedItemCount;
110
+ skippedReasons.push(...result.skippedReasons);
111
+ }
112
+ flushSection(headingSeen);
113
+ if (sections.length === 0) {
114
+ // Empty page — surface a single empty section so downstream code has
115
+ // something to write. Callers typically suppress truly empty pages.
116
+ sections.push({ name: INTRODUCTION_SECTION, content: [] });
117
+ }
118
+ return {
119
+ title,
120
+ sections,
121
+ extractedReferences: refs,
122
+ mediaCount,
123
+ skippedItemCount,
124
+ skippedReasons,
125
+ };
126
+ }
127
+ // -----------------------------------------------------------------
128
+ // Title + body extraction
129
+ // -----------------------------------------------------------------
130
+ function extractTitle(root, override) {
131
+ if (override && override.trim().length > 0)
132
+ return override.trim();
133
+ const titleEl = root.querySelector('title');
134
+ if (titleEl) {
135
+ const t = titleEl.text.trim();
136
+ if (t.length > 0)
137
+ return t;
138
+ }
139
+ const h1 = root.querySelector('h1');
140
+ if (h1) {
141
+ const t = h1.text.trim();
142
+ if (t.length > 0)
143
+ return t;
144
+ }
145
+ return 'Untitled';
146
+ }
147
+ function resolveBodyRoot(root) {
148
+ const body = root.querySelector('body');
149
+ if (body)
150
+ return body;
151
+ // Some responses come in as a naked fragment
152
+ return root;
153
+ }
154
+ /**
155
+ * OneNote pages authored as "canvases" wrap content in absolute-positioned
156
+ * <div> blocks. Reading order in the DOM may not reflect visual reading order.
157
+ *
158
+ * If the body has data-absolute-enabled="true", we sort direct children by
159
+ * (top, left) coordinates parsed from `style`. Otherwise DOM order is used.
160
+ *
161
+ * Returned nodes are the *direct children of body* (or a flattened version
162
+ * when the body is an absolute canvas).
163
+ */
164
+ function flattenForReadingOrder(body) {
165
+ const isAbsoluteCanvas = body.getAttribute('data-absolute-enabled') === 'true';
166
+ const children = body.childNodes.filter(isElement);
167
+ if (!isAbsoluteCanvas) {
168
+ return children;
169
+ }
170
+ const withCoords = children.map((child) => ({
171
+ node: child,
172
+ top: parsePxStyle(child.getAttribute('style') ?? '', 'top'),
173
+ left: parsePxStyle(child.getAttribute('style') ?? '', 'left'),
174
+ }));
175
+ withCoords.sort((a, b) => {
176
+ if (a.top !== b.top)
177
+ return a.top - b.top;
178
+ return a.left - b.left;
179
+ });
180
+ // Each "canvas" div typically contains the actual content (p/h/img/etc.)
181
+ // Flatten one level of wrapping divs to expose the real elements.
182
+ const flat = [];
183
+ for (const { node } of withCoords) {
184
+ if (node.tagName === 'DIV') {
185
+ for (const grandchild of node.childNodes.filter(isElement)) {
186
+ flat.push(grandchild);
187
+ }
188
+ }
189
+ else {
190
+ flat.push(node);
191
+ }
192
+ }
193
+ return flat;
194
+ }
195
+ function parsePxStyle(style, prop) {
196
+ const match = style.match(new RegExp(`${prop}\\s*:\\s*(-?\\d+(?:\\.\\d+)?)px`));
197
+ return match ? parseFloat(match[1]) : 0;
198
+ }
199
+ function isElement(node) {
200
+ return node.nodeType === NodeType.ELEMENT_NODE;
201
+ }
202
+ async function convertTopLevelNode(node, ctx) {
203
+ const tag = node.tagName?.toUpperCase();
204
+ switch (tag) {
205
+ case 'H1':
206
+ return block(textBlock(`# ${node.text.trim()}`));
207
+ case 'H3':
208
+ return block(textBlock(`### ${extractInlineMarkdown(node, ctx).text}`));
209
+ case 'H4':
210
+ return block(textBlock(`#### ${extractInlineMarkdown(node, ctx).text}`));
211
+ case 'H5':
212
+ return block(textBlock(`##### ${extractInlineMarkdown(node, ctx).text}`));
213
+ case 'H6':
214
+ return block(textBlock(`###### ${extractInlineMarkdown(node, ctx).text}`));
215
+ case 'P':
216
+ case 'SPAN':
217
+ case 'DIV':
218
+ return convertParagraph(node, ctx);
219
+ case 'UL':
220
+ return convertList(node, ctx, 'bullet');
221
+ case 'OL':
222
+ return convertList(node, ctx, 'ordered');
223
+ case 'TABLE':
224
+ return convertTable(node, ctx);
225
+ case 'IMG':
226
+ return convertImage(node, ctx);
227
+ case 'OBJECT':
228
+ return convertObject(node, ctx);
229
+ case 'IFRAME':
230
+ return convertIframe(node);
231
+ case 'BLOCKQUOTE':
232
+ return convertBlockquote(node, ctx);
233
+ case 'PRE':
234
+ return convertPre(node);
235
+ case 'BR':
236
+ return empty();
237
+ default:
238
+ // Unknown top-level: render plain text if any
239
+ const text = node.text.trim();
240
+ return text ? block(textBlock(text)) : empty();
241
+ }
242
+ }
243
+ // -----------------------------------------------------------------
244
+ // Block conversions
245
+ // -----------------------------------------------------------------
246
+ async function convertParagraph(node, ctx) {
247
+ const { refs, mediaBlocks, mediaCount } = await collectInlineMedia(node, ctx);
248
+ const noteTag = node.getAttribute('data-tag');
249
+ const md = extractInlineMarkdown(node, ctx);
250
+ const blocks = [];
251
+ if (noteTag === 'to-do' || noteTag === 'to-do:completed') {
252
+ const checked = noteTag === 'to-do:completed';
253
+ blocks.push(textBlock(`${checked ? '- [x]' : '- [ ]'} ${md.text}`));
254
+ }
255
+ else if (noteTag && NOTE_TAG_EMOJI[noteTag]) {
256
+ blocks.push(textBlock(`${NOTE_TAG_EMOJI[noteTag]} ${md.text}`));
257
+ }
258
+ else if (md.text.trim().length > 0) {
259
+ blocks.push(textBlock(md.text));
260
+ }
261
+ blocks.push(...mediaBlocks);
262
+ return {
263
+ blocks,
264
+ refs: [...md.refs, ...refs.map((r) => ({ ...r, sectionIndex: ctx.sectionIndex }))],
265
+ mediaCount,
266
+ skippedItemCount: 0,
267
+ skippedReasons: [],
268
+ };
269
+ }
270
+ async function convertList(node, ctx, kind) {
271
+ const lines = [];
272
+ const refs = [];
273
+ const mediaBlocks = [];
274
+ let mediaCount = 0;
275
+ let index = 1;
276
+ for (const child of node.childNodes.filter(isElement)) {
277
+ if (child.tagName !== 'LI')
278
+ continue;
279
+ const inline = extractInlineMarkdown(child, ctx);
280
+ refs.push(...inline.refs);
281
+ const bullet = kind === 'bullet' ? '-' : `${index++}.`;
282
+ const noteTag = child.getAttribute('data-tag');
283
+ let prefix = '';
284
+ if (noteTag === 'to-do')
285
+ prefix = '[ ] ';
286
+ else if (noteTag === 'to-do:completed')
287
+ prefix = '[x] ';
288
+ else if (noteTag && NOTE_TAG_EMOJI[noteTag])
289
+ prefix = `${NOTE_TAG_EMOJI[noteTag]} `;
290
+ lines.push(`${bullet} ${prefix}${inline.text.trim()}`);
291
+ const inner = await collectInlineMedia(child, ctx);
292
+ mediaBlocks.push(...inner.mediaBlocks);
293
+ mediaCount += inner.mediaCount;
294
+ }
295
+ return {
296
+ blocks: [textBlock(lines.join('\n')), ...mediaBlocks].filter((b) => !(b.blockType === 'text' && !b.text.trim())),
297
+ refs: refs.map((r) => ({ ...r, sectionIndex: ctx.sectionIndex })),
298
+ mediaCount,
299
+ skippedItemCount: 0,
300
+ skippedReasons: [],
301
+ };
302
+ }
303
+ async function convertTable(node, ctx) {
304
+ const rows = [];
305
+ for (const tr of node.querySelectorAll('tr')) {
306
+ const cells = [];
307
+ for (const cell of tr.childNodes.filter(isElement)) {
308
+ if (cell.tagName !== 'TD' && cell.tagName !== 'TH')
309
+ continue;
310
+ const inline = extractInlineMarkdown(cell, ctx);
311
+ cells.push(inline.text.trim().replace(/\|/g, '\\|') || ' ');
312
+ }
313
+ if (cells.length > 0)
314
+ rows.push(cells);
315
+ }
316
+ if (rows.length === 0)
317
+ return empty();
318
+ // Markdown table: synthesize a header row if the source didn't use <th>
319
+ const header = rows[0];
320
+ const sep = header.map(() => '---');
321
+ const rest = rows.slice(1);
322
+ const md = [
323
+ `| ${header.join(' | ')} |`,
324
+ `| ${sep.join(' | ')} |`,
325
+ ...rest.map((r) => `| ${r.join(' | ')} |`),
326
+ ].join('\n');
327
+ return block(textBlock(md));
328
+ }
329
+ async function convertImage(node, ctx) {
330
+ const src = node.getAttribute('src') ?? node.getAttribute('data-fullres-src');
331
+ if (!src)
332
+ return empty();
333
+ const alt = node.getAttribute('alt');
334
+ const declared = node.getAttribute('data-src-type') ?? undefined;
335
+ const resolved = await ctx.resolveMedia({
336
+ kind: 'image',
337
+ url: src,
338
+ declaredMimeType: declared,
339
+ alt: alt ?? undefined,
340
+ });
341
+ if (!resolved) {
342
+ return {
343
+ blocks: [],
344
+ refs: [],
345
+ mediaCount: 0,
346
+ skippedItemCount: 1,
347
+ skippedReasons: ['image skipped by media resolver'],
348
+ };
349
+ }
350
+ const blk = {
351
+ blockType: 'image',
352
+ type: 'storage',
353
+ key: resolved.key,
354
+ mediaType: resolved.mediaType,
355
+ alt: alt ?? undefined,
356
+ };
357
+ return {
358
+ blocks: [blk],
359
+ refs: [],
360
+ mediaCount: 1,
361
+ skippedItemCount: 0,
362
+ skippedReasons: [],
363
+ };
364
+ }
365
+ async function convertObject(node, ctx) {
366
+ const data = node.getAttribute('data');
367
+ if (!data)
368
+ return empty();
369
+ const mime = node.getAttribute('type') ?? undefined;
370
+ const filename = node.getAttribute('data-attachment') ?? undefined;
371
+ const resolved = await ctx.resolveMedia({
372
+ kind: 'file',
373
+ url: data,
374
+ declaredMimeType: mime,
375
+ declaredFilename: filename,
376
+ });
377
+ if (!resolved) {
378
+ return {
379
+ blocks: [],
380
+ refs: [],
381
+ mediaCount: 0,
382
+ skippedItemCount: 1,
383
+ skippedReasons: [`attachment skipped: ${filename ?? data}`],
384
+ };
385
+ }
386
+ // Pick the right block type based on MIME
387
+ let blk;
388
+ const mediaType = resolved.mediaType;
389
+ if (mediaType === 'application/pdf') {
390
+ blk = {
391
+ blockType: 'document',
392
+ type: 'storage',
393
+ key: resolved.key,
394
+ mediaType,
395
+ filename: resolved.filename ?? filename,
396
+ };
397
+ }
398
+ else if (mediaType === 'text/csv') {
399
+ blk = {
400
+ blockType: 'csv',
401
+ type: 'storage',
402
+ key: resolved.key,
403
+ mediaType,
404
+ filename: resolved.filename ?? filename,
405
+ };
406
+ }
407
+ else {
408
+ blk = {
409
+ blockType: 'file',
410
+ type: 'storage',
411
+ key: resolved.key,
412
+ mediaType,
413
+ filename: resolved.filename ?? filename,
414
+ };
415
+ }
416
+ return {
417
+ blocks: [blk],
418
+ refs: [],
419
+ mediaCount: 1,
420
+ skippedItemCount: 0,
421
+ skippedReasons: [],
422
+ };
423
+ }
424
+ async function convertIframe(node) {
425
+ const src = node.getAttribute('data-original-src') ?? node.getAttribute('src') ?? '';
426
+ if (!src)
427
+ return empty();
428
+ return block(textBlock(`[Embed](${src})`));
429
+ }
430
+ async function convertBlockquote(node, ctx) {
431
+ const md = extractInlineMarkdown(node, ctx);
432
+ const quoted = md.text
433
+ .split('\n')
434
+ .map((l) => (l.length ? `> ${l}` : '>'))
435
+ .join('\n');
436
+ return {
437
+ blocks: [textBlock(quoted)],
438
+ refs: md.refs.map((r) => ({ ...r, sectionIndex: ctx.sectionIndex })),
439
+ mediaCount: 0,
440
+ skippedItemCount: 0,
441
+ skippedReasons: [],
442
+ };
443
+ }
444
+ async function convertPre(node) {
445
+ const text = node.text;
446
+ return block(textBlock('```\n' + text + '\n```'));
447
+ }
448
+ function extractInlineMarkdown(node, ctx) {
449
+ const refs = [];
450
+ const text = walkInline(node, refs, ctx);
451
+ return { text, refs };
452
+ }
453
+ function walkInline(node, refs, ctx) {
454
+ const tag = node.tagName?.toUpperCase();
455
+ const style = node.getAttribute('style') ?? '';
456
+ const children = node.childNodes
457
+ .map((c) => renderChild(c, refs, ctx))
458
+ .join('');
459
+ switch (tag) {
460
+ case 'B':
461
+ case 'STRONG':
462
+ return wrap(children, '**');
463
+ case 'I':
464
+ case 'EM':
465
+ return wrap(children, '*');
466
+ case 'U':
467
+ // Markdown has no native underline — render as HTML so downstream
468
+ // converters (martian, remark) can preserve semantics.
469
+ return `<u>${children}</u>`;
470
+ case 'STRIKE':
471
+ case 'S':
472
+ case 'DEL':
473
+ return wrap(children, '~~');
474
+ case 'CODE':
475
+ return `\`${children}\``;
476
+ case 'SUB':
477
+ return `<sub>${children}</sub>`;
478
+ case 'SUP':
479
+ return `<sup>${children}</sup>`;
480
+ case 'A': {
481
+ const href = node.getAttribute('href') ?? '';
482
+ const ref = detectOneNoteRef(href);
483
+ if (ref) {
484
+ refs.push({
485
+ sectionIndex: ctx.sectionIndex,
486
+ targetOnenotePageId: ref,
487
+ displayText: children,
488
+ });
489
+ }
490
+ if (!children)
491
+ return '';
492
+ return `[${children}](${href})`;
493
+ }
494
+ case 'BR':
495
+ return ' \n';
496
+ case 'SPAN': {
497
+ // OneNote likes to wrap text in <span style="...">. Preserve bold/italic
498
+ // via style when HTML tags weren't used.
499
+ const isBold = /font-weight\s*:\s*(bold|700|800|900)/i.test(style);
500
+ const isItalic = /font-style\s*:\s*italic/i.test(style);
501
+ let out = children;
502
+ if (isBold)
503
+ out = wrap(out, '**');
504
+ if (isItalic)
505
+ out = wrap(out, '*');
506
+ return out;
507
+ }
508
+ default:
509
+ return children;
510
+ }
511
+ }
512
+ function renderChild(node, refs, ctx) {
513
+ if (node.nodeType === NodeType.TEXT_NODE) {
514
+ return collapseWhitespace(node.text);
515
+ }
516
+ if (isElement(node)) {
517
+ return walkInline(node, refs, ctx);
518
+ }
519
+ return '';
520
+ }
521
+ function wrap(text, marker) {
522
+ if (!text.trim())
523
+ return text;
524
+ // Avoid marker collision by only wrapping when inner doesn't end with same marker
525
+ return `${marker}${text}${marker}`;
526
+ }
527
+ function collapseWhitespace(s) {
528
+ return s.replace(/\s+/g, ' ');
529
+ }
530
+ /**
531
+ * Detect OneNote-style cross-page references. We support:
532
+ * - onenote:https://.../section/page.one#pageId={guid}&end
533
+ * - query string containing pageId=...
534
+ * - bare Graph API page URLs with /pages/{id}/
535
+ */
536
+ function detectOneNoteRef(href) {
537
+ if (!href)
538
+ return null;
539
+ // 1) onenote:// URI with pageId
540
+ const pageIdMatch = href.match(/pageid=([^&]+)/i);
541
+ if (pageIdMatch)
542
+ return decodeURIComponent(pageIdMatch[1]);
543
+ // 2) Graph API URL /pages/{id}
544
+ const graphMatch = href.match(/\/pages\/([^/?#]+)/);
545
+ if (graphMatch)
546
+ return decodeURIComponent(graphMatch[1]);
547
+ return null;
548
+ }
549
+ async function collectInlineMedia(node, ctx) {
550
+ const mediaBlocks = [];
551
+ let mediaCount = 0;
552
+ const refs = [];
553
+ for (const img of node.querySelectorAll('img')) {
554
+ const result = await convertImage(img, ctx);
555
+ mediaBlocks.push(...result.blocks);
556
+ mediaCount += result.mediaCount;
557
+ refs.push(...result.refs);
558
+ }
559
+ for (const obj of node.querySelectorAll('object')) {
560
+ const result = await convertObject(obj, ctx);
561
+ mediaBlocks.push(...result.blocks);
562
+ mediaCount += result.mediaCount;
563
+ refs.push(...result.refs);
564
+ }
565
+ return { mediaBlocks, mediaCount, refs };
566
+ }
567
+ // -----------------------------------------------------------------
568
+ // Citation footer detection
569
+ // -----------------------------------------------------------------
570
+ function isCitationFooter(node) {
571
+ if (node.tagName !== 'CITE' && node.tagName !== 'FOOTER') {
572
+ // OneNote typically wraps the citation in a <p> or <cite> with a known text
573
+ const txt = node.text.trim().toLowerCase();
574
+ if (txt.startsWith('from <') || txt.startsWith('copied from'))
575
+ return true;
576
+ // Graph also sometimes emits a <p> with data-citation-visible="true"
577
+ if (node.getAttribute('data-citation-visible') === 'true')
578
+ return true;
579
+ return false;
580
+ }
581
+ return true;
582
+ }
583
+ function isH1(node) {
584
+ return node.tagName === 'H1';
585
+ }
586
+ function isH2(node) {
587
+ return node.tagName === 'H2';
588
+ }
589
+ // -----------------------------------------------------------------
590
+ // Helpers
591
+ // -----------------------------------------------------------------
592
+ function textBlock(text) {
593
+ return { blockType: 'text', text };
594
+ }
595
+ function block(b) {
596
+ return { blocks: [b], refs: [], mediaCount: 0, skippedItemCount: 0, skippedReasons: [] };
597
+ }
598
+ function empty() {
599
+ return { blocks: [], refs: [], mediaCount: 0, skippedItemCount: 0, skippedReasons: [] };
600
+ }
601
+ /**
602
+ * Collapse consecutive text blocks with two newlines between; drop empties.
603
+ */
604
+ function compact(blocks) {
605
+ const out = [];
606
+ for (const b of blocks) {
607
+ if (b.blockType === 'text') {
608
+ if (!b.text.trim())
609
+ continue;
610
+ const last = out[out.length - 1];
611
+ if (last && last.blockType === 'text') {
612
+ last.text = `${last.text}\n\n${b.text}`;
613
+ continue;
614
+ }
615
+ }
616
+ out.push(b);
617
+ }
618
+ return out;
619
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Discovery: walk Microsoft Graph's OneNote hierarchy and flatten into a
3
+ * list of pages with their computed KB path.
4
+ *
5
+ * The hierarchy is: Notebook → SectionGroup* → Section → Page.
6
+ * SectionGroups can nest; OneNote caps at 4 levels of nesting in practice
7
+ * and we cap recursion at 4 to avoid accidental cycles.
8
+ *
9
+ * Selection semantics: callers pass a set of selected Graph IDs (any mix of
10
+ * notebook / sectionGroup / section / page). A page is included iff its
11
+ * own ID or any ancestor's ID is in the set. If the set is empty, every
12
+ * page is included.
13
+ */
14
+ import type { OneNoteApiClient } from './onenote-api.js';
15
+ import type { DiscoveredOneNotePage } from './types.js';
16
+ export interface DiscoverOptions {
17
+ /** Selected Graph IDs (any level). Empty / undefined = include everything. */
18
+ selectedIds?: Set<string>;
19
+ /** Path prefix prepended to every kbPath; defaults to `imported/onenote` */
20
+ pathPrefix?: string;
21
+ modifiedAfter?: Date;
22
+ modifiedBefore?: Date;
23
+ }
24
+ /**
25
+ * Normalize any human text into a ltree-safe slug segment.
26
+ * - lowercases
27
+ * - replaces spaces/underscores/periods with hyphens (ltree forbids _ and .)
28
+ * - drops anything outside [a-z0-9-]
29
+ * - collapses runs of hyphens; trims leading/trailing hyphens
30
+ * Falls back to `untitled` when the result is empty.
31
+ */
32
+ export declare function slugifyPathSegment(raw: string): string;
33
+ export declare function discoverOneNotePages(client: OneNoteApiClient, opts?: DiscoverOptions): Promise<DiscoveredOneNotePage[]>;
34
+ /**
35
+ * Join kbPath segments into `/a/b/c` — public helper used by the orchestrator
36
+ * when it needs to derive a path outside the discovery walk.
37
+ */
38
+ export declare function joinKbPath(...segments: Array<string | undefined>): string;