dsh-plugin-onenote 0.0.0-stage → 0.2.1

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/client.js ADDED
@@ -0,0 +1,518 @@
1
+ /**
2
+ * Browser half of `dsh-plugin-onenote`: the settings card.
3
+ *
4
+ * This is the ONLY surface where a person can sign in or change which notebooks
5
+ * are readable. The agent tools are read-only, so the allow-list is a real
6
+ * boundary: a page that tries to talk the model into reading another notebook
7
+ * has nothing to call.
8
+ *
9
+ * The card is hand-written as a `__ModuleLoader__` bundle (no build step). It
10
+ * talks to the Host over the plugin's own `/api/onenote/*` routes and uses only
11
+ * host theme tokens for styling, so it follows light/dark automatically.
12
+ */
13
+ window.__ModuleLoader__.load({
14
+ id: 'dsh-plugin-onenote',
15
+ factory(require) {
16
+ const React = require('react')
17
+ const h = React.createElement
18
+
19
+ const API = '/api/onenote'
20
+ /** The card re-polls while a device code is outstanding; the Host never blocks. */
21
+ const POLL_MS = 3000
22
+ const NS = 'onenote'
23
+
24
+ const en = {
25
+ 'title': 'OneNote',
26
+ 'summary': 'Sign in to Microsoft OneNote and choose which notebooks this agent may read.',
27
+ 'loading': 'Loading…',
28
+ 'error': 'Something went wrong',
29
+ 'retry': 'Try again',
30
+ 'clientId': 'Azure application (client) ID',
31
+ 'clientId.hint': 'Set clientId and tenant in cordis.patch.yml to use your own app registration.',
32
+ 'signedOut.title': 'Not connected',
33
+ 'signedOut.body': 'Sign in with a Microsoft account that can open the notebooks you want this agent to read. Nothing is readable until you also choose notebooks below.',
34
+ 'signIn': 'Sign in with Microsoft',
35
+ 'signIn.busy': 'Starting…',
36
+ 'pending.title': 'Enter this code',
37
+ 'pending.body': 'Open the sign-in page and enter the code below. This panel finishes on its own once you approve.',
38
+ 'pending.open': 'Open sign-in page',
39
+ 'pending.expires': 'Expires in {seconds}s',
40
+ 'pending.expired': 'That code expired. Start sign-in again for a fresh one.',
41
+ 'pending.copy': 'Copy code',
42
+ 'pending.copied': 'Copied',
43
+ 'signedIn.title': 'Connected',
44
+ 'signedIn.as': 'Signed in as {name}',
45
+ 'signedIn.account': 'Signed in',
46
+ 'signedIn.renews': 'Access renews automatically.',
47
+ 'signOut': 'Sign out',
48
+ 'notebooks.title': 'Readable notebooks',
49
+ 'notebooks.body': 'Only the notebooks you tick can be listed, searched, or read. Every other notebook stays invisible to the agent.',
50
+ 'notebooks.empty': 'No notebooks are allowed, so the agent can read nothing right now.',
51
+ 'notebooks.none': 'This account has no notebooks.',
52
+ 'notebooks.refresh': 'Refresh list',
53
+ 'notebooks.shared': 'shared',
54
+ 'notebooks.default': 'default',
55
+ 'notebooks.count': '{count} of {total} allowed',
56
+ 'save': 'Save',
57
+ 'saving': 'Saving…',
58
+ 'saved': 'Saved. {count} notebook(s) are now readable.',
59
+ 'clear': 'Allow none',
60
+ 'cleared': 'Cleared. Nothing is readable now.',
61
+ 'loadFailed': 'Could not load the notebook list: {error}',
62
+ 'signInFailed': 'Sign-in failed: {error}',
63
+ 'saveFailed': 'Could not save: {error}',
64
+ 'settingsPath': 'Settings → Plugins → OneNote',
65
+ 'unreachable': 'The plugin routes are not registered in this process. Restart DeepSeek Harness, then reopen this card.'
66
+ }
67
+
68
+ const zh = {
69
+ 'title': 'OneNote',
70
+ 'summary': '登录 Microsoft OneNote,并选择允许本助手读取哪些笔记本。',
71
+ 'loading': '加载中…',
72
+ 'error': '出了点问题',
73
+ 'retry': '重试',
74
+ 'clientId': 'Azure 应用(客户端)ID',
75
+ 'clientId.hint': '如需使用自己的应用注册,请在 cordis.patch.yml 中设置 clientId 与 tenant。',
76
+ 'signedOut.title': '未连接',
77
+ 'signedOut.body': '请用能够打开目标笔记本的 Microsoft 账户登录。在下方勾选笔记本之前,助手读不到任何内容。',
78
+ 'signIn': '使用 Microsoft 登录',
79
+ 'signIn.busy': '正在启动…',
80
+ 'pending.title': '请输入此代码',
81
+ 'pending.body': '打开登录页面并输入下方代码。你确认后本面板会自动完成。',
82
+ 'pending.open': '打开登录页面',
83
+ 'pending.expires': '{seconds} 秒后过期',
84
+ 'pending.expired': '代码已过期,请重新开始登录。',
85
+ 'pending.copy': '复制代码',
86
+ 'pending.copied': '已复制',
87
+ 'signedIn.title': '已连接',
88
+ 'signedIn.as': '已登录:{name}',
89
+ 'signedIn.account': '已登录',
90
+ 'signedIn.renews': '访问令牌会自动续期。',
91
+ 'signOut': '退出登录',
92
+ 'notebooks.title': '可读笔记本',
93
+ 'notebooks.body': '只有勾选的笔记本可以被列出、搜索和读取,其余笔记本对助手完全不可见。',
94
+ 'notebooks.empty': '当前没有授权任何笔记本,助手暂时读不到内容。',
95
+ 'notebooks.none': '该账户下没有笔记本。',
96
+ 'notebooks.refresh': '刷新列表',
97
+ 'notebooks.shared': '共享',
98
+ 'notebooks.default': '默认',
99
+ 'notebooks.count': '已授权 {count} / {total}',
100
+ 'save': '保存',
101
+ 'saving': '保存中…',
102
+ 'saved': '已保存,当前可读 {count} 个笔记本。',
103
+ 'clear': '全部取消',
104
+ 'cleared': '已清空,当前不可读取任何笔记本。',
105
+ 'loadFailed': '无法加载笔记本列表:{error}',
106
+ 'signInFailed': '登录失败:{error}',
107
+ 'saveFailed': '保存失败:{error}',
108
+ 'settingsPath': '设置 → 插件 → OneNote',
109
+ 'unreachable': '本进程没有注册插件路由。请重启 DeepSeek Harness,然后重新打开本卡片。'
110
+ }
111
+
112
+ const CSS = [
113
+ '.dsh-onenote{display:flex;flex-direction:column;gap:16px;min-width:0;color:var(--dsw-alias-label-primary)}',
114
+ '.dsh-onenote-h{margin:0;font-size:15px;font-weight:600;line-height:22px}',
115
+ '.dsh-onenote-p{margin:0;font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary)}',
116
+ '.dsh-onenote-muted{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}',
117
+ '.dsh-onenote-box{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);border-radius:10px;padding:14px;display:flex;flex-direction:column;gap:10px}',
118
+ '.dsh-onenote-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}',
119
+ '.dsh-onenote-spread{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}',
120
+ '.dsh-onenote-btn{height:30px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);padding:0 12px;font-size:13px;cursor:pointer}',
121
+ '.dsh-onenote-btn:hover:not(:disabled){background:var(--dsw-alias-bg-overlay)}',
122
+ '.dsh-onenote-btn:disabled{opacity:.5;cursor:default}',
123
+ // The primary button must pair the host's own fill and label tokens.
124
+ // `brand-primary` inverts between themes (near-black on light, near-white
125
+ // on dark), so using it as a fill with a literal white label produced
126
+ // white-on-white and an unreadable sign-in button in dark mode.
127
+ '.dsh-onenote-btn-primary{background:var(--dsw-alias-button-primary-fill);border-color:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground)}',
128
+ '.dsh-onenote-btn-primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover);border-color:var(--dsw-alias-button-primary-hover)}',
129
+ '.dsh-onenote-code{font-family:var(--ds-font-family-code,monospace);font-size:26px;letter-spacing:4px;font-weight:600;padding:10px 14px;border-radius:10px;border:1px dashed var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);align-self:flex-start}',
130
+ '.dsh-onenote-link{color:var(--dsw-alias-link);font-size:13px;text-decoration:none;word-break:break-all}',
131
+ '.dsh-onenote-link:hover{text-decoration:underline}',
132
+ '.dsh-onenote-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:2px;max-height:320px;overflow:auto}',
133
+ '.dsh-onenote-item{display:flex;align-items:center;gap:10px;padding:7px 8px;border-radius:8px}',
134
+ '.dsh-onenote-item:hover{background:var(--dsw-alias-bg-layer-2)}',
135
+ '.dsh-onenote-item input{margin:0;width:15px;height:15px;accent-color:var(--dsw-alias-brand-primary);cursor:pointer}',
136
+ '.dsh-onenote-name{font-size:13px;line-height:20px;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
137
+ '.dsh-onenote-tag{font-size:11px;line-height:16px;padding:1px 7px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);white-space:nowrap}',
138
+ '.dsh-onenote-warn{display:flex;gap:8px;align-items:flex-start;font-size:13px;line-height:20px;border-radius:8px;padding:10px 12px;background:var(--dsw-alias-bg-layer-2);border-left:3px solid var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-label-primary)}',
139
+ '.dsh-onenote-err{font-size:13px;line-height:20px;color:var(--dsw-alias-state-error-primary);margin:0}',
140
+ '.dsh-onenote-ok{font-size:13px;line-height:20px;color:var(--dsw-alias-state-success-primary);margin:0}',
141
+ '.dsh-onenote-caption{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}'
142
+ ].join('\n')
143
+
144
+ /** Translate with `{name}` interpolation, falling back to the key itself. */
145
+ function makeT(dict, fallbackDict) {
146
+ return (key, params) => {
147
+ const raw = dict[key] !== undefined ? dict[key] : (fallbackDict[key] !== undefined ? fallbackDict[key] : key)
148
+ if (params === undefined) return raw
149
+ return raw.replace(/\{(\w+)\}/g, (match, name) => (params[name] === undefined ? match : String(params[name])))
150
+ }
151
+ }
152
+
153
+ /**
154
+ * One fetch wrapper: every route answers JSON, and a network fault becomes data.
155
+ *
156
+ * A non-JSON body is reported with its status and a first line of text,
157
+ * never as a bare "unreadable response": the usual cause is that the Host
158
+ * never registered `/api/onenote/*`, so the request fell through to the
159
+ * Web server's own `/api` prefix and got its plain-text 401. Naming that
160
+ * tells the user to restart, which is the only thing that helps.
161
+ */
162
+ async function call(path, init) {
163
+ let response
164
+ try {
165
+ response = await fetch(API + path, Object.assign({ cache: 'no-store' }, init || {}))
166
+ } catch (error) {
167
+ return { ok: false, error: String(error && error.message ? error.message : error) }
168
+ }
169
+ const text = await response.text().catch(() => '')
170
+ let parsed
171
+ try {
172
+ parsed = JSON.parse(text)
173
+ } catch {
174
+ const detail = text.trim().split('\n', 1)[0].slice(0, 120)
175
+ return {
176
+ ok: false,
177
+ error: 'HTTP ' + response.status + (detail.length > 0 ? ': ' + detail : ''),
178
+ unreachable: true
179
+ }
180
+ }
181
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
182
+ return { ok: false, error: 'The server answered with a non-object JSON value.', unreachable: true }
183
+ }
184
+ return parsed
185
+ }
186
+
187
+ function Spinner() {
188
+ return h('span', {
189
+ 'aria-hidden': true,
190
+ style: {
191
+ width: 12, height: 12, borderRadius: '50%',
192
+ border: '2px solid var(--dsw-alias-border-l2)',
193
+ borderTopColor: 'var(--dsw-alias-brand-primary)',
194
+ display: 'inline-block', animation: 'dsh-onenote-spin 0.9s linear infinite'
195
+ }
196
+ })
197
+ }
198
+
199
+ function OneNoteCard(props) {
200
+ const { t: hostT, view } = props
201
+ const t = React.useMemo(() => makeT(zh, en), [])
202
+ // Prefer the host locale binding when the page supplies one.
203
+ const tr = React.useCallback((key, params) => {
204
+ if (typeof hostT === 'function') {
205
+ const translated = hostT(NS + '.' + key, params)
206
+ if (translated !== NS + '.' + key && translated !== key) return translated
207
+ }
208
+ return t(key, params)
209
+ }, [hostT, t])
210
+
211
+ const [state, setState] = React.useState({ phase: 'loading' })
212
+ const [pending, setPending] = React.useState(null)
213
+ const [selected, setSelected] = React.useState(null)
214
+ const [busy, setBusy] = React.useState(false)
215
+ const [notice, setNotice] = React.useState(null)
216
+ const [error, setError] = React.useState(null)
217
+ const [copied, setCopied] = React.useState(false)
218
+ const alive = React.useRef(true)
219
+
220
+ React.useEffect(() => () => { alive.current = false }, [])
221
+
222
+ const applyState = React.useCallback((next) => {
223
+ if (!alive.current) return
224
+ if (next === null || next === undefined || next.ok !== true) {
225
+ setError(next && next.error ? next.error : 'Unknown error')
226
+ // A non-JSON answer means the Host half never mounted its routes, so
227
+ // "Try again" cannot help; the card offers the restart instead.
228
+ setState({ phase: 'error', unreachable: next !== null && next !== undefined && next.unreachable === true })
229
+ return
230
+ }
231
+ setError(null)
232
+ const notebooks = Array.isArray(next.notebooks) ? next.notebooks : []
233
+ setState({
234
+ phase: 'ready',
235
+ signedIn: next.signedIn === true,
236
+ account: next.account,
237
+ expiresAt: next.expiresAt,
238
+ allowedCount: typeof next.allowedCount === 'number' ? next.allowedCount : 0,
239
+ notebooks,
240
+ notebooksError: next.notebooksError
241
+ })
242
+ setSelected(notebooks.filter((nb) => nb.allowed === true).map((nb) => nb.id))
243
+ }, [])
244
+
245
+ const load = React.useCallback(async () => {
246
+ const next = await call('/state')
247
+ applyState(next)
248
+ }, [applyState])
249
+
250
+ React.useEffect(() => { load() }, [load])
251
+
252
+ // While a device code is outstanding, poll once every few seconds. The Host
253
+ // answers immediately, so this never holds a request open.
254
+ React.useEffect(() => {
255
+ if (pending === null) return undefined
256
+ let cancelled = false
257
+ const timer = setInterval(async () => {
258
+ const next = await call('/device-code')
259
+ if (cancelled || !alive.current) return
260
+ if (next.ok !== true) {
261
+ setPending(null)
262
+ setError(next.error || 'Sign-in failed')
263
+ return
264
+ }
265
+ if (next.status === 'signed-in') {
266
+ setPending(null)
267
+ setNotice(tr('signedIn.title'))
268
+ applyState(next)
269
+ return
270
+ }
271
+ if (next.status === 'expired') {
272
+ setPending(null)
273
+ setError(tr('pending.expired'))
274
+ return
275
+ }
276
+ setPending((current) => current === null ? current : Object.assign({}, current, {
277
+ expiresInSeconds: next.expiresInSeconds
278
+ }))
279
+ }, POLL_MS)
280
+ return () => { cancelled = true; clearInterval(timer) }
281
+ }, [pending, applyState, tr])
282
+
283
+ const startSignIn = async () => {
284
+ setBusy(true)
285
+ setError(null)
286
+ setNotice(null)
287
+ const next = await call('/device-code/start', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
288
+ if (!alive.current) return
289
+ setBusy(false)
290
+ if (next.ok !== true) {
291
+ setError(tr('signInFailed', { error: next.error || 'unknown' }))
292
+ return
293
+ }
294
+ setPending({
295
+ userCode: next.userCode,
296
+ verificationUri: next.verificationUri,
297
+ expiresInSeconds: next.expiresInSeconds
298
+ })
299
+ }
300
+
301
+ const doSignOut = async () => {
302
+ setBusy(true)
303
+ setError(null)
304
+ setNotice(null)
305
+ await call('/sign-out', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
306
+ setPending(null)
307
+ setBusy(false)
308
+ await load()
309
+ }
310
+
311
+ const save = async () => {
312
+ if (selected === null) return
313
+ setBusy(true)
314
+ setError(null)
315
+ setNotice(null)
316
+ const next = await call('/notebooks', {
317
+ method: 'POST',
318
+ headers: { 'content-type': 'application/json' },
319
+ body: JSON.stringify({ allowedIds: selected })
320
+ })
321
+ if (!alive.current) return
322
+ setBusy(false)
323
+ if (next.ok !== true) {
324
+ setError(tr('saveFailed', { error: next.error || 'unknown' }))
325
+ return
326
+ }
327
+ setNotice(tr('saved', { count: selected.length }))
328
+ applyState(next)
329
+ }
330
+
331
+ const clearAll = async () => {
332
+ setBusy(true)
333
+ setError(null)
334
+ setNotice(null)
335
+ const next = await call('/notebooks', {
336
+ method: 'POST',
337
+ headers: { 'content-type': 'application/json' },
338
+ body: JSON.stringify({ action: 'clear' })
339
+ })
340
+ if (!alive.current) return
341
+ setBusy(false)
342
+ if (next.ok !== true) {
343
+ setError(tr('saveFailed', { error: next.error || 'unknown' }))
344
+ return
345
+ }
346
+ setNotice(tr('cleared'))
347
+ applyState(next)
348
+ }
349
+
350
+ const copyCode = async () => {
351
+ if (pending === null) return
352
+ try {
353
+ await navigator.clipboard.writeText(pending.userCode)
354
+ setCopied(true)
355
+ setTimeout(() => { if (alive.current) setCopied(false) }, 1600)
356
+ } catch {
357
+ setCopied(false)
358
+ }
359
+ }
360
+
361
+ const toggle = (id) => {
362
+ setSelected((current) => {
363
+ const base = Array.isArray(current) ? current.slice() : []
364
+ const at = base.indexOf(id)
365
+ if (at >= 0) base.splice(at, 1)
366
+ else base.push(id)
367
+ return base
368
+ })
369
+ setNotice(null)
370
+ }
371
+
372
+ // The Plugins page draws the title, icon, and breadcrumb, so the summary
373
+ // view returns the one-line description and the page view returns the form.
374
+ if (view === 'summary') return tr('summary')
375
+
376
+ const style = h('style', { key: 'style' }, CSS + '\n@keyframes dsh-onenote-spin{to{transform:rotate(360deg)}}')
377
+
378
+ if (state.phase === 'loading') {
379
+ return h('div', { className: 'dsh-onenote' },
380
+ style,
381
+ h('p', { className: 'dsh-onenote-p' }, tr('loading'))
382
+ )
383
+ }
384
+
385
+ if (state.phase === 'error') {
386
+ return h('div', { className: 'dsh-onenote' },
387
+ style,
388
+ h('p', { className: 'dsh-onenote-err' }, tr('error') + ': ' + (error || '')),
389
+ state.unreachable === true
390
+ ? h('p', { className: 'dsh-onenote-warn' }, tr('unreachable'))
391
+ : null,
392
+ h('div', { className: 'dsh-onenote-row' },
393
+ h('button', { type: 'button', className: 'dsh-onenote-btn', onClick: () => { setState({ phase: 'loading' }); load() } }, tr('retry'))
394
+ )
395
+ )
396
+ }
397
+
398
+ const children = [style]
399
+
400
+ if (pending !== null) {
401
+ children.push(h('div', { className: 'dsh-onenote-box', key: 'pending' },
402
+ h('h3', { className: 'dsh-onenote-h' }, tr('pending.title')),
403
+ h('p', { className: 'dsh-onenote-p' }, tr('pending.body')),
404
+ h('div', { className: 'dsh-onenote-code' }, pending.userCode || '—'),
405
+ h('div', { className: 'dsh-onenote-row' },
406
+ h('a', {
407
+ className: 'dsh-onenote-link',
408
+ href: pending.verificationUri || 'https://microsoft.com/devicelogin',
409
+ target: '_blank',
410
+ rel: 'noreferrer noopener'
411
+ }, tr('pending.open')),
412
+ h('button', { type: 'button', className: 'dsh-onenote-btn', onClick: copyCode }, copied ? tr('pending.copied') : tr('pending.copy')),
413
+ h('span', { className: 'dsh-onenote-caption' }, tr('pending.expires', { seconds: pending.expiresInSeconds }))
414
+ )
415
+ ))
416
+ }
417
+
418
+ if (!state.signedIn && pending === null) {
419
+ children.push(h('div', { className: 'dsh-onenote-box', key: 'signed-out' },
420
+ h('h3', { className: 'dsh-onenote-h' }, tr('signedOut.title')),
421
+ h('p', { className: 'dsh-onenote-p' }, tr('signedOut.body')),
422
+ h('div', { className: 'dsh-onenote-row' },
423
+ h('button', {
424
+ type: 'button',
425
+ className: 'dsh-onenote-btn dsh-onenote-btn-primary',
426
+ disabled: busy,
427
+ onClick: startSignIn
428
+ }, busy ? tr('signIn.busy') : tr('signIn'))
429
+ )
430
+ ))
431
+ }
432
+
433
+ if (state.signedIn) {
434
+ const who = state.account && (state.account.displayName || state.account.userPrincipalName)
435
+ children.push(h('div', { className: 'dsh-onenote-box', key: 'signed-in' },
436
+ h('div', { className: 'dsh-onenote-spread' },
437
+ h('div', null,
438
+ h('h3', { className: 'dsh-onenote-h' }, tr('signedIn.title')),
439
+ h('p', { className: 'dsh-onenote-p' }, who ? tr('signedIn.as', { name: who }) : tr('signedIn.account')),
440
+ h('p', { className: 'dsh-onenote-muted' }, tr('signedIn.renews'))
441
+ ),
442
+ h('button', { type: 'button', className: 'dsh-onenote-btn', disabled: busy, onClick: doSignOut }, tr('signOut'))
443
+ )
444
+ ))
445
+
446
+ const notebooks = state.notebooks
447
+ const rows = notebooks.map((notebook) => {
448
+ const checked = Array.isArray(selected) && selected.indexOf(notebook.id) >= 0
449
+ return h('li', { key: notebook.id, className: 'dsh-onenote-item' },
450
+ h('input', {
451
+ type: 'checkbox',
452
+ checked,
453
+ id: 'dsh-onenote-nb-' + notebook.id,
454
+ onChange: () => toggle(notebook.id)
455
+ }),
456
+ h('label', { className: 'dsh-onenote-name', htmlFor: 'dsh-onenote-nb-' + notebook.id, title: notebook.name }, notebook.name),
457
+ notebook.isShared ? h('span', { className: 'dsh-onenote-tag' }, tr('notebooks.shared')) : null,
458
+ notebook.isDefault ? h('span', { className: 'dsh-onenote-tag' }, tr('notebooks.default')) : null
459
+ )
460
+ })
461
+
462
+ children.push(h('div', { className: 'dsh-onenote-box', key: 'notebooks' },
463
+ h('div', { className: 'dsh-onenote-spread' },
464
+ h('div', null,
465
+ h('h3', { className: 'dsh-onenote-h' }, tr('notebooks.title')),
466
+ h('p', { className: 'dsh-onenote-p' }, tr('notebooks.body'))
467
+ ),
468
+ h('button', { type: 'button', className: 'dsh-onenote-btn', disabled: busy, onClick: load }, tr('notebooks.refresh'))
469
+ ),
470
+ state.notebooksError
471
+ ? h('p', { className: 'dsh-onenote-err' }, tr('loadFailed', { error: state.notebooksError }))
472
+ : null,
473
+ state.allowedCount === 0
474
+ ? h('div', { className: 'dsh-onenote-warn' }, tr('notebooks.empty'))
475
+ : null,
476
+ notebooks.length === 0
477
+ ? h('p', { className: 'dsh-onenote-muted' }, tr('notebooks.none'))
478
+ : h('ul', { className: 'dsh-onenote-list' }, rows),
479
+ h('div', { className: 'dsh-onenote-spread' },
480
+ h('span', { className: 'dsh-onenote-caption' }, tr('notebooks.count', {
481
+ count: Array.isArray(selected) ? selected.length : 0,
482
+ total: notebooks.length
483
+ })),
484
+ h('div', { className: 'dsh-onenote-row' },
485
+ h('button', { type: 'button', className: 'dsh-onenote-btn', disabled: busy, onClick: clearAll }, tr('clear')),
486
+ h('button', {
487
+ type: 'button',
488
+ className: 'dsh-onenote-btn dsh-onenote-btn-primary',
489
+ disabled: busy || notebooks.length === 0,
490
+ onClick: save
491
+ }, busy ? tr('saving') : tr('save'))
492
+ )
493
+ )
494
+ ))
495
+ }
496
+
497
+ if (notice !== null) children.push(h('p', { className: 'dsh-onenote-ok', key: 'notice' }, notice))
498
+ if (error !== null) children.push(h('p', { className: 'dsh-onenote-err', key: 'error' }, error))
499
+ if (busy) children.push(h('div', { className: 'dsh-onenote-row', key: 'busy' }, h(Spinner, null)))
500
+
501
+ return h('div', { className: 'dsh-onenote' }, children)
502
+ }
503
+
504
+ function apply(ctx) {
505
+ ctx.effect(() => ctx.locale.register(NS, { en, zh }))
506
+ ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({
507
+ name: 'plugins.bundle.config',
508
+ // Keyed by the bundle's package name: the Plugins page dispatches this
509
+ // entry on the dsh-plugin-onenote bundle page.
510
+ key: 'dsh-plugin-onenote',
511
+ id: 'dsh-plugin-onenote',
512
+ locale: NS
513
+ }, OneNoteCard))
514
+ }
515
+
516
+ return { inject: ['slots', 'locale'], apply }
517
+ }
518
+ })
@@ -0,0 +1,9 @@
1
+ # OneNote reader bundle: one Host row (tools + settings routes) and one Client
2
+ # half (the settings card). Replace clientId/tenant with your own Azure app
3
+ # registration if you do not want to use the bundled one.
4
+ - insert:
5
+ - id: onenote
6
+ name: 'dsh-plugin-onenote'
7
+ config:
8
+ clientId: a2e9773e-e783-4e5c-83ff-8c627eec0e6e
9
+ tenant: common
package/icon.svg ADDED
@@ -0,0 +1,16 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" role="img" aria-label="OneNote Reader">
2
+ <defs>
3
+ <linearGradient id="sheet" x1="0" y1="0" x2="0" y2="1">
4
+ <stop offset="0%" stop-color="#7b3fe4"/>
5
+ <stop offset="100%" stop-color="#4a1fa8"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect x="6" y="5" width="36" height="38" rx="5" fill="url(#sheet)"/>
9
+ <rect x="12" y="11" width="24" height="3" rx="1.5" fill="#ffffff" opacity="0.92"/>
10
+ <rect x="12" y="18" width="24" height="3" rx="1.5" fill="#ffffff" opacity="0.7"/>
11
+ <rect x="12" y="25" width="17" height="3" rx="1.5" fill="#ffffff" opacity="0.7"/>
12
+ <g fill="#ffffff">
13
+ <path d="M26 30h7a2 2 0 0 1 2 2v4.6a2 2 0 0 1-2 2h-4.4L25 41.9a.6.6 0 0 1-1-.44V32a2 2 0 0 1 2-2Z" opacity="0.95"/>
14
+ <path d="M28.5 34.2c.5-.9 2.1-.9 2.5.2.5-1.1 2.1-1.1 2.5-.2.4 1-.9 2.3-2.5 3.3-1.6-1-2.9-2.3-2.5-3.3Z" fill="#4a1fa8"/>
15
+ </g>
16
+ </svg>
package/index.js ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * `dsh-plugin-onenote` — read the Microsoft OneNote notebooks a user allows.
3
+ *
4
+ * The row takes `clientId` and `tenant`; tokens and consent live under
5
+ * `$DSH_HOME/onenote`. Two surfaces share one service:
6
+ *
7
+ * - agent tools (`src/tools.js`) — read-only, gated by the allow-list;
8
+ * - settings card (`client.js` + `src/routes.js`) — the only place a user can
9
+ * sign in or change which notebooks are readable.
10
+ *
11
+ * Unloading the row unregisters the tools, the prompt section, and the routes
12
+ * through effect-owned disposers.
13
+ */
14
+ import { resolveConfig } from './src/config.js';
15
+ import { registerTools } from './src/tools.js';
16
+ import { createOneNoteService } from './src/service.js';
17
+ import { installRoutes } from './src/routes.js';
18
+
19
+ export const name = 'onenote';
20
+
21
+ /** Tools are required; everything else is optional in a bare composition. */
22
+ export const inject = ['tools'];
23
+
24
+ const PROMPT_SECTION = 'tool:onenote';
25
+ /** Sits beside the other external-content tools, after the MCP servers block. */
26
+ const PROMPT_ORDER = 3150;
27
+
28
+ export function apply(ctx, rawConfig) {
29
+ const config = resolveConfig(rawConfig, {
30
+ warn: (message) => ctx.logger?.warn?.(message),
31
+ });
32
+
33
+ const service = createOneNoteService(config);
34
+
35
+ const disposers = registerTools(ctx, service);
36
+ ctx.effect(() => () => {
37
+ for (const dispose of disposers) dispose();
38
+ });
39
+
40
+ // The routes are mounted from a dependency callback, never from a one-shot
41
+ // `ctx.get('webServer')`. The Loader creates every row concurrently and the
42
+ // webServer row stays LOADING while it binds its socket, so a strict read at
43
+ // apply time answers `undefined` and the five routes are silently dropped:
44
+ // the card then reaches Connection's `/api` prefix and gets its plain-text
45
+ // 401, which is not JSON. `ctx.inject` pends until the service is ACTIVE and
46
+ // re-runs if it is replaced, which is the only ordering-safe mount.
47
+ ctx.inject(['webServer'], (scoped) => {
48
+ installRoutes(scoped, service);
49
+ });
50
+
51
+ const systemPrompt = ctx.get('systemPrompt');
52
+ if (systemPrompt !== undefined) {
53
+ const section = systemPrompt.section({
54
+ name: PROMPT_SECTION,
55
+ order: PROMPT_ORDER,
56
+ text: () => renderSection(service),
57
+ });
58
+ ctx.effect(() => () => section());
59
+ }
60
+ }
61
+
62
+ /**
63
+ * One short paragraph, regenerated per assembly so it reflects the live
64
+ * sign-in and consent state. An unconfigured plugin says what the user must do
65
+ * rather than advertising a capability the model cannot use yet.
66
+ */
67
+ function renderSection(service) {
68
+ const status = service.status();
69
+ if (!status.signedIn) {
70
+ return [
71
+ 'OneNote is available but not connected. When the user asks about their notes,',
72
+ 'tell them to open Settings → Plugins → OneNote and sign in; do not attempt to sign in for them.',
73
+ ].join(' ');
74
+ }
75
+
76
+ const names = status.allowedNotebooks.map((entry) => entry.name);
77
+ const scope = names.length === 0
78
+ ? 'No notebook is allowed yet, so nothing is readable; the user chooses notebooks in Settings → Plugins → OneNote.'
79
+ : `Readable notebooks: ${names.join(', ')}. Every other notebook is unreadable by design.`;
80
+
81
+ const who = status.account?.displayName === undefined ? '' : ` Signed in as ${status.account.displayName}.`;
82
+ return [
83
+ `Microsoft OneNote is connected.${who} ${scope}`,
84
+ 'Use `onenote_search` or `onenote_list_pages` to find a page, then `onenote_read_page` to read it.',
85
+ 'These tools only ever see the allowed notebooks; never claim access to a notebook outside that list, and never suggest that you can widen it yourself.',
86
+ ].join(' ');
87
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "OneNote Reader",
4
+ "description": "Reads the Microsoft OneNote notebooks you allow. Sign in and tick notebooks in Settings → Plugins → OneNote; the agent can only ever see what you ticked."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "OneNote 笔记本读取",
4
+ "description": "读取你授权范围内的 Microsoft OneNote 笔记本。在「设置 → 插件 → OneNote」中登录并勾选笔记本;助手只能看到你勾选的内容。"
5
+ }
6
+ }