@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
|
@@ -182,9 +182,7 @@ export class NotionApiClient {
|
|
|
182
182
|
catch (fetchError) {
|
|
183
183
|
// Network-level failure (DNS, TCP reset, timeout, etc.)
|
|
184
184
|
lastError =
|
|
185
|
-
fetchError instanceof Error
|
|
186
|
-
? fetchError
|
|
187
|
-
: new Error(String(fetchError));
|
|
185
|
+
fetchError instanceof Error ? fetchError : new Error(String(fetchError));
|
|
188
186
|
if (attempt < MAX_RETRIES) {
|
|
189
187
|
const delay = Math.min(1000 * Math.pow(2, attempt), 30000);
|
|
190
188
|
console.warn(`Network error: ${lastError.message}. Retrying in ${delay}ms (attempt ${attempt + 1}/${MAX_RETRIES})`);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { readFile } from 'fs/promises';
|
|
2
|
+
import { join, dirname } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import { convertOneNoteHtmlToSections, } from '../onenote-html.js';
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const fixtureDir = join(__dirname, 'fixtures');
|
|
8
|
+
async function loadFixture(name) {
|
|
9
|
+
return readFile(join(fixtureDir, name), 'utf8');
|
|
10
|
+
}
|
|
11
|
+
/** Stubs every image/attachment to a predictable storage key. */
|
|
12
|
+
const storageResolver = async (ctx) => {
|
|
13
|
+
const ext = ctx.kind === 'image' ? 'png' : 'bin';
|
|
14
|
+
const hash = ctx.url.split('/').slice(-2, -1)[0] ?? 'anon';
|
|
15
|
+
const key = `kb/onenote/test/${hash}.${ext}`;
|
|
16
|
+
const mediaType = ctx.declaredMimeType ??
|
|
17
|
+
(ctx.kind === 'image' ? 'image/png' : 'application/octet-stream');
|
|
18
|
+
return {
|
|
19
|
+
key,
|
|
20
|
+
mediaType,
|
|
21
|
+
filename: ctx.declaredFilename,
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
describe('convertOneNoteHtmlToSections', () => {
|
|
25
|
+
it('extracts title and splits sections at H2 boundaries', async () => {
|
|
26
|
+
const html = await loadFixture('simple-page.html');
|
|
27
|
+
const result = await convertOneNoteHtmlToSections(html, {
|
|
28
|
+
resolveMedia: storageResolver,
|
|
29
|
+
});
|
|
30
|
+
expect(result.title).toBe('AAPL — Long Thesis');
|
|
31
|
+
expect(result.sections.map((s) => s.name)).toEqual([
|
|
32
|
+
'Introduction',
|
|
33
|
+
'Thesis',
|
|
34
|
+
'Risks',
|
|
35
|
+
]);
|
|
36
|
+
// Pre-H2 content (after the title H1) goes in Introduction
|
|
37
|
+
const intro = result.sections[0];
|
|
38
|
+
expect(intro.content).toHaveLength(1);
|
|
39
|
+
expect(intro.content[0].blockType).toBe('text');
|
|
40
|
+
if (intro.content[0].blockType === 'text') {
|
|
41
|
+
expect(intro.content[0].text).toContain('high-quality compounder');
|
|
42
|
+
// Bold preserved as markdown
|
|
43
|
+
expect(intro.content[0].text).toMatch(/\*\*high-quality compounder\*\*/);
|
|
44
|
+
}
|
|
45
|
+
// Thesis section has paragraph + bullet list
|
|
46
|
+
const thesis = result.sections[1];
|
|
47
|
+
const thesisText = thesis.content
|
|
48
|
+
.filter((b) => b.blockType === 'text')
|
|
49
|
+
.map((b) => b.text)
|
|
50
|
+
.join('\n');
|
|
51
|
+
expect(thesisText).toContain('Services mix shift');
|
|
52
|
+
expect(thesisText).toContain('- Installed base of 2.2B devices');
|
|
53
|
+
expect(thesisText).toContain('- Services revenue growing 14% YoY');
|
|
54
|
+
// Risks section
|
|
55
|
+
expect(result.sections[2].content).toHaveLength(1);
|
|
56
|
+
});
|
|
57
|
+
it('maps note-tags to task items and emoji prefixes', async () => {
|
|
58
|
+
const html = await loadFixture('todo-tags.html');
|
|
59
|
+
const result = await convertOneNoteHtmlToSections(html, {
|
|
60
|
+
resolveMedia: storageResolver,
|
|
61
|
+
});
|
|
62
|
+
// Only one section because no H2
|
|
63
|
+
expect(result.sections).toHaveLength(1);
|
|
64
|
+
const lines = result.sections[0].content
|
|
65
|
+
.filter((b) => b.blockType === 'text')
|
|
66
|
+
.map((b) => b.text)
|
|
67
|
+
.join('\n')
|
|
68
|
+
.split('\n');
|
|
69
|
+
expect(lines).toContainEqual(expect.stringMatching(/^- \[ \] Pull quarterly/));
|
|
70
|
+
expect(lines).toContainEqual(expect.stringMatching(/^- \[x\] Re-read/));
|
|
71
|
+
expect(lines).toContainEqual(expect.stringMatching(/^⭐ Position sizing/));
|
|
72
|
+
expect(lines).toContainEqual(expect.stringMatching(/^❓ Does the hyperscaler/));
|
|
73
|
+
expect(lines).toContainEqual(expect.stringMatching(/^💡 Pair trade/));
|
|
74
|
+
expect(lines).toContainEqual(expect.stringContaining('Plain paragraph'));
|
|
75
|
+
});
|
|
76
|
+
it('renders tables as GitHub-style markdown and re-hosts media', async () => {
|
|
77
|
+
const html = await loadFixture('table-with-media.html');
|
|
78
|
+
const resolverCalls = [];
|
|
79
|
+
const spyResolver = async (ctx) => {
|
|
80
|
+
resolverCalls.push({
|
|
81
|
+
kind: ctx.kind,
|
|
82
|
+
url: ctx.url,
|
|
83
|
+
filename: ctx.declaredFilename,
|
|
84
|
+
});
|
|
85
|
+
return storageResolver(ctx);
|
|
86
|
+
};
|
|
87
|
+
const result = await convertOneNoteHtmlToSections(html, {
|
|
88
|
+
resolveMedia: spyResolver,
|
|
89
|
+
});
|
|
90
|
+
// Section split: Introduction (table) / Charts (image) / Sellside (attachment)
|
|
91
|
+
expect(result.sections.map((s) => s.name)).toEqual([
|
|
92
|
+
'Introduction',
|
|
93
|
+
'Charts',
|
|
94
|
+
'Sellside',
|
|
95
|
+
]);
|
|
96
|
+
// Table rendered in Introduction
|
|
97
|
+
const introText = result.sections[0].content
|
|
98
|
+
.filter((b) => b.blockType === 'text')
|
|
99
|
+
.map((b) => b.text)
|
|
100
|
+
.join('\n');
|
|
101
|
+
expect(introText).toContain('| Ticker | Thesis | Conviction | Target size |');
|
|
102
|
+
expect(introText).toContain('| NVDA |');
|
|
103
|
+
expect(introText).toContain('| AAPL |');
|
|
104
|
+
// Image block in Charts section
|
|
105
|
+
const charts = result.sections[1];
|
|
106
|
+
const imageBlock = charts.content.find((b) => b.blockType === 'image');
|
|
107
|
+
expect(imageBlock).toBeDefined();
|
|
108
|
+
if (imageBlock && imageBlock.blockType === 'image' && imageBlock.type === 'storage') {
|
|
109
|
+
expect(imageBlock.key).toMatch(/kb\/onenote\/test\/abc-123\./);
|
|
110
|
+
expect(imageBlock.mediaType).toBe('image/png');
|
|
111
|
+
expect(imageBlock.alt).toBe('semis relative performance');
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
throw new Error('expected storage-keyed image block');
|
|
115
|
+
}
|
|
116
|
+
// Attachment block in Sellside section — PDF maps to document
|
|
117
|
+
const sellside = result.sections[2];
|
|
118
|
+
const docBlock = sellside.content.find((b) => b.blockType === 'document');
|
|
119
|
+
expect(docBlock).toBeDefined();
|
|
120
|
+
if (docBlock && docBlock.blockType === 'document' && docBlock.type === 'storage') {
|
|
121
|
+
expect(docBlock.mediaType).toBe('application/pdf');
|
|
122
|
+
expect(docBlock.filename).toBe('gs-semis-call.pdf');
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
throw new Error('expected storage-keyed document block');
|
|
126
|
+
}
|
|
127
|
+
// Resolver was called for both media items
|
|
128
|
+
expect(resolverCalls).toHaveLength(2);
|
|
129
|
+
expect(resolverCalls[0].kind).toBe('image');
|
|
130
|
+
expect(resolverCalls[1].kind).toBe('file');
|
|
131
|
+
expect(resolverCalls[1].filename).toBe('gs-semis-call.pdf');
|
|
132
|
+
expect(result.mediaCount).toBe(2);
|
|
133
|
+
});
|
|
134
|
+
it('extracts OneNote cross-page references and preserves external links', async () => {
|
|
135
|
+
const html = await loadFixture('crosslinks.html');
|
|
136
|
+
const result = await convertOneNoteHtmlToSections(html, {
|
|
137
|
+
resolveMedia: storageResolver,
|
|
138
|
+
});
|
|
139
|
+
// Three refs expected: the onenote:// URI, the Graph URL. External skipped.
|
|
140
|
+
const refIds = result.extractedReferences.map((r) => r.targetOnenotePageId);
|
|
141
|
+
expect(refIds).toContain('1-def456');
|
|
142
|
+
expect(refIds).toContain('1-aapl789');
|
|
143
|
+
expect(refIds).not.toContain('www.example.com');
|
|
144
|
+
// Display text preserved
|
|
145
|
+
const nvdaRef = result.extractedReferences.find((r) => r.targetOnenotePageId === '1-def456');
|
|
146
|
+
expect(nvdaRef?.displayText).toBe('NVDA thesis');
|
|
147
|
+
// Section index is 0 for all (no H2 split in fixture)
|
|
148
|
+
expect(result.extractedReferences.every((r) => r.sectionIndex === 0)).toBe(true);
|
|
149
|
+
// External link rendered as plain markdown
|
|
150
|
+
const intro = result.sections[0].content
|
|
151
|
+
.filter((b) => b.blockType === 'text')
|
|
152
|
+
.map((b) => b.text)
|
|
153
|
+
.join('\n');
|
|
154
|
+
expect(intro).toContain('[Example report](https://www.example.com/report)');
|
|
155
|
+
});
|
|
156
|
+
it('handles citation footers as blockquotes by default', async () => {
|
|
157
|
+
const html = await loadFixture('citation-footer.html');
|
|
158
|
+
const result = await convertOneNoteHtmlToSections(html, {
|
|
159
|
+
resolveMedia: storageResolver,
|
|
160
|
+
});
|
|
161
|
+
const text = result.sections[0].content
|
|
162
|
+
.filter((b) => b.blockType === 'text')
|
|
163
|
+
.map((b) => b.text)
|
|
164
|
+
.join('\n\n');
|
|
165
|
+
expect(text).toContain('> From <');
|
|
166
|
+
expect(text).toContain('More of our own notes');
|
|
167
|
+
});
|
|
168
|
+
it('drops citation footers when configured', async () => {
|
|
169
|
+
const html = await loadFixture('citation-footer.html');
|
|
170
|
+
const result = await convertOneNoteHtmlToSections(html, {
|
|
171
|
+
resolveMedia: storageResolver,
|
|
172
|
+
dropCitations: true,
|
|
173
|
+
});
|
|
174
|
+
const text = result.sections[0].content
|
|
175
|
+
.filter((b) => b.blockType === 'text')
|
|
176
|
+
.map((b) => b.text)
|
|
177
|
+
.join('\n\n');
|
|
178
|
+
expect(text).not.toContain('From <');
|
|
179
|
+
expect(text).toContain('decelerated but remains above');
|
|
180
|
+
});
|
|
181
|
+
it('skips embeds when resolver returns null and records a reason', async () => {
|
|
182
|
+
const html = await loadFixture('table-with-media.html');
|
|
183
|
+
const nullResolver = async () => null;
|
|
184
|
+
const result = await convertOneNoteHtmlToSections(html, {
|
|
185
|
+
resolveMedia: nullResolver,
|
|
186
|
+
});
|
|
187
|
+
// No media blocks
|
|
188
|
+
expect(result.mediaCount).toBe(0);
|
|
189
|
+
expect(result.skippedItemCount).toBe(2);
|
|
190
|
+
expect(result.skippedReasons.length).toBe(2);
|
|
191
|
+
expect(result.skippedReasons.some((r) => r.includes('gs-semis-call.pdf'))).toBe(true);
|
|
192
|
+
// Sections still created, just without the media blocks
|
|
193
|
+
expect(result.sections.map((s) => s.name)).toEqual([
|
|
194
|
+
'Introduction',
|
|
195
|
+
'Charts',
|
|
196
|
+
'Sellside',
|
|
197
|
+
]);
|
|
198
|
+
});
|
|
199
|
+
it('sorts absolute-positioned canvas children by (top, left)', async () => {
|
|
200
|
+
const html = `<html><body data-absolute-enabled="true">
|
|
201
|
+
<div style="position:absolute;top:400px;left:50px"><p>Third</p></div>
|
|
202
|
+
<div style="position:absolute;top:100px;left:50px"><p>First</p></div>
|
|
203
|
+
<div style="position:absolute;top:100px;left:300px"><p>Second</p></div>
|
|
204
|
+
</body></html>`;
|
|
205
|
+
const result = await convertOneNoteHtmlToSections(html, {
|
|
206
|
+
resolveMedia: storageResolver,
|
|
207
|
+
});
|
|
208
|
+
const text = result.sections[0].content
|
|
209
|
+
.filter((b) => b.blockType === 'text')
|
|
210
|
+
.map((b) => b.text)
|
|
211
|
+
.join('\n\n');
|
|
212
|
+
const firstIdx = text.indexOf('First');
|
|
213
|
+
const secondIdx = text.indexOf('Second');
|
|
214
|
+
const thirdIdx = text.indexOf('Third');
|
|
215
|
+
expect(firstIdx).toBeLessThan(secondIdx);
|
|
216
|
+
expect(secondIdx).toBeLessThan(thirdIdx);
|
|
217
|
+
});
|
|
218
|
+
it('renders nothing gracefully for an empty page', async () => {
|
|
219
|
+
const result = await convertOneNoteHtmlToSections('<html><head><title>Empty</title></head><body></body></html>', { resolveMedia: storageResolver });
|
|
220
|
+
expect(result.title).toBe('Empty');
|
|
221
|
+
expect(result.sections).toHaveLength(1);
|
|
222
|
+
expect(result.sections[0].content).toHaveLength(0);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
describe('slugifyPathSegment + joinKbPath', () => {
|
|
226
|
+
it('slug round-trips realistic names', async () => {
|
|
227
|
+
const { slugifyPathSegment, joinKbPath } = await import('../onenote-tree.js');
|
|
228
|
+
expect(slugifyPathSegment('AAPL — Long Thesis')).toBe('aapl-long-thesis');
|
|
229
|
+
expect(slugifyPathSegment('Meeting Notes / 2026-04-22')).toBe('meeting-notes-2026-04-22');
|
|
230
|
+
expect(slugifyPathSegment(' ')).toBe('untitled');
|
|
231
|
+
expect(slugifyPathSegment('weird.stuff_here')).toBe('weird-stuff-here');
|
|
232
|
+
expect(joinKbPath('imported/onenote', 'research', undefined, 'aapl')).toBe('/imported/onenote/research/aapl');
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OneNote source barrel. Re-exports the pieces the trigger task and
|
|
3
|
+
* server-side code need.
|
|
4
|
+
*/
|
|
5
|
+
export { OneNoteApiClient } from './onenote-api.js';
|
|
6
|
+
export type { OneNoteApiClientOptions } from './onenote-api.js';
|
|
7
|
+
export { exchangeCodeForTokens, getAccessTokenSilent, buildAuthorizeUrl, generatePkcePair, generateOAuthState, ONENOTE_READ_SCOPES, ONENOTE_GRAPH_SCOPE, } from './onenote-auth.js';
|
|
8
|
+
export type { OneNoteOAuthConfig, CodeExchangeResult, SilentTokenResult, PkcePair, } from './onenote-auth.js';
|
|
9
|
+
export { discoverOneNotePages, slugifyPathSegment, joinKbPath, } from './onenote-tree.js';
|
|
10
|
+
export type { DiscoverOptions } from './onenote-tree.js';
|
|
11
|
+
export { convertOneNoteHtmlToSections } from './onenote-html.js';
|
|
12
|
+
export type { ConvertOptions, ResolveMediaFn, ResolveMediaContext, ResolvedMedia, MediaKind, } from './onenote-html.js';
|
|
13
|
+
export type { OneNoteNotebook, OneNoteSection, OneNoteSectionGroup, OneNotePage, DiscoveredOneNotePage, OneNoteExtractedReference, ConvertedOneNotePage, } from './types.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OneNote source barrel. Re-exports the pieces the trigger task and
|
|
3
|
+
* server-side code need.
|
|
4
|
+
*/
|
|
5
|
+
export { OneNoteApiClient } from './onenote-api.js';
|
|
6
|
+
export { exchangeCodeForTokens, getAccessTokenSilent, buildAuthorizeUrl, generatePkcePair, generateOAuthState, ONENOTE_READ_SCOPES, ONENOTE_GRAPH_SCOPE, } from './onenote-auth.js';
|
|
7
|
+
export { discoverOneNotePages, slugifyPathSegment, joinKbPath, } from './onenote-tree.js';
|
|
8
|
+
export { convertOneNoteHtmlToSections } from './onenote-html.js';
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Microsoft Graph OneNote client.
|
|
3
|
+
*
|
|
4
|
+
* Thin wrapper over fetch — no @microsoft/microsoft-graph-client dependency in
|
|
5
|
+
* this module (we only use MSAL-node for tokens). Graph's OneNote endpoints
|
|
6
|
+
* are simple enough that a hand-rolled client is cheaper than the SDK and
|
|
7
|
+
* gives us precise control over 429 handling.
|
|
8
|
+
*/
|
|
9
|
+
import type { OneNoteNotebook, OneNotePage, OneNoteSection, OneNoteSectionGroup } from './types.js';
|
|
10
|
+
export interface OneNoteApiClientOptions {
|
|
11
|
+
/** Alternate base URL — for testing only. */
|
|
12
|
+
baseUrl?: string;
|
|
13
|
+
/** Override the userPrincipalName or `me` slug. Default: `me`. */
|
|
14
|
+
userSlug?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare class OneNoteApiClient {
|
|
17
|
+
private readonly token;
|
|
18
|
+
private readonly baseUrl;
|
|
19
|
+
private readonly userSlug;
|
|
20
|
+
constructor(accessToken: string, options?: OneNoteApiClientOptions);
|
|
21
|
+
/** Minimal whoami used during OAuth to capture display metadata. */
|
|
22
|
+
getMe(): Promise<{
|
|
23
|
+
displayName?: string;
|
|
24
|
+
userPrincipalName?: string;
|
|
25
|
+
mail?: string;
|
|
26
|
+
id: string;
|
|
27
|
+
}>;
|
|
28
|
+
listNotebooks(): Promise<OneNoteNotebook[]>;
|
|
29
|
+
listSectionGroups(notebookId: string): Promise<OneNoteSectionGroup[]>;
|
|
30
|
+
/** Nested groups (groups inside groups). Graph supports recursion one level. */
|
|
31
|
+
listNestedSectionGroups(sectionGroupId: string): Promise<OneNoteSectionGroup[]>;
|
|
32
|
+
listSectionsInNotebook(notebookId: string): Promise<OneNoteSection[]>;
|
|
33
|
+
listSectionsInGroup(sectionGroupId: string): Promise<OneNoteSection[]>;
|
|
34
|
+
listPages(sectionId: string, options?: {
|
|
35
|
+
modifiedAfter?: Date;
|
|
36
|
+
modifiedBefore?: Date;
|
|
37
|
+
}): Promise<OneNotePage[]>;
|
|
38
|
+
getPage(pageId: string): Promise<OneNotePage>;
|
|
39
|
+
/**
|
|
40
|
+
* Get the HTML content of a page. `includeIDs=true` stamps every element
|
|
41
|
+
* with a `data-id`, which we use for stable cross-link targets.
|
|
42
|
+
*/
|
|
43
|
+
getPageContent(pageId: string): Promise<{
|
|
44
|
+
html: string;
|
|
45
|
+
etag?: string;
|
|
46
|
+
}>;
|
|
47
|
+
/** Fetch a binary resource (image or file attachment). */
|
|
48
|
+
getResource(resourceId: string): Promise<{
|
|
49
|
+
buffer: Buffer;
|
|
50
|
+
contentType: string;
|
|
51
|
+
}>;
|
|
52
|
+
/**
|
|
53
|
+
* Fetch a resource from its full Graph URL — simpler than parsing out
|
|
54
|
+
* the resource ID, handy when HTML embeds `src="https://graph.microsoft..."`.
|
|
55
|
+
*/
|
|
56
|
+
getResourceByUrl(url: string): Promise<{
|
|
57
|
+
buffer: Buffer;
|
|
58
|
+
contentType: string;
|
|
59
|
+
}>;
|
|
60
|
+
private paginate;
|
|
61
|
+
private requestJson;
|
|
62
|
+
private requestRaw;
|
|
63
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Microsoft Graph OneNote client.
|
|
3
|
+
*
|
|
4
|
+
* Thin wrapper over fetch — no @microsoft/microsoft-graph-client dependency in
|
|
5
|
+
* this module (we only use MSAL-node for tokens). Graph's OneNote endpoints
|
|
6
|
+
* are simple enough that a hand-rolled client is cheaper than the SDK and
|
|
7
|
+
* gives us precise control over 429 handling.
|
|
8
|
+
*/
|
|
9
|
+
const GRAPH_BASE = 'https://graph.microsoft.com/v1.0';
|
|
10
|
+
const MAX_RETRIES = 5;
|
|
11
|
+
const DEFAULT_PAGE_SIZE = 100;
|
|
12
|
+
export class OneNoteApiClient {
|
|
13
|
+
token;
|
|
14
|
+
baseUrl;
|
|
15
|
+
userSlug;
|
|
16
|
+
constructor(accessToken, options = {}) {
|
|
17
|
+
this.token = accessToken;
|
|
18
|
+
this.baseUrl = options.baseUrl ?? GRAPH_BASE;
|
|
19
|
+
this.userSlug = options.userSlug ?? 'me';
|
|
20
|
+
}
|
|
21
|
+
// -----------------------------------------------------------------
|
|
22
|
+
// Listings
|
|
23
|
+
// -----------------------------------------------------------------
|
|
24
|
+
/** Minimal whoami used during OAuth to capture display metadata. */
|
|
25
|
+
async getMe() {
|
|
26
|
+
return this.requestJson(`${this.baseUrl}/me?$select=id,displayName,userPrincipalName,mail`);
|
|
27
|
+
}
|
|
28
|
+
async listNotebooks() {
|
|
29
|
+
return this.paginate(`${this.baseUrl}/${this.userSlug}/onenote/notebooks?$top=${DEFAULT_PAGE_SIZE}`);
|
|
30
|
+
}
|
|
31
|
+
async listSectionGroups(notebookId) {
|
|
32
|
+
return this.paginate(`${this.baseUrl}/${this.userSlug}/onenote/notebooks/${encodeURIComponent(notebookId)}/sectionGroups?$top=${DEFAULT_PAGE_SIZE}`);
|
|
33
|
+
}
|
|
34
|
+
/** Nested groups (groups inside groups). Graph supports recursion one level. */
|
|
35
|
+
async listNestedSectionGroups(sectionGroupId) {
|
|
36
|
+
return this.paginate(`${this.baseUrl}/${this.userSlug}/onenote/sectionGroups/${encodeURIComponent(sectionGroupId)}/sectionGroups?$top=${DEFAULT_PAGE_SIZE}`);
|
|
37
|
+
}
|
|
38
|
+
async listSectionsInNotebook(notebookId) {
|
|
39
|
+
return this.paginate(`${this.baseUrl}/${this.userSlug}/onenote/notebooks/${encodeURIComponent(notebookId)}/sections?$top=${DEFAULT_PAGE_SIZE}`);
|
|
40
|
+
}
|
|
41
|
+
async listSectionsInGroup(sectionGroupId) {
|
|
42
|
+
return this.paginate(`${this.baseUrl}/${this.userSlug}/onenote/sectionGroups/${encodeURIComponent(sectionGroupId)}/sections?$top=${DEFAULT_PAGE_SIZE}`);
|
|
43
|
+
}
|
|
44
|
+
async listPages(sectionId, options) {
|
|
45
|
+
const select = '$select=id,title,lastModifiedDateTime,links';
|
|
46
|
+
const filters = [];
|
|
47
|
+
if (options?.modifiedAfter) {
|
|
48
|
+
filters.push(`lastModifiedDateTime ge ${options.modifiedAfter.toISOString()}`);
|
|
49
|
+
}
|
|
50
|
+
if (options?.modifiedBefore) {
|
|
51
|
+
filters.push(`lastModifiedDateTime le ${options.modifiedBefore.toISOString()}`);
|
|
52
|
+
}
|
|
53
|
+
const filter = filters.length ? `&$filter=${encodeURIComponent(filters.join(' and '))}` : '';
|
|
54
|
+
return this.paginate(`${this.baseUrl}/${this.userSlug}/onenote/sections/${encodeURIComponent(sectionId)}/pages?$top=${DEFAULT_PAGE_SIZE}${filter}${filter ? '' : ''}&${select.slice(1)}`);
|
|
55
|
+
}
|
|
56
|
+
async getPage(pageId) {
|
|
57
|
+
return this.requestJson(`${this.baseUrl}/${this.userSlug}/onenote/pages/${encodeURIComponent(pageId)}`);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Get the HTML content of a page. `includeIDs=true` stamps every element
|
|
61
|
+
* with a `data-id`, which we use for stable cross-link targets.
|
|
62
|
+
*/
|
|
63
|
+
async getPageContent(pageId) {
|
|
64
|
+
const response = await this.requestRaw(`${this.baseUrl}/${this.userSlug}/onenote/pages/${encodeURIComponent(pageId)}/content?includeIDs=true`);
|
|
65
|
+
const html = await response.text();
|
|
66
|
+
const etag = response.headers.get('etag') ?? undefined;
|
|
67
|
+
return { html, etag };
|
|
68
|
+
}
|
|
69
|
+
/** Fetch a binary resource (image or file attachment). */
|
|
70
|
+
async getResource(resourceId) {
|
|
71
|
+
const response = await this.requestRaw(`${this.baseUrl}/${this.userSlug}/onenote/resources/${encodeURIComponent(resourceId)}/$value`);
|
|
72
|
+
const arr = await response.arrayBuffer();
|
|
73
|
+
const contentType = response.headers.get('content-type') ?? 'application/octet-stream';
|
|
74
|
+
return { buffer: Buffer.from(arr), contentType };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Fetch a resource from its full Graph URL — simpler than parsing out
|
|
78
|
+
* the resource ID, handy when HTML embeds `src="https://graph.microsoft..."`.
|
|
79
|
+
*/
|
|
80
|
+
async getResourceByUrl(url) {
|
|
81
|
+
const response = await this.requestRaw(url);
|
|
82
|
+
const arr = await response.arrayBuffer();
|
|
83
|
+
const contentType = response.headers.get('content-type') ?? 'application/octet-stream';
|
|
84
|
+
return { buffer: Buffer.from(arr), contentType };
|
|
85
|
+
}
|
|
86
|
+
// -----------------------------------------------------------------
|
|
87
|
+
// HTTP internals
|
|
88
|
+
// -----------------------------------------------------------------
|
|
89
|
+
async paginate(firstUrl) {
|
|
90
|
+
const results = [];
|
|
91
|
+
let url = firstUrl;
|
|
92
|
+
while (url) {
|
|
93
|
+
const resp = await this.requestJson(url);
|
|
94
|
+
results.push(...(resp.value ?? []));
|
|
95
|
+
url = resp['@odata.nextLink'];
|
|
96
|
+
}
|
|
97
|
+
return results;
|
|
98
|
+
}
|
|
99
|
+
async requestJson(url) {
|
|
100
|
+
const response = await this.requestRaw(url);
|
|
101
|
+
return (await response.json());
|
|
102
|
+
}
|
|
103
|
+
async requestRaw(url) {
|
|
104
|
+
let lastError = null;
|
|
105
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
106
|
+
let response;
|
|
107
|
+
try {
|
|
108
|
+
response = await fetch(url, {
|
|
109
|
+
method: 'GET',
|
|
110
|
+
headers: {
|
|
111
|
+
Authorization: `Bearer ${this.token}`,
|
|
112
|
+
Accept: 'application/json',
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
// Network-level failure — retry with exponential backoff
|
|
118
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
119
|
+
if (attempt < MAX_RETRIES) {
|
|
120
|
+
await sleep(Math.min(1000 * 2 ** attempt, 30000));
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
throw lastError;
|
|
124
|
+
}
|
|
125
|
+
if (response.ok)
|
|
126
|
+
return response;
|
|
127
|
+
if (response.status === 429 || response.status >= 500) {
|
|
128
|
+
const retryAfter = parseInt(response.headers.get('Retry-After') ?? '', 10);
|
|
129
|
+
const baseDelay = Number.isFinite(retryAfter) && retryAfter > 0
|
|
130
|
+
? retryAfter * 1000
|
|
131
|
+
: 1000 * 2 ** attempt;
|
|
132
|
+
const delay = Math.min(baseDelay, 30000);
|
|
133
|
+
lastError = new Error(`Graph API ${response.status} on ${url} — retrying in ${delay}ms`);
|
|
134
|
+
if (attempt < MAX_RETRIES) {
|
|
135
|
+
await sleep(delay);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const errorBody = await response.text().catch(() => '');
|
|
140
|
+
throw new Error(`Graph API ${response.status} on ${url}: ${errorBody.slice(0, 400)}`);
|
|
141
|
+
}
|
|
142
|
+
throw lastError ?? new Error(`Graph API request failed after ${MAX_RETRIES} retries`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function sleep(ms) {
|
|
146
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
147
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MSAL wrapper for OneNote / Microsoft Graph OAuth.
|
|
3
|
+
*
|
|
4
|
+
* We use MSAL's ConfidentialClientApplication and let it handle:
|
|
5
|
+
* - code-for-token exchange
|
|
6
|
+
* - silent refresh via the stored token cache
|
|
7
|
+
* - cache serialization
|
|
8
|
+
*
|
|
9
|
+
* The serialized cache JSON is what we persist (encrypted) in
|
|
10
|
+
* kb_integration_credentials. On each use we deserialize, call MSAL, and
|
|
11
|
+
* write back any updated cache.
|
|
12
|
+
*/
|
|
13
|
+
export interface OneNoteOAuthConfig {
|
|
14
|
+
clientId: string;
|
|
15
|
+
clientSecret: string;
|
|
16
|
+
/** Usually "common" for multi-tenant + personal MSA */
|
|
17
|
+
tenant: string;
|
|
18
|
+
redirectUri: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Scopes we request. Read-only for v1 (see plan §OAuth design).
|
|
22
|
+
* `User.Read` is needed only during the initial code exchange to populate
|
|
23
|
+
* the credential metadata (displayName, email) — after that MSAL doesn't
|
|
24
|
+
* need it to refresh the OneNote access token.
|
|
25
|
+
*/
|
|
26
|
+
export declare const ONENOTE_READ_SCOPES: string[];
|
|
27
|
+
export declare const ONENOTE_GRAPH_SCOPE: string[];
|
|
28
|
+
export interface CodeExchangeResult {
|
|
29
|
+
accessToken: string;
|
|
30
|
+
/** Opaque MSAL cache JSON to persist encrypted. */
|
|
31
|
+
cacheBlob: string;
|
|
32
|
+
/** MSAL's "home account" identifier — use to retrieve the account later. */
|
|
33
|
+
accountId: string;
|
|
34
|
+
/** UPN (email) from the id_token, for display. */
|
|
35
|
+
userPrincipalName?: string;
|
|
36
|
+
tenantId?: string;
|
|
37
|
+
displayName?: string;
|
|
38
|
+
expiresOn?: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Exchange an authorization code (plus PKCE verifier) for tokens, and return
|
|
42
|
+
* an encrypted-cache-ready payload. Called once from the OAuth callback.
|
|
43
|
+
*/
|
|
44
|
+
export declare function exchangeCodeForTokens(cfg: OneNoteOAuthConfig, params: {
|
|
45
|
+
code: string;
|
|
46
|
+
codeVerifier: string;
|
|
47
|
+
/** Scopes the authorize URL requested. */
|
|
48
|
+
scopes?: string[];
|
|
49
|
+
}): Promise<CodeExchangeResult>;
|
|
50
|
+
export interface SilentTokenResult {
|
|
51
|
+
accessToken: string;
|
|
52
|
+
/**
|
|
53
|
+
* If MSAL updated the internal cache during this call (e.g. refreshed the
|
|
54
|
+
* access token), the new serialized blob. Caller should persist it.
|
|
55
|
+
* Null when the cache was unchanged.
|
|
56
|
+
*/
|
|
57
|
+
updatedCacheBlob: string | null;
|
|
58
|
+
expiresOn?: string;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Silently acquire an access token using a persisted MSAL cache.
|
|
62
|
+
* Auto-refreshes if the access token is expired (needs offline_access in cache).
|
|
63
|
+
*/
|
|
64
|
+
export declare function getAccessTokenSilent(cfg: OneNoteOAuthConfig, params: {
|
|
65
|
+
cacheBlob: string;
|
|
66
|
+
accountId: string;
|
|
67
|
+
scopes?: string[];
|
|
68
|
+
}): Promise<SilentTokenResult>;
|
|
69
|
+
/**
|
|
70
|
+
* Build the Microsoft authorize URL for the popup/redirect. Called from
|
|
71
|
+
* the "Connect OneNote" server action.
|
|
72
|
+
*/
|
|
73
|
+
export declare function buildAuthorizeUrl(cfg: OneNoteOAuthConfig, params: {
|
|
74
|
+
state: string;
|
|
75
|
+
codeChallenge: string;
|
|
76
|
+
scopes?: string[];
|
|
77
|
+
}): Promise<string>;
|
|
78
|
+
export interface PkcePair {
|
|
79
|
+
verifier: string;
|
|
80
|
+
challenge: string;
|
|
81
|
+
}
|
|
82
|
+
export declare function generatePkcePair(): PkcePair;
|
|
83
|
+
export declare function generateOAuthState(): string;
|