@oceanum/oceanumlab 4.8.0 → 4.10.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.
@@ -237,45 +237,25 @@ export function partitionSummaries(items, email) {
237
237
  shared: sorted.filter(item => !isMine(item))
238
238
  };
239
239
  }
240
- /** The `kind` of a Notebook Demo record's spec. */
241
- export const NOTEBOOK_DEMO_KIND = 'notebook-demo';
242
- function nonBlank(value) {
243
- return typeof value === 'string' && value.trim() !== '';
244
- }
240
+ /** The spec store type of the Notebook Demo specs, which are the Examples. */
241
+ export const NOTEBOOK_DEMO_TYPE = 'notebook-demo';
245
242
  /**
246
- * The sections of a Notebook Demo record's spec, keeping only what can be listed: items
247
- * with a record id and a title, in sections with a title and at least one such item.
248
- * A spec of another kind or version gives no sections, with a warning.
243
+ * The examples in a listing of Notebook Demo specs, ordered by name as people number
244
+ * them ("2 …" before "10 …").
249
245
  */
250
- export function parseNotebookDemo(spec) {
251
- if (!isObject(spec) ||
252
- spec.kind !== NOTEBOOK_DEMO_KIND ||
253
- spec.version !== 1 ||
254
- !Array.isArray(spec.sections)) {
255
- console.warn('Oceanum: ignoring a Notebook Demo record this version cannot read.');
256
- return [];
257
- }
258
- const sections = [];
259
- for (const section of spec.sections) {
260
- if (!isObject(section) ||
261
- !nonBlank(section.title) ||
262
- !Array.isArray(section.items)) {
263
- continue;
264
- }
265
- const items = [];
266
- for (const item of section.items) {
267
- if (!isObject(item) || !isSpecId(item.id) || !nonBlank(item.title)) {
268
- continue;
269
- }
270
- items.push(typeof item.summary === 'string'
271
- ? { id: item.id, title: item.title, summary: item.summary }
272
- : { id: item.id, title: item.title });
273
- }
274
- if (items.length > 0) {
275
- sections.push({ title: section.title, items });
276
- }
277
- }
278
- return sections;
246
+ export function demoItemsFromSummaries(summaries) {
247
+ // No id check: the client's listing already rejects any summary without a spec id.
248
+ return summaries
249
+ .map(summary => {
250
+ const title = summary.name.trim() || 'Untitled';
251
+ return summary.description
252
+ ? { id: summary.id, title, summary: summary.description }
253
+ : { id: summary.id, title };
254
+ })
255
+ .sort((a, b) => a.title.localeCompare(b.title, undefined, {
256
+ numeric: true,
257
+ sensitivity: 'base'
258
+ }));
279
259
  }
280
260
  /** Parse a spec store timestamp, which is UTC but usually has no zone designator. */
281
261
  export function parseTimestamp(value) {
@@ -9,7 +9,8 @@ import { startSignIn } from '../auth/startSignIn';
9
9
  import { IOceanumAuth } from '../auth/tokens';
10
10
  import { SpecStoreClient, SpecStoreError } from './client';
11
11
  import { notebooksChanged } from './events';
12
- import { buildSpecBody, isSpecId, METADATA_KEY, notebookFromRecord, notebookFromUpload, notebookNamesFor, OCEANUM_DIR, partitionSummaries, QUERY_PARAM, readLink, reportForShareFailures, sanitizeName, shareLink, uniqueNotebookPath, unlinkedNotebookFromRecord } from './notebook';
12
+ import { decodePayload, hashWithoutPayload, notebookFromDatamesh, notebookName, payloadFromHash } from './fromDatamesh';
13
+ import { buildSpecBody, isSpecId, METADATA_KEY, notebookFromRecord, notebookFromUpload, notebookNamesFor, NOTEBOOK_DEMO_TYPE, OCEANUM_DIR, partitionSummaries, QUERY_PARAM, readLink, reportForShareFailures, sanitizeName, shareLink, uniqueNotebookPath, unlinkedNotebookFromRecord } from './notebook';
13
14
  import { hideFileBrowser, moveToTop, NON_NOTEBOOK_NEW_COMMANDS, OCEANUM_PANEL_ID, pruneBeforeEachOpen, pruneFileMenu, routeSaves } from './oceanumOnly';
14
15
  import { chooseNotebookFile } from './upload';
15
16
  import { NotebookListBody, ShareBody } from './widgets';
@@ -51,6 +52,11 @@ export const sharePlugin = {
51
52
  client: new SpecStoreClient({
52
53
  specsUrl,
53
54
  getAccessToken: () => auth.getAccessToken()
55
+ }),
56
+ demos: new SpecStoreClient({
57
+ specsUrl,
58
+ getAccessToken: () => auth.getAccessToken(),
59
+ specType: NOTEBOOK_DEMO_TYPE
54
60
  })
55
61
  };
56
62
  const oceanumOnly = store !== null && ((_c = auth.environment) === null || _c === void 0 ? void 0 : _c.fileManagement) === 'oceanum';
@@ -64,6 +70,22 @@ export const sharePlugin = {
64
70
  let signInGeneration = 0;
65
71
  let warnedSignedOut = false;
66
72
  const signedIn = () => store !== null && auth.user !== null;
73
+ // A workspace sent by the Datamesh UI's "Open in notebook". Read it now, before
74
+ // anything can rewrite the address. It stays in the address until the notebook
75
+ // is written (see openFromDatamesh), so nothing is lost if the page is left
76
+ // before then. Not handled: a full-page sign-in redirect (signInRedirect) puts
77
+ // the hash back only after this plugin has activated, so the workspace opens
78
+ // on the next load rather than this one.
79
+ const datameshPayload = payloadFromHash(window.location.hash);
80
+ /** Remove the payload from the address, so a reload does not open a copy. */
81
+ const clearDatameshPayload = () => {
82
+ const url = new URL(window.location.href);
83
+ const hash = hashWithoutPayload(url.hash);
84
+ if (hash !== url.hash) {
85
+ url.hash = hash;
86
+ window.history.replaceState(window.history.state, '', url.toString());
87
+ }
88
+ };
67
89
  const label = (text) => () => store ? text : `${text}${UNAVAILABLE}`;
68
90
  // --- what a command acts on ---------------------------------------------------
69
91
  /**
@@ -293,7 +315,7 @@ export const sharePlugin = {
293
315
  }
294
316
  openingExamples.add(id);
295
317
  try {
296
- const record = await store.client.get(id);
318
+ const record = await store.demos.get(id);
297
319
  const content = unlinkedNotebookFromRecord(record);
298
320
  const path = uniqueNotebookPath(title || record.name, await oceanumFolderNames());
299
321
  exampleCopies.set(id, await writeAndOpen(path, content));
@@ -798,6 +820,37 @@ export const sharePlugin = {
798
820
  }
799
821
  clearQuery();
800
822
  }
823
+ /**
824
+ * Open a new notebook that loads a Datamesh UI workspace or query. It is written to
825
+ * the Oceanum folder unlinked, like an example, so the first save creates a record
826
+ * of the user's own. Nothing runs until the user runs it.
827
+ */
828
+ async function openFromDatamesh(encoded) {
829
+ let content;
830
+ let name;
831
+ try {
832
+ const request = decodePayload(encoded);
833
+ content = notebookFromDatamesh(request);
834
+ name = notebookName(request);
835
+ }
836
+ catch (error) {
837
+ clearDatameshPayload();
838
+ Notification.warning(error instanceof Error
839
+ ? error.message
840
+ : 'The Datamesh link is not valid.', { autoClose: 5000 });
841
+ return;
842
+ }
843
+ try {
844
+ const path = uniqueNotebookPath(name, await oceanumFolderNames());
845
+ await writeAndOpen(path, content);
846
+ }
847
+ catch (error) {
848
+ await reportError('Opening the Datamesh workspace failed', error);
849
+ }
850
+ finally {
851
+ clearDatameshPayload();
852
+ }
853
+ }
801
854
  // --- commands -----------------------------------------------------------------
802
855
  /** Whether the command can act on the pointer's row, the target notebook, or both. */
803
856
  const enabledFor = (wants, args) => {
@@ -977,5 +1030,8 @@ export const sharePlugin = {
977
1030
  Promise.all([app.restored, auth.ready])
978
1031
  .then(() => openFromQuery())
979
1032
  .catch(error => console.error('Failed to open the Oceanum notebook link', error));
1033
+ if (datameshPayload !== null) {
1034
+ void app.restored.then(() => openFromDatamesh(datameshPayload));
1035
+ }
980
1036
  }
981
1037
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oceanum/oceanumlab",
3
- "version": "4.8.0",
3
+ "version": "4.10.0",
4
4
  "description": "A Jupyterlab extension to interact with the Oceanum.io platform",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -33,7 +33,7 @@
33
33
  "showExamples": {
34
34
  "type": "boolean",
35
35
  "title": "Show examples",
36
- "description": "List Oceanum's example notebooks in the Notebooks tab, where the site provides them. Also switched from the Examples heading.",
36
+ "description": "List Oceanum's example notebooks (its Notebook Demo specs) in the Notebooks tab. Also switched from the Examples heading.",
37
37
  "default": true
38
38
  }
39
39
  },
@@ -1,13 +1,13 @@
1
1
  import * as React from 'react';
2
2
 
3
3
  import { IOceanumAuth } from './auth/tokens';
4
- import { SpecStoreClient } from './share/client';
4
+ import { SpecStoreClient, SpecStoreError } from './share/client';
5
5
  import { notebooksChanged } from './share/events';
6
6
  import {
7
+ demoItemsFromSummaries,
7
8
  INotebookDemoItem,
8
- INotebookDemoSection,
9
9
  ISpecSummary,
10
- parseNotebookDemo,
10
+ NOTEBOOK_DEMO_TYPE,
11
11
  partitionSummaries
12
12
  } from './share/notebook';
13
13
  import { SPEC_ID_ATTRIBUTE } from './share/plugin';
@@ -45,11 +45,11 @@ export interface IStoredNotebooksProps {
45
45
  onSignIn?: () => void;
46
46
  }
47
47
 
48
- /** The deployment's Notebook Demo record, which lists the Examples. */
48
+ /** The Notebook Demo specs the user can read, which are the Examples. */
49
49
  type Examples =
50
50
  | { state: 'none' }
51
51
  | { state: 'error'; message: string }
52
- | { state: 'ready'; sections: INotebookDemoSection[] };
52
+ | { state: 'ready'; items: INotebookDemoItem[] };
53
53
 
54
54
  type Load =
55
55
  | { state: 'loading' }
@@ -57,26 +57,29 @@ type Load =
57
57
  | { state: 'ready'; items: ISpecSummary[]; examples: Examples };
58
58
 
59
59
  /**
60
- * Read the Notebook Demo record, if the deployment names one. Never rejects: a failure
61
- * here is shown in the Examples section and leaves the rest of the tab alone.
60
+ * List the Notebook Demo specs, each one an example notebook. Only org oceanum may
61
+ * create them (spec store 0.6.2), so every one listed is Oceanum's. Never rejects: a
62
+ * failure here is shown in the Examples section and leaves the rest of the tab alone.
62
63
  */
63
64
  async function loadExamples(
64
65
  auth: IOceanumAuth,
65
- specsUrl: string,
66
- id: string | null
66
+ specsUrl: string
67
67
  ): Promise<Examples> {
68
- if (!id) {
69
- return { state: 'none' };
70
- }
71
68
  try {
72
69
  const client = new SpecStoreClient({
73
70
  specsUrl,
74
71
  getAccessToken: () => auth.getAccessToken(),
75
- specType: 'notebook-demo'
72
+ specType: NOTEBOOK_DEMO_TYPE
76
73
  });
77
- const record = await client.get(id);
78
- return { state: 'ready', sections: parseNotebookDemo(record.spec) };
74
+ return {
75
+ state: 'ready',
76
+ items: demoItemsFromSummaries(await client.list())
77
+ };
79
78
  } catch (error) {
79
+ // A store with no Notebook Demo type has no examples, which is not an error.
80
+ if (error instanceof SpecStoreError && error.kind === 'not-found') {
81
+ return { state: 'none' };
82
+ }
80
83
  return {
81
84
  state: 'error',
82
85
  message:
@@ -86,9 +89,10 @@ async function loadExamples(
86
89
  }
87
90
 
88
91
  /**
89
- * The curated examples. Their rows deliberately leave out SPEC_ID_ATTRIBUTE and the
90
- * stored-notebook row class: the share plugin's row menu (Open, Rename, Share, Delete)
91
- * is bound to those, and none of it applies to Oceanum's own example records.
92
+ * The examples: Oceanum's Notebook Demo specs. Their rows deliberately leave out
93
+ * SPEC_ID_ATTRIBUTE and the stored-notebook row class: the share plugin's row menu
94
+ * (Open, Rename, Share, Delete) is bound to those, and none of it applies to
95
+ * Oceanum's own example records.
92
96
  */
93
97
  function ExampleSections({
94
98
  examples,
@@ -105,13 +109,7 @@ function ExampleSections({
105
109
  if (examples.state === 'none' || show === null || (!show && !onShowChange)) {
106
110
  return null;
107
111
  }
108
- const count =
109
- examples.state === 'ready'
110
- ? examples.sections.reduce(
111
- (total, section) => total + section.items.length,
112
- 0
113
- )
114
- : null;
112
+ const count = examples.state === 'ready' ? examples.items.length : null;
115
113
  if (count === 0) {
116
114
  return null;
117
115
  }
@@ -155,45 +153,39 @@ function ExampleSections({
155
153
  return (
156
154
  <div className="oceanum-notebooks-section">
157
155
  {heading}
158
- {examples.sections.map((section, index) => (
159
- <div key={index}>
160
- {/* Titles come from the record: React escapes them, never set as HTML. */}
161
- <div className="oceanum-notebooks-subheading">{section.title}</div>
162
- <ul className="oceanum-notebooks-list">
163
- {section.items.map((item, position) => {
164
- const attributes = {
165
- [EXAMPLE_ID_ATTRIBUTE]: item.id,
166
- title: item.summary
167
- };
168
- const name = (
169
- <span className="oceanum-notebooks-name">{item.title}</span>
170
- );
171
- return (
172
- // A record may list the same example twice; the id alone is no key.
173
- <li key={`${position}:${item.id}`}>
174
- {onOpen ? (
175
- <button
176
- type="button"
177
- className="oceanum-notebooks-example"
178
- onClick={() => onOpen(item)}
179
- {...attributes}
180
- >
181
- {name}
182
- </button>
183
- ) : (
184
- <div
185
- className="oceanum-notebooks-example oceanum-notebooks-item-static"
186
- {...attributes}
187
- >
188
- {name}
189
- </div>
190
- )}
191
- </li>
192
- );
193
- })}
194
- </ul>
195
- </div>
196
- ))}
156
+ <ul className="oceanum-notebooks-list">
157
+ {examples.items.map(item => {
158
+ const attributes = {
159
+ [EXAMPLE_ID_ATTRIBUTE]: item.id,
160
+ title: item.summary
161
+ };
162
+ // Names come from the spec store: React escapes them, never set as HTML.
163
+ const name = (
164
+ <span className="oceanum-notebooks-name">{item.title}</span>
165
+ );
166
+ return (
167
+ <li key={item.id}>
168
+ {onOpen ? (
169
+ <button
170
+ type="button"
171
+ className="oceanum-notebooks-example"
172
+ onClick={() => onOpen(item)}
173
+ {...attributes}
174
+ >
175
+ {name}
176
+ </button>
177
+ ) : (
178
+ <div
179
+ className="oceanum-notebooks-example oceanum-notebooks-item-static"
180
+ {...attributes}
181
+ >
182
+ {name}
183
+ </div>
184
+ )}
185
+ </li>
186
+ );
187
+ })}
188
+ </ul>
197
189
  </div>
198
190
  );
199
191
  }
@@ -302,11 +294,9 @@ export function StoredNotebooks({
302
294
  specsUrl: specs,
303
295
  getAccessToken: () => auth.getAccessToken()
304
296
  });
305
- // Only the record the deployment names: every signed-in user can create
306
- // notebook-demo records, so the examples never come from a listing.
307
297
  const [items, examples] = await Promise.all([
308
298
  client.list(),
309
- loadExamples(auth, specs, auth.environment?.notebookDemo ?? null)
299
+ loadExamples(auth, specs)
310
300
  ]);
311
301
  if (!cancelled) {
312
302
  setLoad({ state: 'ready', items, examples });
@@ -369,15 +359,6 @@ export function StoredNotebooks({
369
359
  }
370
360
 
371
361
  const { mine, shared } = partitionSummaries(load.items, user.email);
372
- // A public example is readable by everyone, so the listing returns it as shared with
373
- // every signed-in user; it is shown under Examples instead.
374
- const exampleIds = new Set(
375
- load.examples.state === 'ready'
376
- ? load.examples.sections.flatMap(section =>
377
- section.items.map(item => item.id)
378
- )
379
- : []
380
- );
381
362
  return (
382
363
  <div className="oceanum-notebooks">
383
364
  {/* Without a spec store the upload command does nothing. */}
@@ -401,7 +382,7 @@ export function StoredNotebooks({
401
382
  />
402
383
  <Section
403
384
  title="Shared with me"
404
- items={shared.filter(item => !exampleIds.has(item.id))}
385
+ items={shared}
405
386
  empty="No notebooks have been shared with you."
406
387
  onOpen={onOpen}
407
388
  />
@@ -160,7 +160,6 @@ describe('the Oceanum panel', () => {
160
160
  });
161
161
 
162
162
  describe('the Examples switch in the Oceanum panel', () => {
163
- const DEMO_ID = '3e4f5a6b-4444-4a2b-8c3d-9e8f7a6b5c4d';
164
163
  const EXAMPLE = '0b7d9f2e-1111-4a2b-8c3d-9e8f7a6b5c4d';
165
164
  const originalFetch = globalThis.fetch;
166
165
  afterEach(() => {
@@ -179,27 +178,23 @@ describe('the Examples switch in the Oceanum panel', () => {
179
178
 
180
179
  it('saves the choice, and follows the setting once it has changed', async () => {
181
180
  globalThis.fetch = (async (input: RequestInfo | URL) => {
182
- const demo = String(input).includes('/specs/notebook-demo/');
183
- const data: unknown = demo
184
- ? {
185
- id: DEMO_ID,
186
- name: 'Notebook Demo',
187
- description: null as string | null,
188
- modified: '2026-09-22T10:00:00',
189
- creator: null as string | null,
190
- spec: {
191
- kind: 'notebook-demo',
192
- version: 1,
193
- sections: [
194
- { title: 'Start', items: [{ id: EXAMPLE, title: 'Query' }] }
195
- ]
181
+ // The Notebook Demo listing has one example; the user has no notebooks.
182
+ const demos = String(input).endsWith('/specs/notebook-demo');
183
+ const data: unknown = demos
184
+ ? [
185
+ {
186
+ id: EXAMPLE,
187
+ name: 'Query',
188
+ description: null as string | null,
189
+ modified: '2026-09-22T10:00:00',
190
+ creator: null as string | null
196
191
  }
197
- }
192
+ ]
198
193
  : [];
199
194
  return { ok: true, status: 200, json: async () => data } as Response;
200
195
  }) as typeof fetch;
201
196
  const auth = {
202
- environment: { notebookDemo: DEMO_ID },
197
+ environment: {},
203
198
  urls: { specs: 'https://specs.example.com' },
204
199
  ready: Promise.resolve(),
205
200
  user: {