@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.
- package/dist/sources/notion-api.js +1 -3
- package/dist/sources/onenote/__tests__/onenote-html.test.d.ts +1 -0
- package/dist/sources/onenote/__tests__/onenote-html.test.js +234 -0
- package/dist/sources/onenote/index.d.ts +13 -0
- package/dist/sources/onenote/index.js +8 -0
- package/dist/sources/onenote/onenote-api.d.ts +63 -0
- package/dist/sources/onenote/onenote-api.js +147 -0
- package/dist/sources/onenote/onenote-auth.d.ts +83 -0
- package/dist/sources/onenote/onenote-auth.js +136 -0
- package/dist/sources/onenote/onenote-html.d.ts +54 -0
- package/dist/sources/onenote/onenote-html.js +619 -0
- package/dist/sources/onenote/onenote-tree.d.ts +38 -0
- package/dist/sources/onenote/onenote-tree.js +131 -0
- package/dist/sources/onenote/types.d.ts +124 -0
- package/dist/sources/onenote/types.js +8 -0
- package/package.json +24 -1
|
@@ -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;
|