@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.
@@ -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
- /** Records the spec store calls a command makes, and answers them. */
82
- function store() {
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
- : record(id, names[id] ?? 'Alpha')
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 => undefined,
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
- return {};
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
+ });
@@ -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 = 'datamesh-connect';
194
+ datameshConnectWidget.id = OCEANUM_PANEL_ID;
161
195
  datameshConnectWidget.title.icon = oceanumIcon;
162
196
  datameshConnectWidget.title.caption = 'Datamesh Connect';
163
197
 
@@ -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 the `notebook` spec type of the Oceanum spec store. */
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
- this._base = `${options.specsUrl.replace(/\/+$/, '')}/specs/notebook`;
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
  }