@moxn/kb-migrate 0.4.28 → 0.4.30

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.
@@ -171,11 +171,26 @@ export class NotionApiClient {
171
171
  };
172
172
  if (body)
173
173
  headers['Content-Type'] = 'application/json';
174
- const response = await fetch(`${NOTION_API_BASE}${path}`, {
175
- method,
176
- headers,
177
- body: body ? JSON.stringify(body) : undefined,
178
- });
174
+ let response;
175
+ try {
176
+ response = await fetch(`${NOTION_API_BASE}${path}`, {
177
+ method,
178
+ headers,
179
+ body: body ? JSON.stringify(body) : undefined,
180
+ });
181
+ }
182
+ catch (fetchError) {
183
+ // Network-level failure (DNS, TCP reset, timeout, etc.)
184
+ lastError =
185
+ fetchError instanceof Error ? fetchError : new Error(String(fetchError));
186
+ if (attempt < MAX_RETRIES) {
187
+ const delay = Math.min(1000 * Math.pow(2, attempt), 30000);
188
+ console.warn(`Network error: ${lastError.message}. Retrying in ${delay}ms (attempt ${attempt + 1}/${MAX_RETRIES})`);
189
+ await sleep(delay);
190
+ continue;
191
+ }
192
+ throw lastError;
193
+ }
179
194
  if (response.ok) {
180
195
  return (await response.json());
181
196
  }
@@ -188,7 +188,7 @@ function convertHeading(block, level) {
188
188
  function convertCode(block) {
189
189
  const c = block;
190
190
  const code = richTextToPlain(c.code.rich_text);
191
- const lang = c.code.language === 'plain text' ? '' : c.code.language;
191
+ const lang = c.code.language === 'plain text' ? 'text' : c.code.language;
192
192
  const caption = richTextToPlain(c.code.caption);
193
193
  let text = '```' + lang + '\n' + code + '\n```';
194
194
  if (caption)
@@ -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,createdDateTime,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;