@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,416 @@
1
+ /**
2
+ * Notebook records in the Oceanum spec store: the link a local notebook keeps to its
3
+ * record, the body that is stored, and the names, paths and addresses around them.
4
+ *
5
+ * Ported from oceanum-notebook's share-oceanum package, which notebook.oceanum.io will
6
+ * drop in favour of this one.
7
+ */
8
+ import type { ICodeCell, INotebookContent } from '@jupyterlab/nbformat';
9
+
10
+ /** Notebook metadata key linking a local notebook to its spec store record. */
11
+ export const METADATA_KEY = 'oceanum';
12
+
13
+ /** Folder that notebooks opened from Oceanum.io are written to. */
14
+ export const OCEANUM_DIR = 'Oceanum';
15
+
16
+ /** Query parameter carrying a shared notebook id. */
17
+ export const QUERY_PARAM = 'oceanum-notebook';
18
+
19
+ /**
20
+ * Largest request body we send. Cloud Run rejects requests over 32 MiB; keep a margin
21
+ * for headers and the server's own encoding.
22
+ */
23
+ export const MAX_BODY_BYTES = 30 * 1024 * 1024;
24
+
25
+ /** The `oceanum` notebook metadata value. */
26
+ export interface IOceanumLink {
27
+ spec_id: string;
28
+ /** Spec store the id belongs to, so a development id is never PUT to production. */
29
+ specs_url: string;
30
+ description?: string;
31
+ }
32
+
33
+ /** A spec store request body for `POST`/`PUT /specs/notebook`. */
34
+ export interface ISpecBody {
35
+ name: string;
36
+ description: string | null;
37
+ spec: INotebookContent;
38
+ }
39
+
40
+ /**
41
+ * The fields a `PATCH /specs/notebook/{id}` may change. A field left out keeps its
42
+ * stored value, so a rename need not send the notebook back.
43
+ *
44
+ * The store refuses anything else outright rather than ignoring it, and refuses a patch
45
+ * that carries no field at all, so never send an empty one. `spec` is replaced whole:
46
+ * there is no way to change one key of a stored notebook.
47
+ */
48
+ export interface ISpecPatch {
49
+ name?: string;
50
+ description?: string | null;
51
+ spec?: INotebookContent;
52
+ }
53
+
54
+ /** A spec store list entry (the list omits `spec`). */
55
+ export interface ISpecSummary {
56
+ id: string;
57
+ name: string;
58
+ description: string | null;
59
+ /**
60
+ * When the record was created, or last patched. A `PUT` does not touch it, so for a
61
+ * record nothing has patched this is still a creation time.
62
+ */
63
+ modified: string;
64
+ /** Null unless the caller created the record: the store hides other creators. */
65
+ creator: string | null;
66
+ }
67
+
68
+ /** A full spec store record. */
69
+ export interface ISpecRecord extends ISpecSummary {
70
+ spec: unknown;
71
+ }
72
+
73
+ export type BuildResult =
74
+ | { ok: true; body: ISpecBody; bytes: number; strippedOutputs: boolean }
75
+ | { ok: false; bytes: number };
76
+
77
+ const UUID_RE =
78
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
79
+
80
+ /** Whether `value` is a UUID, and so safe to put in a spec store URL. */
81
+ export function isSpecId(value: unknown): value is string {
82
+ return typeof value === 'string' && UUID_RE.test(value);
83
+ }
84
+
85
+ function isObject(value: unknown): value is Record<string, unknown> {
86
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
87
+ }
88
+
89
+ /**
90
+ * Read the link from an `oceanum` metadata value, or `null` if it is missing, malformed,
91
+ * or belongs to a different spec store.
92
+ */
93
+ export function readLink(
94
+ value: unknown,
95
+ specsUrl: string
96
+ ): IOceanumLink | null {
97
+ if (
98
+ !isObject(value) ||
99
+ !isSpecId(value.spec_id) ||
100
+ value.specs_url !== specsUrl
101
+ ) {
102
+ return null;
103
+ }
104
+ const link: IOceanumLink = { spec_id: value.spec_id, specs_url: specsUrl };
105
+ if (typeof value.description === 'string') {
106
+ link.description = value.description;
107
+ }
108
+ return link;
109
+ }
110
+
111
+ /** The notebook name stored on Oceanum.io for a local path. */
112
+ export function nameFromPath(path: string): string {
113
+ const base = path.split('/').pop() ?? '';
114
+ return base.replace(/\.ipynb$/i, '') || 'Untitled';
115
+ }
116
+
117
+ function byteLength(text: string): number {
118
+ return new TextEncoder().encode(text).length;
119
+ }
120
+
121
+ function withoutLink(content: INotebookContent): INotebookContent {
122
+ const metadata = { ...content.metadata };
123
+ delete metadata[METADATA_KEY];
124
+ return { ...content, metadata };
125
+ }
126
+
127
+ /**
128
+ * The notebook with every cell's `trusted` metadata removed.
129
+ *
130
+ * JupyterLab trusts a cell whose metadata says `trusted: true` and then renders its HTML
131
+ * and Markdown unsanitised, running any script in them. A notebook from the spec store
132
+ * (anyone with write access can edit a record) must arrive untrusted; its outputs
133
+ * become trusted again when the reader re-runs the cells.
134
+ */
135
+ export function withoutTrust(content: INotebookContent): INotebookContent {
136
+ const cells = content.cells.map(cell => {
137
+ if (!isObject(cell.metadata) || !('trusted' in cell.metadata)) {
138
+ return cell;
139
+ }
140
+ const metadata = { ...cell.metadata };
141
+ delete metadata.trusted;
142
+ return { ...cell, metadata };
143
+ });
144
+ return { ...content, cells };
145
+ }
146
+
147
+ function withoutOutputs(content: INotebookContent): INotebookContent {
148
+ const cells = content.cells.map(cell =>
149
+ cell.cell_type === 'code'
150
+ ? ({ ...cell, outputs: [], execution_count: null } as ICodeCell)
151
+ : cell
152
+ );
153
+ return { ...content, cells };
154
+ }
155
+
156
+ /**
157
+ * Build the spec store body for a notebook. The `oceanum` link is not stored in the
158
+ * record. If the body is over `maxBytes`, code-cell outputs and execution counts are
159
+ * dropped; if it is still too large, the result is not ok.
160
+ */
161
+ export function buildSpecBody(
162
+ content: INotebookContent,
163
+ path: string,
164
+ specsUrl: string,
165
+ maxBytes: number = MAX_BODY_BYTES
166
+ ): BuildResult {
167
+ const link = readLink(content.metadata?.[METADATA_KEY], specsUrl);
168
+ const make = (spec: INotebookContent): ISpecBody => ({
169
+ name: nameFromPath(path),
170
+ description: link?.description ?? null,
171
+ spec
172
+ });
173
+
174
+ const full = make(withoutTrust(withoutLink(content)));
175
+ const fullBytes = byteLength(JSON.stringify(full));
176
+ if (fullBytes <= maxBytes) {
177
+ return { ok: true, body: full, bytes: fullBytes, strippedOutputs: false };
178
+ }
179
+ const stripped = make(withoutOutputs(full.spec));
180
+ const strippedBytes = byteLength(JSON.stringify(stripped));
181
+ if (strippedBytes <= maxBytes) {
182
+ return {
183
+ ok: true,
184
+ body: stripped,
185
+ bytes: strippedBytes,
186
+ strippedOutputs: true
187
+ };
188
+ }
189
+ return { ok: false, bytes: strippedBytes };
190
+ }
191
+
192
+ /**
193
+ * The notebook stored in a record, with its `oceanum` link set. Throws if the record
194
+ * does not hold an nbformat 4 notebook.
195
+ */
196
+ export function notebookFromRecord(
197
+ record: ISpecRecord,
198
+ specsUrl: string
199
+ ): INotebookContent {
200
+ const spec = record.spec;
201
+ if (
202
+ !isObject(spec) ||
203
+ spec.nbformat !== 4 ||
204
+ !Array.isArray(spec.cells) ||
205
+ !isObject(spec.metadata)
206
+ ) {
207
+ throw new Error('This Oceanum.io record is not a valid notebook.');
208
+ }
209
+ const link: IOceanumLink = { spec_id: record.id, specs_url: specsUrl };
210
+ if (record.description) {
211
+ link.description = record.description;
212
+ }
213
+ const content = withoutTrust(spec as unknown as INotebookContent);
214
+ return {
215
+ ...content,
216
+ metadata: { ...content.metadata, [METADATA_KEY]: { ...link } }
217
+ };
218
+ }
219
+
220
+ /** Make a record name safe to use as a single path segment. */
221
+ export function sanitizeName(name: string): string {
222
+ const clean = name
223
+ .trim()
224
+ .replace(/\.ipynb$/i, '')
225
+ .replace(/[/\\]/g, '-')
226
+ .trim();
227
+ return clean === '' || clean === '.' || clean === '..' ? 'Untitled' : clean;
228
+ }
229
+
230
+ /**
231
+ * A path `Oceanum/<name>.ipynb` that does not collide with `existing` (file names in the
232
+ * `Oceanum` folder), appending ` (1)`, ` (2)`, ... as needed.
233
+ */
234
+ export function uniqueNotebookPath(
235
+ name: string,
236
+ existing: Iterable<string>
237
+ ): string {
238
+ const taken = new Set(existing);
239
+ const base = sanitizeName(name);
240
+ let candidate = `${base}.ipynb`;
241
+ for (let i = 1; taken.has(candidate); i++) {
242
+ candidate = `${base} (${i}).ipynb`;
243
+ }
244
+ return `${OCEANUM_DIR}/${candidate}`;
245
+ }
246
+
247
+ /**
248
+ * The names in `existing` that `uniqueNotebookPath` could have given a record called
249
+ * `name`, in the order it would have tried them: `<base>.ipynb`, then `<base> (1).ipynb`,
250
+ * `<base> (2).ipynb` and so on.
251
+ *
252
+ * Opening a record looks through these for the copy a previous session left, so the same
253
+ * record keeps one local file. Without that the second copy is named `<base> (1)`, and
254
+ * since a record takes its name from the file on every save, opening a notebook twice
255
+ * would quietly rename it on Oceanum.
256
+ */
257
+ export function notebookNamesFor(
258
+ name: string,
259
+ existing: Iterable<string>
260
+ ): string[] {
261
+ const base = sanitizeName(name);
262
+ const prefix = `${base} (`;
263
+ const suffix = ').ipynb';
264
+ const plain: string[] = [];
265
+ const numbered: { name: string; index: number }[] = [];
266
+ for (const candidate of existing) {
267
+ if (candidate === `${base}.ipynb`) {
268
+ plain.push(candidate);
269
+ continue;
270
+ }
271
+ if (!candidate.startsWith(prefix) || !candidate.endsWith(suffix)) {
272
+ continue;
273
+ }
274
+ const index = candidate.slice(
275
+ prefix.length,
276
+ candidate.length - suffix.length
277
+ );
278
+ if (/^\d+$/.test(index)) {
279
+ numbered.push({ name: candidate, index: Number(index) });
280
+ }
281
+ }
282
+ numbered.sort((a, b) => a.index - b.index);
283
+ return [...plain, ...numbered.map(entry => entry.name)];
284
+ }
285
+
286
+ /**
287
+ * Split a listing into the caller's own notebooks and the rest, newest first.
288
+ *
289
+ * The store only sends `creator` on records the caller created, so anything else it
290
+ * returns is shared with them — directly, or because it is public. Addresses are
291
+ * compared case-insensitively: the store matches entities as case-sensitive globs, so
292
+ * the two sides can differ in case.
293
+ */
294
+ export function partitionSummaries(
295
+ items: readonly ISpecSummary[],
296
+ email: string | null
297
+ ): { mine: ISpecSummary[]; shared: ISpecSummary[] } {
298
+ const me = email?.toLowerCase() ?? null;
299
+ const sorted = [...items].sort((a, b) =>
300
+ b.modified.localeCompare(a.modified)
301
+ );
302
+ const isMine = (item: ISpecSummary): boolean =>
303
+ me !== null && item.creator?.toLowerCase() === me;
304
+ return {
305
+ mine: sorted.filter(isMine),
306
+ shared: sorted.filter(item => !isMine(item))
307
+ };
308
+ }
309
+
310
+ /** Parse a spec store timestamp, which is UTC but usually has no zone designator. */
311
+ export function parseTimestamp(value: string): Date | null {
312
+ const zoned = /(Z|[+-]\d{2}:?\d{2})$/i.test(value) ? value : `${value}Z`;
313
+ const date = new Date(zoned);
314
+ return Number.isNaN(date.getTime()) ? null : date;
315
+ }
316
+
317
+ /**
318
+ * Whether `email` can be granted access. Glob characters are refused: the server
319
+ * matches user entities as globs.
320
+ */
321
+ export function isShareableEmail(email: string): boolean {
322
+ return /^[^\s@*?[\]]+@[^\s@*?[\]]+\.[^\s@*?[\]]+$/.test(email);
323
+ }
324
+
325
+ /** Addresses typed into the share dialog, split into the ones we can grant and the rest. */
326
+ export interface IParsedShareEmails {
327
+ /** Valid, lower-cased and de-duplicated, in the order first written. */
328
+ emails: string[];
329
+ /** Everything that is not a shareable address, as written, for reporting back. */
330
+ invalid: string[];
331
+ }
332
+
333
+ /**
334
+ * Split what the user typed into individual addresses. Commas, semicolons and any
335
+ * whitespace all separate, so one-per-line and a comma-separated list both work.
336
+ *
337
+ * Bare addresses only: a mail client's "To:" line carries display names, and
338
+ * `"Alice B" <alice@example.com>` splits into tokens that are not addresses. Those
339
+ * come back in `invalid` so the user is told, rather than being silently dropped.
340
+ *
341
+ * Addresses are lower-cased because the spec store matches the user entity as a
342
+ * case-sensitive glob against the Auth0 email claim, which Auth0 stores lower case.
343
+ */
344
+ export function parseShareEmails(raw: string): IParsedShareEmails {
345
+ const emails: string[] = [];
346
+ const invalid: string[] = [];
347
+ const seen = new Set<string>();
348
+ for (const part of raw.split(/[\s,;]+/)) {
349
+ const entry = part.trim();
350
+ if (entry === '') {
351
+ continue;
352
+ }
353
+ const email = entry.toLowerCase();
354
+ if (!isShareableEmail(email)) {
355
+ // Report it as written, so the user recognises what they typed.
356
+ invalid.push(entry);
357
+ continue;
358
+ }
359
+ if (!seen.has(email)) {
360
+ seen.add(email);
361
+ emails.push(email);
362
+ }
363
+ }
364
+ return { emails, invalid };
365
+ }
366
+
367
+ /**
368
+ * How to report share failures.
369
+ *
370
+ * `original` means hand the underlying error to the reporter untouched — it holds the
371
+ * only explanation the user gets, and the reporter reads its type to decide whether to
372
+ * offer sign-in. `partial` is only for the case the original error cannot describe on
373
+ * its own: some addresses worked and some did not, so the message has to name them.
374
+ */
375
+ export type ShareReport =
376
+ { kind: 'original' } | { kind: 'partial'; named: string[] };
377
+
378
+ /**
379
+ * Decide how to report a set of failed grants.
380
+ *
381
+ * `entities` are the failed grants' entities, in order; the public grant's entity is
382
+ * the empty string, which names nobody.
383
+ */
384
+ export function reportForShareFailures(
385
+ grantCount: number,
386
+ entities: readonly string[],
387
+ needsSignIn: boolean
388
+ ): ShareReport {
389
+ const named = entities.filter(entity => entity !== '');
390
+ // A sign-in problem must reach the reporter as the original error or the user is
391
+ // never offered a way back in; an unnamed or total failure has nothing extra worth
392
+ // saying.
393
+ if (needsSignIn || named.length === 0 || entities.length >= grantCount) {
394
+ return { kind: 'original' };
395
+ }
396
+ return { kind: 'partial', named };
397
+ }
398
+
399
+ /**
400
+ * The link that opens a shared notebook in this deployment.
401
+ *
402
+ * JupyterLite is static files, so its page is `lab/index.html`; a JupyterLab server
403
+ * serves the application at `lab` and has no `index.html` to route.
404
+ */
405
+ export function shareLink(
406
+ baseUrl: string,
407
+ pageHref: string,
408
+ id: string
409
+ ): string {
410
+ const app = new URL(pageHref).pathname.endsWith('/index.html')
411
+ ? 'lab/index.html'
412
+ : 'lab';
413
+ const url = new URL(app, new URL(baseUrl, pageHref));
414
+ url.searchParams.set(QUERY_PARAM, id);
415
+ return url.toString();
416
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Oceanum-only file management: what a deployment gets when its environment says
3
+ * `fileManagement: 'oceanum'`, as notebook.oceanum.io does.
4
+ *
5
+ * The local drive stays as the working copy underneath, out of sight: the file browser
6
+ * is taken out of the sidebar, the File menu loses the entries that manage local files,
7
+ * and every completed local save is pushed to the spec store, so Ctrl+S, the toolbar
8
+ * button, Save All and autosave all reach Oceanum.
9
+ */
10
+ import type { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook';
11
+ import { MessageLoop } from '@lumino/messaging';
12
+ import type { Menu, Widget } from '@lumino/widgets';
13
+
14
+ /** The widget id `@jupyterlab/filebrowser-extension:browser` gives the file browser. */
15
+ export const FILE_BROWSER_ID = 'filebrowser';
16
+
17
+ /**
18
+ * File menu entries that manage local files. Save, Save All and Rename stay: saving is
19
+ * routed to Oceanum, and renaming the local file is how a notebook gets its name.
20
+ */
21
+ export const LOCAL_FILE_COMMANDS: readonly string[] = [
22
+ 'filebrowser:open-path',
23
+ 'filebrowser:open-url',
24
+ 'docmanager:save-as',
25
+ 'docmanager:reload',
26
+ 'docmanager:restore-checkpoint',
27
+ 'docmanager:duplicate',
28
+ 'docmanager:download'
29
+ ];
30
+
31
+ /**
32
+ * Take the file browser out of the sidebar; true if it was there. It is closed, not
33
+ * disposed: other extensions hold it through IDefaultFileBrowser and still use it to
34
+ * open paths.
35
+ */
36
+ export function hideFileBrowser(shell: {
37
+ widgets(area: 'left'): Iterable<Widget>;
38
+ }): boolean {
39
+ for (const widget of shell.widgets('left')) {
40
+ if (widget.id === FILE_BROWSER_ID) {
41
+ widget.close();
42
+ return true;
43
+ }
44
+ }
45
+ return false;
46
+ }
47
+
48
+ /**
49
+ * Submenus of local files. "Open Recent" is built by the document manager rather than
50
+ * declared in a schema, and its menu has no id, so it is known by the commands it holds.
51
+ */
52
+ export const LOCAL_FILE_SUBMENU_COMMANDS: readonly string[] = [
53
+ 'docmanager:open-recent',
54
+ 'docmanager:clear-recents'
55
+ ];
56
+
57
+ function isLocalSubmenu(item: Menu.IItem): boolean {
58
+ return (
59
+ item.type === 'submenu' &&
60
+ !!item.submenu &&
61
+ (item.submenu.title.label === 'Open Recent' ||
62
+ item.submenu.items.some(child =>
63
+ LOCAL_FILE_SUBMENU_COMMANDS.includes(child.command)
64
+ ))
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Remove the local-file entries from a menu; returns how many went. Idempotent, so it
70
+ * can run every time the menu is about to show: entries arrive from settings schemas as
71
+ * plugins load, not all at once.
72
+ */
73
+ export function pruneFileMenu(
74
+ menu: Menu,
75
+ commands: readonly string[] = LOCAL_FILE_COMMANDS
76
+ ): number {
77
+ let removed = 0;
78
+ for (let i = menu.items.length - 1; i >= 0; i--) {
79
+ const item = menu.items[i];
80
+ if (
81
+ (item.type === 'command' && commands.includes(item.command)) ||
82
+ isLocalSubmenu(item)
83
+ ) {
84
+ menu.removeItemAt(i);
85
+ removed++;
86
+ }
87
+ }
88
+ return removed;
89
+ }
90
+
91
+ /**
92
+ * Prune the menu again every time it opens. Entries arrive from settings schemas as
93
+ * plugins load rather than all at once, and a menu has no "about to show" signal; it
94
+ * is attached to the document each time it opens, which a message hook can watch.
95
+ */
96
+ export function pruneBeforeEachOpen(
97
+ menu: Menu,
98
+ commands: readonly string[] = LOCAL_FILE_COMMANDS
99
+ ): void {
100
+ MessageLoop.installMessageHook(menu, (_, message) => {
101
+ if (message.type === 'before-attach') {
102
+ pruneFileMenu(menu, commands);
103
+ }
104
+ return true;
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Call `push` after every completed local save of every notebook, open now or later.
110
+ * `push` must guard against its own saves: writing the record link back to the notebook
111
+ * saves it again.
112
+ */
113
+ export function routeSaves(
114
+ tracker: INotebookTracker,
115
+ push: (panel: NotebookPanel) => Promise<void>
116
+ ): void {
117
+ const watch = (panel: NotebookPanel): void => {
118
+ panel.context.saveState.connect((_, state) => {
119
+ if (state === 'completed') {
120
+ void push(panel);
121
+ }
122
+ });
123
+ };
124
+ tracker.forEach(watch);
125
+ tracker.widgetAdded.connect((_, panel) => watch(panel));
126
+ }