dsh-plugin-onenote 0.0.0-stage → 0.2.2
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/LICENSE +21 -0
- package/README.md +150 -2
- package/README.zh.md +135 -0
- package/client.js +518 -0
- package/cordis.patch.yml +9 -0
- package/icon.svg +16 -0
- package/index.js +87 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +73 -4
- package/src/auth.js +291 -0
- package/src/config.js +53 -0
- package/src/graph-client.js +274 -0
- package/src/parser.js +393 -0
- package/src/permissions.js +108 -0
- package/src/routes.js +166 -0
- package/src/service.js +393 -0
- package/src/state.js +150 -0
- package/src/tools.js +295 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notebook consent: the allow-list and the guard every reading tool runs first.
|
|
3
|
+
*
|
|
4
|
+
* This module is the privacy boundary. Nothing that reads note content may call
|
|
5
|
+
* Microsoft Graph before {@link assertNotebookAllowed} has confirmed the owning
|
|
6
|
+
* notebook was explicitly chosen by the user, so a prompt-injected page id can
|
|
7
|
+
* never reach a notebook that was not consented to.
|
|
8
|
+
*/
|
|
9
|
+
import { readAllowedNotebooks, writeAllowedNotebooks } from './state.js';
|
|
10
|
+
|
|
11
|
+
/** Error code used when a notebook was not consented to. */
|
|
12
|
+
export const NOT_ALLOWED = 'NOTEBOOK_NOT_ALLOWED';
|
|
13
|
+
|
|
14
|
+
/** A denial the tools surface verbatim, with the recovery step included. */
|
|
15
|
+
export class NotebookNotAllowedError extends Error {
|
|
16
|
+
constructor(notebookNameOrId) {
|
|
17
|
+
super(
|
|
18
|
+
`Notebook "${notebookNameOrId}" is not in the allowed list, so its content is not readable. ` +
|
|
19
|
+
'The user chooses readable notebooks in Settings → Plugins → OneNote; ask them to add it there if they want it read.',
|
|
20
|
+
);
|
|
21
|
+
this.name = 'NotebookNotAllowedError';
|
|
22
|
+
this.code = NOT_ALLOWED;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The current allow-list, read fresh so an edit through another path takes effect. */
|
|
27
|
+
export function allowedNotebooks() {
|
|
28
|
+
return readAllowedNotebooks();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Whether the allow-list is still empty, i.e. the user has not chosen yet. */
|
|
32
|
+
export function hasConsent() {
|
|
33
|
+
return readAllowedNotebooks().length > 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Normalize one id for comparison. Graph ids are case-insensitive GUIDs, and a
|
|
38
|
+
* notebook id may arrive percent-encoded inside a page or section URL.
|
|
39
|
+
*/
|
|
40
|
+
function normalizeId(value) {
|
|
41
|
+
if (typeof value !== 'string') return '';
|
|
42
|
+
let id = value.trim();
|
|
43
|
+
if (id.length === 0) return '';
|
|
44
|
+
try {
|
|
45
|
+
if (id.includes('%')) id = decodeURIComponent(id);
|
|
46
|
+
} catch {
|
|
47
|
+
/* a malformed escape stays as written and simply fails the comparison */
|
|
48
|
+
}
|
|
49
|
+
return id.toLowerCase();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Whether a notebook id is allowed. */
|
|
53
|
+
export function isNotebookAllowed(notebookId) {
|
|
54
|
+
const wanted = normalizeId(notebookId);
|
|
55
|
+
if (wanted.length === 0) return false;
|
|
56
|
+
return allowedNotebooks().some((entry) => normalizeId(entry.id) === wanted);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Throw unless the notebook is allowed.
|
|
61
|
+
* @param {string} notebookId - the owning notebook id.
|
|
62
|
+
* @param {string} [label] - a name to use in the denial message.
|
|
63
|
+
* @returns {{ id: string, name: string }} the matching allow-list entry.
|
|
64
|
+
*/
|
|
65
|
+
export function assertNotebookAllowed(notebookId, label) {
|
|
66
|
+
const wanted = normalizeId(notebookId);
|
|
67
|
+
const match = allowedNotebooks().find((entry) => normalizeId(entry.id) === wanted);
|
|
68
|
+
if (match === undefined) throw new NotebookNotAllowedError(label ?? notebookId);
|
|
69
|
+
return match;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve the notebook that owns a section, then assert consent.
|
|
74
|
+
* Graph does not return a section's notebook on every payload, so callers pass
|
|
75
|
+
* the notebook id they already know when they have it.
|
|
76
|
+
* @param {string} notebookId - the owning notebook id.
|
|
77
|
+
* @param {string} [label] - a name for the denial message.
|
|
78
|
+
*/
|
|
79
|
+
export function assertNotebookAllowedForSection(notebookId, label) {
|
|
80
|
+
return assertNotebookAllowed(notebookId, label);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Replace the allow-list with the chosen notebooks.
|
|
85
|
+
* @param {readonly { id: string, name?: string }[]} notebooks - chosen notebooks.
|
|
86
|
+
* @returns {{ id: string, name: string }[]} the stored entries.
|
|
87
|
+
*/
|
|
88
|
+
export function setAllowedNotebooks(notebooks) {
|
|
89
|
+
const seen = new Set();
|
|
90
|
+
const stored = [];
|
|
91
|
+
for (const notebook of notebooks ?? []) {
|
|
92
|
+
if (notebook === null || typeof notebook !== 'object') continue;
|
|
93
|
+
const id = typeof notebook.id === 'string' ? notebook.id.trim() : '';
|
|
94
|
+
const key = normalizeId(id);
|
|
95
|
+
if (key.length === 0 || seen.has(key)) continue;
|
|
96
|
+
seen.add(key);
|
|
97
|
+
const name = typeof notebook.name === 'string' && notebook.name.trim().length > 0 ? notebook.name.trim() : id;
|
|
98
|
+
stored.push({ id, name });
|
|
99
|
+
}
|
|
100
|
+
writeAllowedNotebooks(stored);
|
|
101
|
+
return stored;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Forget every consent, so nothing is readable until the user chooses again. */
|
|
105
|
+
export function clearAllowedNotebooks() {
|
|
106
|
+
writeAllowedNotebooks([]);
|
|
107
|
+
return [];
|
|
108
|
+
}
|
package/src/routes.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser routes for the settings card.
|
|
3
|
+
*
|
|
4
|
+
* The card cannot import Host code, so it talks to this module over the Web
|
|
5
|
+
* server's own origin. Every route answers JSON and never throws: a handler
|
|
6
|
+
* failure becomes `{ ok: false, error }` so the card can show a message instead
|
|
7
|
+
* of a dead panel.
|
|
8
|
+
*
|
|
9
|
+
* Sign-in and consent changes are reachable ONLY here. The agent tools never
|
|
10
|
+
* expose them, which keeps "who may widen my read scope" a user decision.
|
|
11
|
+
*/
|
|
12
|
+
import { describeFailure, OneNoteError } from './service.js';
|
|
13
|
+
|
|
14
|
+
const PREFIX = '/api/onenote';
|
|
15
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A route table entry: method, path, and the body it accepts.
|
|
19
|
+
*
|
|
20
|
+
* Each path appears once: the Web server rejects a duplicate `(kind, path)`
|
|
21
|
+
* pair, so a second method on the same path needs its own path segment.
|
|
22
|
+
*/
|
|
23
|
+
const ROUTES = [
|
|
24
|
+
{ method: 'GET', path: `${PREFIX}/state`, run: (service) => stateOf(service) },
|
|
25
|
+
{ method: 'POST', path: `${PREFIX}/device-code/start`, run: (service) => beginSignIn(service) },
|
|
26
|
+
{ method: 'GET', path: `${PREFIX}/device-code`, run: (service) => pollSignIn(service) },
|
|
27
|
+
{ method: 'POST', path: `${PREFIX}/notebooks`, run: (service, body) => saveNotebooks(service, body) },
|
|
28
|
+
{ method: 'POST', path: `${PREFIX}/sign-out`, run: (service) => signOut(service) },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
function send(res, status, payload) {
|
|
32
|
+
res.writeHead(status, {
|
|
33
|
+
'content-type': 'application/json; charset=utf-8',
|
|
34
|
+
'cache-control': 'no-store',
|
|
35
|
+
});
|
|
36
|
+
res.end(JSON.stringify(payload));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function readJsonBody(req) {
|
|
40
|
+
const chunks = [];
|
|
41
|
+
let size = 0;
|
|
42
|
+
for await (const chunk of req) {
|
|
43
|
+
size += chunk.length;
|
|
44
|
+
if (size > MAX_BODY_BYTES) throw new OneNoteError('The request body is too large.', 'BODY_TOO_LARGE');
|
|
45
|
+
chunks.push(chunk);
|
|
46
|
+
}
|
|
47
|
+
if (chunks.length === 0) return {};
|
|
48
|
+
const text = Buffer.concat(chunks).toString('utf8').trim();
|
|
49
|
+
if (text.length === 0) return {};
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(text);
|
|
52
|
+
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
53
|
+
} catch {
|
|
54
|
+
throw new OneNoteError('The request body is not valid JSON.', 'INVALID_BODY');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The settings card's whole view of the plugin, in one request. */
|
|
59
|
+
async function stateOf(service) {
|
|
60
|
+
const status = service.status();
|
|
61
|
+
const base = {
|
|
62
|
+
ok: true,
|
|
63
|
+
signedIn: status.signedIn,
|
|
64
|
+
renewsAutomatically: status.renewsAutomatically,
|
|
65
|
+
expiresAt: status.expiresAt,
|
|
66
|
+
account: status.account,
|
|
67
|
+
allowedCount: status.allowedCount,
|
|
68
|
+
allowedNotebooks: status.allowedNotebooks,
|
|
69
|
+
pendingSignIn: status.pendingSignIn,
|
|
70
|
+
notebooks: [],
|
|
71
|
+
notebooksError: undefined,
|
|
72
|
+
};
|
|
73
|
+
// The notebook list needs a live token; signed out, the card still renders.
|
|
74
|
+
if (!status.signedIn) return base;
|
|
75
|
+
try {
|
|
76
|
+
base.notebooks = await service.listNotebooks();
|
|
77
|
+
} catch (error) {
|
|
78
|
+
base.notebooksError = describeFailure(error);
|
|
79
|
+
}
|
|
80
|
+
return base;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function beginSignIn(service) {
|
|
84
|
+
const started = await service.beginSignIn();
|
|
85
|
+
return { ok: true, ...started };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function pollSignIn(service) {
|
|
89
|
+
const outcome = await service.pollSignIn();
|
|
90
|
+
if (outcome.status === 'signed-in') {
|
|
91
|
+
return { ...(await stateOf(service)), status: 'signed-in', account: outcome.account };
|
|
92
|
+
}
|
|
93
|
+
return { ok: true, status: outcome.status, expiresInSeconds: outcome.expiresInSeconds };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function saveNotebooks(service, body) {
|
|
97
|
+
if (body.action === 'clear') {
|
|
98
|
+
service.clearAllowedNotebooks();
|
|
99
|
+
return { ...(await stateOf(service)), allowedCount: 0 };
|
|
100
|
+
}
|
|
101
|
+
const ids = Array.isArray(body.allowedIds) ? body.allowedIds : [];
|
|
102
|
+
const stored = await service.setAllowedNotebooks(ids);
|
|
103
|
+
return { ...(await stateOf(service)), allowedCount: stored.length };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function signOut(service) {
|
|
107
|
+
service.signOut();
|
|
108
|
+
return {
|
|
109
|
+
ok: true,
|
|
110
|
+
signedIn: false,
|
|
111
|
+
allowedCount: service.allowedNotebooks().length,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Register every route on the Web server.
|
|
117
|
+
*
|
|
118
|
+
* Call this from inside `ctx.inject(['webServer'], ...)`, never directly from
|
|
119
|
+
* `apply`. The Loader starts every row concurrently and the webServer row is
|
|
120
|
+
* still LOADING while it binds its socket, so `ctx.get('webServer')` at apply
|
|
121
|
+
* time answers `undefined` and every route is skipped. The card then falls
|
|
122
|
+
* through to Connection's `/api` prefix, which answers a plain-text 401 that is
|
|
123
|
+
* not JSON, and the card shows an error instead of the sign-in form.
|
|
124
|
+
*
|
|
125
|
+
* @param {object} ctx - the plugin context, whose `webServer` may be absent.
|
|
126
|
+
* @param {object} service - the shared OneNote service.
|
|
127
|
+
* @returns {boolean} whether the routes were registered.
|
|
128
|
+
*/
|
|
129
|
+
export function installRoutes(ctx, service) {
|
|
130
|
+
const webServer = ctx.get('webServer');
|
|
131
|
+
if (webServer === undefined) {
|
|
132
|
+
ctx.logger?.warn?.('onenote: no web server in this composition, so the settings card has no data source; the tools still work.');
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
for (const route of ROUTES) {
|
|
137
|
+
ctx.effect(() => webServer.register({
|
|
138
|
+
kind: 'exact',
|
|
139
|
+
path: route.path,
|
|
140
|
+
handler: async (req, res) => {
|
|
141
|
+
if (req.method !== route.method) {
|
|
142
|
+
send(res, 405, { ok: false, error: `Use ${route.method} for this endpoint.` });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const body = route.method === 'POST' ? await readJsonBody(req) : {};
|
|
147
|
+
const payload = await route.run(service, body);
|
|
148
|
+
// `ok: true` is authoritative even when a spread carried `ok: false`.
|
|
149
|
+
send(res, 200, { ...payload, ok: true });
|
|
150
|
+
} catch (error) {
|
|
151
|
+
send(res, 200, { ok: false, error: describeFailure(error), code: error?.code });
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
}), `onenote route ${route.method} ${route.path}`);
|
|
155
|
+
}
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The endpoint paths, exported so tests and the client share one source. */
|
|
160
|
+
export const ENDPOINTS = {
|
|
161
|
+
state: `${PREFIX}/state`,
|
|
162
|
+
startDeviceCode: `${PREFIX}/device-code/start`,
|
|
163
|
+
pollDeviceCode: `${PREFIX}/device-code`,
|
|
164
|
+
notebooks: `${PREFIX}/notebooks`,
|
|
165
|
+
signOut: `${PREFIX}/sign-out`,
|
|
166
|
+
};
|
package/src/service.js
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one implementation of every OneNote operation.
|
|
3
|
+
*
|
|
4
|
+
* Both callers go through here: the agent tools in `tools.js` and the browser
|
|
5
|
+
* routes in `routes.js`. Neither re-implements a Graph call or a consent check,
|
|
6
|
+
* so the two surfaces can never disagree about what is allowed.
|
|
7
|
+
*
|
|
8
|
+
* Consent is enforced inside this module, not by its callers: every read path
|
|
9
|
+
* resolves the owning notebook and asserts the allow-list before touching page
|
|
10
|
+
* content, so a page id guessed by a model or injected into a note still cannot
|
|
11
|
+
* reach an unapproved notebook.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
OneNoteAuthError,
|
|
15
|
+
pollDeviceCodeOnce,
|
|
16
|
+
rememberAccount,
|
|
17
|
+
signInSummary,
|
|
18
|
+
signOut,
|
|
19
|
+
startDeviceCode,
|
|
20
|
+
} from './auth.js';
|
|
21
|
+
import {
|
|
22
|
+
getMe,
|
|
23
|
+
getPageContent,
|
|
24
|
+
getPageMeta,
|
|
25
|
+
getSectionMeta,
|
|
26
|
+
GraphError,
|
|
27
|
+
listNotebooks,
|
|
28
|
+
listPagesInNotebook,
|
|
29
|
+
listPagesInSection,
|
|
30
|
+
listSections,
|
|
31
|
+
} from './graph-client.js';
|
|
32
|
+
import { htmlToPlainText, renderPage } from './parser.js';
|
|
33
|
+
import {
|
|
34
|
+
allowedNotebooks,
|
|
35
|
+
assertNotebookAllowed,
|
|
36
|
+
clearAllowedNotebooks,
|
|
37
|
+
hasConsent,
|
|
38
|
+
isNotebookAllowed,
|
|
39
|
+
NotebookNotAllowedError,
|
|
40
|
+
setAllowedNotebooks,
|
|
41
|
+
} from './permissions.js';
|
|
42
|
+
import { readPendingSignIn, writePendingSignIn } from './state.js';
|
|
43
|
+
|
|
44
|
+
/** Page-metadata scan budget for a search, and how many bodies it opens. */
|
|
45
|
+
const SEARCH_PAGE_SCAN = 300;
|
|
46
|
+
const SEARCH_BODY_SCAN = 15;
|
|
47
|
+
const SEARCH_SNIPPET_CHARS = 220;
|
|
48
|
+
|
|
49
|
+
/** A failure whose message is already written for a person to read. */
|
|
50
|
+
export class OneNoteError extends Error {
|
|
51
|
+
constructor(message, code) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.name = 'OneNoteError';
|
|
54
|
+
this.code = code;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Reduce any thrown value to one actionable sentence.
|
|
60
|
+
* @param {unknown} error - whatever was thrown.
|
|
61
|
+
* @returns {string} a message safe to show a model or a browser.
|
|
62
|
+
*/
|
|
63
|
+
export function describeFailure(error) {
|
|
64
|
+
if (error instanceof NotebookNotAllowedError) return `Notebook not allowed. ${error.message}`;
|
|
65
|
+
if (error instanceof OneNoteAuthError) return `OneNote sign-in needed. ${error.message}`;
|
|
66
|
+
if (error instanceof GraphError) return `OneNote request failed. ${error.message}`;
|
|
67
|
+
if (error instanceof OneNoteError) return error.message;
|
|
68
|
+
if (error instanceof Error) return `OneNote operation failed. ${error.message}`;
|
|
69
|
+
return `OneNote operation failed. ${String(error)}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Copy only defined fields, so no property holds `undefined`. */
|
|
73
|
+
function compact(source) {
|
|
74
|
+
const out = {};
|
|
75
|
+
for (const [key, value] of Object.entries(source)) {
|
|
76
|
+
if (value !== undefined) out[key] = value;
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function clampInt(value, fallback, min = 1, max = 200) {
|
|
82
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
|
|
83
|
+
return Math.min(max, Math.max(min, Math.trunc(value)));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function pageItem(page) {
|
|
87
|
+
return compact({
|
|
88
|
+
id: page.id,
|
|
89
|
+
title: page.title,
|
|
90
|
+
lastModified: page.lastModified,
|
|
91
|
+
sectionName: page.sectionName,
|
|
92
|
+
notebookName: page.notebookName,
|
|
93
|
+
webUrl: page.webUrl,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function sectionItem(section) {
|
|
98
|
+
return { id: section.id, name: section.name };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Build the service over one resolved plugin config.
|
|
103
|
+
* @param {{ clientId: string, tenant: string, maxPagesPerList: number, maxPageChars: number }} config - resolved config.
|
|
104
|
+
* @returns {object} the operations shared by tools and routes.
|
|
105
|
+
*/
|
|
106
|
+
export function createOneNoteService(config) {
|
|
107
|
+
return {
|
|
108
|
+
/** Sign-in and consent status, for the tool and the settings card. */
|
|
109
|
+
status() {
|
|
110
|
+
const summary = signInSummary();
|
|
111
|
+
const allowed = allowedNotebooks();
|
|
112
|
+
return {
|
|
113
|
+
signedIn: summary.signedIn,
|
|
114
|
+
renewsAutomatically: summary.renewsAutomatically === true,
|
|
115
|
+
expiresAt: summary.expiresAt,
|
|
116
|
+
account: summary.account,
|
|
117
|
+
allowedCount: allowed.length,
|
|
118
|
+
allowedNotebooks: allowed.map((entry) => ({ id: entry.id, name: entry.name })),
|
|
119
|
+
pendingSignIn: readPendingSignIn() !== undefined,
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
/** Start (or continue) a device-code attempt and return what to show the user. */
|
|
124
|
+
async beginSignIn() {
|
|
125
|
+
const pending = readPendingSignIn();
|
|
126
|
+
if (pending !== undefined) {
|
|
127
|
+
return {
|
|
128
|
+
userCode: pending.userCode,
|
|
129
|
+
verificationUri: pending.verificationUri,
|
|
130
|
+
expiresInSeconds: Math.max(0, Math.round((pending.expiresAt - Date.now()) / 1000)),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const started = await startDeviceCode(config);
|
|
134
|
+
// Persist before returning: the next call is a separate HTTP request, so
|
|
135
|
+
// the attempt must survive this one.
|
|
136
|
+
writePendingSignIn(started);
|
|
137
|
+
return {
|
|
138
|
+
userCode: started.userCode,
|
|
139
|
+
verificationUri: started.verificationUri,
|
|
140
|
+
expiresInSeconds: Math.max(0, Math.round((started.expiresAt - Date.now()) / 1000)),
|
|
141
|
+
};
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Poll the pending attempt exactly once.
|
|
146
|
+
*
|
|
147
|
+
* One poll per call keeps an HTTP request short: the browser repeats it,
|
|
148
|
+
* so the user sees a live countdown instead of a stalled request.
|
|
149
|
+
* @returns {Promise<object>} `pending`, `signed-in`, or a thrown auth error.
|
|
150
|
+
*/
|
|
151
|
+
async pollSignIn() {
|
|
152
|
+
const pending = readPendingSignIn();
|
|
153
|
+
if (pending === undefined) {
|
|
154
|
+
const summary = signInSummary();
|
|
155
|
+
if (summary.signedIn) return { status: 'signed-in', account: summary.account };
|
|
156
|
+
return { status: 'expired' };
|
|
157
|
+
}
|
|
158
|
+
const outcome = await pollDeviceCodeOnce(pending);
|
|
159
|
+
if (outcome.status === 'pending') {
|
|
160
|
+
return {
|
|
161
|
+
status: 'pending',
|
|
162
|
+
expiresInSeconds: Math.max(0, Math.round((pending.expiresAt - Date.now()) / 1000)),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return await this.completeSignIn();
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
/** Read the profile once the token is stored, so the card can name the account. */
|
|
169
|
+
async completeSignIn() {
|
|
170
|
+
const summary = signInSummary();
|
|
171
|
+
try {
|
|
172
|
+
const me = await getMe(config);
|
|
173
|
+
rememberAccount(me);
|
|
174
|
+
return { status: 'signed-in', account: me };
|
|
175
|
+
} catch (error) {
|
|
176
|
+
// The token is already stored; a profile read failure must not read as a failed sign-in.
|
|
177
|
+
if (error instanceof GraphError) return { status: 'signed-in', account: summary.account };
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
/** Forget the stored session. The allow-list is deliberately untouched. */
|
|
183
|
+
signOut() {
|
|
184
|
+
signOut();
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Every notebook the account can see, flagged with whether it is allowed.
|
|
189
|
+
* This is the only read that deliberately looks outside the allow-list: the
|
|
190
|
+
* user has to see the names in order to choose.
|
|
191
|
+
*/
|
|
192
|
+
async listNotebooks() {
|
|
193
|
+
const notebooks = await listNotebooks(config);
|
|
194
|
+
return notebooks.map((notebook) => compact({
|
|
195
|
+
id: notebook.id,
|
|
196
|
+
name: notebook.name,
|
|
197
|
+
allowed: isNotebookAllowed(notebook.id),
|
|
198
|
+
isDefault: notebook.isDefault || undefined,
|
|
199
|
+
isShared: notebook.isShared || undefined,
|
|
200
|
+
lastModified: notebook.lastModified,
|
|
201
|
+
}));
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Replace the allow-list. Only a user action may call this: the tools never
|
|
206
|
+
* expose it, so a prompt-injected note cannot widen its own read scope.
|
|
207
|
+
* @param {readonly string[]} ids - notebook ids to allow.
|
|
208
|
+
* @returns {Promise<object[]>} the stored entries.
|
|
209
|
+
*/
|
|
210
|
+
async setAllowedNotebooks(ids) {
|
|
211
|
+
const requested = Array.isArray(ids) ? ids.filter((id) => typeof id === 'string' && id.trim().length > 0) : [];
|
|
212
|
+
if (requested.length === 0) {
|
|
213
|
+
clearAllowedNotebooks();
|
|
214
|
+
return [];
|
|
215
|
+
}
|
|
216
|
+
const available = await listNotebooks(config);
|
|
217
|
+
const byId = new Map(available.map((notebook) => [notebook.id.toLowerCase(), notebook]));
|
|
218
|
+
const unknown = requested.filter((id) => !byId.has(id.trim().toLowerCase()));
|
|
219
|
+
if (unknown.length > 0) {
|
|
220
|
+
throw new OneNoteError(
|
|
221
|
+
`These notebook ids are not visible to the signed-in account: ${unknown.join(', ')}. Refresh the list and try again.`,
|
|
222
|
+
'UNKNOWN_NOTEBOOK',
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
return setAllowedNotebooks(requested.map((id) => {
|
|
226
|
+
const notebook = byId.get(id.trim().toLowerCase());
|
|
227
|
+
return { id: notebook.id, name: notebook.name };
|
|
228
|
+
}));
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
/** Forget every consent. */
|
|
232
|
+
clearAllowedNotebooks() {
|
|
233
|
+
clearAllowedNotebooks();
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
/** Whether any notebook is readable right now. */
|
|
237
|
+
hasConsent,
|
|
238
|
+
|
|
239
|
+
/** The readable notebooks, as stored. */
|
|
240
|
+
allowedNotebooks,
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Browse allowed structure: no arguments lists notebooks with sections;
|
|
244
|
+
* `notebookId` adds its pages; `sectionId` returns that section's pages.
|
|
245
|
+
* @param {{ notebookId?: string, sectionId?: string, limit?: number }} input - the browse target.
|
|
246
|
+
* @param {AbortSignal} [signal] - caller cancellation.
|
|
247
|
+
*/
|
|
248
|
+
async listPages(input, signal) {
|
|
249
|
+
const limit = clampInt(input.limit, Math.min(50, config.maxPagesPerList), 1, 200);
|
|
250
|
+
if (!hasConsent()) {
|
|
251
|
+
return { message: 'No notebooks are allowed yet. Open Settings → Plugins → OneNote to choose which notebooks this agent may read.' };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (typeof input.sectionId === 'string' && input.sectionId.trim().length > 0) {
|
|
255
|
+
const sectionId = input.sectionId.trim();
|
|
256
|
+
// Resolve the owner before listing: a guessed section id must not read
|
|
257
|
+
// pages from a notebook the user never approved.
|
|
258
|
+
const section = await getSectionMeta(config, sectionId, signal);
|
|
259
|
+
if (section.notebookId === undefined) {
|
|
260
|
+
throw new OneNoteError('Microsoft Graph did not report which notebook owns this section, so it cannot be checked against the allowed list.', 'UNVERIFIABLE_OWNER');
|
|
261
|
+
}
|
|
262
|
+
assertNotebookAllowed(section.notebookId);
|
|
263
|
+
const pages = await listPagesInSection(config, sectionId, signal, limit);
|
|
264
|
+
return {
|
|
265
|
+
message: pages.length === 0 ? `Section ${section.name} has no pages.` : `${pages.length} page(s) in section ${section.name}.`,
|
|
266
|
+
pages: pages.map(pageItem),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (typeof input.notebookId === 'string' && input.notebookId.trim().length > 0) {
|
|
271
|
+
const allowed = assertNotebookAllowed(input.notebookId.trim());
|
|
272
|
+
const sections = await listSections(config, allowed.id, signal);
|
|
273
|
+
const pages = await listPagesInNotebook(config, allowed.id, signal, limit);
|
|
274
|
+
return {
|
|
275
|
+
message: `Notebook ${allowed.name}: ${sections.length} section(s), ${pages.length} page(s).`,
|
|
276
|
+
notebooks: [{ id: allowed.id, name: allowed.name, sections: sections.map(sectionItem) }],
|
|
277
|
+
pages: pages.map(pageItem),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const notebooks = [];
|
|
282
|
+
for (const allowed of allowedNotebooks()) {
|
|
283
|
+
const sections = await listSections(config, allowed.id, signal);
|
|
284
|
+
notebooks.push({ id: allowed.id, name: allowed.name, sections: sections.map(sectionItem) });
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
message: `${notebooks.length} allowed notebook(s). Pass notebook_id or section_id to list pages.`,
|
|
288
|
+
notebooks,
|
|
289
|
+
};
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Search the allowed notebooks: titles first, then the opening text of the
|
|
294
|
+
* most recently modified pages. OneNote's server-side page search was
|
|
295
|
+
* retired in 2024, so matching happens here, bounded to the allowed set.
|
|
296
|
+
* @param {{ query: string, limit?: number }} input - the search.
|
|
297
|
+
* @param {AbortSignal} [signal] - caller cancellation.
|
|
298
|
+
*/
|
|
299
|
+
async search(input, signal) {
|
|
300
|
+
const query = typeof input.query === 'string' ? input.query.trim() : '';
|
|
301
|
+
if (query.length === 0) throw new OneNoteError('A search needs a non-empty query.', 'EMPTY_QUERY');
|
|
302
|
+
const limit = clampInt(input.limit, 10, 1, 50);
|
|
303
|
+
const needle = query.toLowerCase();
|
|
304
|
+
const allowed = allowedNotebooks();
|
|
305
|
+
if (allowed.length === 0) {
|
|
306
|
+
return { message: 'No notebooks are allowed yet. Open Settings → Plugins → OneNote to choose notebooks first.' };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const candidates = [];
|
|
310
|
+
for (const notebook of allowed) {
|
|
311
|
+
const pages = await listPagesInNotebook(config, notebook.id, signal, SEARCH_PAGE_SCAN);
|
|
312
|
+
for (const page of pages) candidates.push({ page, notebook });
|
|
313
|
+
}
|
|
314
|
+
candidates.sort((left, right) => String(right.page.lastModified ?? '').localeCompare(String(left.page.lastModified ?? '')));
|
|
315
|
+
|
|
316
|
+
const results = [];
|
|
317
|
+
const seen = new Set();
|
|
318
|
+
const add = (page, notebook, matchedIn, snippet) => {
|
|
319
|
+
if (seen.has(page.id) || results.length >= limit) return;
|
|
320
|
+
seen.add(page.id);
|
|
321
|
+
results.push(compact({
|
|
322
|
+
id: page.id,
|
|
323
|
+
title: page.title,
|
|
324
|
+
notebookName: notebook.name,
|
|
325
|
+
sectionName: page.sectionName,
|
|
326
|
+
lastModified: page.lastModified,
|
|
327
|
+
matchedIn,
|
|
328
|
+
snippet,
|
|
329
|
+
}));
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
for (const { page, notebook } of candidates) {
|
|
333
|
+
if (results.length >= limit) break;
|
|
334
|
+
if (page.title.toLowerCase().includes(needle)) add(page, notebook, 'title');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (results.length < limit) {
|
|
338
|
+
let scanned = 0;
|
|
339
|
+
for (const { page, notebook } of candidates) {
|
|
340
|
+
if (results.length >= limit || scanned >= SEARCH_BODY_SCAN) break;
|
|
341
|
+
if (seen.has(page.id)) continue;
|
|
342
|
+
scanned += 1;
|
|
343
|
+
try {
|
|
344
|
+
const plain = htmlToPlainText(await getPageContent(config, page.id, signal));
|
|
345
|
+
const at = plain.toLowerCase().indexOf(needle);
|
|
346
|
+
if (at < 0) continue;
|
|
347
|
+
const start = Math.max(0, at - 60);
|
|
348
|
+
add(page, notebook, 'body', plain.slice(start, start + SEARCH_SNIPPET_CHARS).trim());
|
|
349
|
+
} catch (error) {
|
|
350
|
+
// One unreadable page must not fail the search, but a dead session must.
|
|
351
|
+
if (error instanceof OneNoteAuthError) throw error;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return results.length === 0
|
|
357
|
+
? { message: `No page in the allowed notebooks matched "${query}".` }
|
|
358
|
+
: { message: `Found ${results.length} matching page(s) for "${query}".`, results };
|
|
359
|
+
},
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Read one page as Markdown, refusing any page outside the allow-list.
|
|
363
|
+
* @param {{ pageId: string, maxChars?: number }} input - the page to read.
|
|
364
|
+
* @param {AbortSignal} [signal] - caller cancellation.
|
|
365
|
+
*/
|
|
366
|
+
async readPage(input, signal) {
|
|
367
|
+
const pageId = typeof input.pageId === 'string' ? input.pageId.trim() : '';
|
|
368
|
+
if (pageId.length === 0) throw new OneNoteError('Reading a page needs a page id.', 'EMPTY_PAGE_ID');
|
|
369
|
+
const maxChars = clampInt(input.maxChars, config.maxPageChars, 2000, 200000);
|
|
370
|
+
|
|
371
|
+
const meta = await getPageMeta(config, pageId, signal);
|
|
372
|
+
if (meta.notebookId === undefined) {
|
|
373
|
+
throw new OneNoteError(
|
|
374
|
+
'Microsoft Graph did not report which notebook owns this page, so it cannot be checked against the allowed list. List the page from its notebook to get an id that carries its notebook.',
|
|
375
|
+
'UNVERIFIABLE_OWNER',
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
const allowed = assertNotebookAllowed(meta.notebookId, meta.notebookName);
|
|
379
|
+
const html = await getPageContent(config, pageId, signal);
|
|
380
|
+
const rendered = renderPage(html, maxChars);
|
|
381
|
+
return compact({
|
|
382
|
+
id: meta.id,
|
|
383
|
+
title: meta.title,
|
|
384
|
+
notebookName: allowed.name,
|
|
385
|
+
sectionName: meta.sectionName,
|
|
386
|
+
lastModified: meta.lastModified,
|
|
387
|
+
webUrl: meta.webUrl,
|
|
388
|
+
truncated: rendered.truncated,
|
|
389
|
+
content: rendered.content.length > 0 ? rendered.content : '(this page has no readable text)',
|
|
390
|
+
});
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
}
|