@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
@@ -25,6 +25,19 @@ export interface IOceanumEnvironment {
25
25
  * the site origin is an allowed callback URL.
26
26
  */
27
27
  readonly signInRedirect: boolean;
28
+ /**
29
+ * Whether Oceanum.io is the only file management this host shows, as on
30
+ * notebook.oceanum.io: the file browser and the File menu's local-file entries go,
31
+ * and every local save is pushed to the spec store. `'local'` (the default when
32
+ * absent) keeps JupyterLab's file management alongside the Oceanum commands.
33
+ */
34
+ readonly fileManagement?: 'local' | 'oceanum';
35
+ /**
36
+ * The spec store id of this deployment's Notebook Demo record, the curated list the
37
+ * Notebooks tab shows as Examples. Absent where a deployment has none, and never set
38
+ * by a JupyterLab server's sign-in.
39
+ */
40
+ readonly notebookDemo?: string;
28
41
  }
29
42
 
30
43
  /** Service base URLs for an environment, without trailing slashes. */
package/src/index.ts CHANGED
@@ -20,6 +20,8 @@ import { reporterFor } from './progress';
20
20
  import { ConversationPin } from './conversationPin';
21
21
  import { deviceAuthPlugin } from './auth/plugin';
22
22
  import { IOceanumAuth } from './auth/tokens';
23
+ import { CommandIDs as ShareCommandIDs, sharePlugin } from './share/plugin';
24
+ import { OCEANUM_PANEL_ID } from './share/oceanumOnly';
23
25
 
24
26
  import { snapshotFromIpynb, snapshotOf } from './notebookContext';
25
27
  import { notebookHost } from './notebookHost';
@@ -78,6 +80,9 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
78
80
  const aiBackendUrl = auth?.urls?.ai ?? OCEANUM_AI_BACKEND_URL;
79
81
  // The `datameshToken` setting for the AI chat; empty until the settings load.
80
82
  let datameshTokenSetting = '';
83
+ // The `showExamples` setting for the Notebooks tab; null until the settings load, so
84
+ // examples a user has hidden do not show for a moment on every reload.
85
+ let showExamplesSetting: boolean | null = null;
81
86
  // Tells the sidebar the settings above have changed.
82
87
  const settingsChanged = new Signal<JupyterFrontEnd, void>(app);
83
88
 
@@ -99,6 +104,7 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
99
104
  }
100
105
  window.injectToken = set.get('injectToken').user as boolean;
101
106
  datameshTokenSetting = set.get('datameshToken').composite as string;
107
+ showExamplesSetting = set.get('showExamples').composite as boolean;
102
108
  settingsChanged.emit();
103
109
  };
104
110
  //Try to get the datamesh token from the envars
@@ -115,6 +121,10 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
115
121
  console.error(
116
122
  `Something went wrong when reading the Oceanumlab settings.\n${reason}`
117
123
  );
124
+ // Unreadable settings fall back to the default rather than leaving the examples
125
+ // waiting for a value that will never come.
126
+ showExamplesSetting = true;
127
+ settingsChanged.emit();
118
128
  });
119
129
 
120
130
  const getCurrentWidget = (): Widget => {
@@ -149,14 +159,39 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
149
159
  settingsChanged,
150
160
  commands: app.commands,
151
161
  getCurrentWidget,
152
- auth
153
- // No `openStoredNotebook`: nothing can open a spec store record by id yet.
154
- // share-oceanum has `oceanum-share:open`, but it takes no arguments — it opens
155
- // its own picker — so there is nothing to hand an id to. Opening arrives with
156
- // the drive in OCE-182, where the contents API makes it the ordinary path.
157
- // Until then the list reads rather than offering a click that does nothing.
162
+ auth,
163
+ openStoredNotebook: id => {
164
+ app.commands.execute(ShareCommandIDs.open, { id }).catch(error => {
165
+ console.error('Oceanum: could not open the stored notebook.', error);
166
+ });
167
+ },
168
+ openExample: (id, title) => {
169
+ app.commands
170
+ .execute(ShareCommandIDs.openExample, { id, title })
171
+ .catch(error => {
172
+ console.error('Oceanum: could not open the example.', error);
173
+ });
174
+ },
175
+ uploadNotebook: () => {
176
+ app.commands.execute(ShareCommandIDs.upload).catch(error => {
177
+ console.error('Oceanum: could not upload the notebook.', error);
178
+ });
179
+ },
180
+ showExamples: () => showExamplesSetting,
181
+ // Saved as a user setting, so the choice outlasts the page; the settings'
182
+ // `changed` signal brings the new value back to the panel. The registry waits
183
+ // for the plugin's settings to load, so a click before then is not lost.
184
+ setShowExamples: show => {
185
+ settingRegistry.set(PLUGIN_ID, 'showExamples', show).catch(error => {
186
+ // The switch moves only once the setting is saved, so say why it did not.
187
+ console.error('Oceanum: could not save the Examples setting.', error);
188
+ Notification.error('Could not save whether to show the examples.', {
189
+ autoClose: 5000
190
+ });
191
+ });
192
+ }
158
193
  });
159
- datameshConnectWidget.id = 'datamesh-connect';
194
+ datameshConnectWidget.id = OCEANUM_PANEL_ID;
160
195
  datameshConnectWidget.title.icon = oceanumIcon;
161
196
  datameshConnectWidget.title.caption = 'Datamesh Connect';
162
197
 
@@ -399,6 +434,7 @@ export * from './auth/tokens';
399
434
 
400
435
  export default [
401
436
  deviceAuthPlugin,
437
+ sharePlugin,
402
438
  datamesh_connect_extension,
403
439
  oceanum_ai_extension
404
440
  ];
@@ -0,0 +1,291 @@
1
+ import {
2
+ isSpecId,
3
+ ISpecBody,
4
+ ISpecPatch,
5
+ ISpecRecord,
6
+ ISpecSummary
7
+ } from './notebook';
8
+
9
+ export type ErrorKind =
10
+ | 'signed-out'
11
+ | 'expired'
12
+ | 'forbidden'
13
+ | 'not-found'
14
+ | 'too-large'
15
+ | 'network'
16
+ | 'invalid'
17
+ | 'unsupported'
18
+ | 'server';
19
+
20
+ const MESSAGES: Record<Exclude<ErrorKind, 'server'>, string> = {
21
+ 'signed-out': 'Sign in to Oceanum.io to continue.',
22
+ expired: 'Your Oceanum session has expired, sign in again.',
23
+ forbidden:
24
+ 'You have no access to this notebook. It may not exist, or was created under another organisation.',
25
+ 'not-found': 'This notebook no longer exists on Oceanum.io.',
26
+ 'too-large': 'The notebook is too large for Oceanum.io (32 MiB limit).',
27
+ network:
28
+ 'Could not reach Oceanum.io. Check your connection; very large notebooks can also fail this way.',
29
+ invalid: 'Oceanum.io returned an unexpected response.',
30
+ unsupported: 'This Oceanum.io service does not offer that operation yet.'
31
+ };
32
+
33
+ /** A spec store failure with a message that is safe to show to the user. */
34
+ export class SpecStoreError extends Error {
35
+ constructor(
36
+ readonly kind: ErrorKind,
37
+ readonly status: number | null = null
38
+ ) {
39
+ super(
40
+ kind === 'server'
41
+ ? `Oceanum.io returned an error (HTTP ${status}).`
42
+ : MESSAGES[kind]
43
+ );
44
+ this.name = 'SpecStoreError';
45
+ }
46
+ }
47
+
48
+ /** Map a failed HTTP status to an error. */
49
+ export function errorForStatus(status: number): SpecStoreError {
50
+ switch (status) {
51
+ // The spec store's authentication middleware answers a bad or expired token with 400.
52
+ case 400:
53
+ return new SpecStoreError('expired', status);
54
+ // Casbin answers 403 both for no access and for a record that does not exist.
55
+ case 403:
56
+ return new SpecStoreError('forbidden', status);
57
+ case 404:
58
+ return new SpecStoreError('not-found', status);
59
+ case 405:
60
+ // The route is there but not this method: a store from before it had PATCH.
61
+ return new SpecStoreError('unsupported', status);
62
+ case 413:
63
+ return new SpecStoreError('too-large', status);
64
+ default:
65
+ return new SpecStoreError('server', status);
66
+ }
67
+ }
68
+
69
+ export type SharePermission = 'read' | 'write';
70
+
71
+ /** A permission grant, as accepted by `POST /specs/notebook/{id}/permissions`. */
72
+ export type PermissionGrant =
73
+ | { type: 'user'; entity: string; permission: SharePermission }
74
+ | { type: 'public'; entity: ''; permission: 'read' };
75
+
76
+ /**
77
+ * What the share dialog asks for: add grants, or remove one (e.g. stop public access).
78
+ *
79
+ * Granting carries a list because the dialog shares with several people at once, while
80
+ * the spec store takes one grant per request. `invalid` carries back anything typed that
81
+ * is not a shareable address, so the caller can name it rather than failing with
82
+ * "invalid email".
83
+ */
84
+ export type ShareChoice =
85
+ | { action: 'grant'; grants: PermissionGrant[]; invalid: string[] }
86
+ | { action: 'revoke'; grant: PermissionGrant };
87
+
88
+ export interface ISpecStoreClientOptions {
89
+ /** Spec store base URL, e.g. `https://specs.oceanum.io`. */
90
+ specsUrl: string;
91
+ /** Returns a current access token or `null`. Called for every request. */
92
+ getAccessToken: () => Promise<string | null>;
93
+ /** The spec type the client reads and writes; `notebook` unless given. */
94
+ specType?: string;
95
+ fetch?: typeof fetch;
96
+ }
97
+
98
+ function isSummary(value: unknown): value is ISpecSummary {
99
+ if (typeof value !== 'object' || value === null) {
100
+ return false;
101
+ }
102
+ const v = value as Record<string, unknown>;
103
+ return (
104
+ isSpecId(v.id) &&
105
+ typeof v.name === 'string' &&
106
+ typeof v.modified === 'string'
107
+ );
108
+ }
109
+
110
+ function isRecord(value: unknown): value is ISpecRecord {
111
+ return isSummary(value) && 'spec' in value;
112
+ }
113
+
114
+ /** Client for one spec type of the Oceanum spec store, `notebook` by default. */
115
+ export class SpecStoreClient {
116
+ constructor(options: ISpecStoreClientOptions) {
117
+ const specType = options.specType ?? 'notebook';
118
+ this._base = `${options.specsUrl.replace(/\/+$/, '')}/specs/${specType}`;
119
+ this._getAccessToken = options.getAccessToken;
120
+ this._fetch = options.fetch ?? ((input, init) => fetch(input, init));
121
+ }
122
+
123
+ /** Every notebook the user may read, including public ones. */
124
+ async list(): Promise<ISpecSummary[]> {
125
+ const data = await this._request('GET', this._base, { auth: 'required' });
126
+ if (!Array.isArray(data) || !data.every(isSummary)) {
127
+ throw new SpecStoreError('invalid');
128
+ }
129
+ return data;
130
+ }
131
+
132
+ /** Read a record, anonymously when signed out. */
133
+ async get(id: string): Promise<ISpecRecord> {
134
+ return this._record(
135
+ await this._request('GET', this._url(id), { auth: 'optional' })
136
+ );
137
+ }
138
+
139
+ /** Create a record. The server mints the id: use the returned record's. */
140
+ async create(body: ISpecBody): Promise<ISpecRecord> {
141
+ return this._record(
142
+ await this._request('POST', this._base, {
143
+ auth: 'required',
144
+ body: pick(body)
145
+ })
146
+ );
147
+ }
148
+
149
+ /** Replace a record's name, description and notebook. */
150
+ async update(id: string, body: ISpecBody): Promise<ISpecRecord> {
151
+ return this._record(
152
+ await this._request('PUT', this._url(id), {
153
+ auth: 'required',
154
+ body: pick(body)
155
+ })
156
+ );
157
+ }
158
+
159
+ /**
160
+ * Change some of a record's fields and leave the rest as they are. Needs the same
161
+ * write access as `update`, which the store enforces a patch as.
162
+ */
163
+ async patch(id: string, changes: ISpecPatch): Promise<ISpecRecord> {
164
+ if (Object.keys(changes).length === 0) {
165
+ // The store answers an empty patch with 400; there is nothing to send anyway.
166
+ throw new SpecStoreError('invalid');
167
+ }
168
+ return this._record(
169
+ await this._request('PATCH', this._url(id), {
170
+ auth: 'required',
171
+ body: changes
172
+ })
173
+ );
174
+ }
175
+
176
+ /**
177
+ * Give a record a new name, without sending the notebook back with it.
178
+ *
179
+ * Falls back to reading the record and putting it back where the store has no PATCH
180
+ * (production had none when this was written). That fallback races another writer's
181
+ * change to the notebook, which is the whole reason to prefer a patch.
182
+ */
183
+ async rename(id: string, name: string): Promise<ISpecRecord> {
184
+ try {
185
+ return await this.patch(id, { name });
186
+ } catch (error) {
187
+ if (!(error instanceof SpecStoreError) || error.kind !== 'unsupported') {
188
+ throw error;
189
+ }
190
+ const record = await this.get(id);
191
+ return this.update(id, {
192
+ name,
193
+ description: record.description,
194
+ spec: record.spec as ISpecBody['spec']
195
+ });
196
+ }
197
+ }
198
+
199
+ /** Delete a record (requires admin access to it). */
200
+ async remove(id: string): Promise<void> {
201
+ await this._request('DELETE', this._url(id), { auth: 'required' });
202
+ }
203
+
204
+ /** Grant a permission on a record (requires admin access to it). */
205
+ async addPermission(id: string, grant: PermissionGrant): Promise<void> {
206
+ await this._request('POST', `${this._url(id)}/permissions`, {
207
+ auth: 'required',
208
+ body: {
209
+ type: grant.type,
210
+ entity: grant.entity,
211
+ permission: grant.permission
212
+ }
213
+ });
214
+ }
215
+
216
+ /**
217
+ * Remove a permission (requires admin access). The server matches the exact grant and
218
+ * answers 404 when there is no such permission.
219
+ */
220
+ async removePermission(id: string, grant: PermissionGrant): Promise<void> {
221
+ await this._request('DELETE', `${this._url(id)}/permissions`, {
222
+ auth: 'required',
223
+ body: {
224
+ type: grant.type,
225
+ entity: grant.entity,
226
+ permission: grant.permission
227
+ }
228
+ });
229
+ }
230
+
231
+ private _url(id: string): string {
232
+ if (!isSpecId(id)) {
233
+ throw new SpecStoreError('invalid');
234
+ }
235
+ return `${this._base}/${id}`;
236
+ }
237
+
238
+ private _record(data: unknown): ISpecRecord {
239
+ if (!isRecord(data)) {
240
+ throw new SpecStoreError('invalid');
241
+ }
242
+ return data;
243
+ }
244
+
245
+ private async _request(
246
+ method: string,
247
+ url: string,
248
+ options: { auth: 'required' | 'optional'; body?: object }
249
+ ): Promise<unknown> {
250
+ const token = await this._getAccessToken();
251
+ if (token === null && options.auth === 'required') {
252
+ throw new SpecStoreError('signed-out');
253
+ }
254
+ const headers: Record<string, string> = {};
255
+ if (token !== null) {
256
+ headers['Authorization'] = `Bearer ${token}`;
257
+ }
258
+ const init: RequestInit = { method, headers };
259
+ if (options.body !== undefined) {
260
+ headers['Content-Type'] = 'application/json';
261
+ init.body = JSON.stringify(options.body);
262
+ }
263
+
264
+ let response: Response;
265
+ try {
266
+ response = await this._fetch(url, init);
267
+ } catch {
268
+ throw new SpecStoreError('network');
269
+ }
270
+ if (!response.ok) {
271
+ throw errorForStatus(response.status);
272
+ }
273
+ if (response.status === 204) {
274
+ return null;
275
+ }
276
+ try {
277
+ return await response.json();
278
+ } catch {
279
+ throw new SpecStoreError('invalid');
280
+ }
281
+ }
282
+
283
+ private readonly _base: string;
284
+ private readonly _getAccessToken: () => Promise<string | null>;
285
+ private readonly _fetch: typeof fetch;
286
+ }
287
+
288
+ /** Only the fields the server accepts; in particular never send an `id`. */
289
+ function pick(body: ISpecBody): ISpecBody {
290
+ return { name: body.name, description: body.description, spec: body.spec };
291
+ }
@@ -0,0 +1,10 @@
1
+ import { Signal } from '@lumino/signaling';
2
+
3
+ /**
4
+ * Emits after this extension creates, renames or deletes a notebook record, so the
5
+ * Notebooks tab can list again. A plain module-level signal: the tab and the commands
6
+ * live in different plugins, and the change is the only thing they need to share.
7
+ */
8
+ // The sender is a throwaway object, not null: Lumino keys its receivers by sender in a
9
+ // WeakMap, which refuses null.
10
+ export const notebooksChanged = new Signal<object, void>({});