@oceanum/oceanumlab 4.7.2 → 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.
- package/lib/DatameshWidget.d.ts +11 -0
- package/lib/DatameshWidget.js +24 -3
- package/lib/StoredNotebooks.d.ts +20 -2
- package/lib/StoredNotebooks.js +95 -6
- package/lib/auth/tokens.d.ts +6 -0
- 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 +32 -0
- package/lib/share/notebook.js +86 -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 +109 -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 +216 -6
- package/src/__tests__/OceanumPanel.spec.tsx +99 -1
- package/src/__tests__/StoredNotebooks.spec.tsx +374 -3
- 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 +173 -0
- package/src/__tests__/sharePlugin.spec.ts +245 -7
- package/src/auth/tokens.ts +6 -0
- package/src/index.ts +35 -1
- package/src/share/client.ts +5 -2
- package/src/share/notebook.ts +119 -10
- package/src/share/oceanumOnly.ts +55 -4
- package/src/share/plugin.ts +135 -11
- package/src/share/upload.ts +27 -0
- package/style/index.css +101 -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({
|
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
isSpecId,
|
|
7
7
|
nameFromPath,
|
|
8
8
|
notebookFromRecord,
|
|
9
|
+
notebookFromUpload,
|
|
10
|
+
parseNotebookDemo,
|
|
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,132 @@ 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('parseNotebookDemo', () => {
|
|
223
|
+
const OTHER = '0b7d9f2e-1111-4a2b-8c3d-9e8f7a6b5c4d';
|
|
224
|
+
const demo = (sections: unknown, extra: object = {}): unknown => ({
|
|
225
|
+
kind: 'notebook-demo',
|
|
226
|
+
version: 1,
|
|
227
|
+
sections,
|
|
228
|
+
...extra
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
let warn: jest.SpyInstance;
|
|
232
|
+
beforeEach(() => {
|
|
233
|
+
warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
234
|
+
});
|
|
235
|
+
afterEach(() => {
|
|
236
|
+
warn.mockRestore();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('keeps sections and items in record order', () => {
|
|
240
|
+
const sections = parseNotebookDemo(
|
|
241
|
+
demo(
|
|
242
|
+
[
|
|
243
|
+
{
|
|
244
|
+
title: 'Getting started',
|
|
245
|
+
items: [
|
|
246
|
+
{ id: ID, title: 'Query', summary: 'One line' },
|
|
247
|
+
{ id: OTHER, title: 'Plot', note: 'ignored' }
|
|
248
|
+
]
|
|
249
|
+
},
|
|
250
|
+
{ title: 'Waves', items: [{ id: OTHER, title: 'Spectra' }] }
|
|
251
|
+
],
|
|
252
|
+
{ owner: 'ignored' }
|
|
253
|
+
)
|
|
254
|
+
);
|
|
255
|
+
expect(sections).toEqual([
|
|
256
|
+
{
|
|
257
|
+
title: 'Getting started',
|
|
258
|
+
items: [
|
|
259
|
+
{ id: ID, title: 'Query', summary: 'One line' },
|
|
260
|
+
{ id: OTHER, title: 'Plot' }
|
|
261
|
+
]
|
|
262
|
+
},
|
|
263
|
+
{ title: 'Waves', items: [{ id: OTHER, title: 'Spectra' }] }
|
|
264
|
+
]);
|
|
265
|
+
expect(warn).not.toHaveBeenCalled();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('skips items without a record id or a title', () => {
|
|
269
|
+
const sections = parseNotebookDemo(
|
|
270
|
+
demo([
|
|
271
|
+
{
|
|
272
|
+
title: 'Mixed',
|
|
273
|
+
items: [
|
|
274
|
+
{ id: '../eidos', title: 'Not a record id' },
|
|
275
|
+
{ id: ID, title: ' ' },
|
|
276
|
+
{ id: ID },
|
|
277
|
+
'not an item',
|
|
278
|
+
{ id: OTHER, title: 'Kept', summary: 42 }
|
|
279
|
+
]
|
|
280
|
+
}
|
|
281
|
+
])
|
|
282
|
+
);
|
|
283
|
+
expect(sections).toEqual([
|
|
284
|
+
{ title: 'Mixed', items: [{ id: OTHER, title: 'Kept' }] }
|
|
285
|
+
]);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('drops sections with no title or nothing left to list', () => {
|
|
289
|
+
const sections = parseNotebookDemo(
|
|
290
|
+
demo([
|
|
291
|
+
{ title: 'Empty', items: [] },
|
|
292
|
+
{ title: 'All invalid', items: [{ id: 'x', title: 'y' }] },
|
|
293
|
+
{ title: '', items: [{ id: ID, title: 'Untitled section' }] },
|
|
294
|
+
{ title: 'No items' }
|
|
295
|
+
])
|
|
296
|
+
);
|
|
297
|
+
expect(sections).toEqual([]);
|
|
298
|
+
expect(warn).not.toHaveBeenCalled();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('reads nothing from a record of another kind or version, and says so once', () => {
|
|
302
|
+
for (const spec of [
|
|
303
|
+
demo([], { kind: 'workspace' }),
|
|
304
|
+
demo([], { version: 2 }),
|
|
305
|
+
{ kind: 'notebook-demo', version: 1, sections: {} },
|
|
306
|
+
null,
|
|
307
|
+
[],
|
|
308
|
+
'notebook-demo'
|
|
309
|
+
]) {
|
|
310
|
+
warn.mockClear();
|
|
311
|
+
expect(parseNotebookDemo(spec)).toEqual([]);
|
|
312
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
188
317
|
describe('names and paths', () => {
|
|
189
318
|
it('derives the record name from a path', () => {
|
|
190
319
|
expect(nameFromPath('Oceanum/Waves.ipynb')).toBe('Waves');
|
|
@@ -449,3 +578,47 @@ describe('reportForShareFailures', () => {
|
|
|
449
578
|
});
|
|
450
579
|
});
|
|
451
580
|
});
|
|
581
|
+
|
|
582
|
+
describe('notebookFromUpload', () => {
|
|
583
|
+
const linkedAndTrusted: INotebookContent = {
|
|
584
|
+
nbformat: 4,
|
|
585
|
+
nbformat_minor: 5,
|
|
586
|
+
metadata: {
|
|
587
|
+
oceanum: { spec_id: ID, specs_url: SPECS },
|
|
588
|
+
kernelspec: { name: 'python', display_name: 'Python' }
|
|
589
|
+
},
|
|
590
|
+
cells: [
|
|
591
|
+
{ cell_type: 'markdown', source: 'hi', metadata: { trusted: true } }
|
|
592
|
+
]
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
it('drops the oceanum link and cell trust, and keeps everything else', () => {
|
|
596
|
+
const content = notebookFromUpload(JSON.stringify(linkedAndTrusted));
|
|
597
|
+
|
|
598
|
+
expect(content.metadata).toEqual({
|
|
599
|
+
kernelspec: { name: 'python', display_name: 'Python' }
|
|
600
|
+
});
|
|
601
|
+
expect(content.cells).toEqual([
|
|
602
|
+
{ cell_type: 'markdown', source: 'hi', metadata: {} }
|
|
603
|
+
]);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it.each([
|
|
607
|
+
['text that is not JSON', '{'],
|
|
608
|
+
['a JSON array', '[]'],
|
|
609
|
+
['nbformat 3', JSON.stringify({ ...linkedAndTrusted, nbformat: 3 })],
|
|
610
|
+
['no cells', JSON.stringify({ nbformat: 4, metadata: {} })],
|
|
611
|
+
['no metadata', JSON.stringify({ nbformat: 4, cells: [] })],
|
|
612
|
+
[
|
|
613
|
+
'a cell that is not a cell',
|
|
614
|
+
JSON.stringify({ nbformat: 4, metadata: {}, cells: [null, {}] })
|
|
615
|
+
]
|
|
616
|
+
])('refuses %s, without quoting the file', (_, text) => {
|
|
617
|
+
expect(() => notebookFromUpload(text)).toThrow(/not a Jupyter notebook/);
|
|
618
|
+
try {
|
|
619
|
+
notebookFromUpload(text);
|
|
620
|
+
} catch (error) {
|
|
621
|
+
expect((error as Error).message).not.toContain(text);
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
});
|
|
@@ -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,8 +94,11 @@ 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[] = [];
|
|
84
103
|
const bodies: Record<string, unknown>[] = [];
|
|
85
104
|
const names: Record<string, string> = { [ID_A]: 'Alpha', [ID_B]: 'Beta' };
|
|
@@ -100,7 +119,10 @@ function store() {
|
|
|
100
119
|
json: async () =>
|
|
101
120
|
method === 'GET' && id === 'notebook'
|
|
102
121
|
? [record(ID_A, 'Alpha'), record(ID_B, 'Beta')]
|
|
103
|
-
:
|
|
122
|
+
: {
|
|
123
|
+
...record(id, names[id] ?? 'Alpha'),
|
|
124
|
+
spec: stored[id] ?? notebook()
|
|
125
|
+
}
|
|
104
126
|
} as unknown as Response;
|
|
105
127
|
};
|
|
106
128
|
return { calls, bodies, fetcher };
|
|
@@ -139,7 +161,12 @@ interface IHarness {
|
|
|
139
161
|
calls: string[];
|
|
140
162
|
bodies: Record<string, unknown>[];
|
|
141
163
|
saved: string[];
|
|
164
|
+
/** What each save wrote, by path. */
|
|
165
|
+
written: Record<string, unknown>;
|
|
142
166
|
opened: string[];
|
|
167
|
+
/** The widgets `openOrReveal` returned, in order; set `isDisposed` to close one. */
|
|
168
|
+
widgets: { id: string; isDisposed: boolean }[];
|
|
169
|
+
activated: string[];
|
|
143
170
|
renamed: [string, string][];
|
|
144
171
|
/** What a right-click last landed on; never cleared, as JupyterLab does not. */
|
|
145
172
|
setHit: (node: HTMLElement | null) => void;
|
|
@@ -152,12 +179,16 @@ function activate(
|
|
|
152
179
|
panels?: NotebookPanel[];
|
|
153
180
|
current?: NotebookPanel | null;
|
|
154
181
|
folder?: Record<string, unknown>;
|
|
182
|
+
stored?: Record<string, INotebookContent>;
|
|
155
183
|
} = {}
|
|
156
184
|
): IHarness {
|
|
157
185
|
const commands = new CommandRegistry();
|
|
158
|
-
const { calls, bodies, fetcher } = store();
|
|
186
|
+
const { calls, bodies, fetcher } = store(options.stored);
|
|
159
187
|
const saved: string[] = [];
|
|
188
|
+
const written: Record<string, unknown> = {};
|
|
160
189
|
const opened: string[] = [];
|
|
190
|
+
const widgets: { id: string; isDisposed: boolean }[] = [];
|
|
191
|
+
const activated: string[] = [];
|
|
161
192
|
const renamed: [string, string][] = [];
|
|
162
193
|
const panels = options.panels ?? [];
|
|
163
194
|
let current = options.current ?? null;
|
|
@@ -185,7 +216,9 @@ function activate(
|
|
|
185
216
|
get currentWidget() {
|
|
186
217
|
return current;
|
|
187
218
|
},
|
|
188
|
-
activateById: (): void =>
|
|
219
|
+
activateById: (id: string): void => {
|
|
220
|
+
activated.push(id);
|
|
221
|
+
},
|
|
189
222
|
widgets: (): Widget[] => []
|
|
190
223
|
},
|
|
191
224
|
contextMenu: { addItem: (): void => undefined },
|
|
@@ -213,8 +246,13 @@ function activate(
|
|
|
213
246
|
}
|
|
214
247
|
return { type: 'notebook', content: folder[name] };
|
|
215
248
|
},
|
|
216
|
-
save: async (path: string) => {
|
|
249
|
+
save: async (path: string, model?: { content?: unknown }) => {
|
|
217
250
|
saved.push(path);
|
|
251
|
+
written[path] = model?.content;
|
|
252
|
+
// Like the real drive, the folder lists the file from now on.
|
|
253
|
+
if (path.startsWith('Oceanum/')) {
|
|
254
|
+
folder[path.replace('Oceanum/', '')] = model?.content;
|
|
255
|
+
}
|
|
218
256
|
return { path };
|
|
219
257
|
},
|
|
220
258
|
newUntitled: async () => ({ path: 'Untitled Folder' }),
|
|
@@ -224,7 +262,9 @@ function activate(
|
|
|
224
262
|
services: { contents },
|
|
225
263
|
openOrReveal: (path: string) => {
|
|
226
264
|
opened.push(path);
|
|
227
|
-
|
|
265
|
+
const widget = { id: `widget-${widgets.length}`, isDisposed: false };
|
|
266
|
+
widgets.push(widget);
|
|
267
|
+
return widget;
|
|
228
268
|
},
|
|
229
269
|
rename: async (from: string, to: string): Promise<void> => {
|
|
230
270
|
renamed.push([from, to]);
|
|
@@ -265,7 +305,10 @@ function activate(
|
|
|
265
305
|
calls,
|
|
266
306
|
bodies,
|
|
267
307
|
saved,
|
|
308
|
+
written,
|
|
268
309
|
opened,
|
|
310
|
+
widgets,
|
|
311
|
+
activated,
|
|
269
312
|
renamed,
|
|
270
313
|
setHit: node => {
|
|
271
314
|
hit = node;
|
|
@@ -424,3 +467,198 @@ describe('opening a record whose local copy already exists', () => {
|
|
|
424
467
|
expect(harness.saved).toEqual(['Oceanum/Alpha (1).ipynb']);
|
|
425
468
|
});
|
|
426
469
|
});
|
|
470
|
+
|
|
471
|
+
describe('opening an example', () => {
|
|
472
|
+
// As stored on Oceanum: linked to its own record, with a trusted cell.
|
|
473
|
+
const example: INotebookContent = {
|
|
474
|
+
...notebook(ID_A),
|
|
475
|
+
cells: [
|
|
476
|
+
{
|
|
477
|
+
cell_type: 'code',
|
|
478
|
+
source: 'print(1)',
|
|
479
|
+
metadata: { trusted: true },
|
|
480
|
+
outputs: [],
|
|
481
|
+
execution_count: null
|
|
482
|
+
}
|
|
483
|
+
]
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
it('writes an untrusted copy linked to nothing, named from its title', async () => {
|
|
487
|
+
const harness = activate({ stored: { [ID_A]: example } });
|
|
488
|
+
|
|
489
|
+
await harness.commands.execute(CommandIDs.openExample, {
|
|
490
|
+
id: ID_A,
|
|
491
|
+
title: 'Query a datasource'
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
// Read, never written back: the example's record is Oceanum's.
|
|
495
|
+
expect(harness.calls).toEqual([`GET ${ID_A}`]);
|
|
496
|
+
const path = 'Oceanum/Query a datasource.ipynb';
|
|
497
|
+
expect(harness.saved).toEqual([path]);
|
|
498
|
+
expect(harness.opened).toEqual([path]);
|
|
499
|
+
const content = harness.written[path] as INotebookContent;
|
|
500
|
+
// A linked copy's first save would PUT to the example's record and be refused.
|
|
501
|
+
expect(content.metadata).not.toHaveProperty('oceanum');
|
|
502
|
+
expect(content.cells[0].metadata).toEqual({});
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('never overwrites a file already there', async () => {
|
|
506
|
+
const harness = activate({
|
|
507
|
+
folder: { 'Query a datasource.ipynb': notebook() }
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
await harness.commands.execute(CommandIDs.openExample, {
|
|
511
|
+
id: ID_A,
|
|
512
|
+
title: 'Query a datasource'
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
expect(harness.saved).toEqual(['Oceanum/Query a datasource (1).ipynb']);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('brings its open copy forward instead of writing another', async () => {
|
|
519
|
+
const harness = activate();
|
|
520
|
+
const args = { id: ID_A, title: 'Example' };
|
|
521
|
+
|
|
522
|
+
await harness.commands.execute(CommandIDs.openExample, args);
|
|
523
|
+
await harness.commands.execute(CommandIDs.openExample, args);
|
|
524
|
+
|
|
525
|
+
expect(harness.saved).toEqual(['Oceanum/Example.ipynb']);
|
|
526
|
+
expect(harness.activated).toEqual([harness.widgets[0].id]);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it('writes one copy when opened twice before the first finishes', async () => {
|
|
530
|
+
const harness = activate();
|
|
531
|
+
const args = { id: ID_A, title: 'Example' };
|
|
532
|
+
|
|
533
|
+
await Promise.all([
|
|
534
|
+
harness.commands.execute(CommandIDs.openExample, args),
|
|
535
|
+
harness.commands.execute(CommandIDs.openExample, args)
|
|
536
|
+
]);
|
|
537
|
+
|
|
538
|
+
expect(harness.saved).toEqual(['Oceanum/Example.ipynb']);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it('writes a fresh copy once the last one was closed', async () => {
|
|
542
|
+
const harness = activate();
|
|
543
|
+
const args = { id: ID_A, title: 'Example' };
|
|
544
|
+
|
|
545
|
+
await harness.commands.execute(CommandIDs.openExample, args);
|
|
546
|
+
harness.widgets[0].isDisposed = true;
|
|
547
|
+
await harness.commands.execute(CommandIDs.openExample, args);
|
|
548
|
+
|
|
549
|
+
expect(harness.saved).toEqual([
|
|
550
|
+
'Oceanum/Example.ipynb',
|
|
551
|
+
'Oceanum/Example (1).ipynb'
|
|
552
|
+
]);
|
|
553
|
+
expect(harness.activated).toEqual([]);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it('falls back to the record name when given no title', async () => {
|
|
557
|
+
const harness = activate();
|
|
558
|
+
|
|
559
|
+
await harness.commands.execute(CommandIDs.openExample, { id: ID_A });
|
|
560
|
+
|
|
561
|
+
expect(harness.saved).toEqual(['Oceanum/Alpha.ipynb']);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it('ignores an id that is not a record id', async () => {
|
|
565
|
+
const harness = activate();
|
|
566
|
+
|
|
567
|
+
await harness.commands.execute(CommandIDs.openExample, {
|
|
568
|
+
id: '../eidos',
|
|
569
|
+
title: 'Nope'
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
expect(harness.calls).toEqual([]);
|
|
573
|
+
expect(harness.saved).toEqual([]);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it('is disabled while signed out', () => {
|
|
577
|
+
const harness = activate({ user: null });
|
|
578
|
+
|
|
579
|
+
expect(harness.commands.isEnabled(CommandIDs.openExample)).toBe(false);
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
describe('uploading a notebook', () => {
|
|
584
|
+
// As downloaded from someone else's record: linked to it, with a trusted cell.
|
|
585
|
+
const downloaded: INotebookContent = {
|
|
586
|
+
...notebook(ID_B),
|
|
587
|
+
cells: [
|
|
588
|
+
{
|
|
589
|
+
cell_type: 'code',
|
|
590
|
+
source: 'print(1)',
|
|
591
|
+
metadata: { trusted: true },
|
|
592
|
+
outputs: [],
|
|
593
|
+
execution_count: null
|
|
594
|
+
}
|
|
595
|
+
]
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
it('writes an untrusted copy linked to nothing into the Oceanum folder, and opens it', async () => {
|
|
599
|
+
mockPicked.file = {
|
|
600
|
+
name: 'analysis.ipynb',
|
|
601
|
+
text: JSON.stringify(downloaded)
|
|
602
|
+
};
|
|
603
|
+
const harness = activate();
|
|
604
|
+
|
|
605
|
+
await harness.commands.execute(CommandIDs.upload);
|
|
606
|
+
|
|
607
|
+
const path = 'Oceanum/analysis.ipynb';
|
|
608
|
+
expect(harness.saved).toEqual([path]);
|
|
609
|
+
expect(harness.opened).toEqual([path]);
|
|
610
|
+
const content = harness.written[path] as INotebookContent;
|
|
611
|
+
// Linked, its first save would write over the record it was downloaded from.
|
|
612
|
+
expect(content.metadata).not.toHaveProperty('oceanum');
|
|
613
|
+
expect(content.cells[0].metadata).toEqual({});
|
|
614
|
+
// Nothing reaches Oceanum until the notebook is saved.
|
|
615
|
+
expect(harness.calls).toEqual([]);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it('never overwrites a file already there', async () => {
|
|
619
|
+
mockPicked.file = {
|
|
620
|
+
name: 'analysis.ipynb',
|
|
621
|
+
text: JSON.stringify(notebook())
|
|
622
|
+
};
|
|
623
|
+
const harness = activate({ folder: { 'analysis.ipynb': notebook() } });
|
|
624
|
+
|
|
625
|
+
await harness.commands.execute(CommandIDs.upload);
|
|
626
|
+
|
|
627
|
+
expect(harness.saved).toEqual(['Oceanum/analysis (1).ipynb']);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it.each([
|
|
631
|
+
['not JSON', 'not json at all'],
|
|
632
|
+
['JSON that is not a notebook', JSON.stringify({ cells: [] })],
|
|
633
|
+
['an nbformat 3 notebook', JSON.stringify({ ...notebook(), nbformat: 3 })]
|
|
634
|
+
])('writes nothing for %s', async (_, text) => {
|
|
635
|
+
mockPicked.file = { name: 'bad.ipynb', text };
|
|
636
|
+
const harness = activate();
|
|
637
|
+
|
|
638
|
+
await harness.commands.execute(CommandIDs.upload);
|
|
639
|
+
|
|
640
|
+
expect(harness.saved).toEqual([]);
|
|
641
|
+
expect(harness.opened).toEqual([]);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it('writes nothing when the picker is cancelled', async () => {
|
|
645
|
+
const harness = activate();
|
|
646
|
+
|
|
647
|
+
await harness.commands.execute(CommandIDs.upload);
|
|
648
|
+
|
|
649
|
+
expect(mockPickerCalls.count).toBe(1);
|
|
650
|
+
expect(harness.saved).toEqual([]);
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it('asks for sign-in first, and writes nothing when declined', async () => {
|
|
654
|
+
mockPicked.file = { name: 'a.ipynb', text: JSON.stringify(notebook()) };
|
|
655
|
+
mockDialog.accept = false;
|
|
656
|
+
const harness = activate({ user: null });
|
|
657
|
+
|
|
658
|
+
await harness.commands.execute(CommandIDs.upload);
|
|
659
|
+
|
|
660
|
+
expect(mockPickerCalls.count).toBe(0);
|
|
661
|
+
expect(harness.saved).toEqual([]);
|
|
662
|
+
expect(harness.commands.isEnabled(CommandIDs.upload)).toBe(false);
|
|
663
|
+
});
|
|
664
|
+
});
|
package/src/auth/tokens.ts
CHANGED
|
@@ -32,6 +32,12 @@ export interface IOceanumEnvironment {
|
|
|
32
32
|
* absent) keeps JupyterLab's file management alongside the Oceanum commands.
|
|
33
33
|
*/
|
|
34
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;
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
/** Service base URLs for an environment, without trailing slashes. */
|
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
|
}
|