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