@oceanum/oceanumlab 4.7.0 → 4.7.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.
Files changed (81) hide show
  1. package/README.md +102 -11
  2. package/lib/DatameshUI.d.ts +1 -6
  3. package/lib/DatameshUI.js +1 -9
  4. package/lib/DatameshWidget.d.ts +18 -2
  5. package/lib/DatameshWidget.js +80 -12
  6. package/lib/StoredNotebooks.d.ts +20 -0
  7. package/lib/StoredNotebooks.js +112 -0
  8. package/lib/Tabs.d.ts +26 -0
  9. package/lib/Tabs.js +45 -0
  10. package/lib/aiBackend.d.ts +1 -1
  11. package/lib/aiBackend.js +1 -1
  12. package/lib/auth/accountWidget.d.ts +35 -0
  13. package/lib/auth/accountWidget.js +95 -0
  14. package/lib/auth/claims.d.ts +3 -0
  15. package/lib/auth/claims.js +25 -0
  16. package/lib/auth/plugin.d.ts +9 -0
  17. package/lib/auth/plugin.js +126 -0
  18. package/lib/auth/serverAuth.d.ts +79 -0
  19. package/lib/auth/serverAuth.js +254 -0
  20. package/lib/auth/startSignIn.d.ts +11 -0
  21. package/lib/auth/startSignIn.js +16 -0
  22. package/lib/auth/tokens.d.ts +102 -0
  23. package/lib/auth/tokens.js +9 -0
  24. package/lib/chatRouter.d.ts +13 -1
  25. package/lib/chatRouter.js +9 -2
  26. package/lib/constants.d.ts +1 -1
  27. package/lib/constants.js +1 -1
  28. package/lib/datameshUiUrl.d.ts +2 -2
  29. package/lib/datameshUiUrl.js +6 -6
  30. package/lib/index.d.ts +8 -1
  31. package/lib/index.js +58 -17
  32. package/lib/share/client.d.ts +84 -0
  33. package/lib/share/client.js +205 -0
  34. package/lib/share/events.d.ts +7 -0
  35. package/lib/share/events.js +9 -0
  36. package/lib/share/notebook.d.ts +184 -0
  37. package/lib/share/notebook.js +277 -0
  38. package/lib/share/oceanumOnly.d.ts +49 -0
  39. package/lib/share/oceanumOnly.js +90 -0
  40. package/lib/share/plugin.d.ts +21 -0
  41. package/lib/share/plugin.js +883 -0
  42. package/lib/share/widgets.d.ts +25 -0
  43. package/lib/share/widgets.js +154 -0
  44. package/package.json +13 -1
  45. package/schema/datamesh-connect.json +3 -12
  46. package/src/DatameshUI.tsx +1 -10
  47. package/src/DatameshWidget.tsx +143 -19
  48. package/src/StoredNotebooks.tsx +206 -0
  49. package/src/Tabs.tsx +102 -0
  50. package/src/__tests__/DatameshWidget.spec.tsx +33 -20
  51. package/src/__tests__/OceanumPanel.spec.tsx +157 -0
  52. package/src/__tests__/StoredNotebooks.spec.tsx +157 -0
  53. package/src/__tests__/Tabs.spec.tsx +168 -0
  54. package/src/__tests__/aiBackend.spec.ts +5 -6
  55. package/src/__tests__/chatRouter.spec.ts +9 -15
  56. package/src/__tests__/datameshUiUrl.spec.ts +4 -3
  57. package/src/__tests__/oceanumOnly.spec.ts +193 -0
  58. package/src/__tests__/pluginArity.spec.ts +24 -0
  59. package/src/__tests__/serverAuth.spec.ts +277 -0
  60. package/src/__tests__/settingsSchema.spec.ts +73 -0
  61. package/src/__tests__/shareClient.spec.ts +314 -0
  62. package/src/__tests__/shareNotebook.spec.ts +451 -0
  63. package/src/__tests__/sharePlugin.spec.ts +426 -0
  64. package/src/aiBackend.ts +1 -1
  65. package/src/auth/accountWidget.ts +113 -0
  66. package/src/auth/claims.ts +30 -0
  67. package/src/auth/plugin.ts +155 -0
  68. package/src/auth/serverAuth.ts +342 -0
  69. package/src/auth/startSignIn.ts +23 -0
  70. package/src/auth/tokens.ts +111 -0
  71. package/src/chatRouter.ts +8 -4
  72. package/src/constants.ts +1 -1
  73. package/src/datameshUiUrl.ts +6 -6
  74. package/src/index.ts +87 -36
  75. package/src/share/client.ts +288 -0
  76. package/src/share/events.ts +10 -0
  77. package/src/share/notebook.ts +416 -0
  78. package/src/share/oceanumOnly.ts +126 -0
  79. package/src/share/plugin.ts +1096 -0
  80. package/src/share/widgets.ts +217 -0
  81. package/style/index.css +266 -13
@@ -0,0 +1,1096 @@
1
+ import {
2
+ ILabShell,
3
+ JupyterFrontEnd,
4
+ JupyterFrontEndPlugin
5
+ } from '@jupyterlab/application';
6
+ import {
7
+ Dialog,
8
+ ICommandPalette,
9
+ InputDialog,
10
+ Notification,
11
+ showDialog
12
+ } from '@jupyterlab/apputils';
13
+ import { PageConfig } from '@jupyterlab/coreutils';
14
+ import { IDocumentManager } from '@jupyterlab/docmanager';
15
+ import { IMainMenu } from '@jupyterlab/mainmenu';
16
+ import type { INotebookContent } from '@jupyterlab/nbformat';
17
+ import { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook';
18
+ import type { ReadonlyPartialJSONObject } from '@lumino/coreutils';
19
+ import { Menu } from '@lumino/widgets';
20
+
21
+ import { startSignIn } from '../auth/startSignIn';
22
+ import { IOceanumAuth } from '../auth/tokens';
23
+ import { type ErrorKind, SpecStoreClient, SpecStoreError } from './client';
24
+ import { notebooksChanged } from './events';
25
+ import {
26
+ buildSpecBody,
27
+ IOceanumLink,
28
+ isSpecId,
29
+ ISpecRecord,
30
+ METADATA_KEY,
31
+ notebookFromRecord,
32
+ notebookNamesFor,
33
+ OCEANUM_DIR,
34
+ partitionSummaries,
35
+ QUERY_PARAM,
36
+ readLink,
37
+ reportForShareFailures,
38
+ sanitizeName,
39
+ shareLink,
40
+ uniqueNotebookPath
41
+ } from './notebook';
42
+ import {
43
+ hideFileBrowser,
44
+ pruneBeforeEachOpen,
45
+ pruneFileMenu,
46
+ routeSaves
47
+ } from './oceanumOnly';
48
+ import { NotebookListBody, ShareBody } from './widgets';
49
+
50
+ export const CommandIDs = {
51
+ open: 'oceanum-share:open',
52
+ save: 'oceanum-share:save',
53
+ share: 'oceanum-share:share',
54
+ rename: 'oceanum-share:rename',
55
+ delete: 'oceanum-share:delete'
56
+ } as const;
57
+
58
+ export const SHARE_PLUGIN_ID = '@oceanum/oceanumlab:share';
59
+
60
+ /** The Notebooks tab marks each listed record with this, for its context menu. */
61
+ export const SPEC_ID_ATTRIBUTE = 'data-spec-id';
62
+ export const NOTEBOOK_ITEM_SELECTOR = `.oceanum-notebooks-item[${SPEC_ID_ATTRIBUTE}]`;
63
+
64
+ const UNAVAILABLE =
65
+ ' (unavailable: Oceanum sign-in is not configured for this host)';
66
+
67
+ interface IStore {
68
+ specsUrl: string;
69
+ client: SpecStoreClient;
70
+ }
71
+
72
+ /**
73
+ * Open, save, share, rename and delete notebooks through the Oceanum spec store.
74
+ *
75
+ * The commands sit in the File menu, the command palette, a notebook's context menu
76
+ * (and its tab's), and the Notebooks tab's rows. They are enabled only while signed in.
77
+ * With `fileManagement: 'oceanum'` in the environment the local drive goes out of
78
+ * sight and every local save is pushed to the store: see `oceanumOnly.ts`.
79
+ */
80
+ export const sharePlugin: JupyterFrontEndPlugin<void> = {
81
+ id: SHARE_PLUGIN_ID,
82
+ description:
83
+ 'Open, save, share, rename and delete notebooks through the Oceanum spec store.',
84
+ autoStart: true,
85
+ requires: [IOceanumAuth, IDocumentManager, INotebookTracker, ICommandPalette],
86
+ optional: [IMainMenu, ILabShell],
87
+ activate: (
88
+ app: JupyterFrontEnd,
89
+ auth: IOceanumAuth,
90
+ docManager: IDocumentManager,
91
+ tracker: INotebookTracker,
92
+ palette: ICommandPalette,
93
+ mainMenu: IMainMenu | null,
94
+ labShell: ILabShell | null
95
+ ): void => {
96
+ const specsUrl = auth.urls?.specs ?? null;
97
+ const store: IStore | null =
98
+ specsUrl === null
99
+ ? null
100
+ : {
101
+ specsUrl,
102
+ client: new SpecStoreClient({
103
+ specsUrl,
104
+ getAccessToken: () => auth.getAccessToken()
105
+ })
106
+ };
107
+ const oceanumOnly =
108
+ store !== null && auth.environment?.fileManagement === 'oceanum';
109
+ const saving = new Set<NotebookPanel>();
110
+ // Notebooks whose routed saves stopped reaching Oceanum, and the sign-in they
111
+ // stopped under: signing in again gives every notebook another go.
112
+ const muted = new WeakMap<NotebookPanel, number>();
113
+ // Notebooks already reported as failing for a passing reason, so a flaky network
114
+ // does not raise the same notification on every autosave.
115
+ const warnedTransient = new WeakSet<NotebookPanel>();
116
+ let signInGeneration = 0;
117
+ let warnedSignedOut = false;
118
+
119
+ const signedIn = (): boolean => store !== null && auth.user !== null;
120
+ const label = (text: string) => () =>
121
+ store ? text : `${text}${UNAVAILABLE}`;
122
+
123
+ // --- what a command acts on ---------------------------------------------------
124
+
125
+ /**
126
+ * Where a command was invoked from. The context menus pass this; the File menu, the
127
+ * palette and a keyboard shortcut pass nothing, and must not be answered with
128
+ * whatever happens to have been right-clicked earlier — JupyterLab keeps the last
129
+ * contextmenu event for the life of the page and never clears it, so the hit tests
130
+ * below answer long after their menu has gone.
131
+ */
132
+ const sourceOf = (
133
+ args: ReadonlyPartialJSONObject
134
+ ): 'row' | 'notebook' | null =>
135
+ args.source === 'row' || args.source === 'notebook' ? args.source : null;
136
+
137
+ /** The record id under the pointer; only ask from the Notebooks tab's own menu. */
138
+ const hitTestSpecId = (): string | null => {
139
+ const node = app.contextMenuHitTest(candidate =>
140
+ candidate.hasAttribute(SPEC_ID_ATTRIBUTE)
141
+ );
142
+ const id = node?.getAttribute(SPEC_ID_ATTRIBUTE);
143
+ return isSpecId(id) ? id : null;
144
+ };
145
+
146
+ /**
147
+ * The notebook a command acts on: from a notebook's or a tab's context menu, the one
148
+ * under the pointer; anywhere else the current notebook, and only that.
149
+ */
150
+ const targetNotebook = (
151
+ source: 'row' | 'notebook' | null
152
+ ): NotebookPanel | null => {
153
+ if (source === 'notebook') {
154
+ const node = app.contextMenuHitTest(
155
+ candidate => !!candidate.dataset.id
156
+ );
157
+ const id = node?.dataset.id;
158
+ if (id) {
159
+ const panel = tracker.find(candidate => candidate.id === id);
160
+ if (panel) {
161
+ return panel;
162
+ }
163
+ }
164
+ }
165
+ const widget = tracker.currentWidget;
166
+ return widget !== null && widget === app.shell.currentWidget
167
+ ? widget
168
+ : null;
169
+ };
170
+
171
+ const linkOf = (panel: NotebookPanel): IOceanumLink | null =>
172
+ store
173
+ ? readLink(
174
+ panel.context.model.getMetadata(METADATA_KEY),
175
+ store.specsUrl
176
+ )
177
+ : null;
178
+
179
+ /** The open notebook linked to a record, if any. */
180
+ const panelFor = (id: string): NotebookPanel | null =>
181
+ tracker.find(panel => linkOf(panel)?.spec_id === id) ?? null;
182
+
183
+ /** A record id from the command's arguments, the pointer, or the target notebook. */
184
+ const targetSpecId = (args: ReadonlyPartialJSONObject): string | null => {
185
+ if (isSpecId(args.id)) {
186
+ return args.id;
187
+ }
188
+ const source = sourceOf(args);
189
+ if (source === 'row') {
190
+ return hitTestSpecId();
191
+ }
192
+ const panel = targetNotebook(source);
193
+ return panel ? (linkOf(panel)?.spec_id ?? null) : null;
194
+ };
195
+
196
+ // --- sign-in and errors -------------------------------------------------------
197
+
198
+ async function offerSignIn(reason: string): Promise<boolean> {
199
+ const result = await showDialog({
200
+ title: 'Sign in to Oceanum.io',
201
+ body: `${reason} Sign in to Oceanum.io?`,
202
+ buttons: [Dialog.cancelButton(), Dialog.okButton({ label: 'Sign in' })]
203
+ });
204
+ if (!result.button.accept) {
205
+ return false;
206
+ }
207
+ try {
208
+ await startSignIn(app.commands, auth);
209
+ } catch (error) {
210
+ console.error('Oceanum.io sign-in could not start', error);
211
+ Notification.error('Oceanum.io sign-in is not available right now.', {
212
+ autoClose: 5000
213
+ });
214
+ return false;
215
+ }
216
+ return true;
217
+ }
218
+
219
+ async function ensureSignedIn(reason: string): Promise<boolean> {
220
+ await auth.ready;
221
+ if (auth.user !== null) {
222
+ return true;
223
+ }
224
+ await offerSignIn(reason);
225
+ return false;
226
+ }
227
+
228
+ /**
229
+ * Whether an error calls for signing in. A signed-in user can't sign in again: the
230
+ * spec store refused a token Auth0 still considers valid, so show the error instead.
231
+ */
232
+ const needsSignIn = (error: SpecStoreError): boolean =>
233
+ (error.kind === 'expired' || error.kind === 'signed-out') &&
234
+ auth.user === null;
235
+
236
+ /**
237
+ * Tell the user. Messages never include notebook content, which can hold
238
+ * credentials. `quiet` is for saves nobody asked for by name (autosave, Ctrl+S in
239
+ * Oceanum-only mode): a notification, never a dialog.
240
+ */
241
+ async function reportError(
242
+ action: string,
243
+ error: unknown,
244
+ quiet = false
245
+ ): Promise<void> {
246
+ if (!quiet && error instanceof SpecStoreError && needsSignIn(error)) {
247
+ await offerSignIn(error.message);
248
+ return;
249
+ }
250
+ console.error(`${action}:`, error);
251
+ const message = error instanceof Error ? error.message : 'Unknown error.';
252
+ Notification.error(`${action}: ${message}`, {
253
+ autoClose: quiet ? 8000 : false
254
+ });
255
+ }
256
+
257
+ /** Sharing failures are usually the owner check, which is worth explaining. */
258
+ function shareError(kind: ErrorKind | null, error: unknown): unknown {
259
+ return kind === 'forbidden'
260
+ ? new Error(
261
+ 'Only the owner can share this notebook; if that is you, switch to the organisation you created it under.'
262
+ )
263
+ : error;
264
+ }
265
+
266
+ // --- opening ------------------------------------------------------------------
267
+
268
+ /** File names in the `Oceanum` folder, creating the folder if it is missing. */
269
+ async function oceanumFolderNames(): Promise<string[]> {
270
+ const contents = docManager.services.contents;
271
+ let dir;
272
+ try {
273
+ dir = await contents.get(OCEANUM_DIR, { content: true });
274
+ } catch {
275
+ dir = null;
276
+ }
277
+ if (!dir) {
278
+ const created = await contents.newUntitled({
279
+ path: '',
280
+ type: 'directory'
281
+ });
282
+ await contents.rename(created.path, OCEANUM_DIR);
283
+ return [];
284
+ }
285
+ if (dir.type !== 'directory' || !Array.isArray(dir.content)) {
286
+ throw new Error(`"${OCEANUM_DIR}" exists and is not a folder.`);
287
+ }
288
+ return dir.content.map((item: { name: string }) => item.name);
289
+ }
290
+
291
+ /**
292
+ * The file in the Oceanum folder already holding this record, or null. Only the
293
+ * names the record itself could have been written to are read.
294
+ */
295
+ async function pathHoldingRecord(
296
+ s: IStore,
297
+ record: ISpecRecord,
298
+ names: readonly string[]
299
+ ): Promise<string | null> {
300
+ for (const name of notebookNamesFor(record.name, names)) {
301
+ const path = `${OCEANUM_DIR}/${name}`;
302
+ try {
303
+ const file = await docManager.services.contents.get(path, {
304
+ content: true
305
+ });
306
+ const metadata = (file.content as INotebookContent | null)?.metadata;
307
+ if (
308
+ readLink(metadata?.[METADATA_KEY], s.specsUrl)?.spec_id ===
309
+ record.id
310
+ ) {
311
+ return path;
312
+ }
313
+ } catch {
314
+ // Unreadable or gone: not the copy we are looking for.
315
+ }
316
+ }
317
+ return null;
318
+ }
319
+
320
+ /** Write a record to the local drive as `Oceanum/<name>.ipynb` and open it. */
321
+ async function openRecord(s: IStore, record: ISpecRecord): Promise<void> {
322
+ // Already open: bring it forward rather than writing a second copy.
323
+ const open = panelFor(record.id);
324
+ if (open) {
325
+ app.shell.activateById(open.id);
326
+ return;
327
+ }
328
+ const content = notebookFromRecord(record, s.specsUrl);
329
+ const names = await oceanumFolderNames();
330
+ // Reuse the file an earlier session left for this record. A second copy would be
331
+ // named "<name> (1)", and a record takes its name from the file on every save, so
332
+ // opening the same notebook twice would quietly rename it on Oceanum.
333
+ const path =
334
+ (await pathHoldingRecord(s, record, names)) ??
335
+ uniqueNotebookPath(record.name, names);
336
+ await docManager.services.contents.save(path, {
337
+ type: 'notebook',
338
+ format: 'json',
339
+ content
340
+ });
341
+ if (!docManager.openOrReveal(path)) {
342
+ throw new Error(`Could not open ${path}.`);
343
+ }
344
+ }
345
+
346
+ async function openById(id: string): Promise<void> {
347
+ if (
348
+ !store ||
349
+ !(await ensureSignedIn(
350
+ 'Opening notebooks needs an Oceanum.io account.'
351
+ ))
352
+ ) {
353
+ return;
354
+ }
355
+ try {
356
+ await openRecord(store, await store.client.get(id));
357
+ } catch (error) {
358
+ await reportError('Open from Oceanum failed', error);
359
+ }
360
+ }
361
+
362
+ async function openFromOceanum(): Promise<void> {
363
+ if (
364
+ !store ||
365
+ !(await ensureSignedIn(
366
+ 'Opening notebooks needs an Oceanum.io account.'
367
+ ))
368
+ ) {
369
+ return;
370
+ }
371
+ try {
372
+ const { mine, shared } = partitionSummaries(
373
+ await store.client.list(),
374
+ auth.user?.email ?? null
375
+ );
376
+ const result = await showDialog({
377
+ title: 'Open from Oceanum',
378
+ body: new NotebookListBody(mine, shared),
379
+ buttons: [Dialog.cancelButton(), Dialog.okButton({ label: 'Open' })]
380
+ });
381
+ if (!result.button.accept || !result.value) {
382
+ return;
383
+ }
384
+ await openRecord(store, await store.client.get(result.value));
385
+ } catch (error) {
386
+ await reportError('Open from Oceanum failed', error);
387
+ }
388
+ }
389
+
390
+ // --- saving -------------------------------------------------------------------
391
+
392
+ /** Save a notebook to Oceanum.io; resolves with its record id, or `null`. */
393
+ async function saveToOceanum(
394
+ panel: NotebookPanel,
395
+ quiet = false
396
+ ): Promise<string | null> {
397
+ // Ignore repeat requests while a save is in flight, so it cannot create two records.
398
+ if (!store || saving.has(panel)) {
399
+ return null;
400
+ }
401
+ saving.add(panel);
402
+ try {
403
+ if (quiet) {
404
+ if (auth.user === null) {
405
+ if (!warnedSignedOut) {
406
+ warnedSignedOut = true;
407
+ Notification.warning(
408
+ 'Not signed in to Oceanum.io: notebooks are only being saved locally.',
409
+ { autoClose: 8000 }
410
+ );
411
+ }
412
+ return null;
413
+ }
414
+ } else if (
415
+ !(await ensureSignedIn(
416
+ 'Saving notebooks needs an Oceanum.io account.'
417
+ ))
418
+ ) {
419
+ return null;
420
+ }
421
+ const id = await saveRecord(store, panel, quiet);
422
+ if (id !== null) {
423
+ muted.delete(panel);
424
+ warnedTransient.delete(panel);
425
+ }
426
+ return id;
427
+ } finally {
428
+ saving.delete(panel);
429
+ }
430
+ }
431
+
432
+ /** A routed save: after every local save in Oceanum-only mode. */
433
+ async function pushQuietly(panel: NotebookPanel): Promise<void> {
434
+ if (muted.get(panel) === signInGeneration) {
435
+ return;
436
+ }
437
+ await saveToOceanum(panel, true);
438
+ }
439
+
440
+ /** Stop routed saves of a notebook until the next explicit save or sign-in. */
441
+ function mute(panel: NotebookPanel): void {
442
+ muted.set(panel, signInGeneration);
443
+ }
444
+
445
+ /** Whether repeating this save unchanged would fail for the same reason. */
446
+ function isPermanent(error: unknown): boolean {
447
+ return (
448
+ error instanceof SpecStoreError &&
449
+ error.kind !== 'network' &&
450
+ error.kind !== 'server'
451
+ );
452
+ }
453
+
454
+ async function saveRecord(
455
+ s: IStore,
456
+ panel: NotebookPanel,
457
+ quiet: boolean
458
+ ): Promise<string | null> {
459
+ await panel.context.ready;
460
+ const model = panel.context.model;
461
+ // INotebookModel types toJSON loosely; NotebookModel returns INotebookContent.
462
+ const content = model.toJSON() as INotebookContent;
463
+ const built = buildSpecBody(content, panel.context.path, s.specsUrl);
464
+ if (!built.ok) {
465
+ if (quiet) {
466
+ mute(panel);
467
+ }
468
+ Notification.error(
469
+ 'This notebook is too large to save to Oceanum.io, even without outputs.',
470
+ { autoClose: quiet ? 8000 : false }
471
+ );
472
+ return null;
473
+ }
474
+ const link = readLink(model.getMetadata(METADATA_KEY), s.specsUrl);
475
+
476
+ let record: ISpecRecord;
477
+ try {
478
+ if (link === null) {
479
+ record = await s.client.create(built.body);
480
+ } else {
481
+ try {
482
+ record = await s.client.update(link.spec_id, built.body);
483
+ } catch (error) {
484
+ if (
485
+ !(error instanceof SpecStoreError) ||
486
+ (error.kind !== 'forbidden' && error.kind !== 'not-found')
487
+ ) {
488
+ throw error;
489
+ }
490
+ if (quiet) {
491
+ // Nobody asked for this save by name, so no dialog: say once why it
492
+ // is not reaching Oceanum and leave the notebook alone until they do.
493
+ mute(panel);
494
+ Notification.error(
495
+ `${error.message} Use Save to Oceanum to save a copy.`,
496
+ { autoClose: 8000 }
497
+ );
498
+ return null;
499
+ }
500
+ const result = await showDialog({
501
+ title: 'Cannot save to the linked Oceanum notebook',
502
+ body: `${error.message} Save as a new copy instead?`,
503
+ buttons: [
504
+ Dialog.cancelButton(),
505
+ Dialog.okButton({ label: 'Save as a new copy' })
506
+ ]
507
+ });
508
+ if (!result.button.accept) {
509
+ return null;
510
+ }
511
+ record = await s.client.create(built.body);
512
+ }
513
+ }
514
+ } catch (error) {
515
+ if (quiet) {
516
+ // A reason that will not pass on its own (no access, gone, signed out) mutes
517
+ // this notebook until the user saves it themselves or signs in again. A
518
+ // passing one (network, 5xx) does not: the next save simply tries again, and
519
+ // the warning is raised once rather than on every autosave until one works.
520
+ if (isPermanent(error)) {
521
+ mute(panel);
522
+ } else if (warnedTransient.has(panel)) {
523
+ console.warn('Save to Oceanum failed again', error);
524
+ return null;
525
+ } else {
526
+ warnedTransient.add(panel);
527
+ }
528
+ }
529
+ await reportError('Save to Oceanum failed', error, quiet);
530
+ return null;
531
+ }
532
+
533
+ if (record.id !== link?.spec_id) {
534
+ const value: IOceanumLink = {
535
+ spec_id: record.id,
536
+ specs_url: s.specsUrl
537
+ };
538
+ if (link?.description) {
539
+ value.description = link.description;
540
+ }
541
+ model.setMetadata(METADATA_KEY, value);
542
+ notebooksChanged.emit();
543
+ }
544
+ try {
545
+ await panel.context.save();
546
+ } catch (error) {
547
+ await reportError(
548
+ 'Saved to Oceanum, but saving the local file failed',
549
+ error,
550
+ quiet
551
+ );
552
+ return record.id;
553
+ }
554
+ if (!quiet) {
555
+ Notification.success(
556
+ built.strippedOutputs
557
+ ? `Saved "${record.name}" to Oceanum without outputs, which made it too large.`
558
+ : `Saved "${record.name}" to Oceanum.`,
559
+ { autoClose: 5000 }
560
+ );
561
+ }
562
+ return record.id;
563
+ }
564
+
565
+ // --- sharing ------------------------------------------------------------------
566
+
567
+ async function shareOnOceanum(panel: NotebookPanel): Promise<void> {
568
+ if (
569
+ !store ||
570
+ !(await ensureSignedIn(
571
+ 'Sharing notebooks needs an Oceanum.io account.'
572
+ ))
573
+ ) {
574
+ return;
575
+ }
576
+ await panel.context.ready;
577
+ let id = linkOf(panel)?.spec_id ?? null;
578
+ if (id === null) {
579
+ const result = await showDialog({
580
+ title: 'Save to Oceanum first',
581
+ body: 'This notebook must be saved to Oceanum before it can be shared. Save it now?',
582
+ buttons: [Dialog.cancelButton(), Dialog.okButton({ label: 'Save' })]
583
+ });
584
+ if (!result.button.accept) {
585
+ return;
586
+ }
587
+ id = await saveToOceanum(panel);
588
+ if (id === null) {
589
+ return;
590
+ }
591
+ }
592
+ await shareRecord(id);
593
+ }
594
+
595
+ async function shareRecord(id: string): Promise<void> {
596
+ if (
597
+ !store ||
598
+ !(await ensureSignedIn(
599
+ 'Sharing notebooks needs an Oceanum.io account.'
600
+ ))
601
+ ) {
602
+ return;
603
+ }
604
+ const result = await showDialog({
605
+ title: 'Share on Oceanum',
606
+ body: new ShareBody(
607
+ shareLink(PageConfig.getBaseUrl(), window.location.href, id)
608
+ ),
609
+ focusNodeSelector: 'textarea.jp-OceanumShare-email',
610
+ buttons: [Dialog.cancelButton(), Dialog.okButton({ label: 'Share' })]
611
+ });
612
+ if (!result.button.accept) {
613
+ return;
614
+ }
615
+ const choice = result.value;
616
+ if (!choice) {
617
+ Notification.warning(
618
+ 'Enter a valid email address to share with a person.',
619
+ { autoClose: 5000 }
620
+ );
621
+ return;
622
+ }
623
+ if (choice.action === 'revoke') {
624
+ const { grant } = choice;
625
+ try {
626
+ await store.client.removePermission(id, grant);
627
+ } catch (error) {
628
+ const kind = error instanceof SpecStoreError ? error.kind : null;
629
+ if (kind === 'not-found') {
630
+ Notification.info('This notebook was not shared with everyone.', {
631
+ autoClose: 5000
632
+ });
633
+ return;
634
+ }
635
+ await reportError('Share on Oceanum failed', shareError(kind, error));
636
+ return;
637
+ }
638
+ Notification.success(
639
+ 'Only people it is shared with can open this notebook now.',
640
+ { autoClose: 5000 }
641
+ );
642
+ return;
643
+ }
644
+
645
+ // Share with the addresses we can and name the rest. Refusing the whole
646
+ // submission would be defensible on its own, but the dialog is rebuilt on every
647
+ // open and is destroyed when it is accepted, so it would also throw away
648
+ // everything typed — one bad address in a pasted list of twenty would mean
649
+ // reassembling all twenty.
650
+ if (choice.invalid.length > 0) {
651
+ Notification.warning(
652
+ `Not a valid email address: ${choice.invalid.join(', ')}.` +
653
+ (choice.grants.length === 0 ? ' Nobody was added.' : ''),
654
+ { autoClose: 8000 }
655
+ );
656
+ if (choice.grants.length === 0) {
657
+ return;
658
+ }
659
+ }
660
+
661
+ // The spec store takes one grant per request, so share with each person in turn
662
+ // and report the outcomes together — a failure part-way through must not leave
663
+ // the user guessing which of them now have access.
664
+ const failures: { entity: string; error: unknown }[] = [];
665
+ let shared = 0;
666
+ for (const grant of choice.grants) {
667
+ try {
668
+ await store.client.addPermission(id, grant);
669
+ shared++;
670
+ } catch (error) {
671
+ failures.push({ entity: grant.entity, error });
672
+ // The owner check is on the record, not the address: if it refuses one it
673
+ // refuses all of them, so stop rather than firing the rest at a wall.
674
+ if (error instanceof SpecStoreError && error.kind === 'forbidden') {
675
+ break;
676
+ }
677
+ }
678
+ }
679
+
680
+ const isPublic = choice.grants.some(grant => grant.type === 'public');
681
+ const forbidden = failures.some(
682
+ failure =>
683
+ failure.error instanceof SpecStoreError &&
684
+ failure.error.kind === 'forbidden'
685
+ );
686
+
687
+ // Losing the owner check fails every grant for the same reason, so say it once.
688
+ if (forbidden && shared === 0) {
689
+ await reportError(
690
+ 'Share on Oceanum failed',
691
+ shareError('forbidden', null)
692
+ );
693
+ return;
694
+ }
695
+ if (failures.length > 0) {
696
+ // Keep the original error rather than describing it. It carries the only
697
+ // explanation the user gets ("Could not reach Oceanum.io", "HTTP 500"), and
698
+ // reportError reads it to decide whether to offer sign-in — a synthetic Error
699
+ // is never a SpecStoreError, so an expired session would leave no way back
700
+ // in. The public grant has an empty entity, so naming entities alone says
701
+ // nothing at all.
702
+ const first = failures[0].error;
703
+ const report = reportForShareFailures(
704
+ choice.grants.length,
705
+ failures.map(failure => failure.entity),
706
+ first instanceof SpecStoreError && needsSignIn(first)
707
+ );
708
+ if (report.kind === 'original') {
709
+ await reportError('Share on Oceanum failed', first);
710
+ return;
711
+ }
712
+ // Some worked: say which did not, and still give the reason for the first.
713
+ const reason =
714
+ first instanceof Error ? first.message : 'Unknown error.';
715
+ await reportError(
716
+ 'Share on Oceanum failed',
717
+ new Error(
718
+ `Shared with ${shared} of ${choice.grants.length}. Could not share with ${report.named.join(', ')}: ${reason}`
719
+ )
720
+ );
721
+ return;
722
+ }
723
+
724
+ const level =
725
+ choice.grants[0]?.permission === 'write' ? 'can edit' : 'can view';
726
+ Notification.success(
727
+ isPublic
728
+ ? 'Anyone with the link can now view this notebook.'
729
+ : shared === 1
730
+ ? `Shared with ${choice.grants[0].entity} (${level}).`
731
+ : `Shared with ${shared} people (${level}).`,
732
+ { autoClose: 5000 }
733
+ );
734
+ }
735
+
736
+ // --- renaming and deleting ----------------------------------------------------
737
+
738
+ async function renameRecord(id: string): Promise<void> {
739
+ if (
740
+ !store ||
741
+ !(await ensureSignedIn(
742
+ 'Renaming notebooks needs an Oceanum.io account.'
743
+ ))
744
+ ) {
745
+ return;
746
+ }
747
+ const open = panelFor(id);
748
+ let record: ISpecRecord;
749
+ try {
750
+ record = await store.client.get(id);
751
+ } catch (error) {
752
+ await reportError('Rename on Oceanum failed', error);
753
+ return;
754
+ }
755
+ const result = await InputDialog.getText({
756
+ title: 'Rename on Oceanum',
757
+ text: record.name,
758
+ okLabel: 'Rename'
759
+ });
760
+ const name = result.value?.trim() ?? '';
761
+ if (!result.button.accept || name === '' || name === record.name) {
762
+ return;
763
+ }
764
+ try {
765
+ if (open) {
766
+ // The record takes its name from the local file on every save, so the file
767
+ // has to follow or the next save would put the old name back. It moves first:
768
+ // if the record is what fails, the next save renames it anyway and the two
769
+ // converge on what was asked for, where the other order would undo itself.
770
+ const path = open.context.path;
771
+ const dir = path.includes('/')
772
+ ? path.slice(0, path.lastIndexOf('/') + 1)
773
+ : '';
774
+ await docManager.rename(path, `${dir}${sanitizeName(name)}.ipynb`);
775
+ }
776
+ // Only the name goes: the notebook stays as it is, so this cannot overwrite a
777
+ // change another writer made to it while the dialog was open.
778
+ await store.client.rename(id, name);
779
+ } catch (error) {
780
+ await reportError('Rename on Oceanum failed', error);
781
+ return;
782
+ }
783
+ notebooksChanged.emit();
784
+ }
785
+
786
+ async function deleteRecord(id: string): Promise<void> {
787
+ if (
788
+ !store ||
789
+ !(await ensureSignedIn(
790
+ 'Deleting notebooks needs an Oceanum.io account.'
791
+ ))
792
+ ) {
793
+ return;
794
+ }
795
+ const open = panelFor(id);
796
+ let name: string;
797
+ try {
798
+ name = (await store.client.get(id)).name;
799
+ } catch (error) {
800
+ await reportError('Delete from Oceanum failed', error);
801
+ return;
802
+ }
803
+ const result = await showDialog({
804
+ title: 'Delete from Oceanum',
805
+ body: `Delete "${name}" from Oceanum.io? Everyone it is shared with loses it, and this cannot be undone.`,
806
+ buttons: [Dialog.cancelButton(), Dialog.warnButton({ label: 'Delete' })]
807
+ });
808
+ if (!result.button.accept) {
809
+ return;
810
+ }
811
+ try {
812
+ await store.client.remove(id);
813
+ } catch (error) {
814
+ await reportError('Delete from Oceanum failed', error);
815
+ return;
816
+ }
817
+ notebooksChanged.emit();
818
+ if (!open) {
819
+ Notification.success(`Deleted "${name}" from Oceanum.`, {
820
+ autoClose: 5000
821
+ });
822
+ return;
823
+ }
824
+ if (oceanumOnly) {
825
+ // The local file is the hidden working copy of a record that is gone; left
826
+ // there, the next routed save would bring the record straight back.
827
+ try {
828
+ open.context.model.dirty = false;
829
+ await docManager.deleteFile(open.context.path);
830
+ } catch (error) {
831
+ await reportError(
832
+ 'Deleted from Oceanum, but closing the notebook failed',
833
+ error
834
+ );
835
+ }
836
+ Notification.success(`Deleted "${name}" from Oceanum.`, {
837
+ autoClose: 5000
838
+ });
839
+ return;
840
+ }
841
+ // Elsewhere the local file is the user's own: keep it, but forget the record so
842
+ // the next Save to Oceanum creates a new one rather than failing on a missing id.
843
+ open.context.model.deleteMetadata(METADATA_KEY);
844
+ Notification.success(
845
+ `Deleted "${name}" from Oceanum. The local file was kept.`,
846
+ { autoClose: 5000 }
847
+ );
848
+ }
849
+
850
+ // --- shared links -------------------------------------------------------------
851
+
852
+ /**
853
+ * Try the link again once a user signs in, through the dialog or the account
854
+ * button. It cannot loop: a signed-in user who still may not open it gets an error
855
+ * and the link clears.
856
+ */
857
+ function openWhenSignedIn(): void {
858
+ if (auth.user !== null) {
859
+ // Already signed in (signIn() does nothing then), so no userChanged will follow.
860
+ void openFromQuery();
861
+ return;
862
+ }
863
+ const onUserChanged = (_: unknown, user: unknown): void => {
864
+ if (user !== null) {
865
+ auth.userChanged.disconnect(onUserChanged);
866
+ void openFromQuery();
867
+ }
868
+ };
869
+ auth.userChanged.connect(onUserChanged);
870
+ }
871
+
872
+ /** Open `?oceanum-notebook=<id>`, then remove the parameter from the address bar. */
873
+ async function openFromQuery(): Promise<void> {
874
+ const url = new URL(window.location.href);
875
+ const id = url.searchParams.get(QUERY_PARAM);
876
+ if (id === null || !store) {
877
+ return;
878
+ }
879
+ const clearQuery = () => {
880
+ url.searchParams.delete(QUERY_PARAM);
881
+ window.history.replaceState(window.history.state, '', url.toString());
882
+ };
883
+ if (!isSpecId(id)) {
884
+ clearQuery();
885
+ Notification.warning('The Oceanum notebook link is not valid.', {
886
+ autoClose: 5000
887
+ });
888
+ return;
889
+ }
890
+ let record: ISpecRecord;
891
+ try {
892
+ record = await store.client.get(id);
893
+ } catch (error) {
894
+ const kind = error instanceof SpecStoreError ? error.kind : null;
895
+ const reason =
896
+ kind === 'forbidden' && auth.user === null
897
+ ? 'This notebook is not shared publicly.'
898
+ : error instanceof SpecStoreError && needsSignIn(error)
899
+ ? error.message
900
+ : null;
901
+ if (reason !== null) {
902
+ if (!(await offerSignIn(reason))) {
903
+ clearQuery();
904
+ return;
905
+ }
906
+ // Leave the parameter in place while signing in, and open the link once
907
+ // someone has. Sign-in does not reload the page to try again.
908
+ openWhenSignedIn();
909
+ return;
910
+ }
911
+ clearQuery();
912
+ await reportError('Opening the shared notebook failed', error);
913
+ return;
914
+ }
915
+ try {
916
+ await openRecord(store, record);
917
+ } catch (error) {
918
+ await reportError('Opening the shared notebook failed', error);
919
+ }
920
+ clearQuery();
921
+ }
922
+
923
+ // --- commands -----------------------------------------------------------------
924
+
925
+ /** Whether the command can act on the pointer's row, the target notebook, or both. */
926
+ const enabledFor = (
927
+ wants: 'row-or-notebook' | 'row-or-link' | 'notebook',
928
+ args: ReadonlyPartialJSONObject
929
+ ): boolean => {
930
+ if (!signedIn()) {
931
+ return false;
932
+ }
933
+ const source = sourceOf(args);
934
+ if (wants !== 'notebook' && source === 'row') {
935
+ return hitTestSpecId() !== null;
936
+ }
937
+ const panel = targetNotebook(source);
938
+ if (panel === null) {
939
+ return false;
940
+ }
941
+ return wants === 'row-or-link' ? linkOf(panel) !== null : true;
942
+ };
943
+
944
+ /** The id a row-sourced command acts on, or null anywhere else. */
945
+ const rowSpecId = (args: ReadonlyPartialJSONObject): string | null =>
946
+ isSpecId(args.id)
947
+ ? args.id
948
+ : sourceOf(args) === 'row'
949
+ ? hitTestSpecId()
950
+ : null;
951
+
952
+ app.commands.addCommand(CommandIDs.open, {
953
+ // On a Notebooks row the command is plainly "Open"; elsewhere it opens a picker.
954
+ label: args => (rowSpecId(args) ? 'Open' : label('Open from Oceanum…')()),
955
+ caption: 'Open a notebook saved on Oceanum.io',
956
+ isEnabled: () => signedIn(),
957
+ execute: async args => {
958
+ const id = rowSpecId(args);
959
+ await (id ? openById(id) : openFromOceanum());
960
+ }
961
+ });
962
+ app.commands.addCommand(CommandIDs.save, {
963
+ label: label('Save to Oceanum'),
964
+ caption: 'Save the notebook to Oceanum.io',
965
+ isEnabled: args => enabledFor('notebook', args),
966
+ execute: async args => {
967
+ const panel = targetNotebook(sourceOf(args));
968
+ if (panel) {
969
+ await saveToOceanum(panel);
970
+ }
971
+ }
972
+ });
973
+ app.commands.addCommand(CommandIDs.share, {
974
+ label: label('Share on Oceanum…'),
975
+ caption: 'Share the notebook through Oceanum.io',
976
+ isEnabled: args => enabledFor('row-or-notebook', args),
977
+ execute: async args => {
978
+ const id = rowSpecId(args);
979
+ if (id) {
980
+ await shareRecord(id);
981
+ return;
982
+ }
983
+ const panel = targetNotebook(sourceOf(args));
984
+ if (panel) {
985
+ await shareOnOceanum(panel);
986
+ }
987
+ }
988
+ });
989
+ app.commands.addCommand(CommandIDs.rename, {
990
+ label: args =>
991
+ rowSpecId(args) ? 'Rename…' : label('Rename on Oceanum…')(),
992
+ caption: 'Rename the notebook on Oceanum.io',
993
+ isEnabled: args => enabledFor('row-or-link', args),
994
+ execute: async args => {
995
+ const id = targetSpecId(args);
996
+ if (id) {
997
+ await renameRecord(id);
998
+ }
999
+ }
1000
+ });
1001
+ app.commands.addCommand(CommandIDs.delete, {
1002
+ label: args =>
1003
+ rowSpecId(args) ? 'Delete' : label('Delete from Oceanum')(),
1004
+ caption: 'Delete the notebook from Oceanum.io',
1005
+ isEnabled: args => enabledFor('row-or-link', args),
1006
+ execute: async args => {
1007
+ const id = targetSpecId(args);
1008
+ if (id) {
1009
+ await deleteRecord(id);
1010
+ }
1011
+ }
1012
+ });
1013
+
1014
+ const commands = [
1015
+ CommandIDs.open,
1016
+ CommandIDs.save,
1017
+ CommandIDs.share,
1018
+ CommandIDs.rename,
1019
+ CommandIDs.delete
1020
+ ];
1021
+ for (const command of commands) {
1022
+ palette.addItem({ command, category: 'Oceanum' });
1023
+ }
1024
+ // After JupyterLab's own Save group (rank 4), before Reload and Rename (rank 5).
1025
+ mainMenu?.fileMenu.addGroup(
1026
+ commands.map(command => ({ command })),
1027
+ 4.5
1028
+ );
1029
+ // Right-click in a notebook, or on its tab.
1030
+ for (const selector of ['.jp-Notebook', '[data-type="document-title"]']) {
1031
+ app.contextMenu.addItem({ type: 'separator', selector, rank: 45 });
1032
+ for (const [i, command] of [
1033
+ CommandIDs.save,
1034
+ CommandIDs.share,
1035
+ CommandIDs.rename,
1036
+ CommandIDs.delete
1037
+ ].entries()) {
1038
+ app.contextMenu.addItem({
1039
+ command,
1040
+ args: { source: 'notebook' },
1041
+ selector,
1042
+ rank: 46 + i
1043
+ });
1044
+ }
1045
+ }
1046
+ // Right-click on a row of the Notebooks tab.
1047
+ for (const [i, command] of [
1048
+ CommandIDs.open,
1049
+ CommandIDs.rename,
1050
+ CommandIDs.share,
1051
+ CommandIDs.delete
1052
+ ].entries()) {
1053
+ app.contextMenu.addItem({
1054
+ command,
1055
+ args: { source: 'row' },
1056
+ selector: NOTEBOOK_ITEM_SELECTOR,
1057
+ rank: i
1058
+ });
1059
+ }
1060
+
1061
+ const refresh = (): void => {
1062
+ for (const command of commands) {
1063
+ app.commands.notifyCommandChanged(command);
1064
+ }
1065
+ };
1066
+ tracker.currentChanged.connect(refresh);
1067
+ labShell?.currentChanged.connect(refresh);
1068
+ auth.userChanged.connect((_, user) => {
1069
+ if (user !== null) {
1070
+ signInGeneration++;
1071
+ }
1072
+ warnedSignedOut = false;
1073
+ refresh();
1074
+ });
1075
+
1076
+ if (oceanumOnly) {
1077
+ void app.restored.then(() => {
1078
+ hideFileBrowser(app.shell);
1079
+ // IFileMenu is the narrow interface; the menu itself is a Lumino Menu, which
1080
+ // is what can drop items.
1081
+ const fileMenu = mainMenu?.fileMenu;
1082
+ if (fileMenu instanceof Menu) {
1083
+ pruneFileMenu(fileMenu);
1084
+ pruneBeforeEachOpen(fileMenu);
1085
+ }
1086
+ routeSaves(tracker, pushQuietly);
1087
+ });
1088
+ }
1089
+
1090
+ Promise.all([app.restored, auth.ready])
1091
+ .then(() => openFromQuery())
1092
+ .catch(error =>
1093
+ console.error('Failed to open the Oceanum notebook link', error)
1094
+ );
1095
+ }
1096
+ };