@oceanum/oceanumlab 4.8.0 → 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.
@@ -1,28 +1,32 @@
1
1
  import * as React from 'react';
2
- import { SpecStoreClient } from './share/client';
2
+ import { SpecStoreClient, SpecStoreError } from './share/client';
3
3
  import { notebooksChanged } from './share/events';
4
- import { parseNotebookDemo, partitionSummaries } from './share/notebook';
4
+ import { demoItemsFromSummaries, NOTEBOOK_DEMO_TYPE, partitionSummaries } from './share/notebook';
5
5
  import { SPEC_ID_ATTRIBUTE } from './share/plugin';
6
6
  /** Example rows carry their record id here, not in SPEC_ID_ATTRIBUTE: see Examples. */
7
7
  export const EXAMPLE_ID_ATTRIBUTE = 'data-example-id';
8
8
  /**
9
- * Read the Notebook Demo record, if the deployment names one. Never rejects: a failure
10
- * here is shown in the Examples section and leaves the rest of the tab alone.
9
+ * List the Notebook Demo specs, each one an example notebook. Only org oceanum may
10
+ * create them (spec store 0.6.2), so every one listed is Oceanum's. Never rejects: a
11
+ * failure here is shown in the Examples section and leaves the rest of the tab alone.
11
12
  */
12
- async function loadExamples(auth, specsUrl, id) {
13
- if (!id) {
14
- return { state: 'none' };
15
- }
13
+ async function loadExamples(auth, specsUrl) {
16
14
  try {
17
15
  const client = new SpecStoreClient({
18
16
  specsUrl,
19
17
  getAccessToken: () => auth.getAccessToken(),
20
- specType: 'notebook-demo'
18
+ specType: NOTEBOOK_DEMO_TYPE
21
19
  });
22
- const record = await client.get(id);
23
- return { state: 'ready', sections: parseNotebookDemo(record.spec) };
20
+ return {
21
+ state: 'ready',
22
+ items: demoItemsFromSummaries(await client.list())
23
+ };
24
24
  }
25
25
  catch (error) {
26
+ // A store with no Notebook Demo type has no examples, which is not an error.
27
+ if (error instanceof SpecStoreError && error.kind === 'not-found') {
28
+ return { state: 'none' };
29
+ }
26
30
  return {
27
31
  state: 'error',
28
32
  message: error instanceof Error ? error.message : 'Could not reach Oceanum.io.'
@@ -30,18 +34,17 @@ async function loadExamples(auth, specsUrl, id) {
30
34
  }
31
35
  }
32
36
  /**
33
- * The curated examples. Their rows deliberately leave out SPEC_ID_ATTRIBUTE and the
34
- * stored-notebook row class: the share plugin's row menu (Open, Rename, Share, Delete)
35
- * is bound to those, and none of it applies to Oceanum's own example records.
37
+ * The examples: Oceanum's Notebook Demo specs. Their rows deliberately leave out
38
+ * SPEC_ID_ATTRIBUTE and the stored-notebook row class: the share plugin's row menu
39
+ * (Open, Rename, Share, Delete) is bound to those, and none of it applies to
40
+ * Oceanum's own example records.
36
41
  */
37
42
  function ExampleSections({ examples, onOpen, show, onShowChange }) {
38
43
  // Not yet known, or hidden with no switch to bring them back: nothing to show.
39
44
  if (examples.state === 'none' || show === null || (!show && !onShowChange)) {
40
45
  return null;
41
46
  }
42
- const count = examples.state === 'ready'
43
- ? examples.sections.reduce((total, section) => total + section.items.length, 0)
44
- : null;
47
+ const count = examples.state === 'ready' ? examples.items.length : null;
45
48
  if (count === 0) {
46
49
  return null;
47
50
  }
@@ -64,18 +67,15 @@ function ExampleSections({ examples, onOpen, show, onShowChange }) {
64
67
  }
65
68
  return (React.createElement("div", { className: "oceanum-notebooks-section" },
66
69
  heading,
67
- examples.sections.map((section, index) => (React.createElement("div", { key: index },
68
- React.createElement("div", { className: "oceanum-notebooks-subheading" }, section.title),
69
- React.createElement("ul", { className: "oceanum-notebooks-list" }, section.items.map((item, position) => {
70
- const attributes = {
71
- [EXAMPLE_ID_ATTRIBUTE]: item.id,
72
- title: item.summary
73
- };
74
- const name = (React.createElement("span", { className: "oceanum-notebooks-name" }, item.title));
75
- return (
76
- // A record may list the same example twice; the id alone is no key.
77
- React.createElement("li", { key: `${position}:${item.id}` }, onOpen ? (React.createElement("button", { type: "button", className: "oceanum-notebooks-example", onClick: () => onOpen(item), ...attributes }, name)) : (React.createElement("div", { className: "oceanum-notebooks-example oceanum-notebooks-item-static", ...attributes }, name))));
78
- })))))));
70
+ React.createElement("ul", { className: "oceanum-notebooks-list" }, examples.items.map(item => {
71
+ const attributes = {
72
+ [EXAMPLE_ID_ATTRIBUTE]: item.id,
73
+ title: item.summary
74
+ };
75
+ // Names come from the spec store: React escapes them, never set as HTML.
76
+ const name = (React.createElement("span", { className: "oceanum-notebooks-name" }, item.title));
77
+ return (React.createElement("li", { key: item.id }, onOpen ? (React.createElement("button", { type: "button", className: "oceanum-notebooks-example", onClick: () => onOpen(item), ...attributes }, name)) : (React.createElement("div", { className: "oceanum-notebooks-example oceanum-notebooks-item-static", ...attributes }, name))));
78
+ }))));
79
79
  }
80
80
  function Section({ title, items, empty, onOpen }) {
81
81
  // Each row carries its record id so the share plugin's context menu (Open, Rename,
@@ -127,17 +127,14 @@ export function StoredNotebooks({ auth, onOpen, onOpenExample, onUpload, showExa
127
127
  }
128
128
  setLoad({ state: 'loading' });
129
129
  void (async () => {
130
- var _a, _b;
131
130
  try {
132
131
  const client = new SpecStoreClient({
133
132
  specsUrl: specs,
134
133
  getAccessToken: () => auth.getAccessToken()
135
134
  });
136
- // Only the record the deployment names: every signed-in user can create
137
- // notebook-demo records, so the examples never come from a listing.
138
135
  const [items, examples] = await Promise.all([
139
136
  client.list(),
140
- loadExamples(auth, specs, (_b = (_a = auth.environment) === null || _a === void 0 ? void 0 : _a.notebookDemo) !== null && _b !== void 0 ? _b : null)
137
+ loadExamples(auth, specs)
141
138
  ]);
142
139
  if (!cancelled) {
143
140
  setLoad({ state: 'ready', items, examples });
@@ -187,15 +184,10 @@ export function StoredNotebooks({ auth, onOpen, onOpenExample, onUpload, showExa
187
184
  return React.createElement("div", { className: "oceanum-text-error" }, load.message);
188
185
  }
189
186
  const { mine, shared } = partitionSummaries(load.items, user.email);
190
- // A public example is readable by everyone, so the listing returns it as shared with
191
- // every signed-in user; it is shown under Examples instead.
192
- const exampleIds = new Set(load.examples.state === 'ready'
193
- ? load.examples.sections.flatMap(section => section.items.map(item => item.id))
194
- : []);
195
187
  return (React.createElement("div", { className: "oceanum-notebooks" },
196
188
  onUpload && ((_a = auth.urls) === null || _a === void 0 ? void 0 : _a.specs) ? (React.createElement("div", { className: "oceanum-notebooks-actions" },
197
189
  React.createElement("button", { type: "button", className: "oceanum-notebooks-upload", title: "Upload a notebook from this computer, to save on Oceanum.io", onClick: onUpload }, "Upload notebook\u2026"))) : null,
198
190
  React.createElement(Section, { title: "My notebooks", items: mine, empty: "You have not saved any notebooks yet.", onOpen: onOpen }),
199
- React.createElement(Section, { title: "Shared with me", items: shared.filter(item => !exampleIds.has(item.id)), empty: "No notebooks have been shared with you.", onOpen: onOpen }),
191
+ React.createElement(Section, { title: "Shared with me", items: shared, empty: "No notebooks have been shared with you.", onOpen: onOpen }),
200
192
  React.createElement(ExampleSections, { examples: load.examples, onOpen: onOpenExample, show: showExamples, onShowChange: onShowExamplesChange })));
201
193
  }
@@ -31,12 +31,6 @@ export interface IOceanumEnvironment {
31
31
  * absent) keeps JupyterLab's file management alongside the Oceanum commands.
32
32
  */
33
33
  readonly fileManagement?: 'local' | 'oceanum';
34
- /**
35
- * The spec store id of this deployment's Notebook Demo record, the curated list the
36
- * Notebooks tab shows as Examples. Absent where a deployment has none, and never set
37
- * by a JupyterLab server's sign-in.
38
- */
39
- readonly notebookDemo?: string;
40
34
  }
41
35
  /** Service base URLs for an environment, without trailing slashes. */
42
36
  export interface IOceanumServiceUrls {
@@ -141,25 +141,19 @@ export declare function partitionSummaries(items: readonly ISpecSummary[], email
141
141
  mine: ISpecSummary[];
142
142
  shared: ISpecSummary[];
143
143
  };
144
- /** The `kind` of a Notebook Demo record's spec. */
145
- export declare const NOTEBOOK_DEMO_KIND = "notebook-demo";
146
- /** One example in a Notebook Demo record: a notebook record and how to list it. */
144
+ /** The spec store type of the Notebook Demo specs, which are the Examples. */
145
+ export declare const NOTEBOOK_DEMO_TYPE = "notebook-demo";
146
+ /** One example: a Notebook Demo spec, whose body is the example notebook. */
147
147
  export interface INotebookDemoItem {
148
148
  id: string;
149
149
  title: string;
150
150
  summary?: string;
151
151
  }
152
- /** A titled group of examples. */
153
- export interface INotebookDemoSection {
154
- title: string;
155
- items: INotebookDemoItem[];
156
- }
157
152
  /**
158
- * The sections of a Notebook Demo record's spec, keeping only what can be listed: items
159
- * with a record id and a title, in sections with a title and at least one such item.
160
- * A spec of another kind or version gives no sections, with a warning.
153
+ * The examples in a listing of Notebook Demo specs, ordered by name as people number
154
+ * them ("2 …" before "10 …").
161
155
  */
162
- export declare function parseNotebookDemo(spec: unknown): INotebookDemoSection[];
156
+ export declare function demoItemsFromSummaries(summaries: readonly ISpecSummary[]): INotebookDemoItem[];
163
157
  /** Parse a spec store timestamp, which is UTC but usually has no zone designator. */
164
158
  export declare function parseTimestamp(value: string): Date | null;
165
159
  /**
@@ -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,7 @@ 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 { buildSpecBody, isSpecId, METADATA_KEY, notebookFromRecord, notebookFromUpload, notebookNamesFor, NOTEBOOK_DEMO_TYPE, OCEANUM_DIR, partitionSummaries, QUERY_PARAM, readLink, reportForShareFailures, sanitizeName, shareLink, uniqueNotebookPath, unlinkedNotebookFromRecord } from './notebook';
13
13
  import { hideFileBrowser, moveToTop, NON_NOTEBOOK_NEW_COMMANDS, OCEANUM_PANEL_ID, pruneBeforeEachOpen, pruneFileMenu, routeSaves } from './oceanumOnly';
14
14
  import { chooseNotebookFile } from './upload';
15
15
  import { NotebookListBody, ShareBody } from './widgets';
@@ -51,6 +51,11 @@ export const sharePlugin = {
51
51
  client: new SpecStoreClient({
52
52
  specsUrl,
53
53
  getAccessToken: () => auth.getAccessToken()
54
+ }),
55
+ demos: new SpecStoreClient({
56
+ specsUrl,
57
+ getAccessToken: () => auth.getAccessToken(),
58
+ specType: NOTEBOOK_DEMO_TYPE
54
59
  })
55
60
  };
56
61
  const oceanumOnly = store !== null && ((_c = auth.environment) === null || _c === void 0 ? void 0 : _c.fileManagement) === 'oceanum';
@@ -293,7 +298,7 @@ export const sharePlugin = {
293
298
  }
294
299
  openingExamples.add(id);
295
300
  try {
296
- const record = await store.client.get(id);
301
+ const record = await store.demos.get(id);
297
302
  const content = unlinkedNotebookFromRecord(record);
298
303
  const path = uniqueNotebookPath(title || record.name, await oceanumFolderNames());
299
304
  exampleCopies.set(id, await writeAndOpen(path, content));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oceanum/oceanumlab",
3
- "version": "4.8.0",
3
+ "version": "4.9.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: {
@@ -86,7 +86,10 @@ describe('the Notebooks tab while signed in', () => {
86
86
  const fetches: string[] = [];
87
87
  const originalFetch = globalThis.fetch;
88
88
  globalThis.fetch = (async (input: RequestInfo | URL) => {
89
- fetches.push(String(input));
89
+ // Counts the notebook listing only; each load also lists the demos.
90
+ if (String(input).endsWith('/specs/notebook')) {
91
+ fetches.push(String(input));
92
+ }
90
93
  return {
91
94
  ok: true,
92
95
  status: 200,
@@ -130,53 +133,41 @@ describe('the Notebooks tab while signed in', () => {
130
133
 
131
134
  describe('the Examples in the Notebooks tab', () => {
132
135
  const MINE = '6f1c1a52-4b8e-4c0f-9a57-1d2e3f4a5b6c';
133
- const PUBLIC_EXAMPLE = '0b7d9f2e-1111-4a2b-8c3d-9e8f7a6b5c4d';
134
- const OTHER_EXAMPLE = '1c2d3e4f-2222-4a2b-8c3d-9e8f7a6b5c4d';
136
+ const QUERY = '0b7d9f2e-1111-4a2b-8c3d-9e8f7a6b5c4d';
137
+ const PLOT = '1c2d3e4f-2222-4a2b-8c3d-9e8f7a6b5c4d';
135
138
  const SHARED = '2d3e4f5a-3333-4a2b-8c3d-9e8f7a6b5c4d';
136
- const DEMO_ID = '3e4f5a6b-4444-4a2b-8c3d-9e8f7a6b5c4d';
137
-
138
- const summary = (id: string, name: string, creator: string | null) => ({
139
+ const SPECTRA = '3e4f5a6b-4444-4a2b-8c3d-9e8f7a6b5c4d';
140
+
141
+ const summary = (
142
+ id: string,
143
+ name: string,
144
+ creator: string | null,
145
+ description: string | null = null
146
+ ) => ({
139
147
  id,
140
148
  name,
141
- description: null as string | null,
149
+ description,
142
150
  modified: '2026-09-12T10:00:00',
143
151
  creator
144
152
  });
145
153
 
146
- // The listing returns a public example as shared with every signed-in user.
154
+ // The user's notebooks.
147
155
  const LISTING = [
148
156
  summary(MINE, 'Mine', 'me@example.com'),
149
- summary(PUBLIC_EXAMPLE, 'Query (example)', null),
150
157
  summary(SHARED, 'Shared', null)
151
158
  ];
152
159
 
153
- const DEMO = {
154
- ...summary(DEMO_ID, 'Notebook Demo', null),
155
- spec: {
156
- kind: 'notebook-demo',
157
- version: 1,
158
- sections: [
159
- {
160
- title: 'Getting started',
161
- items: [
162
- {
163
- id: PUBLIC_EXAMPLE,
164
- title: 'Query a datasource',
165
- summary: 'One line'
166
- },
167
- { id: OTHER_EXAMPLE, title: 'Plot waves' }
168
- ]
169
- },
170
- // A curator's own example is also theirs: it stays in My notebooks.
171
- { title: 'Advanced', items: [{ id: MINE, title: 'Spectra' }] }
172
- ]
173
- }
174
- };
160
+ // The Notebook Demo specs, each one an example notebook, in no particular order.
161
+ const DEMOS = [
162
+ summary(PLOT, '10 Plot waves', null),
163
+ summary(SPECTRA, 'Spectra', null),
164
+ summary(QUERY, '2 Query a datasource', null, 'One line')
165
+ ];
175
166
 
176
167
  const ok = (data: unknown): Response =>
177
168
  ({ ok: true, status: 200, json: async () => data }) as unknown as Response;
178
169
 
179
- function signedIn(notebookDemo?: string): IOceanumAuth {
170
+ function signedIn(): IOceanumAuth {
180
171
  const auth = signedOut();
181
172
  (auth as { user: IOceanumUser | null }).user = {
182
173
  sub: 'auth0|1',
@@ -186,20 +177,19 @@ describe('the Examples in the Notebooks tab', () => {
186
177
  colorScheme: null
187
178
  };
188
179
  auth.getAccessToken = async () => 'tok';
189
- (auth as { environment: unknown }).environment = notebookDemo
190
- ? { notebookDemo }
191
- : {};
180
+ (auth as { environment: unknown }).environment = {};
192
181
  return auth;
193
182
  }
194
183
 
195
184
  const originalFetch = globalThis.fetch;
196
185
  let fetches: string[];
197
- function stubFetch(demo: () => Response = () => ok(DEMO)): void {
186
+ const DEMO_LISTING = 'https://specs.example.com/specs/notebook-demo';
187
+ function stubFetch(demos: () => Response = () => ok(DEMOS)): void {
198
188
  fetches = [];
199
189
  globalThis.fetch = (async (input: RequestInfo | URL) => {
200
190
  const url = String(input);
201
191
  fetches.push(url);
202
- return url.includes('/specs/notebook-demo/') ? demo() : ok(LISTING);
192
+ return url === DEMO_LISTING ? demos() : ok(LISTING);
203
193
  }) as typeof fetch;
204
194
  }
205
195
  afterEach(() => {
@@ -268,73 +258,83 @@ describe('the Examples in the Notebooks tab', () => {
268
258
  Widget.detach(widget);
269
259
  });
270
260
 
271
- it('lists the record’s examples in order, under their section titles', async () => {
261
+ it('lists every Notebook Demo spec as an example, ordered by name', async () => {
272
262
  stubFetch();
273
- const widget = await render(signedIn(DEMO_ID));
263
+ const widget = await render(signedIn());
274
264
 
275
265
  const examples = section(widget, 'Examples')!;
276
266
  expect(examples).not.toBeNull();
277
- expect(
278
- Array.from(
279
- examples.querySelectorAll('.oceanum-notebooks-subheading'),
280
- node => node.textContent
281
- )
282
- ).toEqual(['Getting started', 'Advanced']);
283
267
  const rows = Array.from(
284
268
  examples.querySelectorAll<HTMLElement>('[data-example-id]')
285
269
  );
270
+ // Numbered as people number them: 2 before 10.
286
271
  expect(rows.map(row => row.textContent)).toEqual([
287
- 'Query a datasource',
288
- 'Plot waves',
272
+ '2 Query a datasource',
273
+ '10 Plot waves',
289
274
  'Spectra'
290
275
  ]);
291
276
  expect(rows.map(row => row.getAttribute('data-example-id'))).toEqual([
292
- PUBLIC_EXAMPLE,
293
- OTHER_EXAMPLE,
294
- MINE
277
+ QUERY,
278
+ PLOT,
279
+ SPECTRA
295
280
  ]);
296
281
  expect(rows[0].getAttribute('title')).toBe('One line');
297
282
  expect(
298
283
  examples.querySelector('.oceanum-notebooks-count')?.textContent
299
284
  ).toBe('3');
300
- expect(fetches).toContain(
301
- `https://specs.example.com/specs/notebook-demo/${DEMO_ID}`
302
- );
285
+ expect(fetches).toContain(DEMO_LISTING);
303
286
  widget.dispose();
304
287
  });
305
288
 
306
- it('moves examples out of Shared with me, but leaves My notebooks alone', async () => {
289
+ it('leaves My notebooks and Shared with me as listed', async () => {
307
290
  stubFetch();
308
- const widget = await render(signedIn(DEMO_ID));
291
+ const widget = await render(signedIn());
309
292
 
293
+ expect(idsIn(section(widget, 'My notebooks'), 'data-spec-id')).toEqual([
294
+ MINE
295
+ ]);
310
296
  expect(idsIn(section(widget, 'Shared with me'), 'data-spec-id')).toEqual([
311
297
  SHARED
312
298
  ]);
299
+ widget.dispose();
300
+ });
301
+
302
+ it('shows no Examples section when there are no demos', async () => {
303
+ stubFetch(() => ok([]));
304
+ const widget = await render(signedIn());
305
+
306
+ expect(section(widget, 'Examples')).toBeNull();
307
+ widget.dispose();
308
+ });
309
+
310
+ it('shows no Examples, and no error, where the store has no Notebook Demo type', async () => {
311
+ stubFetch(
312
+ () => ({ ok: false, status: 404, json: async () => ({}) }) as Response
313
+ );
314
+ const widget = await render(signedIn());
315
+
316
+ expect(section(widget, 'Examples')).toBeNull();
313
317
  expect(idsIn(section(widget, 'My notebooks'), 'data-spec-id')).toEqual([
314
318
  MINE
315
319
  ]);
316
320
  widget.dispose();
317
321
  });
318
322
 
319
- it('asks for nothing more and shows no Examples where none is configured', async () => {
323
+ it('asks for no demos while signed out', async () => {
320
324
  stubFetch();
321
- const widget = await render(signedIn());
325
+ const widget = new Harness(signedOut());
326
+ Widget.attach(widget, document.body);
327
+ await new Promise(resolve => setTimeout(resolve, 100));
322
328
 
323
- expect(section(widget, 'Examples')).toBeNull();
324
- expect(fetches).toEqual(['https://specs.example.com/specs/notebook']);
325
- // Without a record, a public example is just another shared notebook.
326
- expect(idsIn(section(widget, 'Shared with me'), 'data-spec-id')).toEqual([
327
- PUBLIC_EXAMPLE,
328
- SHARED
329
- ]);
329
+ expect(fetches).toEqual([]);
330
330
  widget.dispose();
331
331
  });
332
332
 
333
- it('says so when the record cannot be read, and still lists the rest', async () => {
333
+ it('says so when the demos cannot be listed, and still lists the rest', async () => {
334
334
  stubFetch(
335
- () => ({ ok: false, status: 404, json: async () => ({}) }) as Response
335
+ () => ({ ok: false, status: 500, json: async () => ({}) }) as Response
336
336
  );
337
- const widget = await render(signedIn(DEMO_ID));
337
+ const widget = await render(signedIn());
338
338
 
339
339
  const examples = section(widget, 'Examples')!;
340
340
  expect(
@@ -345,7 +345,6 @@ describe('the Examples in the Notebooks tab', () => {
345
345
  MINE
346
346
  ]);
347
347
  expect(idsIn(section(widget, 'Shared with me'), 'data-spec-id')).toEqual([
348
- PUBLIC_EXAMPLE,
349
348
  SHARED
350
349
  ]);
351
350
  widget.dispose();
@@ -354,7 +353,7 @@ describe('the Examples in the Notebooks tab', () => {
354
353
  it('keeps example rows out of the stored-notebook row menu', async () => {
355
354
  // Its Rename and Delete would act on Oceanum's own example records.
356
355
  stubFetch();
357
- const widget = await render(signedIn(DEMO_ID), () => undefined);
356
+ const widget = await render(signedIn(), () => undefined);
358
357
 
359
358
  const rows = section(widget, 'Examples')!.querySelectorAll('button');
360
359
  expect(rows).toHaveLength(3);
@@ -367,13 +366,13 @@ describe('the Examples in the Notebooks tab', () => {
367
366
  it('opens an example through onOpenExample, with its title', async () => {
368
367
  stubFetch();
369
368
  const opened: INotebookDemoItem[] = [];
370
- const widget = await render(signedIn(DEMO_ID), item => opened.push(item));
369
+ const widget = await render(signedIn(), item => opened.push(item));
371
370
 
372
371
  section(widget, 'Examples')!
373
- .querySelector<HTMLButtonElement>(`[data-example-id="${OTHER_EXAMPLE}"]`)!
372
+ .querySelector<HTMLButtonElement>(`[data-example-id="${PLOT}"]`)!
374
373
  .click();
375
374
 
376
- expect(opened).toEqual([{ id: OTHER_EXAMPLE, title: 'Plot waves' }]);
375
+ expect(opened).toEqual([{ id: PLOT, title: '10 Plot waves' }]);
377
376
  widget.dispose();
378
377
  });
379
378
 
@@ -385,7 +384,7 @@ describe('the Examples in the Notebooks tab', () => {
385
384
  it('puts a switch on the Examples heading that asks to hide them', async () => {
386
385
  stubFetch();
387
386
  const changes: boolean[] = [];
388
- const widget = await render(signedIn(DEMO_ID), undefined, {
387
+ const widget = await render(signedIn(), undefined, {
389
388
  showExamples: true,
390
389
  onShowExamplesChange: show => changes.push(show)
391
390
  });
@@ -408,7 +407,7 @@ describe('the Examples in the Notebooks tab', () => {
408
407
  it('keeps hidden examples folded under their heading, with the switch to bring them back', async () => {
409
408
  stubFetch();
410
409
  const changes: boolean[] = [];
411
- const widget = await render(signedIn(DEMO_ID), undefined, {
410
+ const widget = await render(signedIn(), undefined, {
412
411
  showExamples: false,
413
412
  onShowExamplesChange: show => changes.push(show)
414
413
  });
@@ -418,14 +417,8 @@ describe('the Examples in the Notebooks tab', () => {
418
417
  expect(
419
418
  examples.querySelector('.oceanum-notebooks-count')?.textContent
420
419
  ).toBe('3');
421
- // Hidden, not forgotten: the record is still read, and its examples stay out of
422
- // Shared with me.
423
- expect(fetches).toContain(
424
- `https://specs.example.com/specs/notebook-demo/${DEMO_ID}`
425
- );
426
- expect(idsIn(section(widget, 'Shared with me'), 'data-spec-id')).toEqual([
427
- SHARED
428
- ]);
420
+ // Hidden, not forgotten: the demos are still listed, for the count.
421
+ expect(fetches).toContain(DEMO_LISTING);
429
422
 
430
423
  const toggle = switchIn(widget)!;
431
424
  expect(toggle.getAttribute('aria-checked')).toBe('false');
@@ -437,9 +430,9 @@ describe('the Examples in the Notebooks tab', () => {
437
430
 
438
431
  it('shows no error for examples the user has hidden', async () => {
439
432
  stubFetch(
440
- () => ({ ok: false, status: 404, json: async () => ({}) }) as Response
433
+ () => ({ ok: false, status: 500, json: async () => ({}) }) as Response
441
434
  );
442
- const widget = await render(signedIn(DEMO_ID), undefined, {
435
+ const widget = await render(signedIn(), undefined, {
443
436
  showExamples: false,
444
437
  onShowExamplesChange: () => undefined
445
438
  });
@@ -452,7 +445,7 @@ describe('the Examples in the Notebooks tab', () => {
452
445
 
453
446
  it('offers no switch where the choice could not be kept', async () => {
454
447
  stubFetch();
455
- const widget = await render(signedIn(DEMO_ID));
448
+ const widget = await render(signedIn());
456
449
 
457
450
  expect(switchIn(widget)).toBeNull();
458
451
  expect(
@@ -464,7 +457,7 @@ describe('the Examples in the Notebooks tab', () => {
464
457
  it('shows no examples while the setting is still loading', async () => {
465
458
  // Otherwise examples a user has hidden would show for a moment on every reload.
466
459
  stubFetch();
467
- const widget = await render(signedIn(DEMO_ID), undefined, {
460
+ const widget = await render(signedIn(), undefined, {
468
461
  showExamples: null,
469
462
  onShowExamplesChange: () => undefined
470
463
  });
@@ -479,7 +472,7 @@ describe('the Examples in the Notebooks tab', () => {
479
472
 
480
473
  it('shows nothing for hidden examples that no switch could bring back', async () => {
481
474
  stubFetch();
482
- const widget = await render(signedIn(DEMO_ID), undefined, {
475
+ const widget = await render(signedIn(), undefined, {
483
476
  showExamples: false
484
477
  });
485
478
 
@@ -2,12 +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,
9
10
  notebookFromUpload,
10
- parseNotebookDemo,
11
11
  parseShareEmails,
12
12
  parseTimestamp,
13
13
  partitionSummaries,
@@ -219,98 +219,41 @@ describe('unlinkedNotebookFromRecord', () => {
219
219
  });
220
220
  });
221
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
- ]);
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
286
232
  });
287
233
 
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();
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' }]);
299
238
  });
300
239
 
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
- }
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
+ ]);
314
257
  });
315
258
  });
316
259
 
@@ -100,6 +100,7 @@ const record = (id: string, name: string): ISpecRecord => ({
100
100
  */
101
101
  function store(stored: Record<string, INotebookContent> = {}) {
102
102
  const calls: string[] = [];
103
+ const urls: string[] = [];
103
104
  const bodies: Record<string, unknown>[] = [];
104
105
  const names: Record<string, string> = { [ID_A]: 'Alpha', [ID_B]: 'Beta' };
105
106
  const fetcher = async (
@@ -110,6 +111,7 @@ function store(stored: Record<string, INotebookContent> = {}) {
110
111
  const method = init?.method ?? 'GET';
111
112
  const id = url.split('/').pop() ?? '';
112
113
  calls.push(`${method} ${id}`);
114
+ urls.push(url);
113
115
  if (init?.body) {
114
116
  bodies.push(JSON.parse(String(init.body)));
115
117
  }
@@ -125,7 +127,7 @@ function store(stored: Record<string, INotebookContent> = {}) {
125
127
  }
126
128
  } as unknown as Response;
127
129
  };
128
- return { calls, bodies, fetcher };
130
+ return { calls, urls, bodies, fetcher };
129
131
  }
130
132
 
131
133
  /** A notebook panel with just the surface the plugin touches. */
@@ -159,6 +161,8 @@ function panel(id: string, path: string, link?: string): NotebookPanel {
159
161
  interface IHarness {
160
162
  commands: CommandRegistry;
161
163
  calls: string[];
164
+ /** Every URL the plugin fetched, in order. */
165
+ urls: string[];
162
166
  bodies: Record<string, unknown>[];
163
167
  saved: string[];
164
168
  /** What each save wrote, by path. */
@@ -183,7 +187,7 @@ function activate(
183
187
  } = {}
184
188
  ): IHarness {
185
189
  const commands = new CommandRegistry();
186
- const { calls, bodies, fetcher } = store(options.stored);
190
+ const { calls, urls, bodies, fetcher } = store(options.stored);
187
191
  const saved: string[] = [];
188
192
  const written: Record<string, unknown> = {};
189
193
  const opened: string[] = [];
@@ -303,6 +307,7 @@ function activate(
303
307
  return {
304
308
  commands,
305
309
  calls,
310
+ urls,
306
311
  bodies,
307
312
  saved,
308
313
  written,
@@ -493,6 +498,8 @@ describe('opening an example', () => {
493
498
 
494
499
  // Read, never written back: the example's record is Oceanum's.
495
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}`]);
496
503
  const path = 'Oceanum/Query a datasource.ipynb';
497
504
  expect(harness.saved).toEqual([path]);
498
505
  expect(harness.opened).toEqual([path]);
@@ -32,12 +32,6 @@ 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;
41
35
  }
42
36
 
43
37
  /** Service base URLs for an environment, without trailing slashes. */
@@ -352,68 +352,37 @@ export function partitionSummaries(
352
352
  };
353
353
  }
354
354
 
355
- /** The `kind` of a Notebook Demo record's spec. */
356
- export const NOTEBOOK_DEMO_KIND = 'notebook-demo';
355
+ /** The spec store type of the Notebook Demo specs, which are the Examples. */
356
+ export const NOTEBOOK_DEMO_TYPE = 'notebook-demo';
357
357
 
358
- /** One example in a Notebook Demo record: a notebook record and how to list it. */
358
+ /** One example: a Notebook Demo spec, whose body is the example notebook. */
359
359
  export interface INotebookDemoItem {
360
360
  id: string;
361
361
  title: string;
362
362
  summary?: string;
363
363
  }
364
364
 
365
- /** A titled group of examples. */
366
- export interface INotebookDemoSection {
367
- title: string;
368
- items: INotebookDemoItem[];
369
- }
370
-
371
- function nonBlank(value: unknown): value is string {
372
- return typeof value === 'string' && value.trim() !== '';
373
- }
374
-
375
365
  /**
376
- * The sections of a Notebook Demo record's spec, keeping only what can be listed: items
377
- * with a record id and a title, in sections with a title and at least one such item.
378
- * A spec of another kind or version gives no sections, with a warning.
366
+ * The examples in a listing of Notebook Demo specs, ordered by name as people number
367
+ * them ("2 …" before "10 …").
379
368
  */
380
- export function parseNotebookDemo(spec: unknown): INotebookDemoSection[] {
381
- if (
382
- !isObject(spec) ||
383
- spec.kind !== NOTEBOOK_DEMO_KIND ||
384
- spec.version !== 1 ||
385
- !Array.isArray(spec.sections)
386
- ) {
387
- console.warn(
388
- 'Oceanum: ignoring a Notebook Demo record this version cannot read.'
369
+ export function demoItemsFromSummaries(
370
+ summaries: readonly ISpecSummary[]
371
+ ): INotebookDemoItem[] {
372
+ // No id check: the client's listing already rejects any summary without a spec id.
373
+ return summaries
374
+ .map(summary => {
375
+ const title = summary.name.trim() || 'Untitled';
376
+ return summary.description
377
+ ? { id: summary.id, title, summary: summary.description }
378
+ : { id: summary.id, title };
379
+ })
380
+ .sort((a, b) =>
381
+ a.title.localeCompare(b.title, undefined, {
382
+ numeric: true,
383
+ sensitivity: 'base'
384
+ })
389
385
  );
390
- return [];
391
- }
392
- const sections: INotebookDemoSection[] = [];
393
- for (const section of spec.sections) {
394
- if (
395
- !isObject(section) ||
396
- !nonBlank(section.title) ||
397
- !Array.isArray(section.items)
398
- ) {
399
- continue;
400
- }
401
- const items: INotebookDemoItem[] = [];
402
- for (const item of section.items) {
403
- if (!isObject(item) || !isSpecId(item.id) || !nonBlank(item.title)) {
404
- continue;
405
- }
406
- items.push(
407
- typeof item.summary === 'string'
408
- ? { id: item.id, title: item.title, summary: item.summary }
409
- : { id: item.id, title: item.title }
410
- );
411
- }
412
- if (items.length > 0) {
413
- sections.push({ title: section.title, items });
414
- }
415
- }
416
- return sections;
417
386
  }
418
387
 
419
388
  /** Parse a spec store timestamp, which is UTC but usually has no zone designator. */
@@ -31,6 +31,7 @@ import {
31
31
  notebookFromRecord,
32
32
  notebookFromUpload,
33
33
  notebookNamesFor,
34
+ NOTEBOOK_DEMO_TYPE,
34
35
  OCEANUM_DIR,
35
36
  partitionSummaries,
36
37
  QUERY_PARAM,
@@ -75,6 +76,8 @@ const UNAVAILABLE =
75
76
  interface IStore {
76
77
  specsUrl: string;
77
78
  client: SpecStoreClient;
79
+ /** The Notebook Demo specs, which are the Examples. */
80
+ demos: SpecStoreClient;
78
81
  }
79
82
 
80
83
  /**
@@ -110,6 +113,11 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
110
113
  client: new SpecStoreClient({
111
114
  specsUrl,
112
115
  getAccessToken: () => auth.getAccessToken()
116
+ }),
117
+ demos: new SpecStoreClient({
118
+ specsUrl,
119
+ getAccessToken: () => auth.getAccessToken(),
120
+ specType: NOTEBOOK_DEMO_TYPE
113
121
  })
114
122
  };
115
123
  const oceanumOnly =
@@ -409,7 +417,7 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
409
417
  }
410
418
  openingExamples.add(id);
411
419
  try {
412
- const record = await store.client.get(id);
420
+ const record = await store.demos.get(id);
413
421
  const content = unlinkedNotebookFromRecord(record);
414
422
  const path = uniqueNotebookPath(
415
423
  title || record.name,
package/style/index.css CHANGED
@@ -717,13 +717,6 @@
717
717
  }
718
718
  }
719
719
 
720
- /* A group within Examples: quieter than the tab's section headings. */
721
- .oceanum-notebooks-subheading {
722
- padding: 4px 8px 0;
723
- color: var(--jp-ui-font-color2);
724
- font-size: var(--jp-ui-font-size0);
725
- }
726
-
727
720
  .oceanum-notebooks-name {
728
721
  overflow: hidden;
729
722
  text-overflow: ellipsis;