@oceanum/oceanumlab 4.7.2 → 4.9.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.
- package/lib/DatameshWidget.d.ts +11 -0
- package/lib/DatameshWidget.js +24 -3
- package/lib/StoredNotebooks.d.ts +20 -2
- package/lib/StoredNotebooks.js +88 -7
- package/lib/index.js +35 -1
- package/lib/share/client.d.ts +3 -1
- package/lib/share/client.js +5 -4
- package/lib/share/notebook.d.ts +26 -0
- package/lib/share/notebook.js +66 -8
- package/lib/share/oceanumOnly.d.ts +25 -2
- package/lib/share/oceanumOnly.js +39 -2
- package/lib/share/plugin.d.ts +2 -0
- package/lib/share/plugin.js +114 -11
- package/lib/share/upload.d.ts +6 -0
- package/lib/share/upload.js +27 -0
- package/package.json +1 -1
- package/schema/datamesh-connect.json +6 -0
- package/src/DatameshWidget.tsx +58 -0
- package/src/StoredNotebooks.tsx +197 -6
- package/src/__tests__/OceanumPanel.spec.tsx +94 -1
- package/src/__tests__/StoredNotebooks.spec.tsx +368 -4
- package/src/__tests__/oceanumOnly.spec.ts +127 -3
- package/src/__tests__/settingsSchema.spec.ts +11 -1
- package/src/__tests__/shareClient.spec.ts +14 -0
- package/src/__tests__/shareNotebook.spec.ts +116 -0
- package/src/__tests__/sharePlugin.spec.ts +253 -8
- package/src/index.ts +35 -1
- package/src/share/client.ts +5 -2
- package/src/share/notebook.ts +88 -10
- package/src/share/oceanumOnly.ts +55 -4
- package/src/share/plugin.ts +143 -11
- package/src/share/upload.ts +27 -0
- package/style/index.css +94 -2
|
@@ -95,6 +95,20 @@ describe('SpecStoreClient', () => {
|
|
|
95
95
|
expect(call().headers).toEqual({});
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
+
it('reads another spec type when given one', async () => {
|
|
99
|
+
const fetch = jest.fn(async () => json(record()));
|
|
100
|
+
const client = new SpecStoreClient({
|
|
101
|
+
specsUrl: 'https://specs.example.com/',
|
|
102
|
+
getAccessToken: async () => 'tok',
|
|
103
|
+
specType: 'notebook-demo',
|
|
104
|
+
fetch: fetch as unknown as typeof globalThis.fetch
|
|
105
|
+
});
|
|
106
|
+
await client.get(ID);
|
|
107
|
+
expect(String((fetch.mock.calls[0] as unknown[])[0])).toBe(
|
|
108
|
+
`https://specs.example.com/specs/notebook-demo/${ID}`
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
98
112
|
it('refuses ids that are not UUIDs before building a URL', async () => {
|
|
99
113
|
const { client, fetch } = setup(() => json(record()));
|
|
100
114
|
await expect(client.get('../eidos')).rejects.toMatchObject({
|
|
@@ -2,10 +2,12 @@ import type { INotebookContent } from '@jupyterlab/nbformat';
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
buildSpecBody,
|
|
5
|
+
demoItemsFromSummaries,
|
|
5
6
|
isShareableEmail,
|
|
6
7
|
isSpecId,
|
|
7
8
|
nameFromPath,
|
|
8
9
|
notebookFromRecord,
|
|
10
|
+
notebookFromUpload,
|
|
9
11
|
parseShareEmails,
|
|
10
12
|
parseTimestamp,
|
|
11
13
|
partitionSummaries,
|
|
@@ -14,6 +16,7 @@ import {
|
|
|
14
16
|
sanitizeName,
|
|
15
17
|
shareLink,
|
|
16
18
|
uniqueNotebookPath,
|
|
19
|
+
unlinkedNotebookFromRecord,
|
|
17
20
|
withoutTrust
|
|
18
21
|
} from '../share/notebook';
|
|
19
22
|
|
|
@@ -185,6 +188,75 @@ describe('notebookFromRecord', () => {
|
|
|
185
188
|
});
|
|
186
189
|
});
|
|
187
190
|
|
|
191
|
+
describe('unlinkedNotebookFromRecord', () => {
|
|
192
|
+
const base = {
|
|
193
|
+
id: ID,
|
|
194
|
+
name: 'Waves',
|
|
195
|
+
description: 'Wave stats',
|
|
196
|
+
modified: '2026-09-12T10:00:00',
|
|
197
|
+
creator: null as string | null
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
it('links the copy to nothing, even when the stored notebook carries a link', () => {
|
|
201
|
+
const spec = notebook('', { oceanum: { spec_id: ID, specs_url: SPECS } });
|
|
202
|
+
const content = unlinkedNotebookFromRecord({ ...base, spec });
|
|
203
|
+
expect(content.metadata).not.toHaveProperty('oceanum');
|
|
204
|
+
expect(content.metadata.kernelspec).toBeDefined();
|
|
205
|
+
expect(content.cells).toHaveLength(2);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('arrives untrusted', () => {
|
|
209
|
+
const spec = notebook();
|
|
210
|
+
spec.cells[1].metadata = { trusted: true, tags: ['keep'] };
|
|
211
|
+
const content = unlinkedNotebookFromRecord({ ...base, spec });
|
|
212
|
+
expect(content.cells[1].metadata).toEqual({ tags: ['keep'] });
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('rejects records that are not notebooks', () => {
|
|
216
|
+
expect(() =>
|
|
217
|
+
unlinkedNotebookFromRecord({ ...base, spec: { nbformat: 3 } })
|
|
218
|
+
).toThrow(/not a valid notebook/);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('demoItemsFromSummaries', () => {
|
|
223
|
+
const A = '0b7d9f2e-1111-4a2b-8c3d-9e8f7a6b5c4d';
|
|
224
|
+
const B = '1c2d3e4f-2222-4a2b-8c3d-9e8f7a6b5c4d';
|
|
225
|
+
const C = '2d3e4f5a-3333-4a2b-8c3d-9e8f7a6b5c4d';
|
|
226
|
+
const summary = (id: string, name: string, description: string | null) => ({
|
|
227
|
+
id,
|
|
228
|
+
name,
|
|
229
|
+
description,
|
|
230
|
+
modified: '2026-09-25T10:00:00',
|
|
231
|
+
creator: null as string | null
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('lists each spec by name, with its description as the summary', () => {
|
|
235
|
+
expect(
|
|
236
|
+
demoItemsFromSummaries([summary(A, '01_quickstart', 'A quick query')])
|
|
237
|
+
).toEqual([{ id: A, title: '01_quickstart', summary: 'A quick query' }]);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('orders by name as people number them, ignoring case', () => {
|
|
241
|
+
const items = demoItemsFromSummaries([
|
|
242
|
+
summary(A, '10 Plot waves', null),
|
|
243
|
+
summary(B, 'spectra', null),
|
|
244
|
+
summary(C, '2 Query', null)
|
|
245
|
+
]);
|
|
246
|
+
expect(items.map(item => item.title)).toEqual([
|
|
247
|
+
'2 Query',
|
|
248
|
+
'10 Plot waves',
|
|
249
|
+
'spectra'
|
|
250
|
+
]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('names an unnamed spec, and leaves out a summary it does not have', () => {
|
|
254
|
+
expect(demoItemsFromSummaries([summary(A, ' ', null)])).toEqual([
|
|
255
|
+
{ id: A, title: 'Untitled' }
|
|
256
|
+
]);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
188
260
|
describe('names and paths', () => {
|
|
189
261
|
it('derives the record name from a path', () => {
|
|
190
262
|
expect(nameFromPath('Oceanum/Waves.ipynb')).toBe('Waves');
|
|
@@ -449,3 +521,47 @@ describe('reportForShareFailures', () => {
|
|
|
449
521
|
});
|
|
450
522
|
});
|
|
451
523
|
});
|
|
524
|
+
|
|
525
|
+
describe('notebookFromUpload', () => {
|
|
526
|
+
const linkedAndTrusted: INotebookContent = {
|
|
527
|
+
nbformat: 4,
|
|
528
|
+
nbformat_minor: 5,
|
|
529
|
+
metadata: {
|
|
530
|
+
oceanum: { spec_id: ID, specs_url: SPECS },
|
|
531
|
+
kernelspec: { name: 'python', display_name: 'Python' }
|
|
532
|
+
},
|
|
533
|
+
cells: [
|
|
534
|
+
{ cell_type: 'markdown', source: 'hi', metadata: { trusted: true } }
|
|
535
|
+
]
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
it('drops the oceanum link and cell trust, and keeps everything else', () => {
|
|
539
|
+
const content = notebookFromUpload(JSON.stringify(linkedAndTrusted));
|
|
540
|
+
|
|
541
|
+
expect(content.metadata).toEqual({
|
|
542
|
+
kernelspec: { name: 'python', display_name: 'Python' }
|
|
543
|
+
});
|
|
544
|
+
expect(content.cells).toEqual([
|
|
545
|
+
{ cell_type: 'markdown', source: 'hi', metadata: {} }
|
|
546
|
+
]);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it.each([
|
|
550
|
+
['text that is not JSON', '{'],
|
|
551
|
+
['a JSON array', '[]'],
|
|
552
|
+
['nbformat 3', JSON.stringify({ ...linkedAndTrusted, nbformat: 3 })],
|
|
553
|
+
['no cells', JSON.stringify({ nbformat: 4, metadata: {} })],
|
|
554
|
+
['no metadata', JSON.stringify({ nbformat: 4, cells: [] })],
|
|
555
|
+
[
|
|
556
|
+
'a cell that is not a cell',
|
|
557
|
+
JSON.stringify({ nbformat: 4, metadata: {}, cells: [null, {}] })
|
|
558
|
+
]
|
|
559
|
+
])('refuses %s, without quoting the file', (_, text) => {
|
|
560
|
+
expect(() => notebookFromUpload(text)).toThrow(/not a Jupyter notebook/);
|
|
561
|
+
try {
|
|
562
|
+
notebookFromUpload(text);
|
|
563
|
+
} catch (error) {
|
|
564
|
+
expect((error as Error).message).not.toContain(text);
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
});
|
|
@@ -43,8 +43,24 @@ jest.mock('@jupyterlab/apputils', () => {
|
|
|
43
43
|
};
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
+
/** What the file picker answers: a file, or null for a cancelled picker. */
|
|
47
|
+
const mockPicked: { file: { name: string; text: string } | null } = {
|
|
48
|
+
file: null
|
|
49
|
+
};
|
|
50
|
+
const mockPickerCalls = { count: 0 };
|
|
51
|
+
|
|
52
|
+
jest.mock('../share/upload', () => ({
|
|
53
|
+
chooseNotebookFile: async () => {
|
|
54
|
+
mockPickerCalls.count++;
|
|
55
|
+
const picked = mockPicked.file;
|
|
56
|
+
return picked && { name: picked.name, text: async () => picked.text };
|
|
57
|
+
}
|
|
58
|
+
}));
|
|
59
|
+
|
|
46
60
|
const originalFetch = globalThis.fetch;
|
|
47
61
|
afterEach(() => {
|
|
62
|
+
mockPicked.file = null;
|
|
63
|
+
mockPickerCalls.count = 0;
|
|
48
64
|
globalThis.fetch = originalFetch;
|
|
49
65
|
mockDialog.accept = true;
|
|
50
66
|
mockDialog.value = null;
|
|
@@ -78,9 +94,13 @@ const record = (id: string, name: string): ISpecRecord => ({
|
|
|
78
94
|
spec: notebook()
|
|
79
95
|
});
|
|
80
96
|
|
|
81
|
-
/**
|
|
82
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Records the spec store calls a command makes, and answers them. A record holds
|
|
99
|
+
* `stored[id]` when given, else an empty notebook.
|
|
100
|
+
*/
|
|
101
|
+
function store(stored: Record<string, INotebookContent> = {}) {
|
|
83
102
|
const calls: string[] = [];
|
|
103
|
+
const urls: string[] = [];
|
|
84
104
|
const bodies: Record<string, unknown>[] = [];
|
|
85
105
|
const names: Record<string, string> = { [ID_A]: 'Alpha', [ID_B]: 'Beta' };
|
|
86
106
|
const fetcher = async (
|
|
@@ -91,6 +111,7 @@ function store() {
|
|
|
91
111
|
const method = init?.method ?? 'GET';
|
|
92
112
|
const id = url.split('/').pop() ?? '';
|
|
93
113
|
calls.push(`${method} ${id}`);
|
|
114
|
+
urls.push(url);
|
|
94
115
|
if (init?.body) {
|
|
95
116
|
bodies.push(JSON.parse(String(init.body)));
|
|
96
117
|
}
|
|
@@ -100,10 +121,13 @@ function store() {
|
|
|
100
121
|
json: async () =>
|
|
101
122
|
method === 'GET' && id === 'notebook'
|
|
102
123
|
? [record(ID_A, 'Alpha'), record(ID_B, 'Beta')]
|
|
103
|
-
:
|
|
124
|
+
: {
|
|
125
|
+
...record(id, names[id] ?? 'Alpha'),
|
|
126
|
+
spec: stored[id] ?? notebook()
|
|
127
|
+
}
|
|
104
128
|
} as unknown as Response;
|
|
105
129
|
};
|
|
106
|
-
return { calls, bodies, fetcher };
|
|
130
|
+
return { calls, urls, bodies, fetcher };
|
|
107
131
|
}
|
|
108
132
|
|
|
109
133
|
/** A notebook panel with just the surface the plugin touches. */
|
|
@@ -137,9 +161,16 @@ function panel(id: string, path: string, link?: string): NotebookPanel {
|
|
|
137
161
|
interface IHarness {
|
|
138
162
|
commands: CommandRegistry;
|
|
139
163
|
calls: string[];
|
|
164
|
+
/** Every URL the plugin fetched, in order. */
|
|
165
|
+
urls: string[];
|
|
140
166
|
bodies: Record<string, unknown>[];
|
|
141
167
|
saved: string[];
|
|
168
|
+
/** What each save wrote, by path. */
|
|
169
|
+
written: Record<string, unknown>;
|
|
142
170
|
opened: string[];
|
|
171
|
+
/** The widgets `openOrReveal` returned, in order; set `isDisposed` to close one. */
|
|
172
|
+
widgets: { id: string; isDisposed: boolean }[];
|
|
173
|
+
activated: string[];
|
|
143
174
|
renamed: [string, string][];
|
|
144
175
|
/** What a right-click last landed on; never cleared, as JupyterLab does not. */
|
|
145
176
|
setHit: (node: HTMLElement | null) => void;
|
|
@@ -152,12 +183,16 @@ function activate(
|
|
|
152
183
|
panels?: NotebookPanel[];
|
|
153
184
|
current?: NotebookPanel | null;
|
|
154
185
|
folder?: Record<string, unknown>;
|
|
186
|
+
stored?: Record<string, INotebookContent>;
|
|
155
187
|
} = {}
|
|
156
188
|
): IHarness {
|
|
157
189
|
const commands = new CommandRegistry();
|
|
158
|
-
const { calls, bodies, fetcher } = store();
|
|
190
|
+
const { calls, urls, bodies, fetcher } = store(options.stored);
|
|
159
191
|
const saved: string[] = [];
|
|
192
|
+
const written: Record<string, unknown> = {};
|
|
160
193
|
const opened: string[] = [];
|
|
194
|
+
const widgets: { id: string; isDisposed: boolean }[] = [];
|
|
195
|
+
const activated: string[] = [];
|
|
161
196
|
const renamed: [string, string][] = [];
|
|
162
197
|
const panels = options.panels ?? [];
|
|
163
198
|
let current = options.current ?? null;
|
|
@@ -185,7 +220,9 @@ function activate(
|
|
|
185
220
|
get currentWidget() {
|
|
186
221
|
return current;
|
|
187
222
|
},
|
|
188
|
-
activateById: (): void =>
|
|
223
|
+
activateById: (id: string): void => {
|
|
224
|
+
activated.push(id);
|
|
225
|
+
},
|
|
189
226
|
widgets: (): Widget[] => []
|
|
190
227
|
},
|
|
191
228
|
contextMenu: { addItem: (): void => undefined },
|
|
@@ -213,8 +250,13 @@ function activate(
|
|
|
213
250
|
}
|
|
214
251
|
return { type: 'notebook', content: folder[name] };
|
|
215
252
|
},
|
|
216
|
-
save: async (path: string) => {
|
|
253
|
+
save: async (path: string, model?: { content?: unknown }) => {
|
|
217
254
|
saved.push(path);
|
|
255
|
+
written[path] = model?.content;
|
|
256
|
+
// Like the real drive, the folder lists the file from now on.
|
|
257
|
+
if (path.startsWith('Oceanum/')) {
|
|
258
|
+
folder[path.replace('Oceanum/', '')] = model?.content;
|
|
259
|
+
}
|
|
218
260
|
return { path };
|
|
219
261
|
},
|
|
220
262
|
newUntitled: async () => ({ path: 'Untitled Folder' }),
|
|
@@ -224,7 +266,9 @@ function activate(
|
|
|
224
266
|
services: { contents },
|
|
225
267
|
openOrReveal: (path: string) => {
|
|
226
268
|
opened.push(path);
|
|
227
|
-
|
|
269
|
+
const widget = { id: `widget-${widgets.length}`, isDisposed: false };
|
|
270
|
+
widgets.push(widget);
|
|
271
|
+
return widget;
|
|
228
272
|
},
|
|
229
273
|
rename: async (from: string, to: string): Promise<void> => {
|
|
230
274
|
renamed.push([from, to]);
|
|
@@ -263,9 +307,13 @@ function activate(
|
|
|
263
307
|
return {
|
|
264
308
|
commands,
|
|
265
309
|
calls,
|
|
310
|
+
urls,
|
|
266
311
|
bodies,
|
|
267
312
|
saved,
|
|
313
|
+
written,
|
|
268
314
|
opened,
|
|
315
|
+
widgets,
|
|
316
|
+
activated,
|
|
269
317
|
renamed,
|
|
270
318
|
setHit: node => {
|
|
271
319
|
hit = node;
|
|
@@ -424,3 +472,200 @@ describe('opening a record whose local copy already exists', () => {
|
|
|
424
472
|
expect(harness.saved).toEqual(['Oceanum/Alpha (1).ipynb']);
|
|
425
473
|
});
|
|
426
474
|
});
|
|
475
|
+
|
|
476
|
+
describe('opening an example', () => {
|
|
477
|
+
// As stored on Oceanum: linked to its own record, with a trusted cell.
|
|
478
|
+
const example: INotebookContent = {
|
|
479
|
+
...notebook(ID_A),
|
|
480
|
+
cells: [
|
|
481
|
+
{
|
|
482
|
+
cell_type: 'code',
|
|
483
|
+
source: 'print(1)',
|
|
484
|
+
metadata: { trusted: true },
|
|
485
|
+
outputs: [],
|
|
486
|
+
execution_count: null
|
|
487
|
+
}
|
|
488
|
+
]
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
it('writes an untrusted copy linked to nothing, named from its title', async () => {
|
|
492
|
+
const harness = activate({ stored: { [ID_A]: example } });
|
|
493
|
+
|
|
494
|
+
await harness.commands.execute(CommandIDs.openExample, {
|
|
495
|
+
id: ID_A,
|
|
496
|
+
title: 'Query a datasource'
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// Read, never written back: the example's record is Oceanum's.
|
|
500
|
+
expect(harness.calls).toEqual([`GET ${ID_A}`]);
|
|
501
|
+
// An example is a Notebook Demo spec, not a notebook.
|
|
502
|
+
expect(harness.urls).toEqual([`${SPECS}/specs/notebook-demo/${ID_A}`]);
|
|
503
|
+
const path = 'Oceanum/Query a datasource.ipynb';
|
|
504
|
+
expect(harness.saved).toEqual([path]);
|
|
505
|
+
expect(harness.opened).toEqual([path]);
|
|
506
|
+
const content = harness.written[path] as INotebookContent;
|
|
507
|
+
// A linked copy's first save would PUT to the example's record and be refused.
|
|
508
|
+
expect(content.metadata).not.toHaveProperty('oceanum');
|
|
509
|
+
expect(content.cells[0].metadata).toEqual({});
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it('never overwrites a file already there', async () => {
|
|
513
|
+
const harness = activate({
|
|
514
|
+
folder: { 'Query a datasource.ipynb': notebook() }
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
await harness.commands.execute(CommandIDs.openExample, {
|
|
518
|
+
id: ID_A,
|
|
519
|
+
title: 'Query a datasource'
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
expect(harness.saved).toEqual(['Oceanum/Query a datasource (1).ipynb']);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('brings its open copy forward instead of writing another', async () => {
|
|
526
|
+
const harness = activate();
|
|
527
|
+
const args = { id: ID_A, title: 'Example' };
|
|
528
|
+
|
|
529
|
+
await harness.commands.execute(CommandIDs.openExample, args);
|
|
530
|
+
await harness.commands.execute(CommandIDs.openExample, args);
|
|
531
|
+
|
|
532
|
+
expect(harness.saved).toEqual(['Oceanum/Example.ipynb']);
|
|
533
|
+
expect(harness.activated).toEqual([harness.widgets[0].id]);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it('writes one copy when opened twice before the first finishes', async () => {
|
|
537
|
+
const harness = activate();
|
|
538
|
+
const args = { id: ID_A, title: 'Example' };
|
|
539
|
+
|
|
540
|
+
await Promise.all([
|
|
541
|
+
harness.commands.execute(CommandIDs.openExample, args),
|
|
542
|
+
harness.commands.execute(CommandIDs.openExample, args)
|
|
543
|
+
]);
|
|
544
|
+
|
|
545
|
+
expect(harness.saved).toEqual(['Oceanum/Example.ipynb']);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it('writes a fresh copy once the last one was closed', async () => {
|
|
549
|
+
const harness = activate();
|
|
550
|
+
const args = { id: ID_A, title: 'Example' };
|
|
551
|
+
|
|
552
|
+
await harness.commands.execute(CommandIDs.openExample, args);
|
|
553
|
+
harness.widgets[0].isDisposed = true;
|
|
554
|
+
await harness.commands.execute(CommandIDs.openExample, args);
|
|
555
|
+
|
|
556
|
+
expect(harness.saved).toEqual([
|
|
557
|
+
'Oceanum/Example.ipynb',
|
|
558
|
+
'Oceanum/Example (1).ipynb'
|
|
559
|
+
]);
|
|
560
|
+
expect(harness.activated).toEqual([]);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it('falls back to the record name when given no title', async () => {
|
|
564
|
+
const harness = activate();
|
|
565
|
+
|
|
566
|
+
await harness.commands.execute(CommandIDs.openExample, { id: ID_A });
|
|
567
|
+
|
|
568
|
+
expect(harness.saved).toEqual(['Oceanum/Alpha.ipynb']);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it('ignores an id that is not a record id', async () => {
|
|
572
|
+
const harness = activate();
|
|
573
|
+
|
|
574
|
+
await harness.commands.execute(CommandIDs.openExample, {
|
|
575
|
+
id: '../eidos',
|
|
576
|
+
title: 'Nope'
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
expect(harness.calls).toEqual([]);
|
|
580
|
+
expect(harness.saved).toEqual([]);
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it('is disabled while signed out', () => {
|
|
584
|
+
const harness = activate({ user: null });
|
|
585
|
+
|
|
586
|
+
expect(harness.commands.isEnabled(CommandIDs.openExample)).toBe(false);
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
describe('uploading a notebook', () => {
|
|
591
|
+
// As downloaded from someone else's record: linked to it, with a trusted cell.
|
|
592
|
+
const downloaded: INotebookContent = {
|
|
593
|
+
...notebook(ID_B),
|
|
594
|
+
cells: [
|
|
595
|
+
{
|
|
596
|
+
cell_type: 'code',
|
|
597
|
+
source: 'print(1)',
|
|
598
|
+
metadata: { trusted: true },
|
|
599
|
+
outputs: [],
|
|
600
|
+
execution_count: null
|
|
601
|
+
}
|
|
602
|
+
]
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
it('writes an untrusted copy linked to nothing into the Oceanum folder, and opens it', async () => {
|
|
606
|
+
mockPicked.file = {
|
|
607
|
+
name: 'analysis.ipynb',
|
|
608
|
+
text: JSON.stringify(downloaded)
|
|
609
|
+
};
|
|
610
|
+
const harness = activate();
|
|
611
|
+
|
|
612
|
+
await harness.commands.execute(CommandIDs.upload);
|
|
613
|
+
|
|
614
|
+
const path = 'Oceanum/analysis.ipynb';
|
|
615
|
+
expect(harness.saved).toEqual([path]);
|
|
616
|
+
expect(harness.opened).toEqual([path]);
|
|
617
|
+
const content = harness.written[path] as INotebookContent;
|
|
618
|
+
// Linked, its first save would write over the record it was downloaded from.
|
|
619
|
+
expect(content.metadata).not.toHaveProperty('oceanum');
|
|
620
|
+
expect(content.cells[0].metadata).toEqual({});
|
|
621
|
+
// Nothing reaches Oceanum until the notebook is saved.
|
|
622
|
+
expect(harness.calls).toEqual([]);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('never overwrites a file already there', async () => {
|
|
626
|
+
mockPicked.file = {
|
|
627
|
+
name: 'analysis.ipynb',
|
|
628
|
+
text: JSON.stringify(notebook())
|
|
629
|
+
};
|
|
630
|
+
const harness = activate({ folder: { 'analysis.ipynb': notebook() } });
|
|
631
|
+
|
|
632
|
+
await harness.commands.execute(CommandIDs.upload);
|
|
633
|
+
|
|
634
|
+
expect(harness.saved).toEqual(['Oceanum/analysis (1).ipynb']);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it.each([
|
|
638
|
+
['not JSON', 'not json at all'],
|
|
639
|
+
['JSON that is not a notebook', JSON.stringify({ cells: [] })],
|
|
640
|
+
['an nbformat 3 notebook', JSON.stringify({ ...notebook(), nbformat: 3 })]
|
|
641
|
+
])('writes nothing for %s', async (_, text) => {
|
|
642
|
+
mockPicked.file = { name: 'bad.ipynb', text };
|
|
643
|
+
const harness = activate();
|
|
644
|
+
|
|
645
|
+
await harness.commands.execute(CommandIDs.upload);
|
|
646
|
+
|
|
647
|
+
expect(harness.saved).toEqual([]);
|
|
648
|
+
expect(harness.opened).toEqual([]);
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it('writes nothing when the picker is cancelled', async () => {
|
|
652
|
+
const harness = activate();
|
|
653
|
+
|
|
654
|
+
await harness.commands.execute(CommandIDs.upload);
|
|
655
|
+
|
|
656
|
+
expect(mockPickerCalls.count).toBe(1);
|
|
657
|
+
expect(harness.saved).toEqual([]);
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it('asks for sign-in first, and writes nothing when declined', async () => {
|
|
661
|
+
mockPicked.file = { name: 'a.ipynb', text: JSON.stringify(notebook()) };
|
|
662
|
+
mockDialog.accept = false;
|
|
663
|
+
const harness = activate({ user: null });
|
|
664
|
+
|
|
665
|
+
await harness.commands.execute(CommandIDs.upload);
|
|
666
|
+
|
|
667
|
+
expect(mockPickerCalls.count).toBe(0);
|
|
668
|
+
expect(harness.saved).toEqual([]);
|
|
669
|
+
expect(harness.commands.isEnabled(CommandIDs.upload)).toBe(false);
|
|
670
|
+
});
|
|
671
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { ConversationPin } from './conversationPin';
|
|
|
21
21
|
import { deviceAuthPlugin } from './auth/plugin';
|
|
22
22
|
import { IOceanumAuth } from './auth/tokens';
|
|
23
23
|
import { CommandIDs as ShareCommandIDs, sharePlugin } from './share/plugin';
|
|
24
|
+
import { OCEANUM_PANEL_ID } from './share/oceanumOnly';
|
|
24
25
|
|
|
25
26
|
import { snapshotFromIpynb, snapshotOf } from './notebookContext';
|
|
26
27
|
import { notebookHost } from './notebookHost';
|
|
@@ -79,6 +80,9 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
|
|
|
79
80
|
const aiBackendUrl = auth?.urls?.ai ?? OCEANUM_AI_BACKEND_URL;
|
|
80
81
|
// The `datameshToken` setting for the AI chat; empty until the settings load.
|
|
81
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;
|
|
82
86
|
// Tells the sidebar the settings above have changed.
|
|
83
87
|
const settingsChanged = new Signal<JupyterFrontEnd, void>(app);
|
|
84
88
|
|
|
@@ -100,6 +104,7 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
|
|
|
100
104
|
}
|
|
101
105
|
window.injectToken = set.get('injectToken').user as boolean;
|
|
102
106
|
datameshTokenSetting = set.get('datameshToken').composite as string;
|
|
107
|
+
showExamplesSetting = set.get('showExamples').composite as boolean;
|
|
103
108
|
settingsChanged.emit();
|
|
104
109
|
};
|
|
105
110
|
//Try to get the datamesh token from the envars
|
|
@@ -116,6 +121,10 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
|
|
|
116
121
|
console.error(
|
|
117
122
|
`Something went wrong when reading the Oceanumlab settings.\n${reason}`
|
|
118
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();
|
|
119
128
|
});
|
|
120
129
|
|
|
121
130
|
const getCurrentWidget = (): Widget => {
|
|
@@ -155,9 +164,34 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
|
|
|
155
164
|
app.commands.execute(ShareCommandIDs.open, { id }).catch(error => {
|
|
156
165
|
console.error('Oceanum: could not open the stored notebook.', error);
|
|
157
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
|
+
});
|
|
158
192
|
}
|
|
159
193
|
});
|
|
160
|
-
datameshConnectWidget.id =
|
|
194
|
+
datameshConnectWidget.id = OCEANUM_PANEL_ID;
|
|
161
195
|
datameshConnectWidget.title.icon = oceanumIcon;
|
|
162
196
|
datameshConnectWidget.title.caption = 'Datamesh Connect';
|
|
163
197
|
|
package/src/share/client.ts
CHANGED
|
@@ -90,6 +90,8 @@ export interface ISpecStoreClientOptions {
|
|
|
90
90
|
specsUrl: string;
|
|
91
91
|
/** Returns a current access token or `null`. Called for every request. */
|
|
92
92
|
getAccessToken: () => Promise<string | null>;
|
|
93
|
+
/** The spec type the client reads and writes; `notebook` unless given. */
|
|
94
|
+
specType?: string;
|
|
93
95
|
fetch?: typeof fetch;
|
|
94
96
|
}
|
|
95
97
|
|
|
@@ -109,10 +111,11 @@ function isRecord(value: unknown): value is ISpecRecord {
|
|
|
109
111
|
return isSummary(value) && 'spec' in value;
|
|
110
112
|
}
|
|
111
113
|
|
|
112
|
-
/** Client for
|
|
114
|
+
/** Client for one spec type of the Oceanum spec store, `notebook` by default. */
|
|
113
115
|
export class SpecStoreClient {
|
|
114
116
|
constructor(options: ISpecStoreClientOptions) {
|
|
115
|
-
|
|
117
|
+
const specType = options.specType ?? 'notebook';
|
|
118
|
+
this._base = `${options.specsUrl.replace(/\/+$/, '')}/specs/${specType}`;
|
|
116
119
|
this._getAccessToken = options.getAccessToken;
|
|
117
120
|
this._fetch = options.fetch ?? ((input, init) => fetch(input, init));
|
|
118
121
|
}
|