@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.
@@ -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
- /** 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[] = [];
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
- : record(id, names[id] ?? 'Alpha')
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 => undefined,
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
- return {};
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 = '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
  }