@oceanum/oceanumlab 4.7.2 → 4.8.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 +95 -6
- package/lib/auth/tokens.d.ts +6 -0
- 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 +32 -0
- package/lib/share/notebook.js +86 -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 +109 -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 +216 -6
- package/src/__tests__/OceanumPanel.spec.tsx +99 -1
- package/src/__tests__/StoredNotebooks.spec.tsx +374 -3
- 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 +173 -0
- package/src/__tests__/sharePlugin.spec.ts +245 -7
- package/src/auth/tokens.ts +6 -0
- package/src/index.ts +35 -1
- package/src/share/client.ts +5 -2
- package/src/share/notebook.ts +119 -10
- package/src/share/oceanumOnly.ts +55 -4
- package/src/share/plugin.ts +135 -11
- package/src/share/upload.ts +27 -0
- package/style/index.css +101 -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,70 @@ export function partitionSummaries(
|
|
|
307
352
|
};
|
|
308
353
|
}
|
|
309
354
|
|
|
355
|
+
/** The `kind` of a Notebook Demo record's spec. */
|
|
356
|
+
export const NOTEBOOK_DEMO_KIND = 'notebook-demo';
|
|
357
|
+
|
|
358
|
+
/** One example in a Notebook Demo record: a notebook record and how to list it. */
|
|
359
|
+
export interface INotebookDemoItem {
|
|
360
|
+
id: string;
|
|
361
|
+
title: string;
|
|
362
|
+
summary?: string;
|
|
363
|
+
}
|
|
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
|
+
/**
|
|
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.
|
|
379
|
+
*/
|
|
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.'
|
|
389
|
+
);
|
|
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
|
+
}
|
|
418
|
+
|
|
310
419
|
/** Parse a spec store timestamp, which is UTC but usually has no zone designator. */
|
|
311
420
|
export function parseTimestamp(value: string): Date | null {
|
|
312
421
|
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,6 +29,7 @@ import {
|
|
|
29
29
|
ISpecRecord,
|
|
30
30
|
METADATA_KEY,
|
|
31
31
|
notebookFromRecord,
|
|
32
|
+
notebookFromUpload,
|
|
32
33
|
notebookNamesFor,
|
|
33
34
|
OCEANUM_DIR,
|
|
34
35
|
partitionSummaries,
|
|
@@ -37,18 +38,25 @@ import {
|
|
|
37
38
|
reportForShareFailures,
|
|
38
39
|
sanitizeName,
|
|
39
40
|
shareLink,
|
|
40
|
-
uniqueNotebookPath
|
|
41
|
+
uniqueNotebookPath,
|
|
42
|
+
unlinkedNotebookFromRecord
|
|
41
43
|
} from './notebook';
|
|
42
44
|
import {
|
|
43
45
|
hideFileBrowser,
|
|
46
|
+
moveToTop,
|
|
47
|
+
NON_NOTEBOOK_NEW_COMMANDS,
|
|
48
|
+
OCEANUM_PANEL_ID,
|
|
44
49
|
pruneBeforeEachOpen,
|
|
45
50
|
pruneFileMenu,
|
|
46
51
|
routeSaves
|
|
47
52
|
} from './oceanumOnly';
|
|
53
|
+
import { chooseNotebookFile } from './upload';
|
|
48
54
|
import { NotebookListBody, ShareBody } from './widgets';
|
|
49
55
|
|
|
50
56
|
export const CommandIDs = {
|
|
51
57
|
open: 'oceanum-share:open',
|
|
58
|
+
openExample: 'oceanum-share:open-example',
|
|
59
|
+
upload: 'oceanum-share:upload',
|
|
52
60
|
save: 'oceanum-share:save',
|
|
53
61
|
share: 'oceanum-share:share',
|
|
54
62
|
rename: 'oceanum-share:rename',
|
|
@@ -317,6 +325,23 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
|
|
|
317
325
|
return null;
|
|
318
326
|
}
|
|
319
327
|
|
|
328
|
+
/** Write a notebook to `path` on the local drive and open it. */
|
|
329
|
+
async function writeAndOpen(
|
|
330
|
+
path: string,
|
|
331
|
+
content: INotebookContent
|
|
332
|
+
): Promise<Widget> {
|
|
333
|
+
await docManager.services.contents.save(path, {
|
|
334
|
+
type: 'notebook',
|
|
335
|
+
format: 'json',
|
|
336
|
+
content
|
|
337
|
+
});
|
|
338
|
+
const widget = docManager.openOrReveal(path);
|
|
339
|
+
if (!widget) {
|
|
340
|
+
throw new Error(`Could not open ${path}.`);
|
|
341
|
+
}
|
|
342
|
+
return widget;
|
|
343
|
+
}
|
|
344
|
+
|
|
320
345
|
/** Write a record to the local drive as `Oceanum/<name>.ipynb` and open it. */
|
|
321
346
|
async function openRecord(s: IStore, record: ISpecRecord): Promise<void> {
|
|
322
347
|
// Already open: bring it forward rather than writing a second copy.
|
|
@@ -333,14 +358,7 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
|
|
|
333
358
|
const path =
|
|
334
359
|
(await pathHoldingRecord(s, record, names)) ??
|
|
335
360
|
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
|
-
}
|
|
361
|
+
await writeAndOpen(path, content);
|
|
344
362
|
}
|
|
345
363
|
|
|
346
364
|
async function openById(id: string): Promise<void> {
|
|
@@ -359,6 +377,84 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
|
|
|
359
377
|
}
|
|
360
378
|
}
|
|
361
379
|
|
|
380
|
+
// The copy each example was last opened into, so opening it again while that copy
|
|
381
|
+
// is still open brings it forward rather than writing another.
|
|
382
|
+
const exampleCopies = new Map<string, Widget>();
|
|
383
|
+
// Examples whose copy is still being written, so a second click in that time does
|
|
384
|
+
// not write a second copy.
|
|
385
|
+
const openingExamples = new Set<string>();
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Open a copy of an example as a new file in the Oceanum folder, untrusted and
|
|
389
|
+
* linked to nothing. Not `openRecord`: that links the file to the example's record,
|
|
390
|
+
* which is Oceanum's and read-only to everyone else, so the first save would be
|
|
391
|
+
* refused. Unlinked, the first save creates a record of the user's own.
|
|
392
|
+
*/
|
|
393
|
+
async function openExample(id: string, title: string): Promise<void> {
|
|
394
|
+
if (
|
|
395
|
+
!store ||
|
|
396
|
+
!(await ensureSignedIn(
|
|
397
|
+
'Opening notebooks needs an Oceanum.io account.'
|
|
398
|
+
))
|
|
399
|
+
) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const copy = exampleCopies.get(id);
|
|
403
|
+
if (copy && !copy.isDisposed) {
|
|
404
|
+
app.shell.activateById(copy.id);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (openingExamples.has(id)) {
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
openingExamples.add(id);
|
|
411
|
+
try {
|
|
412
|
+
const record = await store.client.get(id);
|
|
413
|
+
const content = unlinkedNotebookFromRecord(record);
|
|
414
|
+
const path = uniqueNotebookPath(
|
|
415
|
+
title || record.name,
|
|
416
|
+
await oceanumFolderNames()
|
|
417
|
+
);
|
|
418
|
+
exampleCopies.set(id, await writeAndOpen(path, content));
|
|
419
|
+
} catch (error) {
|
|
420
|
+
await reportError('Open from Oceanum failed', error);
|
|
421
|
+
} finally {
|
|
422
|
+
openingExamples.delete(id);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Bring a notebook from the user's computer into the Oceanum folder and open it,
|
|
428
|
+
* untrusted and linked to nothing. It reaches Oceanum on its first save, like any
|
|
429
|
+
* new notebook; nothing is sent on upload.
|
|
430
|
+
*/
|
|
431
|
+
async function uploadNotebook(): Promise<void> {
|
|
432
|
+
if (
|
|
433
|
+
!store ||
|
|
434
|
+
!(await ensureSignedIn(
|
|
435
|
+
'Uploading notebooks needs an Oceanum.io account.'
|
|
436
|
+
))
|
|
437
|
+
) {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
try {
|
|
441
|
+
const file = await chooseNotebookFile();
|
|
442
|
+
if (!file) {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
const content = notebookFromUpload(await file.text());
|
|
446
|
+
const path = uniqueNotebookPath(file.name, await oceanumFolderNames());
|
|
447
|
+
await writeAndOpen(path, content);
|
|
448
|
+
// Outside Oceanum-only mode a plain Save stays local.
|
|
449
|
+
Notification.info(
|
|
450
|
+
`Uploaded "${file.name}". ${oceanumOnly ? 'Save it' : 'Use Save to Oceanum'} to store it on Oceanum.io.`,
|
|
451
|
+
{ autoClose: 5000 }
|
|
452
|
+
);
|
|
453
|
+
} catch (error) {
|
|
454
|
+
await reportError('Upload failed', error);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
362
458
|
async function openFromOceanum(): Promise<void> {
|
|
363
459
|
if (
|
|
364
460
|
!store ||
|
|
@@ -959,6 +1055,26 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
|
|
|
959
1055
|
await (id ? openById(id) : openFromOceanum());
|
|
960
1056
|
}
|
|
961
1057
|
});
|
|
1058
|
+
// Run by the Notebooks tab's Examples; not offered in menus or the palette.
|
|
1059
|
+
app.commands.addCommand(CommandIDs.openExample, {
|
|
1060
|
+
label: 'Open a copy',
|
|
1061
|
+
caption: 'Open a copy of an Oceanum example notebook',
|
|
1062
|
+
isEnabled: () => signedIn(),
|
|
1063
|
+
execute: async args => {
|
|
1064
|
+
if (isSpecId(args.id)) {
|
|
1065
|
+
await openExample(
|
|
1066
|
+
args.id,
|
|
1067
|
+
typeof args.title === 'string' ? args.title : ''
|
|
1068
|
+
);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
});
|
|
1072
|
+
app.commands.addCommand(CommandIDs.upload, {
|
|
1073
|
+
label: label('Upload Notebook…'),
|
|
1074
|
+
caption: 'Upload a notebook from this computer, to save on Oceanum.io',
|
|
1075
|
+
isEnabled: () => signedIn(),
|
|
1076
|
+
execute: () => uploadNotebook()
|
|
1077
|
+
});
|
|
962
1078
|
app.commands.addCommand(CommandIDs.save, {
|
|
963
1079
|
label: label('Save to Oceanum'),
|
|
964
1080
|
caption: 'Save the notebook to Oceanum.io',
|
|
@@ -1013,6 +1129,7 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
|
|
|
1013
1129
|
|
|
1014
1130
|
const commands = [
|
|
1015
1131
|
CommandIDs.open,
|
|
1132
|
+
CommandIDs.upload,
|
|
1016
1133
|
CommandIDs.save,
|
|
1017
1134
|
CommandIDs.share,
|
|
1018
1135
|
CommandIDs.rename,
|
|
@@ -1075,7 +1192,9 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
|
|
|
1075
1192
|
|
|
1076
1193
|
if (oceanumOnly) {
|
|
1077
1194
|
void app.restored.then(() => {
|
|
1078
|
-
hideFileBrowser(app.shell);
|
|
1195
|
+
hideFileBrowser(app.shell, OCEANUM_PANEL_ID);
|
|
1196
|
+
// Oceanum is where notebooks live now, so its panel leads the sidebar.
|
|
1197
|
+
moveToTop(app.shell, OCEANUM_PANEL_ID);
|
|
1079
1198
|
// IFileMenu is the narrow interface; the menu itself is a Lumino Menu, which
|
|
1080
1199
|
// is what can drop items.
|
|
1081
1200
|
const fileMenu = mainMenu?.fileMenu;
|
|
@@ -1083,6 +1202,11 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
|
|
|
1083
1202
|
pruneFileMenu(fileMenu);
|
|
1084
1203
|
pruneBeforeEachOpen(fileMenu);
|
|
1085
1204
|
}
|
|
1205
|
+
const newMenu = mainMenu?.fileMenu.newMenu;
|
|
1206
|
+
if (newMenu instanceof Menu) {
|
|
1207
|
+
pruneFileMenu(newMenu, NON_NOTEBOOK_NEW_COMMANDS);
|
|
1208
|
+
pruneBeforeEachOpen(newMenu, NON_NOTEBOOK_NEW_COMMANDS);
|
|
1209
|
+
}
|
|
1086
1210
|
routeSaves(tracker, pushQuietly);
|
|
1087
1211
|
});
|
|
1088
1212
|
}
|
|
@@ -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,107 @@
|
|
|
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
|
+
|
|
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
|
+
|
|
628
727
|
.oceanum-notebooks-name {
|
|
629
728
|
overflow: hidden;
|
|
630
729
|
text-overflow: ellipsis;
|