@oceanum/oceanumlab 4.7.2 → 4.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/DatameshWidget.d.ts +11 -0
- package/lib/DatameshWidget.js +24 -3
- package/lib/StoredNotebooks.d.ts +20 -2
- package/lib/StoredNotebooks.js +88 -7
- package/lib/index.js +35 -1
- package/lib/share/client.d.ts +3 -1
- package/lib/share/client.js +5 -4
- package/lib/share/notebook.d.ts +26 -0
- package/lib/share/notebook.js +66 -8
- package/lib/share/oceanumOnly.d.ts +25 -2
- package/lib/share/oceanumOnly.js +39 -2
- package/lib/share/plugin.d.ts +2 -0
- package/lib/share/plugin.js +114 -11
- package/lib/share/upload.d.ts +6 -0
- package/lib/share/upload.js +27 -0
- package/package.json +1 -1
- package/schema/datamesh-connect.json +6 -0
- package/src/DatameshWidget.tsx +58 -0
- package/src/StoredNotebooks.tsx +197 -6
- package/src/__tests__/OceanumPanel.spec.tsx +94 -1
- package/src/__tests__/StoredNotebooks.spec.tsx +368 -4
- package/src/__tests__/oceanumOnly.spec.ts +127 -3
- package/src/__tests__/settingsSchema.spec.ts +11 -1
- package/src/__tests__/shareClient.spec.ts +14 -0
- package/src/__tests__/shareNotebook.spec.ts +116 -0
- package/src/__tests__/sharePlugin.spec.ts +253 -8
- package/src/index.ts +35 -1
- package/src/share/client.ts +5 -2
- package/src/share/notebook.ts +88 -10
- package/src/share/oceanumOnly.ts +55 -4
- package/src/share/plugin.ts +143 -11
- package/src/share/upload.ts +27 -0
- package/style/index.css +94 -2
package/src/share/notebook.ts
CHANGED
|
@@ -189,6 +189,49 @@ export function buildSpecBody(
|
|
|
189
189
|
return { ok: false, bytes: strippedBytes };
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
+
/**
|
|
193
|
+
* The notebook stored in a record, untrusted. Throws if the record does not hold an
|
|
194
|
+
* nbformat 4 notebook.
|
|
195
|
+
*/
|
|
196
|
+
function recordNotebook(record: ISpecRecord): INotebookContent {
|
|
197
|
+
const spec = record.spec;
|
|
198
|
+
if (!isNotebook(spec)) {
|
|
199
|
+
throw new Error('This Oceanum.io record is not a valid notebook.');
|
|
200
|
+
}
|
|
201
|
+
return withoutTrust(spec);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function isNotebook(value: unknown): value is INotebookContent {
|
|
205
|
+
return (
|
|
206
|
+
isObject(value) &&
|
|
207
|
+
value.nbformat === 4 &&
|
|
208
|
+
Array.isArray(value.cells) &&
|
|
209
|
+
value.cells.every(
|
|
210
|
+
cell => isObject(cell) && typeof cell.cell_type === 'string'
|
|
211
|
+
) &&
|
|
212
|
+
isObject(value.metadata)
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* A notebook uploaded from the user's computer, untrusted and linked to nothing: a file
|
|
218
|
+
* someone else downloaded from a record must not save over that record, and its outputs
|
|
219
|
+
* must not run scripts on the origin that holds the Oceanum.io session. Throws if the
|
|
220
|
+
* text is not an nbformat 4 notebook; the message never quotes the file.
|
|
221
|
+
*/
|
|
222
|
+
export function notebookFromUpload(text: string): INotebookContent {
|
|
223
|
+
let parsed: unknown;
|
|
224
|
+
try {
|
|
225
|
+
parsed = JSON.parse(text);
|
|
226
|
+
} catch {
|
|
227
|
+
throw new Error('The file is not a Jupyter notebook: it is not JSON.');
|
|
228
|
+
}
|
|
229
|
+
if (!isNotebook(parsed)) {
|
|
230
|
+
throw new Error('The file is not a Jupyter notebook in nbformat 4.');
|
|
231
|
+
}
|
|
232
|
+
return withoutLink(withoutTrust(parsed));
|
|
233
|
+
}
|
|
234
|
+
|
|
192
235
|
/**
|
|
193
236
|
* The notebook stored in a record, with its `oceanum` link set. Throws if the record
|
|
194
237
|
* does not hold an nbformat 4 notebook.
|
|
@@ -197,26 +240,28 @@ export function notebookFromRecord(
|
|
|
197
240
|
record: ISpecRecord,
|
|
198
241
|
specsUrl: string
|
|
199
242
|
): INotebookContent {
|
|
200
|
-
const spec = record.spec;
|
|
201
|
-
if (
|
|
202
|
-
!isObject(spec) ||
|
|
203
|
-
spec.nbformat !== 4 ||
|
|
204
|
-
!Array.isArray(spec.cells) ||
|
|
205
|
-
!isObject(spec.metadata)
|
|
206
|
-
) {
|
|
207
|
-
throw new Error('This Oceanum.io record is not a valid notebook.');
|
|
208
|
-
}
|
|
209
243
|
const link: IOceanumLink = { spec_id: record.id, specs_url: specsUrl };
|
|
210
244
|
if (record.description) {
|
|
211
245
|
link.description = record.description;
|
|
212
246
|
}
|
|
213
|
-
const content =
|
|
247
|
+
const content = recordNotebook(record);
|
|
214
248
|
return {
|
|
215
249
|
...content,
|
|
216
250
|
metadata: { ...content.metadata, [METADATA_KEY]: { ...link } }
|
|
217
251
|
};
|
|
218
252
|
}
|
|
219
253
|
|
|
254
|
+
/**
|
|
255
|
+
* A copy of the notebook stored in a record, linked to nothing: untrusted, and without
|
|
256
|
+
* any `oceanum` link, so saving it creates a record of the user's own instead of
|
|
257
|
+
* writing to this one. Throws if the record does not hold an nbformat 4 notebook.
|
|
258
|
+
*/
|
|
259
|
+
export function unlinkedNotebookFromRecord(
|
|
260
|
+
record: ISpecRecord
|
|
261
|
+
): INotebookContent {
|
|
262
|
+
return withoutLink(recordNotebook(record));
|
|
263
|
+
}
|
|
264
|
+
|
|
220
265
|
/** Make a record name safe to use as a single path segment. */
|
|
221
266
|
export function sanitizeName(name: string): string {
|
|
222
267
|
const clean = name
|
|
@@ -307,6 +352,39 @@ export function partitionSummaries(
|
|
|
307
352
|
};
|
|
308
353
|
}
|
|
309
354
|
|
|
355
|
+
/** The spec store type of the Notebook Demo specs, which are the Examples. */
|
|
356
|
+
export const NOTEBOOK_DEMO_TYPE = 'notebook-demo';
|
|
357
|
+
|
|
358
|
+
/** One example: a Notebook Demo spec, whose body is the example notebook. */
|
|
359
|
+
export interface INotebookDemoItem {
|
|
360
|
+
id: string;
|
|
361
|
+
title: string;
|
|
362
|
+
summary?: string;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* The examples in a listing of Notebook Demo specs, ordered by name as people number
|
|
367
|
+
* them ("2 …" before "10 …").
|
|
368
|
+
*/
|
|
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
|
+
})
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
310
388
|
/** Parse a spec store timestamp, which is UTC but usually has no zone designator. */
|
|
311
389
|
export function parseTimestamp(value: string): Date | null {
|
|
312
390
|
const zoned = /(Z|[+-]\d{2}:?\d{2})$/i.test(value) ? value : `${value}Z`;
|
package/src/share/oceanumOnly.ts
CHANGED
|
@@ -14,6 +14,9 @@ import type { Menu, Widget } from '@lumino/widgets';
|
|
|
14
14
|
/** The widget id `@jupyterlab/filebrowser-extension:browser` gives the file browser. */
|
|
15
15
|
export const FILE_BROWSER_ID = 'filebrowser';
|
|
16
16
|
|
|
17
|
+
/** The widget id of this extension's Oceanum.io panel, which takes the browser's place. */
|
|
18
|
+
export const OCEANUM_PANEL_ID = 'datamesh-connect';
|
|
19
|
+
|
|
17
20
|
/**
|
|
18
21
|
* File menu entries that manage local files. Save, Save All and Rename stay: saving is
|
|
19
22
|
* routed to Oceanum, and renaming the local file is how a notebook gets its name.
|
|
@@ -28,23 +31,71 @@ export const LOCAL_FILE_COMMANDS: readonly string[] = [
|
|
|
28
31
|
'docmanager:download'
|
|
29
32
|
];
|
|
30
33
|
|
|
34
|
+
/**
|
|
35
|
+
* File > New entries for files that are not notebooks. The spec store holds notebooks
|
|
36
|
+
* only, so such a file could never be saved anywhere the user can see.
|
|
37
|
+
*/
|
|
38
|
+
export const NON_NOTEBOOK_NEW_COMMANDS: readonly string[] = [
|
|
39
|
+
'fileeditor:create-new',
|
|
40
|
+
'fileeditor:create-new-markdown-file'
|
|
41
|
+
];
|
|
42
|
+
|
|
31
43
|
/**
|
|
32
44
|
* Take the file browser out of the sidebar; true if it was there. It is closed, not
|
|
33
45
|
* disposed: other extensions hold it through IDefaultFileBrowser and still use it to
|
|
34
|
-
* open paths.
|
|
46
|
+
* open paths. If it was the open panel, `instead` is opened in its place; a sidebar the
|
|
47
|
+
* user had collapsed stays collapsed.
|
|
35
48
|
*/
|
|
36
|
-
export function hideFileBrowser(
|
|
37
|
-
|
|
38
|
-
|
|
49
|
+
export function hideFileBrowser(
|
|
50
|
+
shell: {
|
|
51
|
+
widgets(area: 'left'): Iterable<Widget>;
|
|
52
|
+
activateById(id: string): void;
|
|
53
|
+
},
|
|
54
|
+
instead?: string
|
|
55
|
+
): boolean {
|
|
39
56
|
for (const widget of shell.widgets('left')) {
|
|
40
57
|
if (widget.id === FILE_BROWSER_ID) {
|
|
58
|
+
const wasOpen = widget.isVisible;
|
|
41
59
|
widget.close();
|
|
60
|
+
if (wasOpen && instead) {
|
|
61
|
+
shell.activateById(instead);
|
|
62
|
+
}
|
|
42
63
|
return true;
|
|
43
64
|
}
|
|
44
65
|
}
|
|
45
66
|
return false;
|
|
46
67
|
}
|
|
47
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Put a left-sidebar widget first, and keep it open if it was. True if it moved.
|
|
71
|
+
*
|
|
72
|
+
* A rank alone is not enough: the layout restorer replays the sidebar order each user
|
|
73
|
+
* last had, so a returning user would keep the panel wherever it used to be. Adding it
|
|
74
|
+
* again after the restore re-inserts it by rank, and the shell saves the new order.
|
|
75
|
+
*/
|
|
76
|
+
export function moveToTop(
|
|
77
|
+
shell: {
|
|
78
|
+
widgets(area: 'left'): Iterable<Widget>;
|
|
79
|
+
add(widget: Widget, area: 'left', options: { rank: number }): void;
|
|
80
|
+
activateById(id: string): void;
|
|
81
|
+
},
|
|
82
|
+
id: string
|
|
83
|
+
): boolean {
|
|
84
|
+
const widgets = Array.from(shell.widgets('left'));
|
|
85
|
+
const index = widgets.findIndex(widget => widget.id === id);
|
|
86
|
+
if (index <= 0) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
const widget = widgets[index];
|
|
90
|
+
// Adding hides the widget, so an open panel has to be opened again.
|
|
91
|
+
const wasOpen = widget.isVisible;
|
|
92
|
+
shell.add(widget, 'left', { rank: 0 });
|
|
93
|
+
if (wasOpen) {
|
|
94
|
+
shell.activateById(id);
|
|
95
|
+
}
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
48
99
|
/**
|
|
49
100
|
* Submenus of local files. "Open Recent" is built by the document manager rather than
|
|
50
101
|
* declared in a schema, and its menu has no id, so it is known by the commands it holds.
|
package/src/share/plugin.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { IMainMenu } from '@jupyterlab/mainmenu';
|
|
|
16
16
|
import type { INotebookContent } from '@jupyterlab/nbformat';
|
|
17
17
|
import { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook';
|
|
18
18
|
import type { ReadonlyPartialJSONObject } from '@lumino/coreutils';
|
|
19
|
-
import { Menu } from '@lumino/widgets';
|
|
19
|
+
import { Menu, type Widget } from '@lumino/widgets';
|
|
20
20
|
|
|
21
21
|
import { startSignIn } from '../auth/startSignIn';
|
|
22
22
|
import { IOceanumAuth } from '../auth/tokens';
|
|
@@ -29,7 +29,9 @@ import {
|
|
|
29
29
|
ISpecRecord,
|
|
30
30
|
METADATA_KEY,
|
|
31
31
|
notebookFromRecord,
|
|
32
|
+
notebookFromUpload,
|
|
32
33
|
notebookNamesFor,
|
|
34
|
+
NOTEBOOK_DEMO_TYPE,
|
|
33
35
|
OCEANUM_DIR,
|
|
34
36
|
partitionSummaries,
|
|
35
37
|
QUERY_PARAM,
|
|
@@ -37,18 +39,25 @@ import {
|
|
|
37
39
|
reportForShareFailures,
|
|
38
40
|
sanitizeName,
|
|
39
41
|
shareLink,
|
|
40
|
-
uniqueNotebookPath
|
|
42
|
+
uniqueNotebookPath,
|
|
43
|
+
unlinkedNotebookFromRecord
|
|
41
44
|
} from './notebook';
|
|
42
45
|
import {
|
|
43
46
|
hideFileBrowser,
|
|
47
|
+
moveToTop,
|
|
48
|
+
NON_NOTEBOOK_NEW_COMMANDS,
|
|
49
|
+
OCEANUM_PANEL_ID,
|
|
44
50
|
pruneBeforeEachOpen,
|
|
45
51
|
pruneFileMenu,
|
|
46
52
|
routeSaves
|
|
47
53
|
} from './oceanumOnly';
|
|
54
|
+
import { chooseNotebookFile } from './upload';
|
|
48
55
|
import { NotebookListBody, ShareBody } from './widgets';
|
|
49
56
|
|
|
50
57
|
export const CommandIDs = {
|
|
51
58
|
open: 'oceanum-share:open',
|
|
59
|
+
openExample: 'oceanum-share:open-example',
|
|
60
|
+
upload: 'oceanum-share:upload',
|
|
52
61
|
save: 'oceanum-share:save',
|
|
53
62
|
share: 'oceanum-share:share',
|
|
54
63
|
rename: 'oceanum-share:rename',
|
|
@@ -67,6 +76,8 @@ const UNAVAILABLE =
|
|
|
67
76
|
interface IStore {
|
|
68
77
|
specsUrl: string;
|
|
69
78
|
client: SpecStoreClient;
|
|
79
|
+
/** The Notebook Demo specs, which are the Examples. */
|
|
80
|
+
demos: SpecStoreClient;
|
|
70
81
|
}
|
|
71
82
|
|
|
72
83
|
/**
|
|
@@ -102,6 +113,11 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
|
|
|
102
113
|
client: new SpecStoreClient({
|
|
103
114
|
specsUrl,
|
|
104
115
|
getAccessToken: () => auth.getAccessToken()
|
|
116
|
+
}),
|
|
117
|
+
demos: new SpecStoreClient({
|
|
118
|
+
specsUrl,
|
|
119
|
+
getAccessToken: () => auth.getAccessToken(),
|
|
120
|
+
specType: NOTEBOOK_DEMO_TYPE
|
|
105
121
|
})
|
|
106
122
|
};
|
|
107
123
|
const oceanumOnly =
|
|
@@ -317,6 +333,23 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
|
|
|
317
333
|
return null;
|
|
318
334
|
}
|
|
319
335
|
|
|
336
|
+
/** Write a notebook to `path` on the local drive and open it. */
|
|
337
|
+
async function writeAndOpen(
|
|
338
|
+
path: string,
|
|
339
|
+
content: INotebookContent
|
|
340
|
+
): Promise<Widget> {
|
|
341
|
+
await docManager.services.contents.save(path, {
|
|
342
|
+
type: 'notebook',
|
|
343
|
+
format: 'json',
|
|
344
|
+
content
|
|
345
|
+
});
|
|
346
|
+
const widget = docManager.openOrReveal(path);
|
|
347
|
+
if (!widget) {
|
|
348
|
+
throw new Error(`Could not open ${path}.`);
|
|
349
|
+
}
|
|
350
|
+
return widget;
|
|
351
|
+
}
|
|
352
|
+
|
|
320
353
|
/** Write a record to the local drive as `Oceanum/<name>.ipynb` and open it. */
|
|
321
354
|
async function openRecord(s: IStore, record: ISpecRecord): Promise<void> {
|
|
322
355
|
// Already open: bring it forward rather than writing a second copy.
|
|
@@ -333,14 +366,7 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
|
|
|
333
366
|
const path =
|
|
334
367
|
(await pathHoldingRecord(s, record, names)) ??
|
|
335
368
|
uniqueNotebookPath(record.name, names);
|
|
336
|
-
await
|
|
337
|
-
type: 'notebook',
|
|
338
|
-
format: 'json',
|
|
339
|
-
content
|
|
340
|
-
});
|
|
341
|
-
if (!docManager.openOrReveal(path)) {
|
|
342
|
-
throw new Error(`Could not open ${path}.`);
|
|
343
|
-
}
|
|
369
|
+
await writeAndOpen(path, content);
|
|
344
370
|
}
|
|
345
371
|
|
|
346
372
|
async function openById(id: string): Promise<void> {
|
|
@@ -359,6 +385,84 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
|
|
|
359
385
|
}
|
|
360
386
|
}
|
|
361
387
|
|
|
388
|
+
// The copy each example was last opened into, so opening it again while that copy
|
|
389
|
+
// is still open brings it forward rather than writing another.
|
|
390
|
+
const exampleCopies = new Map<string, Widget>();
|
|
391
|
+
// Examples whose copy is still being written, so a second click in that time does
|
|
392
|
+
// not write a second copy.
|
|
393
|
+
const openingExamples = new Set<string>();
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Open a copy of an example as a new file in the Oceanum folder, untrusted and
|
|
397
|
+
* linked to nothing. Not `openRecord`: that links the file to the example's record,
|
|
398
|
+
* which is Oceanum's and read-only to everyone else, so the first save would be
|
|
399
|
+
* refused. Unlinked, the first save creates a record of the user's own.
|
|
400
|
+
*/
|
|
401
|
+
async function openExample(id: string, title: string): Promise<void> {
|
|
402
|
+
if (
|
|
403
|
+
!store ||
|
|
404
|
+
!(await ensureSignedIn(
|
|
405
|
+
'Opening notebooks needs an Oceanum.io account.'
|
|
406
|
+
))
|
|
407
|
+
) {
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const copy = exampleCopies.get(id);
|
|
411
|
+
if (copy && !copy.isDisposed) {
|
|
412
|
+
app.shell.activateById(copy.id);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (openingExamples.has(id)) {
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
openingExamples.add(id);
|
|
419
|
+
try {
|
|
420
|
+
const record = await store.demos.get(id);
|
|
421
|
+
const content = unlinkedNotebookFromRecord(record);
|
|
422
|
+
const path = uniqueNotebookPath(
|
|
423
|
+
title || record.name,
|
|
424
|
+
await oceanumFolderNames()
|
|
425
|
+
);
|
|
426
|
+
exampleCopies.set(id, await writeAndOpen(path, content));
|
|
427
|
+
} catch (error) {
|
|
428
|
+
await reportError('Open from Oceanum failed', error);
|
|
429
|
+
} finally {
|
|
430
|
+
openingExamples.delete(id);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Bring a notebook from the user's computer into the Oceanum folder and open it,
|
|
436
|
+
* untrusted and linked to nothing. It reaches Oceanum on its first save, like any
|
|
437
|
+
* new notebook; nothing is sent on upload.
|
|
438
|
+
*/
|
|
439
|
+
async function uploadNotebook(): Promise<void> {
|
|
440
|
+
if (
|
|
441
|
+
!store ||
|
|
442
|
+
!(await ensureSignedIn(
|
|
443
|
+
'Uploading notebooks needs an Oceanum.io account.'
|
|
444
|
+
))
|
|
445
|
+
) {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
try {
|
|
449
|
+
const file = await chooseNotebookFile();
|
|
450
|
+
if (!file) {
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
const content = notebookFromUpload(await file.text());
|
|
454
|
+
const path = uniqueNotebookPath(file.name, await oceanumFolderNames());
|
|
455
|
+
await writeAndOpen(path, content);
|
|
456
|
+
// Outside Oceanum-only mode a plain Save stays local.
|
|
457
|
+
Notification.info(
|
|
458
|
+
`Uploaded "${file.name}". ${oceanumOnly ? 'Save it' : 'Use Save to Oceanum'} to store it on Oceanum.io.`,
|
|
459
|
+
{ autoClose: 5000 }
|
|
460
|
+
);
|
|
461
|
+
} catch (error) {
|
|
462
|
+
await reportError('Upload failed', error);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
362
466
|
async function openFromOceanum(): Promise<void> {
|
|
363
467
|
if (
|
|
364
468
|
!store ||
|
|
@@ -959,6 +1063,26 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
|
|
|
959
1063
|
await (id ? openById(id) : openFromOceanum());
|
|
960
1064
|
}
|
|
961
1065
|
});
|
|
1066
|
+
// Run by the Notebooks tab's Examples; not offered in menus or the palette.
|
|
1067
|
+
app.commands.addCommand(CommandIDs.openExample, {
|
|
1068
|
+
label: 'Open a copy',
|
|
1069
|
+
caption: 'Open a copy of an Oceanum example notebook',
|
|
1070
|
+
isEnabled: () => signedIn(),
|
|
1071
|
+
execute: async args => {
|
|
1072
|
+
if (isSpecId(args.id)) {
|
|
1073
|
+
await openExample(
|
|
1074
|
+
args.id,
|
|
1075
|
+
typeof args.title === 'string' ? args.title : ''
|
|
1076
|
+
);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
});
|
|
1080
|
+
app.commands.addCommand(CommandIDs.upload, {
|
|
1081
|
+
label: label('Upload Notebook…'),
|
|
1082
|
+
caption: 'Upload a notebook from this computer, to save on Oceanum.io',
|
|
1083
|
+
isEnabled: () => signedIn(),
|
|
1084
|
+
execute: () => uploadNotebook()
|
|
1085
|
+
});
|
|
962
1086
|
app.commands.addCommand(CommandIDs.save, {
|
|
963
1087
|
label: label('Save to Oceanum'),
|
|
964
1088
|
caption: 'Save the notebook to Oceanum.io',
|
|
@@ -1013,6 +1137,7 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
|
|
|
1013
1137
|
|
|
1014
1138
|
const commands = [
|
|
1015
1139
|
CommandIDs.open,
|
|
1140
|
+
CommandIDs.upload,
|
|
1016
1141
|
CommandIDs.save,
|
|
1017
1142
|
CommandIDs.share,
|
|
1018
1143
|
CommandIDs.rename,
|
|
@@ -1075,7 +1200,9 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
|
|
|
1075
1200
|
|
|
1076
1201
|
if (oceanumOnly) {
|
|
1077
1202
|
void app.restored.then(() => {
|
|
1078
|
-
hideFileBrowser(app.shell);
|
|
1203
|
+
hideFileBrowser(app.shell, OCEANUM_PANEL_ID);
|
|
1204
|
+
// Oceanum is where notebooks live now, so its panel leads the sidebar.
|
|
1205
|
+
moveToTop(app.shell, OCEANUM_PANEL_ID);
|
|
1079
1206
|
// IFileMenu is the narrow interface; the menu itself is a Lumino Menu, which
|
|
1080
1207
|
// is what can drop items.
|
|
1081
1208
|
const fileMenu = mainMenu?.fileMenu;
|
|
@@ -1083,6 +1210,11 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
|
|
|
1083
1210
|
pruneFileMenu(fileMenu);
|
|
1084
1211
|
pruneBeforeEachOpen(fileMenu);
|
|
1085
1212
|
}
|
|
1213
|
+
const newMenu = mainMenu?.fileMenu.newMenu;
|
|
1214
|
+
if (newMenu instanceof Menu) {
|
|
1215
|
+
pruneFileMenu(newMenu, NON_NOTEBOOK_NEW_COMMANDS);
|
|
1216
|
+
pruneBeforeEachOpen(newMenu, NON_NOTEBOOK_NEW_COMMANDS);
|
|
1217
|
+
}
|
|
1086
1218
|
routeSaves(tracker, pushQuietly);
|
|
1087
1219
|
});
|
|
1088
1220
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ask the user for a notebook file on their computer; null if they cancel.
|
|
3
|
+
*
|
|
4
|
+
* Kept apart from the plugin because it is browser-only: the tests replace it.
|
|
5
|
+
*/
|
|
6
|
+
export function chooseNotebookFile(): Promise<File | null> {
|
|
7
|
+
return new Promise(resolve => {
|
|
8
|
+
const input = document.createElement('input');
|
|
9
|
+
input.type = 'file';
|
|
10
|
+
input.accept = '.ipynb,application/x-ipynb+json';
|
|
11
|
+
input.hidden = true;
|
|
12
|
+
// Attached while the picker is open: some browsers (Safari) drop the `change`
|
|
13
|
+
// event of a detached input, or collect the input before it fires.
|
|
14
|
+
document.body.appendChild(input);
|
|
15
|
+
const settle = (file: File | null): void => {
|
|
16
|
+
input.remove();
|
|
17
|
+
resolve(file);
|
|
18
|
+
};
|
|
19
|
+
input.addEventListener('change', () => settle(input.files?.[0] ?? null), {
|
|
20
|
+
once: true
|
|
21
|
+
});
|
|
22
|
+
// Browsers fire `cancel` when the picker is dismissed; without it the promise
|
|
23
|
+
// would simply never settle, which is harmless.
|
|
24
|
+
input.addEventListener('cancel', () => settle(null), { once: true });
|
|
25
|
+
input.click();
|
|
26
|
+
});
|
|
27
|
+
}
|
package/style/index.css
CHANGED
|
@@ -600,7 +600,8 @@
|
|
|
600
600
|
list-style: none;
|
|
601
601
|
}
|
|
602
602
|
|
|
603
|
-
.oceanum-notebooks-item
|
|
603
|
+
.oceanum-notebooks-item,
|
|
604
|
+
.oceanum-notebooks-example {
|
|
604
605
|
display: block;
|
|
605
606
|
width: 100%;
|
|
606
607
|
padding: 4px 8px;
|
|
@@ -613,7 +614,8 @@
|
|
|
613
614
|
cursor: pointer;
|
|
614
615
|
}
|
|
615
616
|
|
|
616
|
-
.oceanum-notebooks-item:hover
|
|
617
|
+
.oceanum-notebooks-item:hover,
|
|
618
|
+
.oceanum-notebooks-example:hover {
|
|
617
619
|
background: var(--jp-layout-color2);
|
|
618
620
|
}
|
|
619
621
|
|
|
@@ -621,10 +623,100 @@
|
|
|
621
623
|
cursor: default;
|
|
622
624
|
}
|
|
623
625
|
|
|
626
|
+
.oceanum-notebooks-actions {
|
|
627
|
+
padding: 6px 8px 2px;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
.oceanum-notebooks-upload {
|
|
631
|
+
padding: 3px 8px;
|
|
632
|
+
border: var(--jp-border-width) solid var(--jp-border-color1);
|
|
633
|
+
border-radius: var(--jp-border-radius);
|
|
634
|
+
background: var(--jp-layout-color1);
|
|
635
|
+
color: var(--jp-ui-font-color1);
|
|
636
|
+
font-family: var(--jp-ui-font-family);
|
|
637
|
+
font-size: var(--jp-ui-font-size1);
|
|
638
|
+
cursor: pointer;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
.oceanum-notebooks-upload:hover {
|
|
642
|
+
background: var(--jp-layout-color2);
|
|
643
|
+
}
|
|
644
|
+
|
|
624
645
|
.oceanum-notebooks-item-static:hover {
|
|
625
646
|
background: transparent;
|
|
626
647
|
}
|
|
627
648
|
|
|
649
|
+
/* The Examples heading's count and show/hide switch, kept together on the right. */
|
|
650
|
+
.oceanum-notebooks-heading-end {
|
|
651
|
+
display: flex;
|
|
652
|
+
align-items: center;
|
|
653
|
+
gap: 6px;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/* The Examples show/hide switch (a button with role="switch"). */
|
|
657
|
+
.oceanum-notebooks-switch {
|
|
658
|
+
position: relative;
|
|
659
|
+
flex: none;
|
|
660
|
+
width: 24px;
|
|
661
|
+
height: 14px;
|
|
662
|
+
padding: 0;
|
|
663
|
+
border: none;
|
|
664
|
+
border-radius: 7px;
|
|
665
|
+
background: var(--jp-layout-color3);
|
|
666
|
+
cursor: pointer;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
.oceanum-notebooks-switch[aria-checked='true'] {
|
|
670
|
+
background: var(--jp-brand-color1);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
.oceanum-notebooks-switch:focus-visible {
|
|
674
|
+
outline: 1px solid var(--jp-brand-color1);
|
|
675
|
+
outline-offset: 1px;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
.oceanum-notebooks-switch-thumb {
|
|
679
|
+
position: absolute;
|
|
680
|
+
top: 2px;
|
|
681
|
+
left: 2px;
|
|
682
|
+
width: 10px;
|
|
683
|
+
height: 10px;
|
|
684
|
+
border-radius: 50%;
|
|
685
|
+
background: var(--jp-layout-color0);
|
|
686
|
+
transition: left 0.1s ease;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
.oceanum-notebooks-switch[aria-checked='true'] .oceanum-notebooks-switch-thumb {
|
|
690
|
+
left: 12px;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/* Forced colours replace backgrounds, which would leave a switch drawn only with them
|
|
694
|
+
invisible: draw it with system colours instead. */
|
|
695
|
+
@media (forced-colors: active) {
|
|
696
|
+
.oceanum-notebooks-switch {
|
|
697
|
+
forced-color-adjust: none;
|
|
698
|
+
box-sizing: border-box;
|
|
699
|
+
border: 1px solid ButtonText;
|
|
700
|
+
background: Canvas;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
.oceanum-notebooks-switch[aria-checked='true'] {
|
|
704
|
+
background: Highlight;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
.oceanum-notebooks-switch-thumb {
|
|
708
|
+
top: 1px;
|
|
709
|
+
left: 1px;
|
|
710
|
+
background: ButtonText;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
.oceanum-notebooks-switch[aria-checked='true']
|
|
714
|
+
.oceanum-notebooks-switch-thumb {
|
|
715
|
+
left: 11px;
|
|
716
|
+
background: HighlightText;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
628
720
|
.oceanum-notebooks-name {
|
|
629
721
|
overflow: hidden;
|
|
630
722
|
text-overflow: ellipsis;
|