@oceanum/oceanumlab 4.7.1 → 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 +35 -5
- package/lib/StoredNotebooks.d.ts +27 -5
- package/lib/StoredNotebooks.js +141 -24
- package/lib/auth/serverAuth.js +1 -0
- package/lib/auth/startSignIn.d.ts +11 -0
- package/lib/auth/startSignIn.js +16 -0
- package/lib/auth/tokens.d.ts +13 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +43 -7
- package/lib/share/client.d.ts +86 -0
- package/lib/share/client.js +206 -0
- package/lib/share/events.d.ts +7 -0
- package/lib/share/events.js +9 -0
- package/lib/share/notebook.d.ts +216 -0
- package/lib/share/notebook.js +355 -0
- package/lib/share/oceanumOnly.d.ts +72 -0
- package/lib/share/oceanumOnly.js +127 -0
- package/lib/share/plugin.d.ts +23 -0
- package/lib/share/plugin.js +981 -0
- package/lib/share/upload.d.ts +6 -0
- package/lib/share/upload.js +27 -0
- package/lib/share/widgets.d.ts +25 -0
- package/lib/share/widgets.js +154 -0
- package/package.json +5 -1
- package/schema/datamesh-connect.json +6 -0
- package/src/DatameshWidget.tsx +79 -2
- package/src/StoredNotebooks.tsx +274 -24
- package/src/__tests__/OceanumPanel.spec.tsx +159 -5
- package/src/__tests__/StoredNotebooks.spec.tsx +528 -0
- package/src/__tests__/oceanumOnly.spec.ts +317 -0
- package/src/__tests__/settingsSchema.spec.ts +11 -1
- package/src/__tests__/shareClient.spec.ts +328 -0
- package/src/__tests__/shareNotebook.spec.ts +624 -0
- package/src/__tests__/sharePlugin.spec.ts +664 -0
- package/src/auth/serverAuth.ts +1 -0
- package/src/auth/startSignIn.ts +23 -0
- package/src/auth/tokens.ts +13 -0
- package/src/index.ts +43 -7
- package/src/share/client.ts +291 -0
- package/src/share/events.ts +10 -0
- package/src/share/notebook.ts +525 -0
- package/src/share/oceanumOnly.ts +177 -0
- package/src/share/plugin.ts +1220 -0
- package/src/share/upload.ts +27 -0
- package/src/share/widgets.ts +217 -0
- package/style/index.css +212 -18
- package/lib/specStore.d.ts +0 -37
- package/lib/specStore.js +0 -57
- package/src/__tests__/specStore.spec.ts +0 -126
- package/src/specStore.ts +0 -82
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/** Notebook metadata key linking a local notebook to its spec store record. */
|
|
2
|
+
export const METADATA_KEY = 'oceanum';
|
|
3
|
+
/** Folder that notebooks opened from Oceanum.io are written to. */
|
|
4
|
+
export const OCEANUM_DIR = 'Oceanum';
|
|
5
|
+
/** Query parameter carrying a shared notebook id. */
|
|
6
|
+
export const QUERY_PARAM = 'oceanum-notebook';
|
|
7
|
+
/**
|
|
8
|
+
* Largest request body we send. Cloud Run rejects requests over 32 MiB; keep a margin
|
|
9
|
+
* for headers and the server's own encoding.
|
|
10
|
+
*/
|
|
11
|
+
export const MAX_BODY_BYTES = 30 * 1024 * 1024;
|
|
12
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
13
|
+
/** Whether `value` is a UUID, and so safe to put in a spec store URL. */
|
|
14
|
+
export function isSpecId(value) {
|
|
15
|
+
return typeof value === 'string' && UUID_RE.test(value);
|
|
16
|
+
}
|
|
17
|
+
function isObject(value) {
|
|
18
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Read the link from an `oceanum` metadata value, or `null` if it is missing, malformed,
|
|
22
|
+
* or belongs to a different spec store.
|
|
23
|
+
*/
|
|
24
|
+
export function readLink(value, specsUrl) {
|
|
25
|
+
if (!isObject(value) ||
|
|
26
|
+
!isSpecId(value.spec_id) ||
|
|
27
|
+
value.specs_url !== specsUrl) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const link = { spec_id: value.spec_id, specs_url: specsUrl };
|
|
31
|
+
if (typeof value.description === 'string') {
|
|
32
|
+
link.description = value.description;
|
|
33
|
+
}
|
|
34
|
+
return link;
|
|
35
|
+
}
|
|
36
|
+
/** The notebook name stored on Oceanum.io for a local path. */
|
|
37
|
+
export function nameFromPath(path) {
|
|
38
|
+
var _a;
|
|
39
|
+
const base = (_a = path.split('/').pop()) !== null && _a !== void 0 ? _a : '';
|
|
40
|
+
return base.replace(/\.ipynb$/i, '') || 'Untitled';
|
|
41
|
+
}
|
|
42
|
+
function byteLength(text) {
|
|
43
|
+
return new TextEncoder().encode(text).length;
|
|
44
|
+
}
|
|
45
|
+
function withoutLink(content) {
|
|
46
|
+
const metadata = { ...content.metadata };
|
|
47
|
+
delete metadata[METADATA_KEY];
|
|
48
|
+
return { ...content, metadata };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The notebook with every cell's `trusted` metadata removed.
|
|
52
|
+
*
|
|
53
|
+
* JupyterLab trusts a cell whose metadata says `trusted: true` and then renders its HTML
|
|
54
|
+
* and Markdown unsanitised, running any script in them. A notebook from the spec store
|
|
55
|
+
* (anyone with write access can edit a record) must arrive untrusted; its outputs
|
|
56
|
+
* become trusted again when the reader re-runs the cells.
|
|
57
|
+
*/
|
|
58
|
+
export function withoutTrust(content) {
|
|
59
|
+
const cells = content.cells.map(cell => {
|
|
60
|
+
if (!isObject(cell.metadata) || !('trusted' in cell.metadata)) {
|
|
61
|
+
return cell;
|
|
62
|
+
}
|
|
63
|
+
const metadata = { ...cell.metadata };
|
|
64
|
+
delete metadata.trusted;
|
|
65
|
+
return { ...cell, metadata };
|
|
66
|
+
});
|
|
67
|
+
return { ...content, cells };
|
|
68
|
+
}
|
|
69
|
+
function withoutOutputs(content) {
|
|
70
|
+
const cells = content.cells.map(cell => cell.cell_type === 'code'
|
|
71
|
+
? { ...cell, outputs: [], execution_count: null }
|
|
72
|
+
: cell);
|
|
73
|
+
return { ...content, cells };
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Build the spec store body for a notebook. The `oceanum` link is not stored in the
|
|
77
|
+
* record. If the body is over `maxBytes`, code-cell outputs and execution counts are
|
|
78
|
+
* dropped; if it is still too large, the result is not ok.
|
|
79
|
+
*/
|
|
80
|
+
export function buildSpecBody(content, path, specsUrl, maxBytes = MAX_BODY_BYTES) {
|
|
81
|
+
var _a;
|
|
82
|
+
const link = readLink((_a = content.metadata) === null || _a === void 0 ? void 0 : _a[METADATA_KEY], specsUrl);
|
|
83
|
+
const make = (spec) => {
|
|
84
|
+
var _a;
|
|
85
|
+
return ({
|
|
86
|
+
name: nameFromPath(path),
|
|
87
|
+
description: (_a = link === null || link === void 0 ? void 0 : link.description) !== null && _a !== void 0 ? _a : null,
|
|
88
|
+
spec
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
const full = make(withoutTrust(withoutLink(content)));
|
|
92
|
+
const fullBytes = byteLength(JSON.stringify(full));
|
|
93
|
+
if (fullBytes <= maxBytes) {
|
|
94
|
+
return { ok: true, body: full, bytes: fullBytes, strippedOutputs: false };
|
|
95
|
+
}
|
|
96
|
+
const stripped = make(withoutOutputs(full.spec));
|
|
97
|
+
const strippedBytes = byteLength(JSON.stringify(stripped));
|
|
98
|
+
if (strippedBytes <= maxBytes) {
|
|
99
|
+
return {
|
|
100
|
+
ok: true,
|
|
101
|
+
body: stripped,
|
|
102
|
+
bytes: strippedBytes,
|
|
103
|
+
strippedOutputs: true
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return { ok: false, bytes: strippedBytes };
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The notebook stored in a record, untrusted. Throws if the record does not hold an
|
|
110
|
+
* nbformat 4 notebook.
|
|
111
|
+
*/
|
|
112
|
+
function recordNotebook(record) {
|
|
113
|
+
const spec = record.spec;
|
|
114
|
+
if (!isNotebook(spec)) {
|
|
115
|
+
throw new Error('This Oceanum.io record is not a valid notebook.');
|
|
116
|
+
}
|
|
117
|
+
return withoutTrust(spec);
|
|
118
|
+
}
|
|
119
|
+
function isNotebook(value) {
|
|
120
|
+
return (isObject(value) &&
|
|
121
|
+
value.nbformat === 4 &&
|
|
122
|
+
Array.isArray(value.cells) &&
|
|
123
|
+
value.cells.every(cell => isObject(cell) && typeof cell.cell_type === 'string') &&
|
|
124
|
+
isObject(value.metadata));
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* A notebook uploaded from the user's computer, untrusted and linked to nothing: a file
|
|
128
|
+
* someone else downloaded from a record must not save over that record, and its outputs
|
|
129
|
+
* must not run scripts on the origin that holds the Oceanum.io session. Throws if the
|
|
130
|
+
* text is not an nbformat 4 notebook; the message never quotes the file.
|
|
131
|
+
*/
|
|
132
|
+
export function notebookFromUpload(text) {
|
|
133
|
+
let parsed;
|
|
134
|
+
try {
|
|
135
|
+
parsed = JSON.parse(text);
|
|
136
|
+
}
|
|
137
|
+
catch (_a) {
|
|
138
|
+
throw new Error('The file is not a Jupyter notebook: it is not JSON.');
|
|
139
|
+
}
|
|
140
|
+
if (!isNotebook(parsed)) {
|
|
141
|
+
throw new Error('The file is not a Jupyter notebook in nbformat 4.');
|
|
142
|
+
}
|
|
143
|
+
return withoutLink(withoutTrust(parsed));
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The notebook stored in a record, with its `oceanum` link set. Throws if the record
|
|
147
|
+
* does not hold an nbformat 4 notebook.
|
|
148
|
+
*/
|
|
149
|
+
export function notebookFromRecord(record, specsUrl) {
|
|
150
|
+
const link = { spec_id: record.id, specs_url: specsUrl };
|
|
151
|
+
if (record.description) {
|
|
152
|
+
link.description = record.description;
|
|
153
|
+
}
|
|
154
|
+
const content = recordNotebook(record);
|
|
155
|
+
return {
|
|
156
|
+
...content,
|
|
157
|
+
metadata: { ...content.metadata, [METADATA_KEY]: { ...link } }
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* A copy of the notebook stored in a record, linked to nothing: untrusted, and without
|
|
162
|
+
* any `oceanum` link, so saving it creates a record of the user's own instead of
|
|
163
|
+
* writing to this one. Throws if the record does not hold an nbformat 4 notebook.
|
|
164
|
+
*/
|
|
165
|
+
export function unlinkedNotebookFromRecord(record) {
|
|
166
|
+
return withoutLink(recordNotebook(record));
|
|
167
|
+
}
|
|
168
|
+
/** Make a record name safe to use as a single path segment. */
|
|
169
|
+
export function sanitizeName(name) {
|
|
170
|
+
const clean = name
|
|
171
|
+
.trim()
|
|
172
|
+
.replace(/\.ipynb$/i, '')
|
|
173
|
+
.replace(/[/\\]/g, '-')
|
|
174
|
+
.trim();
|
|
175
|
+
return clean === '' || clean === '.' || clean === '..' ? 'Untitled' : clean;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* A path `Oceanum/<name>.ipynb` that does not collide with `existing` (file names in the
|
|
179
|
+
* `Oceanum` folder), appending ` (1)`, ` (2)`, ... as needed.
|
|
180
|
+
*/
|
|
181
|
+
export function uniqueNotebookPath(name, existing) {
|
|
182
|
+
const taken = new Set(existing);
|
|
183
|
+
const base = sanitizeName(name);
|
|
184
|
+
let candidate = `${base}.ipynb`;
|
|
185
|
+
for (let i = 1; taken.has(candidate); i++) {
|
|
186
|
+
candidate = `${base} (${i}).ipynb`;
|
|
187
|
+
}
|
|
188
|
+
return `${OCEANUM_DIR}/${candidate}`;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The names in `existing` that `uniqueNotebookPath` could have given a record called
|
|
192
|
+
* `name`, in the order it would have tried them: `<base>.ipynb`, then `<base> (1).ipynb`,
|
|
193
|
+
* `<base> (2).ipynb` and so on.
|
|
194
|
+
*
|
|
195
|
+
* Opening a record looks through these for the copy a previous session left, so the same
|
|
196
|
+
* record keeps one local file. Without that the second copy is named `<base> (1)`, and
|
|
197
|
+
* since a record takes its name from the file on every save, opening a notebook twice
|
|
198
|
+
* would quietly rename it on Oceanum.
|
|
199
|
+
*/
|
|
200
|
+
export function notebookNamesFor(name, existing) {
|
|
201
|
+
const base = sanitizeName(name);
|
|
202
|
+
const prefix = `${base} (`;
|
|
203
|
+
const suffix = ').ipynb';
|
|
204
|
+
const plain = [];
|
|
205
|
+
const numbered = [];
|
|
206
|
+
for (const candidate of existing) {
|
|
207
|
+
if (candidate === `${base}.ipynb`) {
|
|
208
|
+
plain.push(candidate);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (!candidate.startsWith(prefix) || !candidate.endsWith(suffix)) {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const index = candidate.slice(prefix.length, candidate.length - suffix.length);
|
|
215
|
+
if (/^\d+$/.test(index)) {
|
|
216
|
+
numbered.push({ name: candidate, index: Number(index) });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
numbered.sort((a, b) => a.index - b.index);
|
|
220
|
+
return [...plain, ...numbered.map(entry => entry.name)];
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Split a listing into the caller's own notebooks and the rest, newest first.
|
|
224
|
+
*
|
|
225
|
+
* The store only sends `creator` on records the caller created, so anything else it
|
|
226
|
+
* returns is shared with them — directly, or because it is public. Addresses are
|
|
227
|
+
* compared case-insensitively: the store matches entities as case-sensitive globs, so
|
|
228
|
+
* the two sides can differ in case.
|
|
229
|
+
*/
|
|
230
|
+
export function partitionSummaries(items, email) {
|
|
231
|
+
var _a;
|
|
232
|
+
const me = (_a = email === null || email === void 0 ? void 0 : email.toLowerCase()) !== null && _a !== void 0 ? _a : null;
|
|
233
|
+
const sorted = [...items].sort((a, b) => b.modified.localeCompare(a.modified));
|
|
234
|
+
const isMine = (item) => { var _a; return me !== null && ((_a = item.creator) === null || _a === void 0 ? void 0 : _a.toLowerCase()) === me; };
|
|
235
|
+
return {
|
|
236
|
+
mine: sorted.filter(isMine),
|
|
237
|
+
shared: sorted.filter(item => !isMine(item))
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
/** The `kind` of a Notebook Demo record's spec. */
|
|
241
|
+
export const NOTEBOOK_DEMO_KIND = 'notebook-demo';
|
|
242
|
+
function nonBlank(value) {
|
|
243
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* The sections of a Notebook Demo record's spec, keeping only what can be listed: items
|
|
247
|
+
* with a record id and a title, in sections with a title and at least one such item.
|
|
248
|
+
* A spec of another kind or version gives no sections, with a warning.
|
|
249
|
+
*/
|
|
250
|
+
export function parseNotebookDemo(spec) {
|
|
251
|
+
if (!isObject(spec) ||
|
|
252
|
+
spec.kind !== NOTEBOOK_DEMO_KIND ||
|
|
253
|
+
spec.version !== 1 ||
|
|
254
|
+
!Array.isArray(spec.sections)) {
|
|
255
|
+
console.warn('Oceanum: ignoring a Notebook Demo record this version cannot read.');
|
|
256
|
+
return [];
|
|
257
|
+
}
|
|
258
|
+
const sections = [];
|
|
259
|
+
for (const section of spec.sections) {
|
|
260
|
+
if (!isObject(section) ||
|
|
261
|
+
!nonBlank(section.title) ||
|
|
262
|
+
!Array.isArray(section.items)) {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const items = [];
|
|
266
|
+
for (const item of section.items) {
|
|
267
|
+
if (!isObject(item) || !isSpecId(item.id) || !nonBlank(item.title)) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
items.push(typeof item.summary === 'string'
|
|
271
|
+
? { id: item.id, title: item.title, summary: item.summary }
|
|
272
|
+
: { id: item.id, title: item.title });
|
|
273
|
+
}
|
|
274
|
+
if (items.length > 0) {
|
|
275
|
+
sections.push({ title: section.title, items });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return sections;
|
|
279
|
+
}
|
|
280
|
+
/** Parse a spec store timestamp, which is UTC but usually has no zone designator. */
|
|
281
|
+
export function parseTimestamp(value) {
|
|
282
|
+
const zoned = /(Z|[+-]\d{2}:?\d{2})$/i.test(value) ? value : `${value}Z`;
|
|
283
|
+
const date = new Date(zoned);
|
|
284
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Whether `email` can be granted access. Glob characters are refused: the server
|
|
288
|
+
* matches user entities as globs.
|
|
289
|
+
*/
|
|
290
|
+
export function isShareableEmail(email) {
|
|
291
|
+
return /^[^\s@*?[\]]+@[^\s@*?[\]]+\.[^\s@*?[\]]+$/.test(email);
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Split what the user typed into individual addresses. Commas, semicolons and any
|
|
295
|
+
* whitespace all separate, so one-per-line and a comma-separated list both work.
|
|
296
|
+
*
|
|
297
|
+
* Bare addresses only: a mail client's "To:" line carries display names, and
|
|
298
|
+
* `"Alice B" <alice@example.com>` splits into tokens that are not addresses. Those
|
|
299
|
+
* come back in `invalid` so the user is told, rather than being silently dropped.
|
|
300
|
+
*
|
|
301
|
+
* Addresses are lower-cased because the spec store matches the user entity as a
|
|
302
|
+
* case-sensitive glob against the Auth0 email claim, which Auth0 stores lower case.
|
|
303
|
+
*/
|
|
304
|
+
export function parseShareEmails(raw) {
|
|
305
|
+
const emails = [];
|
|
306
|
+
const invalid = [];
|
|
307
|
+
const seen = new Set();
|
|
308
|
+
for (const part of raw.split(/[\s,;]+/)) {
|
|
309
|
+
const entry = part.trim();
|
|
310
|
+
if (entry === '') {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const email = entry.toLowerCase();
|
|
314
|
+
if (!isShareableEmail(email)) {
|
|
315
|
+
// Report it as written, so the user recognises what they typed.
|
|
316
|
+
invalid.push(entry);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (!seen.has(email)) {
|
|
320
|
+
seen.add(email);
|
|
321
|
+
emails.push(email);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return { emails, invalid };
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Decide how to report a set of failed grants.
|
|
328
|
+
*
|
|
329
|
+
* `entities` are the failed grants' entities, in order; the public grant's entity is
|
|
330
|
+
* the empty string, which names nobody.
|
|
331
|
+
*/
|
|
332
|
+
export function reportForShareFailures(grantCount, entities, needsSignIn) {
|
|
333
|
+
const named = entities.filter(entity => entity !== '');
|
|
334
|
+
// A sign-in problem must reach the reporter as the original error or the user is
|
|
335
|
+
// never offered a way back in; an unnamed or total failure has nothing extra worth
|
|
336
|
+
// saying.
|
|
337
|
+
if (needsSignIn || named.length === 0 || entities.length >= grantCount) {
|
|
338
|
+
return { kind: 'original' };
|
|
339
|
+
}
|
|
340
|
+
return { kind: 'partial', named };
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* The link that opens a shared notebook in this deployment.
|
|
344
|
+
*
|
|
345
|
+
* JupyterLite is static files, so its page is `lab/index.html`; a JupyterLab server
|
|
346
|
+
* serves the application at `lab` and has no `index.html` to route.
|
|
347
|
+
*/
|
|
348
|
+
export function shareLink(baseUrl, pageHref, id) {
|
|
349
|
+
const app = new URL(pageHref).pathname.endsWith('/index.html')
|
|
350
|
+
? 'lab/index.html'
|
|
351
|
+
: 'lab';
|
|
352
|
+
const url = new URL(app, new URL(baseUrl, pageHref));
|
|
353
|
+
url.searchParams.set(QUERY_PARAM, id);
|
|
354
|
+
return url.toString();
|
|
355
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Oceanum-only file management: what a deployment gets when its environment says
|
|
3
|
+
* `fileManagement: 'oceanum'`, as notebook.oceanum.io does.
|
|
4
|
+
*
|
|
5
|
+
* The local drive stays as the working copy underneath, out of sight: the file browser
|
|
6
|
+
* is taken out of the sidebar, the File menu loses the entries that manage local files,
|
|
7
|
+
* and every completed local save is pushed to the spec store, so Ctrl+S, the toolbar
|
|
8
|
+
* button, Save All and autosave all reach Oceanum.
|
|
9
|
+
*/
|
|
10
|
+
import type { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook';
|
|
11
|
+
import type { Menu, Widget } from '@lumino/widgets';
|
|
12
|
+
/** The widget id `@jupyterlab/filebrowser-extension:browser` gives the file browser. */
|
|
13
|
+
export declare const FILE_BROWSER_ID = "filebrowser";
|
|
14
|
+
/** The widget id of this extension's Oceanum.io panel, which takes the browser's place. */
|
|
15
|
+
export declare const OCEANUM_PANEL_ID = "datamesh-connect";
|
|
16
|
+
/**
|
|
17
|
+
* File menu entries that manage local files. Save, Save All and Rename stay: saving is
|
|
18
|
+
* routed to Oceanum, and renaming the local file is how a notebook gets its name.
|
|
19
|
+
*/
|
|
20
|
+
export declare const LOCAL_FILE_COMMANDS: readonly string[];
|
|
21
|
+
/**
|
|
22
|
+
* File > New entries for files that are not notebooks. The spec store holds notebooks
|
|
23
|
+
* only, so such a file could never be saved anywhere the user can see.
|
|
24
|
+
*/
|
|
25
|
+
export declare const NON_NOTEBOOK_NEW_COMMANDS: readonly string[];
|
|
26
|
+
/**
|
|
27
|
+
* Take the file browser out of the sidebar; true if it was there. It is closed, not
|
|
28
|
+
* disposed: other extensions hold it through IDefaultFileBrowser and still use it to
|
|
29
|
+
* open paths. If it was the open panel, `instead` is opened in its place; a sidebar the
|
|
30
|
+
* user had collapsed stays collapsed.
|
|
31
|
+
*/
|
|
32
|
+
export declare function hideFileBrowser(shell: {
|
|
33
|
+
widgets(area: 'left'): Iterable<Widget>;
|
|
34
|
+
activateById(id: string): void;
|
|
35
|
+
}, instead?: string): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Put a left-sidebar widget first, and keep it open if it was. True if it moved.
|
|
38
|
+
*
|
|
39
|
+
* A rank alone is not enough: the layout restorer replays the sidebar order each user
|
|
40
|
+
* last had, so a returning user would keep the panel wherever it used to be. Adding it
|
|
41
|
+
* again after the restore re-inserts it by rank, and the shell saves the new order.
|
|
42
|
+
*/
|
|
43
|
+
export declare function moveToTop(shell: {
|
|
44
|
+
widgets(area: 'left'): Iterable<Widget>;
|
|
45
|
+
add(widget: Widget, area: 'left', options: {
|
|
46
|
+
rank: number;
|
|
47
|
+
}): void;
|
|
48
|
+
activateById(id: string): void;
|
|
49
|
+
}, id: string): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Submenus of local files. "Open Recent" is built by the document manager rather than
|
|
52
|
+
* declared in a schema, and its menu has no id, so it is known by the commands it holds.
|
|
53
|
+
*/
|
|
54
|
+
export declare const LOCAL_FILE_SUBMENU_COMMANDS: readonly string[];
|
|
55
|
+
/**
|
|
56
|
+
* Remove the local-file entries from a menu; returns how many went. Idempotent, so it
|
|
57
|
+
* can run every time the menu is about to show: entries arrive from settings schemas as
|
|
58
|
+
* plugins load, not all at once.
|
|
59
|
+
*/
|
|
60
|
+
export declare function pruneFileMenu(menu: Menu, commands?: readonly string[]): number;
|
|
61
|
+
/**
|
|
62
|
+
* Prune the menu again every time it opens. Entries arrive from settings schemas as
|
|
63
|
+
* plugins load rather than all at once, and a menu has no "about to show" signal; it
|
|
64
|
+
* is attached to the document each time it opens, which a message hook can watch.
|
|
65
|
+
*/
|
|
66
|
+
export declare function pruneBeforeEachOpen(menu: Menu, commands?: readonly string[]): void;
|
|
67
|
+
/**
|
|
68
|
+
* Call `push` after every completed local save of every notebook, open now or later.
|
|
69
|
+
* `push` must guard against its own saves: writing the record link back to the notebook
|
|
70
|
+
* saves it again.
|
|
71
|
+
*/
|
|
72
|
+
export declare function routeSaves(tracker: INotebookTracker, push: (panel: NotebookPanel) => Promise<void>): void;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { MessageLoop } from '@lumino/messaging';
|
|
2
|
+
/** The widget id `@jupyterlab/filebrowser-extension:browser` gives the file browser. */
|
|
3
|
+
export const FILE_BROWSER_ID = 'filebrowser';
|
|
4
|
+
/** The widget id of this extension's Oceanum.io panel, which takes the browser's place. */
|
|
5
|
+
export const OCEANUM_PANEL_ID = 'datamesh-connect';
|
|
6
|
+
/**
|
|
7
|
+
* File menu entries that manage local files. Save, Save All and Rename stay: saving is
|
|
8
|
+
* routed to Oceanum, and renaming the local file is how a notebook gets its name.
|
|
9
|
+
*/
|
|
10
|
+
export const LOCAL_FILE_COMMANDS = [
|
|
11
|
+
'filebrowser:open-path',
|
|
12
|
+
'filebrowser:open-url',
|
|
13
|
+
'docmanager:save-as',
|
|
14
|
+
'docmanager:reload',
|
|
15
|
+
'docmanager:restore-checkpoint',
|
|
16
|
+
'docmanager:duplicate',
|
|
17
|
+
'docmanager:download'
|
|
18
|
+
];
|
|
19
|
+
/**
|
|
20
|
+
* File > New entries for files that are not notebooks. The spec store holds notebooks
|
|
21
|
+
* only, so such a file could never be saved anywhere the user can see.
|
|
22
|
+
*/
|
|
23
|
+
export const NON_NOTEBOOK_NEW_COMMANDS = [
|
|
24
|
+
'fileeditor:create-new',
|
|
25
|
+
'fileeditor:create-new-markdown-file'
|
|
26
|
+
];
|
|
27
|
+
/**
|
|
28
|
+
* Take the file browser out of the sidebar; true if it was there. It is closed, not
|
|
29
|
+
* disposed: other extensions hold it through IDefaultFileBrowser and still use it to
|
|
30
|
+
* open paths. If it was the open panel, `instead` is opened in its place; a sidebar the
|
|
31
|
+
* user had collapsed stays collapsed.
|
|
32
|
+
*/
|
|
33
|
+
export function hideFileBrowser(shell, instead) {
|
|
34
|
+
for (const widget of shell.widgets('left')) {
|
|
35
|
+
if (widget.id === FILE_BROWSER_ID) {
|
|
36
|
+
const wasOpen = widget.isVisible;
|
|
37
|
+
widget.close();
|
|
38
|
+
if (wasOpen && instead) {
|
|
39
|
+
shell.activateById(instead);
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Put a left-sidebar widget first, and keep it open if it was. True if it moved.
|
|
48
|
+
*
|
|
49
|
+
* A rank alone is not enough: the layout restorer replays the sidebar order each user
|
|
50
|
+
* last had, so a returning user would keep the panel wherever it used to be. Adding it
|
|
51
|
+
* again after the restore re-inserts it by rank, and the shell saves the new order.
|
|
52
|
+
*/
|
|
53
|
+
export function moveToTop(shell, id) {
|
|
54
|
+
const widgets = Array.from(shell.widgets('left'));
|
|
55
|
+
const index = widgets.findIndex(widget => widget.id === id);
|
|
56
|
+
if (index <= 0) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
const widget = widgets[index];
|
|
60
|
+
// Adding hides the widget, so an open panel has to be opened again.
|
|
61
|
+
const wasOpen = widget.isVisible;
|
|
62
|
+
shell.add(widget, 'left', { rank: 0 });
|
|
63
|
+
if (wasOpen) {
|
|
64
|
+
shell.activateById(id);
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Submenus of local files. "Open Recent" is built by the document manager rather than
|
|
70
|
+
* declared in a schema, and its menu has no id, so it is known by the commands it holds.
|
|
71
|
+
*/
|
|
72
|
+
export const LOCAL_FILE_SUBMENU_COMMANDS = [
|
|
73
|
+
'docmanager:open-recent',
|
|
74
|
+
'docmanager:clear-recents'
|
|
75
|
+
];
|
|
76
|
+
function isLocalSubmenu(item) {
|
|
77
|
+
return (item.type === 'submenu' &&
|
|
78
|
+
!!item.submenu &&
|
|
79
|
+
(item.submenu.title.label === 'Open Recent' ||
|
|
80
|
+
item.submenu.items.some(child => LOCAL_FILE_SUBMENU_COMMANDS.includes(child.command))));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Remove the local-file entries from a menu; returns how many went. Idempotent, so it
|
|
84
|
+
* can run every time the menu is about to show: entries arrive from settings schemas as
|
|
85
|
+
* plugins load, not all at once.
|
|
86
|
+
*/
|
|
87
|
+
export function pruneFileMenu(menu, commands = LOCAL_FILE_COMMANDS) {
|
|
88
|
+
let removed = 0;
|
|
89
|
+
for (let i = menu.items.length - 1; i >= 0; i--) {
|
|
90
|
+
const item = menu.items[i];
|
|
91
|
+
if ((item.type === 'command' && commands.includes(item.command)) ||
|
|
92
|
+
isLocalSubmenu(item)) {
|
|
93
|
+
menu.removeItemAt(i);
|
|
94
|
+
removed++;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return removed;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Prune the menu again every time it opens. Entries arrive from settings schemas as
|
|
101
|
+
* plugins load rather than all at once, and a menu has no "about to show" signal; it
|
|
102
|
+
* is attached to the document each time it opens, which a message hook can watch.
|
|
103
|
+
*/
|
|
104
|
+
export function pruneBeforeEachOpen(menu, commands = LOCAL_FILE_COMMANDS) {
|
|
105
|
+
MessageLoop.installMessageHook(menu, (_, message) => {
|
|
106
|
+
if (message.type === 'before-attach') {
|
|
107
|
+
pruneFileMenu(menu, commands);
|
|
108
|
+
}
|
|
109
|
+
return true;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Call `push` after every completed local save of every notebook, open now or later.
|
|
114
|
+
* `push` must guard against its own saves: writing the record link back to the notebook
|
|
115
|
+
* saves it again.
|
|
116
|
+
*/
|
|
117
|
+
export function routeSaves(tracker, push) {
|
|
118
|
+
const watch = (panel) => {
|
|
119
|
+
panel.context.saveState.connect((_, state) => {
|
|
120
|
+
if (state === 'completed') {
|
|
121
|
+
void push(panel);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
tracker.forEach(watch);
|
|
126
|
+
tracker.widgetAdded.connect((_, panel) => watch(panel));
|
|
127
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { JupyterFrontEndPlugin } from '@jupyterlab/application';
|
|
2
|
+
export declare const CommandIDs: {
|
|
3
|
+
readonly open: "oceanum-share:open";
|
|
4
|
+
readonly openExample: "oceanum-share:open-example";
|
|
5
|
+
readonly upload: "oceanum-share:upload";
|
|
6
|
+
readonly save: "oceanum-share:save";
|
|
7
|
+
readonly share: "oceanum-share:share";
|
|
8
|
+
readonly rename: "oceanum-share:rename";
|
|
9
|
+
readonly delete: "oceanum-share:delete";
|
|
10
|
+
};
|
|
11
|
+
export declare const SHARE_PLUGIN_ID = "@oceanum/oceanumlab:share";
|
|
12
|
+
/** The Notebooks tab marks each listed record with this, for its context menu. */
|
|
13
|
+
export declare const SPEC_ID_ATTRIBUTE = "data-spec-id";
|
|
14
|
+
export declare const NOTEBOOK_ITEM_SELECTOR = ".oceanum-notebooks-item[data-spec-id]";
|
|
15
|
+
/**
|
|
16
|
+
* Open, save, share, rename and delete notebooks through the Oceanum spec store.
|
|
17
|
+
*
|
|
18
|
+
* The commands sit in the File menu, the command palette, a notebook's context menu
|
|
19
|
+
* (and its tab's), and the Notebooks tab's rows. They are enabled only while signed in.
|
|
20
|
+
* With `fileManagement: 'oceanum'` in the environment the local drive goes out of
|
|
21
|
+
* sight and every local save is pushed to the store: see `oceanumOnly.ts`.
|
|
22
|
+
*/
|
|
23
|
+
export declare const sharePlugin: JupyterFrontEndPlugin<void>;
|