@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.
@@ -0,0 +1,477 @@
1
+ /**
2
+ * Notebooks generated from a Datamesh UI workspace or query.
3
+ *
4
+ * The Datamesh UI's "Open in notebook" button opens this app with the workspace in the
5
+ * address fragment, `#oceanum-datamesh=<base64url JSON>`. The fragment is never sent
6
+ * to a server, so the queries stay out of request logs and are not bound by URL
7
+ * length limits on the way in.
8
+ *
9
+ * Anyone can craft such a link, so the payload is treated as data only: it is
10
+ * validated, every value reaches the notebook as a Python literal, and the notebook is
11
+ * built from fixed templates, opened untrusted and never run automatically.
12
+ */
13
+ import type { ICell, INotebookContent } from '@jupyterlab/nbformat';
14
+
15
+ /** Fragment key carrying a Datamesh workspace. */
16
+ export const FRAGMENT_KEY = 'oceanum-datamesh';
17
+
18
+ /** Payload version this module reads. */
19
+ export const PAYLOAD_VERSION = 1;
20
+
21
+ /** Most queries one link may carry. */
22
+ export const MAX_ITEMS = 100;
23
+
24
+ /** Longest encoded payload accepted (characters of base64url). */
25
+ export const MAX_ENCODED_LENGTH = 4 * 1024 * 1024;
26
+
27
+ /**
28
+ * Deepest nesting of arrays and objects accepted. Real queries, GeoJSON
29
+ * multipolygon geofilters included, nest about 8 deep.
30
+ */
31
+ export const MAX_DEPTH = 32;
32
+
33
+ /** Nesting depth of a JSON value (a scalar is 0), without recursion. */
34
+ function depthOf(value: unknown): number {
35
+ let max = 0;
36
+ const stack: [unknown, number][] = [[value, 0]];
37
+ while (stack.length) {
38
+ const [v, d] = stack.pop()!;
39
+ if (v !== null && typeof v === 'object') {
40
+ max = Math.max(max, d + 1);
41
+ if (max > MAX_DEPTH) {
42
+ return max;
43
+ }
44
+ for (const child of Object.values(v)) {
45
+ stack.push([child, d + 1]);
46
+ }
47
+ }
48
+ }
49
+ return max;
50
+ }
51
+
52
+ /** The fields of oceanum-python's `Query`. Anything else would make it warn. */
53
+ const QUERY_FIELDS = [
54
+ 'datasource',
55
+ 'parameters',
56
+ 'description',
57
+ 'variables',
58
+ 'timefilter',
59
+ 'geofilter',
60
+ 'levelfilter',
61
+ 'coordfilter',
62
+ 'crs',
63
+ 'aggregate',
64
+ 'functions',
65
+ 'limit'
66
+ ] as const;
67
+
68
+ /** Fields that make a query select something rather than the whole datasource. */
69
+ const FILTER_FIELDS = QUERY_FIELDS.filter(
70
+ f => f !== 'datasource' && f !== 'description'
71
+ );
72
+
73
+ /** One query: an OceanQL spec plus the Datamesh UI's own `label`. */
74
+ export interface IDatameshQuery {
75
+ datasource: string;
76
+ label?: string | null;
77
+ description?: string | null;
78
+ [key: string]: unknown;
79
+ }
80
+
81
+ /** What the fragment carries. */
82
+ export interface IDatameshNotebookRequest {
83
+ v: typeof PAYLOAD_VERSION;
84
+ /** Workspace name, used for the notebook name and heading. */
85
+ name?: string;
86
+ items: IDatameshQuery[];
87
+ }
88
+
89
+ function isObject(value: unknown): value is Record<string, unknown> {
90
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
91
+ }
92
+
93
+ /** UTF-8 JSON to base64url, without padding. */
94
+ export function encodePayload(request: IDatameshNotebookRequest): string {
95
+ const bytes = new TextEncoder().encode(JSON.stringify(request));
96
+ let binary = '';
97
+ for (const b of bytes) {
98
+ binary += String.fromCharCode(b);
99
+ }
100
+ return btoa(binary)
101
+ .replace(/\+/g, '-')
102
+ .replace(/\//g, '_')
103
+ .replace(/=+$/, '');
104
+ }
105
+
106
+ /**
107
+ * Read and validate a payload. Throws an `Error` whose message can be shown to the
108
+ * user; it never quotes the payload.
109
+ */
110
+ export function decodePayload(encoded: string): IDatameshNotebookRequest {
111
+ if (encoded.length > MAX_ENCODED_LENGTH) {
112
+ throw new Error('The Datamesh link is too large to open.');
113
+ }
114
+ let parsed: unknown;
115
+ try {
116
+ const b64 = encoded.replace(/-/g, '+').replace(/_/g, '/');
117
+ const binary = atob(b64);
118
+ const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
119
+ parsed = JSON.parse(
120
+ new TextDecoder('utf-8', { fatal: true }).decode(bytes)
121
+ );
122
+ } catch {
123
+ throw new Error('The Datamesh link is not valid.');
124
+ }
125
+ if (!isObject(parsed) || parsed.v !== PAYLOAD_VERSION) {
126
+ throw new Error(
127
+ 'The Datamesh link is not valid, or comes from a newer Datamesh UI.'
128
+ );
129
+ }
130
+ const { name, items } = parsed;
131
+ // null is accepted wherever a string is optional: the UI copies datasource
132
+ // properties that may be null.
133
+ const optionalText = (value: unknown): boolean =>
134
+ value === undefined || value === null || typeof value === 'string';
135
+ if (!optionalText(name)) {
136
+ throw new Error('The Datamesh link is not valid.');
137
+ }
138
+ if (!Array.isArray(items) || items.length === 0) {
139
+ throw new Error('The Datamesh link holds no queries.');
140
+ }
141
+ if (items.length > MAX_ITEMS) {
142
+ throw new Error(
143
+ `The Datamesh link holds more than ${MAX_ITEMS} queries, which is too many to open.`
144
+ );
145
+ }
146
+ if (depthOf(items) > MAX_DEPTH) {
147
+ throw new Error('The Datamesh link holds a query that is not valid.');
148
+ }
149
+ for (const item of items) {
150
+ if (
151
+ !isObject(item) ||
152
+ typeof item.datasource !== 'string' ||
153
+ item.datasource.trim() === '' ||
154
+ !optionalText(item.label) ||
155
+ !optionalText(item.description)
156
+ ) {
157
+ throw new Error('The Datamesh link holds a query that is not valid.');
158
+ }
159
+ }
160
+ return {
161
+ v: PAYLOAD_VERSION,
162
+ ...(typeof name === 'string' ? { name } : {}),
163
+ items: items as IDatameshQuery[]
164
+ };
165
+ }
166
+
167
+ const PAYLOAD_PREFIX = `${FRAGMENT_KEY}=`;
168
+
169
+ /** The hash's `&`-separated parts, without the leading `#`. */
170
+ function hashParts(hash: string): string[] {
171
+ const body = hash.replace(/^#/, '');
172
+ return body === '' ? [] : body.split('&');
173
+ }
174
+
175
+ /**
176
+ * The payload in a location hash (`#oceanum-datamesh=...`), or null when the hash
177
+ * carries none. Other parts of the hash are ignored.
178
+ */
179
+ export function payloadFromHash(hash: string): string | null {
180
+ const part = hashParts(hash).find(p => p.startsWith(PAYLOAD_PREFIX));
181
+ return part === undefined ? null : part.slice(PAYLOAD_PREFIX.length);
182
+ }
183
+
184
+ /**
185
+ * The hash with the payload removed (`''` when nothing else is left). Every other
186
+ * part is kept exactly as it was.
187
+ */
188
+ export function hashWithoutPayload(hash: string): string {
189
+ const rest = hashParts(hash).filter(p => !p.startsWith(PAYLOAD_PREFIX));
190
+ return rest.length ? `#${rest.join('&')}` : '';
191
+ }
192
+
193
+ const INLINE_WIDTH = 72;
194
+
195
+ /** Characters JSON leaves raw that Python's `splitlines()` still breaks on. */
196
+ const RAW_BREAKS = new RegExp(
197
+ `[\\x7f-\\x9f${String.fromCharCode(0x2028, 0x2029)}]`,
198
+ 'g'
199
+ );
200
+
201
+ /**
202
+ * A Python string literal. JSON's escaping is valid Python and escapes C0 controls;
203
+ * the C1 controls and U+2028/U+2029 it leaves raw are escaped too, so the generated
204
+ * source has no character a kernel could treat as a line break.
205
+ */
206
+ export function pyString(text: string): string {
207
+ return JSON.stringify(text).replace(
208
+ RAW_BREAKS,
209
+ c => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`
210
+ );
211
+ }
212
+
213
+ /** A Python literal for a scalar, or null for an array or object. */
214
+ function pyScalar(value: unknown): string | null {
215
+ if (value === null || value === undefined) {
216
+ return 'None';
217
+ }
218
+ if (typeof value === 'boolean') {
219
+ return value ? 'True' : 'False';
220
+ }
221
+ if (typeof value === 'number') {
222
+ // Parsed JSON has no NaN or Infinity; guard anyway rather than emit bad code.
223
+ return Number.isFinite(value) ? JSON.stringify(value) : 'None';
224
+ }
225
+ if (typeof value === 'string') {
226
+ return pyString(value);
227
+ }
228
+ return Array.isArray(value) || isObject(value) ? null : 'None';
229
+ }
230
+
231
+ function definedEntries(value: Record<string, unknown>): [string, unknown][] {
232
+ return Object.entries(value).filter(([, v]) => v !== undefined);
233
+ }
234
+
235
+ /** A Python literal on one line, rendering each node once. */
236
+ function pyInline(value: unknown): string {
237
+ const scalar = pyScalar(value);
238
+ if (scalar !== null) {
239
+ return scalar;
240
+ }
241
+ if (Array.isArray(value)) {
242
+ return `[${value.map(pyInline).join(', ')}]`;
243
+ }
244
+ return `{${definedEntries(value as Record<string, unknown>)
245
+ .map(([k, v]) => `${pyString(k)}: ${pyInline(v)}`)
246
+ .join(', ')}}`;
247
+ }
248
+
249
+ /**
250
+ * A Python literal for a JSON value, wrapped over indented lines when it does not
251
+ * fit on one. Strings and keys go through `pyString`, so no value can end the
252
+ * literal early or break the line.
253
+ */
254
+ export function pyLiteral(value: unknown, indent = 0): string {
255
+ const scalar = pyScalar(value);
256
+ if (scalar !== null) {
257
+ return scalar;
258
+ }
259
+ const pad = ' '.repeat(indent + 4);
260
+ const end = ' '.repeat(indent);
261
+ const inline = pyInline(value);
262
+ if (Array.isArray(value)) {
263
+ if (inline.length + indent <= INLINE_WIDTH || value.length === 0) {
264
+ return inline;
265
+ }
266
+ const lines = value.map(v => pad + pyLiteral(v, indent + 4));
267
+ return `[\n${lines.join(',\n')},\n${end}]`;
268
+ }
269
+ if (isObject(value)) {
270
+ const entries = definedEntries(value);
271
+ if (inline.length + indent <= INLINE_WIDTH || entries.length === 0) {
272
+ return inline;
273
+ }
274
+ const lines = entries.map(
275
+ ([k, v]) => `${pad}${pyString(k)}: ${pyLiteral(v, indent + 4)}`
276
+ );
277
+ return `{\n${lines.join(',\n')},\n${end}}`;
278
+ }
279
+ return 'None';
280
+ }
281
+
282
+ const PY_KEYWORDS = new Set([
283
+ 'False',
284
+ 'None',
285
+ 'True',
286
+ 'and',
287
+ 'as',
288
+ 'assert',
289
+ 'async',
290
+ 'await',
291
+ 'break',
292
+ 'class',
293
+ 'continue',
294
+ 'def',
295
+ 'del',
296
+ 'elif',
297
+ 'else',
298
+ 'except',
299
+ 'finally',
300
+ 'for',
301
+ 'from',
302
+ 'global',
303
+ 'if',
304
+ 'import',
305
+ 'in',
306
+ 'is',
307
+ 'lambda',
308
+ 'nonlocal',
309
+ 'not',
310
+ 'or',
311
+ 'pass',
312
+ 'raise',
313
+ 'return',
314
+ 'try',
315
+ 'while',
316
+ 'with',
317
+ 'yield',
318
+ // Names the generated code itself uses.
319
+ 'datamesh',
320
+ 'Connector'
321
+ ]);
322
+
323
+ /** A Python identifier for `text`, not in `taken`; adds it to `taken`. */
324
+ export function pyIdentifier(text: string, taken: Set<string>): string {
325
+ let base = text
326
+ .replace(/[^A-Za-z0-9_]+/g, '_')
327
+ .replace(/_+/g, '_')
328
+ .replace(/^_+|_+$/g, '')
329
+ .toLowerCase();
330
+ if (base === '') {
331
+ base = 'data';
332
+ }
333
+ if (/^[0-9]/.test(base)) {
334
+ base = `ds_${base}`;
335
+ }
336
+ if (PY_KEYWORDS.has(base)) {
337
+ base = `${base}_`;
338
+ }
339
+ let name = base;
340
+ for (let i = 2; taken.has(name); i++) {
341
+ name = `${base}_${i}`;
342
+ }
343
+ taken.add(name);
344
+ return name;
345
+ }
346
+
347
+ /**
348
+ * Every C0 and C1 control character, plus U+2028 and U+2029. This is a superset of
349
+ * what Python's `str.splitlines()` breaks on (LF, CR, VT, FF, FS, GS, RS, NEL and the
350
+ * two separators), which IPython's input transformer uses to split a cell into lines:
351
+ * left in a `#` comment, any of them would end the comment and run what follows.
352
+ */
353
+ const LINE_BREAKS = new RegExp(
354
+ `[\\x00-\\x1f\\x7f-\\x9f${String.fromCharCode(0x2028, 0x2029)}]+`,
355
+ 'g'
356
+ );
357
+
358
+ /** One line of text safe to put after a `#` comment marker. */
359
+ function commentText(text: string): string {
360
+ return text.replace(LINE_BREAKS, ' ').trim();
361
+ }
362
+
363
+ /**
364
+ * Text safe to put in a Markdown heading: no HTML, no Markdown syntax, and no
365
+ * autolink (`:`, `/` and `.` are escaped too). `&`, `<` and `>` become entities;
366
+ * every other ASCII punctuation character is backslash-escaped except `;` (part
367
+ * of the entities) and `@`: JupyterLab's math handling keeps `@@0@@` as a
368
+ * placeholder, and an escaped `\@\@0\@\@` slips past it and breaks rendering.
369
+ */
370
+ function markdownText(text: string): string {
371
+ return commentText(text)
372
+ .replace(/&/g, '&amp;')
373
+ .replace(/</g, '&lt;')
374
+ .replace(/>/g, '&gt;')
375
+ .replace(/([!-%'-/:=?[-`{-~])/g, '\\$1');
376
+ }
377
+
378
+ /** The OceanQL query to send: only fields oceanum-python's `Query` accepts. */
379
+ export function queryFields(item: IDatameshQuery): Record<string, unknown> {
380
+ const query: Record<string, unknown> = {};
381
+ for (const field of QUERY_FIELDS) {
382
+ if (item[field] !== undefined && item[field] !== null) {
383
+ query[field] = item[field];
384
+ }
385
+ }
386
+ return query;
387
+ }
388
+
389
+ /** Whether a query selects part of its datasource (else it loads the whole one). */
390
+ function isFiltered(query: Record<string, unknown>): boolean {
391
+ return FILTER_FIELDS.some(f => {
392
+ const v = query[f];
393
+ return !(Array.isArray(v) && v.length === 0) && v !== undefined;
394
+ });
395
+ }
396
+
397
+ /** The code cell that loads one query into a variable and shows it. */
398
+ export function queryCellSource(
399
+ item: IDatameshQuery,
400
+ variable: string
401
+ ): string {
402
+ const query = queryFields(item);
403
+ const lines: string[] = [];
404
+ const title = commentText(item.description ?? item.label ?? '');
405
+ if (title) {
406
+ lines.push(`# ${title}`);
407
+ }
408
+ if (isFiltered(query)) {
409
+ lines.push(`${variable} = datamesh.query(${pyLiteral(query)})`);
410
+ } else {
411
+ // No filter: open the datasource lazily rather than download all of it.
412
+ lines.push(
413
+ `${variable} = datamesh.load_datasource(${pyLiteral(item.datasource)})`
414
+ );
415
+ }
416
+ lines.push(variable);
417
+ return lines.join('\n');
418
+ }
419
+
420
+ function codeCell(source: string): ICell {
421
+ return {
422
+ cell_type: 'code',
423
+ execution_count: null,
424
+ metadata: {},
425
+ outputs: [],
426
+ source
427
+ };
428
+ }
429
+
430
+ /** The notebook name for a request: the workspace name, or the single query's. */
431
+ export function notebookName(request: IDatameshNotebookRequest): string {
432
+ const only = request.items.length === 1 ? request.items[0] : null;
433
+ const name =
434
+ request.name?.trim() ||
435
+ only?.label?.trim() ||
436
+ only?.datasource.trim() ||
437
+ 'Datamesh workspace';
438
+ return commentText(name);
439
+ }
440
+
441
+ /**
442
+ * A new notebook that loads every query in `request`. It carries no `oceanum` link and
443
+ * no trust, so saving it creates a record of the user's own.
444
+ */
445
+ export function notebookFromDatamesh(
446
+ request: IDatameshNotebookRequest
447
+ ): INotebookContent {
448
+ const taken = new Set<string>();
449
+ const cells: ICell[] = [
450
+ {
451
+ cell_type: 'markdown',
452
+ metadata: {},
453
+ source:
454
+ `# ${markdownText(notebookName(request))}\n\n` +
455
+ 'Opened from the Oceanum Datamesh UI. Run the cells to load the data.'
456
+ },
457
+ codeCell(
458
+ 'from oceanum.datamesh import Connector\n\ndatamesh = Connector()'
459
+ ),
460
+ ...request.items.map(item =>
461
+ codeCell(
462
+ queryCellSource(
463
+ item,
464
+ pyIdentifier(item.label || item.datasource, taken)
465
+ )
466
+ )
467
+ )
468
+ ];
469
+ return {
470
+ nbformat: 4,
471
+ nbformat_minor: 5,
472
+ // No kernelspec: the kernel is `python` in JupyterLite and `python3` in
473
+ // JupyterLab, and the language alone lets either pick its Python kernel.
474
+ metadata: { language_info: { name: 'python' } },
475
+ cells
476
+ };
477
+ }
@@ -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. */
@@ -22,6 +22,13 @@ import { startSignIn } from '../auth/startSignIn';
22
22
  import { IOceanumAuth } from '../auth/tokens';
23
23
  import { type ErrorKind, SpecStoreClient, SpecStoreError } from './client';
24
24
  import { notebooksChanged } from './events';
25
+ import {
26
+ decodePayload,
27
+ hashWithoutPayload,
28
+ notebookFromDatamesh,
29
+ notebookName,
30
+ payloadFromHash
31
+ } from './fromDatamesh';
25
32
  import {
26
33
  buildSpecBody,
27
34
  IOceanumLink,
@@ -31,6 +38,7 @@ import {
31
38
  notebookFromRecord,
32
39
  notebookFromUpload,
33
40
  notebookNamesFor,
41
+ NOTEBOOK_DEMO_TYPE,
34
42
  OCEANUM_DIR,
35
43
  partitionSummaries,
36
44
  QUERY_PARAM,
@@ -75,6 +83,8 @@ const UNAVAILABLE =
75
83
  interface IStore {
76
84
  specsUrl: string;
77
85
  client: SpecStoreClient;
86
+ /** The Notebook Demo specs, which are the Examples. */
87
+ demos: SpecStoreClient;
78
88
  }
79
89
 
80
90
  /**
@@ -110,6 +120,11 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
110
120
  client: new SpecStoreClient({
111
121
  specsUrl,
112
122
  getAccessToken: () => auth.getAccessToken()
123
+ }),
124
+ demos: new SpecStoreClient({
125
+ specsUrl,
126
+ getAccessToken: () => auth.getAccessToken(),
127
+ specType: NOTEBOOK_DEMO_TYPE
113
128
  })
114
129
  };
115
130
  const oceanumOnly =
@@ -125,6 +140,23 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
125
140
  let warnedSignedOut = false;
126
141
 
127
142
  const signedIn = (): boolean => store !== null && auth.user !== null;
143
+
144
+ // A workspace sent by the Datamesh UI's "Open in notebook". Read it now, before
145
+ // anything can rewrite the address. It stays in the address until the notebook
146
+ // is written (see openFromDatamesh), so nothing is lost if the page is left
147
+ // before then. Not handled: a full-page sign-in redirect (signInRedirect) puts
148
+ // the hash back only after this plugin has activated, so the workspace opens
149
+ // on the next load rather than this one.
150
+ const datameshPayload = payloadFromHash(window.location.hash);
151
+ /** Remove the payload from the address, so a reload does not open a copy. */
152
+ const clearDatameshPayload = (): void => {
153
+ const url = new URL(window.location.href);
154
+ const hash = hashWithoutPayload(url.hash);
155
+ if (hash !== url.hash) {
156
+ url.hash = hash;
157
+ window.history.replaceState(window.history.state, '', url.toString());
158
+ }
159
+ };
128
160
  const label = (text: string) => () =>
129
161
  store ? text : `${text}${UNAVAILABLE}`;
130
162
 
@@ -409,7 +441,7 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
409
441
  }
410
442
  openingExamples.add(id);
411
443
  try {
412
- const record = await store.client.get(id);
444
+ const record = await store.demos.get(id);
413
445
  const content = unlinkedNotebookFromRecord(record);
414
446
  const path = uniqueNotebookPath(
415
447
  title || record.name,
@@ -1016,6 +1048,38 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
1016
1048
  clearQuery();
1017
1049
  }
1018
1050
 
1051
+ /**
1052
+ * Open a new notebook that loads a Datamesh UI workspace or query. It is written to
1053
+ * the Oceanum folder unlinked, like an example, so the first save creates a record
1054
+ * of the user's own. Nothing runs until the user runs it.
1055
+ */
1056
+ async function openFromDatamesh(encoded: string): Promise<void> {
1057
+ let content: INotebookContent;
1058
+ let name: string;
1059
+ try {
1060
+ const request = decodePayload(encoded);
1061
+ content = notebookFromDatamesh(request);
1062
+ name = notebookName(request);
1063
+ } catch (error) {
1064
+ clearDatameshPayload();
1065
+ Notification.warning(
1066
+ error instanceof Error
1067
+ ? error.message
1068
+ : 'The Datamesh link is not valid.',
1069
+ { autoClose: 5000 }
1070
+ );
1071
+ return;
1072
+ }
1073
+ try {
1074
+ const path = uniqueNotebookPath(name, await oceanumFolderNames());
1075
+ await writeAndOpen(path, content);
1076
+ } catch (error) {
1077
+ await reportError('Opening the Datamesh workspace failed', error);
1078
+ } finally {
1079
+ clearDatameshPayload();
1080
+ }
1081
+ }
1082
+
1019
1083
  // --- commands -----------------------------------------------------------------
1020
1084
 
1021
1085
  /** Whether the command can act on the pointer's row, the target notebook, or both. */
@@ -1216,5 +1280,9 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
1216
1280
  .catch(error =>
1217
1281
  console.error('Failed to open the Oceanum notebook link', error)
1218
1282
  );
1283
+
1284
+ if (datameshPayload !== null) {
1285
+ void app.restored.then(() => openFromDatamesh(datameshPayload));
1286
+ }
1219
1287
  }
1220
1288
  };
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;