@oceanum/oceanumlab 4.7.1 → 4.8.0

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 (51) hide show
  1. package/lib/DatameshWidget.d.ts +11 -0
  2. package/lib/DatameshWidget.js +35 -5
  3. package/lib/StoredNotebooks.d.ts +27 -5
  4. package/lib/StoredNotebooks.js +141 -24
  5. package/lib/auth/serverAuth.js +1 -0
  6. package/lib/auth/startSignIn.d.ts +11 -0
  7. package/lib/auth/startSignIn.js +16 -0
  8. package/lib/auth/tokens.d.ts +13 -0
  9. package/lib/index.d.ts +1 -1
  10. package/lib/index.js +43 -7
  11. package/lib/share/client.d.ts +86 -0
  12. package/lib/share/client.js +206 -0
  13. package/lib/share/events.d.ts +7 -0
  14. package/lib/share/events.js +9 -0
  15. package/lib/share/notebook.d.ts +216 -0
  16. package/lib/share/notebook.js +355 -0
  17. package/lib/share/oceanumOnly.d.ts +72 -0
  18. package/lib/share/oceanumOnly.js +127 -0
  19. package/lib/share/plugin.d.ts +23 -0
  20. package/lib/share/plugin.js +981 -0
  21. package/lib/share/upload.d.ts +6 -0
  22. package/lib/share/upload.js +27 -0
  23. package/lib/share/widgets.d.ts +25 -0
  24. package/lib/share/widgets.js +154 -0
  25. package/package.json +5 -1
  26. package/schema/datamesh-connect.json +6 -0
  27. package/src/DatameshWidget.tsx +79 -2
  28. package/src/StoredNotebooks.tsx +274 -24
  29. package/src/__tests__/OceanumPanel.spec.tsx +159 -5
  30. package/src/__tests__/StoredNotebooks.spec.tsx +528 -0
  31. package/src/__tests__/oceanumOnly.spec.ts +317 -0
  32. package/src/__tests__/settingsSchema.spec.ts +11 -1
  33. package/src/__tests__/shareClient.spec.ts +328 -0
  34. package/src/__tests__/shareNotebook.spec.ts +624 -0
  35. package/src/__tests__/sharePlugin.spec.ts +664 -0
  36. package/src/auth/serverAuth.ts +1 -0
  37. package/src/auth/startSignIn.ts +23 -0
  38. package/src/auth/tokens.ts +13 -0
  39. package/src/index.ts +43 -7
  40. package/src/share/client.ts +291 -0
  41. package/src/share/events.ts +10 -0
  42. package/src/share/notebook.ts +525 -0
  43. package/src/share/oceanumOnly.ts +177 -0
  44. package/src/share/plugin.ts +1220 -0
  45. package/src/share/upload.ts +27 -0
  46. package/src/share/widgets.ts +217 -0
  47. package/style/index.css +212 -18
  48. package/lib/specStore.d.ts +0 -37
  49. package/lib/specStore.js +0 -57
  50. package/src/__tests__/specStore.spec.ts +0 -126
  51. package/src/specStore.ts +0 -82
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Ask the user for a notebook file on their computer; null if they cancel.
3
+ *
4
+ * Kept apart from the plugin because it is browser-only: the tests replace it.
5
+ */
6
+ export declare function chooseNotebookFile(): Promise<File | null>;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Ask the user for a notebook file on their computer; null if they cancel.
3
+ *
4
+ * Kept apart from the plugin because it is browser-only: the tests replace it.
5
+ */
6
+ export function chooseNotebookFile() {
7
+ return new Promise(resolve => {
8
+ const input = document.createElement('input');
9
+ input.type = 'file';
10
+ input.accept = '.ipynb,application/x-ipynb+json';
11
+ input.hidden = true;
12
+ // Attached while the picker is open: some browsers (Safari) drop the `change`
13
+ // event of a detached input, or collect the input before it fires.
14
+ document.body.appendChild(input);
15
+ const settle = (file) => {
16
+ input.remove();
17
+ resolve(file);
18
+ };
19
+ input.addEventListener('change', () => { var _a, _b; return settle((_b = (_a = input.files) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : null); }, {
20
+ once: true
21
+ });
22
+ // Browsers fire `cancel` when the picker is dismissed; without it the promise
23
+ // would simply never settle, which is harmless.
24
+ input.addEventListener('cancel', () => settle(null), { once: true });
25
+ input.click();
26
+ });
27
+ }
@@ -0,0 +1,25 @@
1
+ import { Dialog } from '@jupyterlab/apputils';
2
+ import { Widget } from '@lumino/widgets';
3
+ import type { ShareChoice } from './client';
4
+ import { ISpecSummary } from './notebook';
5
+ /** Dialog body listing notebooks to open; its value is the selected record id. */
6
+ export declare class NotebookListBody extends Widget implements Dialog.IBodyWidget<string | null> {
7
+ constructor(mine: readonly ISpecSummary[], shared: readonly ISpecSummary[]);
8
+ getValue(): string | null;
9
+ private _addSection;
10
+ private _select;
11
+ private _selected;
12
+ }
13
+ export declare const EVERYONE_WARNING: string;
14
+ /** Dialog body for sharing; its value is the change to make, or `null` if incomplete. */
15
+ export declare class ShareBody extends Widget implements Dialog.IBodyWidget<ShareChoice | null> {
16
+ constructor(link: string);
17
+ getValue(): ShareChoice | null;
18
+ private _radio;
19
+ private static _count;
20
+ private readonly _person;
21
+ private readonly _everyone;
22
+ private readonly _stopEveryone;
23
+ private readonly _email;
24
+ private readonly _level;
25
+ }
@@ -0,0 +1,154 @@
1
+ import { Clipboard } from '@jupyterlab/apputils';
2
+ import { Widget } from '@lumino/widgets';
3
+ import { parseShareEmails, parseTimestamp } from './notebook';
4
+ function element(tag, className, text) {
5
+ const node = document.createElement(tag);
6
+ node.className = className;
7
+ if (text !== undefined) {
8
+ // Names and emails come from other users: always set text, never HTML.
9
+ node.textContent = text;
10
+ }
11
+ return node;
12
+ }
13
+ /** Dialog body listing notebooks to open; its value is the selected record id. */
14
+ export class NotebookListBody extends Widget {
15
+ constructor(mine, shared) {
16
+ super();
17
+ this._selected = null;
18
+ this.addClass('jp-OceanumShare-body');
19
+ this._addSection('My notebooks', mine, false);
20
+ this._addSection('Shared with me', shared, true);
21
+ }
22
+ getValue() {
23
+ return this._selected;
24
+ }
25
+ _addSection(title, items, showCreator) {
26
+ this.node.appendChild(element('h3', 'jp-OceanumShare-heading', title));
27
+ if (items.length === 0) {
28
+ this.node.appendChild(element('p', 'jp-OceanumShare-empty', 'None'));
29
+ return;
30
+ }
31
+ const list = element('ul', 'jp-OceanumShare-list');
32
+ list.setAttribute('role', 'listbox');
33
+ list.setAttribute('aria-label', title);
34
+ for (const item of items) {
35
+ const option = element('li', 'jp-OceanumShare-item');
36
+ option.setAttribute('role', 'option');
37
+ option.setAttribute('aria-selected', 'false');
38
+ option.tabIndex = 0;
39
+ option.appendChild(element('span', 'jp-OceanumShare-name', item.name));
40
+ const modified = parseTimestamp(item.modified);
41
+ const details = [
42
+ modified ? `Modified ${modified.toLocaleDateString()}` : '',
43
+ showCreator && item.creator ? item.creator : ''
44
+ ].filter(Boolean);
45
+ option.appendChild(element('span', 'jp-OceanumShare-meta', details.join(' · ')));
46
+ const select = () => this._select(item.id, option);
47
+ option.addEventListener('click', select);
48
+ option.addEventListener('focus', select);
49
+ list.appendChild(option);
50
+ }
51
+ this.node.appendChild(list);
52
+ }
53
+ _select(id, option) {
54
+ this._selected = id;
55
+ for (const node of this.node.querySelectorAll('.jp-OceanumShare-item')) {
56
+ const selected = node === option;
57
+ node.classList.toggle('jp-mod-selected', selected);
58
+ node.setAttribute('aria-selected', String(selected));
59
+ }
60
+ }
61
+ }
62
+ export const EVERYONE_WARNING = 'Anyone with the link can read this notebook, including people without an Oceanum ' +
63
+ 'account. This cannot be limited to your organisation. Its name and your email ' +
64
+ 'address also become visible to anyone who lists Oceanum notebooks.';
65
+ const PUBLIC_GRANT = {
66
+ type: 'public',
67
+ entity: '',
68
+ permission: 'read'
69
+ };
70
+ /** Dialog body for sharing; its value is the change to make, or `null` if incomplete. */
71
+ export class ShareBody extends Widget {
72
+ constructor(link) {
73
+ super();
74
+ this.addClass('jp-OceanumShare-body');
75
+ this.node.appendChild(element('h3', 'jp-OceanumShare-heading', 'Link'));
76
+ const row = element('div', 'jp-OceanumShare-linkRow');
77
+ const linkInput = element('input', 'jp-OceanumShare-link');
78
+ linkInput.readOnly = true;
79
+ linkInput.value = link;
80
+ linkInput.setAttribute('aria-label', 'Notebook link');
81
+ const copy = element('button', 'jp-mod-styled jp-OceanumShare-copy', 'Copy link');
82
+ copy.type = 'button';
83
+ copy.addEventListener('click', () => {
84
+ Clipboard.copyToSystem(link);
85
+ copy.textContent = 'Copied';
86
+ });
87
+ row.append(linkInput, copy);
88
+ this.node.appendChild(row);
89
+ this.node.appendChild(element('p', 'jp-OceanumShare-hint', 'The link opens the notebook for anyone it is shared with.'));
90
+ this.node.appendChild(element('h3', 'jp-OceanumShare-heading', 'Share with'));
91
+ const name = `jp-OceanumShare-who-${++ShareBody._count}`;
92
+ this._person = this._radio(name, 'A specific person', true);
93
+ const personRow = element('div', 'jp-OceanumShare-personRow');
94
+ this._email = element('textarea', 'jp-OceanumShare-email');
95
+ this._email.rows = 2;
96
+ this._email.placeholder = 'name@example.com, another@example.com';
97
+ this._email.setAttribute('aria-label', 'Email addresses');
98
+ this._level = element('select', 'jp-OceanumShare-level');
99
+ this._level.setAttribute('aria-label', 'Access');
100
+ for (const [value, label] of [
101
+ ['read', 'can view'],
102
+ ['write', 'can edit']
103
+ ]) {
104
+ const option = element('option', '', label);
105
+ option.value = value;
106
+ this._level.appendChild(option);
107
+ }
108
+ personRow.append(this._email, this._level);
109
+ this.node.appendChild(personRow);
110
+ this.node.appendChild(element('p', 'jp-OceanumShare-hint',
111
+ // Not "new lines": JupyterLab's dialog swallows Enter before it reaches the
112
+ // textarea, so one cannot be typed here. Pasted ones still separate addresses.
113
+ 'Separate several addresses with commas or spaces. Everyone you add gets the access chosen here.'));
114
+ this._everyone = this._radio(name, 'Everyone with the link', false);
115
+ this.node.appendChild(element('p', 'jp-OceanumShare-warning', EVERYONE_WARNING));
116
+ this._stopEveryone = this._radio(name, 'Stop sharing with everyone', false);
117
+ const update = () => {
118
+ this._email.disabled = !this._person.checked;
119
+ this._level.disabled = !this._person.checked;
120
+ };
121
+ for (const radio of [this._person, this._everyone, this._stopEveryone]) {
122
+ radio.addEventListener('change', update);
123
+ }
124
+ }
125
+ getValue() {
126
+ if (this._everyone.checked) {
127
+ return { action: 'grant', grants: [PUBLIC_GRANT], invalid: [] };
128
+ }
129
+ if (this._stopEveryone.checked) {
130
+ return { action: 'revoke', grant: PUBLIC_GRANT };
131
+ }
132
+ const { emails, invalid } = parseShareEmails(this._email.value);
133
+ if (emails.length === 0 && invalid.length === 0) {
134
+ return null;
135
+ }
136
+ const permission = this._level.value === 'write' ? 'write' : 'read';
137
+ return {
138
+ action: 'grant',
139
+ grants: emails.map(entity => ({ type: 'user', entity, permission })),
140
+ invalid
141
+ };
142
+ }
143
+ _radio(name, text, checked) {
144
+ const label = element('label', 'jp-OceanumShare-choice');
145
+ const input = element('input', 'jp-OceanumShare-radio');
146
+ input.type = 'radio';
147
+ input.name = name;
148
+ input.checked = checked;
149
+ label.append(input, document.createTextNode(` ${text}`));
150
+ this.node.appendChild(label);
151
+ return input;
152
+ }
153
+ }
154
+ ShareBody._count = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oceanum/oceanumlab",
3
- "version": "4.7.1",
3
+ "version": "4.8.0",
4
4
  "description": "A Jupyterlab extension to interact with the Oceanum.io platform",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -62,12 +62,16 @@
62
62
  "@jupyterlab/cells": "^4.6.3",
63
63
  "@jupyterlab/console": "^4.6.3",
64
64
  "@jupyterlab/coreutils": "^6.6.3",
65
+ "@jupyterlab/docmanager": "^4.6.3",
66
+ "@jupyterlab/mainmenu": "^4.6.3",
67
+ "@jupyterlab/nbformat": "^4.6.3",
65
68
  "@jupyterlab/notebook": "^4.6.3",
66
69
  "@jupyterlab/services": "^7.6.3",
67
70
  "@jupyterlab/settingregistry": "^4.6.3",
68
71
  "@lumino/commands": "^2.3.3",
69
72
  "@lumino/coreutils": "^2.2.2",
70
73
  "@lumino/disposable": "^2.1.5",
74
+ "@lumino/messaging": "^2.0.3",
71
75
  "@lumino/signaling": "^2.1.5",
72
76
  "@lumino/widgets": "^2.8.0",
73
77
  "@phosphor-icons/react": "^2.1.10",
@@ -29,6 +29,12 @@
29
29
  "title": "Iterate",
30
30
  "description": "After running, send each cell's output back to the AI so it can fix an error or take the next step. Only takes effect with Auto-run code on.",
31
31
  "default": false
32
+ },
33
+ "showExamples": {
34
+ "type": "boolean",
35
+ "title": "Show examples",
36
+ "description": "List Oceanum's example notebooks in the Notebooks tab, where the site provides them. Also switched from the Examples heading.",
37
+ "default": true
32
38
  }
33
39
  },
34
40
  "patternProperties": {
@@ -22,6 +22,7 @@ import { marked } from 'marked';
22
22
 
23
23
  import { DatasourceItem } from './DatasourceItem';
24
24
  import { IOceanumAuth } from './auth/tokens';
25
+ import { startSignIn } from './auth/startSignIn';
25
26
  import { StoredNotebooks } from './StoredNotebooks';
26
27
  import { ITab, Tabs } from './Tabs';
27
28
  import {
@@ -29,6 +30,7 @@ import {
29
30
  aiBackendUrl,
30
31
  aiCredentialSource,
31
32
  canSignIn,
33
+ isSignedIn,
32
34
  pastedToken,
33
35
  resolveAiCredential,
34
36
  signInToken,
@@ -496,6 +498,8 @@ interface IAiAccess {
496
498
  pasted: string;
497
499
  /** Whether the host can sign the user in to Oceanum.io on this site. */
498
500
  canSignIn: boolean;
501
+ /** Whether the host says the user is signed in to Oceanum.io. */
502
+ signedIn: boolean;
499
503
  /** The backend address: the deployment's Oceanum AI. */
500
504
  backend: string;
501
505
  }
@@ -509,6 +513,7 @@ function readAiAccess(
509
513
  source: aiCredentialSource(datameshToken, commands),
510
514
  pasted: pastedToken(datameshToken),
511
515
  canSignIn: canSignIn(commands),
516
+ signedIn: isSignedIn(commands),
512
517
  backend: aiBackendUrl(settings.aiBackendUrl())
513
518
  };
514
519
  }
@@ -550,6 +555,34 @@ function useAiAccess(
550
555
  return access;
551
556
  }
552
557
 
558
+ /**
559
+ * The `showExamples` setting, read again whenever the settings change. True where the
560
+ * host passes no such setting; null while the host's setting has not loaded.
561
+ */
562
+ function useShowExamples(
563
+ settingsChanged: ISignal<unknown, void>,
564
+ read: (() => boolean | null) | undefined
565
+ ): boolean | null {
566
+ const [show, setShow] = React.useState<boolean | null>(() =>
567
+ read ? read() : true
568
+ );
569
+
570
+ React.useEffect(() => {
571
+ if (!read) {
572
+ return;
573
+ }
574
+ const update = () => setShow(read());
575
+ // Anything that changed between the first render and now.
576
+ update();
577
+ settingsChanged.connect(update);
578
+ return () => {
579
+ settingsChanged.disconnect(update);
580
+ };
581
+ }, [settingsChanged, read]);
582
+
583
+ return show;
584
+ }
585
+
553
586
  /**
554
587
  * Shows a message prompting the user to sign in to Oceanum.io, or to configure
555
588
  * their Datamesh token where the site has no sign-in. Only renders when there
@@ -961,6 +994,17 @@ export interface IDatameshWidgetProps {
961
994
  auth?: IOceanumAuth | null;
962
995
  /** Open a stored notebook by spec store id. */
963
996
  openStoredNotebook?: (id: string) => void;
997
+ /** Open a copy of an example by spec store id, named from its title. */
998
+ openExample?: (id: string, title: string) => void;
999
+ /** Upload a notebook from the user's computer. */
1000
+ uploadNotebook?: () => void;
1001
+ /**
1002
+ * The `showExamples` setting: whether the Notebooks tab lists the examples; null
1003
+ * until the settings have loaded.
1004
+ */
1005
+ showExamples?: () => boolean | null;
1006
+ /** Save the `showExamples` setting, from the switch on the Examples heading. */
1007
+ setShowExamples?: (show: boolean) => void;
964
1008
  }
965
1009
 
966
1010
  /**
@@ -977,6 +1021,11 @@ function PanelTabs({
977
1021
  commands,
978
1022
  settings,
979
1023
  renderDatamesh,
1024
+ openStoredNotebook,
1025
+ openExample,
1026
+ uploadNotebook,
1027
+ showExamples,
1028
+ setShowExamples,
980
1029
  selected,
981
1030
  onSelect
982
1031
  }: {
@@ -984,10 +1033,16 @@ function PanelTabs({
984
1033
  commands: CommandRegistry;
985
1034
  settings: IAiSettings;
986
1035
  renderDatamesh: () => React.ReactElement;
1036
+ openStoredNotebook?: (id: string) => void;
1037
+ openExample?: (id: string, title: string) => void;
1038
+ uploadNotebook?: () => void;
1039
+ showExamples?: () => boolean | null;
1040
+ setShowExamples?: (show: boolean) => void;
987
1041
  selected: string;
988
1042
  onSelect: (id: string) => void;
989
1043
  }): React.ReactElement {
990
1044
  const access = useAiAccess(commands, settings);
1045
+ const examplesShown = useShowExamples(settings.settingsChanged, showExamples);
991
1046
  const tabs: ITab[] = [
992
1047
  {
993
1048
  id: 'notebooks',
@@ -996,7 +1051,21 @@ function PanelTabs({
996
1051
  // An auth with no environment is a provider that has nothing to sign in to (the
997
1052
  // server has sign-in turned off). Asking the user to sign in would be a dead end.
998
1053
  auth?.environment ? (
999
- <StoredNotebooks auth={auth} />
1054
+ <StoredNotebooks
1055
+ auth={auth}
1056
+ onOpen={openStoredNotebook && (item => openStoredNotebook(item.id))}
1057
+ onOpenExample={
1058
+ openExample && (item => openExample(item.id, item.title))
1059
+ }
1060
+ onUpload={uploadNotebook}
1061
+ showExamples={examplesShown}
1062
+ onShowExamplesChange={setShowExamples}
1063
+ onSignIn={() => {
1064
+ startSignIn(commands, auth).catch(error => {
1065
+ console.warn('Oceanum.io sign-in could not start.', error);
1066
+ });
1067
+ }}
1068
+ />
1000
1069
  ) : (
1001
1070
  <div className="oceanum-text-empty">
1002
1071
  Oceanum.io sign-in is not configured for this host.
@@ -1005,7 +1074,10 @@ function PanelTabs({
1005
1074
  },
1006
1075
  { id: 'datamesh', label: 'Datamesh', render: renderDatamesh }
1007
1076
  ];
1008
- if (access.source) {
1077
+ // Signed in, not merely holding a credential: a Datamesh token pasted into the
1078
+ // settings would reach the backend, but the tab stays out of the way until someone
1079
+ // has signed in to Oceanum.io.
1080
+ if (access.signedIn) {
1009
1081
  tabs.push({
1010
1082
  id: 'ai',
1011
1083
  label: 'Oceanum AI',
@@ -1128,6 +1200,11 @@ export class DatameshConnectWidget extends ReactWidget {
1128
1200
  commands={this.props.commands}
1129
1201
  settings={this.props}
1130
1202
  renderDatamesh={() => this.renderDatamesh()}
1203
+ openStoredNotebook={this.props.openStoredNotebook}
1204
+ openExample={this.props.openExample}
1205
+ uploadNotebook={this.props.uploadNotebook}
1206
+ showExamples={this.props.showExamples}
1207
+ setShowExamples={this.props.setShowExamples}
1131
1208
  selected={this.selectedTab}
1132
1209
  onSelect={id => this.selectTab(id)}
1133
1210
  />