@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.
- package/lib/StoredNotebooks.js +31 -39
- package/lib/auth/tokens.d.ts +0 -6
- package/lib/share/fromDatamesh.d.ts +82 -0
- package/lib/share/fromDatamesh.js +379 -0
- package/lib/share/notebook.d.ts +6 -12
- package/lib/share/notebook.js +17 -37
- package/lib/share/plugin.js +58 -2
- package/package.json +1 -1
- package/schema/datamesh-connect.json +1 -1
- package/src/StoredNotebooks.tsx +58 -77
- package/src/__tests__/OceanumPanel.spec.tsx +12 -17
- package/src/__tests__/StoredNotebooks.spec.tsx +80 -87
- package/src/__tests__/fromDatamesh.spec.ts +301 -0
- package/src/__tests__/shareNotebook.spec.ts +32 -89
- package/src/__tests__/sharePlugin.spec.ts +57 -2
- package/src/auth/tokens.ts +0 -6
- package/src/share/fromDatamesh.ts +477 -0
- package/src/share/notebook.ts +21 -52
- package/src/share/plugin.ts +69 -1
- package/style/index.css +0 -7
package/lib/share/notebook.js
CHANGED
|
@@ -237,45 +237,25 @@ export function partitionSummaries(items, email) {
|
|
|
237
237
|
shared: sorted.filter(item => !isMine(item))
|
|
238
238
|
};
|
|
239
239
|
}
|
|
240
|
-
/** The
|
|
241
|
-
export const
|
|
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
|
|
247
|
-
*
|
|
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
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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) {
|
package/lib/share/plugin.js
CHANGED
|
@@ -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 {
|
|
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.
|
|
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
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"showExamples": {
|
|
34
34
|
"type": "boolean",
|
|
35
35
|
"title": "Show examples",
|
|
36
|
-
"description": "List Oceanum's example notebooks
|
|
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
|
},
|
package/src/StoredNotebooks.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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';
|
|
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
|
-
*
|
|
61
|
-
*
|
|
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:
|
|
72
|
+
specType: NOTEBOOK_DEMO_TYPE
|
|
76
73
|
});
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
90
|
-
* stored-notebook row class: the share plugin's row menu
|
|
91
|
-
* is bound to those, and none of it applies to
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
{
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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: {
|
|
197
|
+
environment: {},
|
|
203
198
|
urls: { specs: 'https://specs.example.com' },
|
|
204
199
|
ready: Promise.resolve(),
|
|
205
200
|
user: {
|