@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
package/src/auth/tokens.ts
CHANGED
|
@@ -25,6 +25,19 @@ export interface IOceanumEnvironment {
|
|
|
25
25
|
* the site origin is an allowed callback URL.
|
|
26
26
|
*/
|
|
27
27
|
readonly signInRedirect: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Whether Oceanum.io is the only file management this host shows, as on
|
|
30
|
+
* notebook.oceanum.io: the file browser and the File menu's local-file entries go,
|
|
31
|
+
* and every local save is pushed to the spec store. `'local'` (the default when
|
|
32
|
+
* absent) keeps JupyterLab's file management alongside the Oceanum commands.
|
|
33
|
+
*/
|
|
34
|
+
readonly fileManagement?: 'local' | 'oceanum';
|
|
35
|
+
/**
|
|
36
|
+
* The spec store id of this deployment's Notebook Demo record, the curated list the
|
|
37
|
+
* Notebooks tab shows as Examples. Absent where a deployment has none, and never set
|
|
38
|
+
* by a JupyterLab server's sign-in.
|
|
39
|
+
*/
|
|
40
|
+
readonly notebookDemo?: string;
|
|
28
41
|
}
|
|
29
42
|
|
|
30
43
|
/** Service base URLs for an environment, without trailing slashes. */
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,8 @@ import { reporterFor } from './progress';
|
|
|
20
20
|
import { ConversationPin } from './conversationPin';
|
|
21
21
|
import { deviceAuthPlugin } from './auth/plugin';
|
|
22
22
|
import { IOceanumAuth } from './auth/tokens';
|
|
23
|
+
import { CommandIDs as ShareCommandIDs, sharePlugin } from './share/plugin';
|
|
24
|
+
import { OCEANUM_PANEL_ID } from './share/oceanumOnly';
|
|
23
25
|
|
|
24
26
|
import { snapshotFromIpynb, snapshotOf } from './notebookContext';
|
|
25
27
|
import { notebookHost } from './notebookHost';
|
|
@@ -78,6 +80,9 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
|
|
|
78
80
|
const aiBackendUrl = auth?.urls?.ai ?? OCEANUM_AI_BACKEND_URL;
|
|
79
81
|
// The `datameshToken` setting for the AI chat; empty until the settings load.
|
|
80
82
|
let datameshTokenSetting = '';
|
|
83
|
+
// The `showExamples` setting for the Notebooks tab; null until the settings load, so
|
|
84
|
+
// examples a user has hidden do not show for a moment on every reload.
|
|
85
|
+
let showExamplesSetting: boolean | null = null;
|
|
81
86
|
// Tells the sidebar the settings above have changed.
|
|
82
87
|
const settingsChanged = new Signal<JupyterFrontEnd, void>(app);
|
|
83
88
|
|
|
@@ -99,6 +104,7 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
|
|
|
99
104
|
}
|
|
100
105
|
window.injectToken = set.get('injectToken').user as boolean;
|
|
101
106
|
datameshTokenSetting = set.get('datameshToken').composite as string;
|
|
107
|
+
showExamplesSetting = set.get('showExamples').composite as boolean;
|
|
102
108
|
settingsChanged.emit();
|
|
103
109
|
};
|
|
104
110
|
//Try to get the datamesh token from the envars
|
|
@@ -115,6 +121,10 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
|
|
|
115
121
|
console.error(
|
|
116
122
|
`Something went wrong when reading the Oceanumlab settings.\n${reason}`
|
|
117
123
|
);
|
|
124
|
+
// Unreadable settings fall back to the default rather than leaving the examples
|
|
125
|
+
// waiting for a value that will never come.
|
|
126
|
+
showExamplesSetting = true;
|
|
127
|
+
settingsChanged.emit();
|
|
118
128
|
});
|
|
119
129
|
|
|
120
130
|
const getCurrentWidget = (): Widget => {
|
|
@@ -149,14 +159,39 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
|
|
|
149
159
|
settingsChanged,
|
|
150
160
|
commands: app.commands,
|
|
151
161
|
getCurrentWidget,
|
|
152
|
-
auth
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
162
|
+
auth,
|
|
163
|
+
openStoredNotebook: id => {
|
|
164
|
+
app.commands.execute(ShareCommandIDs.open, { id }).catch(error => {
|
|
165
|
+
console.error('Oceanum: could not open the stored notebook.', error);
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
openExample: (id, title) => {
|
|
169
|
+
app.commands
|
|
170
|
+
.execute(ShareCommandIDs.openExample, { id, title })
|
|
171
|
+
.catch(error => {
|
|
172
|
+
console.error('Oceanum: could not open the example.', error);
|
|
173
|
+
});
|
|
174
|
+
},
|
|
175
|
+
uploadNotebook: () => {
|
|
176
|
+
app.commands.execute(ShareCommandIDs.upload).catch(error => {
|
|
177
|
+
console.error('Oceanum: could not upload the notebook.', error);
|
|
178
|
+
});
|
|
179
|
+
},
|
|
180
|
+
showExamples: () => showExamplesSetting,
|
|
181
|
+
// Saved as a user setting, so the choice outlasts the page; the settings'
|
|
182
|
+
// `changed` signal brings the new value back to the panel. The registry waits
|
|
183
|
+
// for the plugin's settings to load, so a click before then is not lost.
|
|
184
|
+
setShowExamples: show => {
|
|
185
|
+
settingRegistry.set(PLUGIN_ID, 'showExamples', show).catch(error => {
|
|
186
|
+
// The switch moves only once the setting is saved, so say why it did not.
|
|
187
|
+
console.error('Oceanum: could not save the Examples setting.', error);
|
|
188
|
+
Notification.error('Could not save whether to show the examples.', {
|
|
189
|
+
autoClose: 5000
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
}
|
|
158
193
|
});
|
|
159
|
-
datameshConnectWidget.id =
|
|
194
|
+
datameshConnectWidget.id = OCEANUM_PANEL_ID;
|
|
160
195
|
datameshConnectWidget.title.icon = oceanumIcon;
|
|
161
196
|
datameshConnectWidget.title.caption = 'Datamesh Connect';
|
|
162
197
|
|
|
@@ -399,6 +434,7 @@ export * from './auth/tokens';
|
|
|
399
434
|
|
|
400
435
|
export default [
|
|
401
436
|
deviceAuthPlugin,
|
|
437
|
+
sharePlugin,
|
|
402
438
|
datamesh_connect_extension,
|
|
403
439
|
oceanum_ai_extension
|
|
404
440
|
];
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isSpecId,
|
|
3
|
+
ISpecBody,
|
|
4
|
+
ISpecPatch,
|
|
5
|
+
ISpecRecord,
|
|
6
|
+
ISpecSummary
|
|
7
|
+
} from './notebook';
|
|
8
|
+
|
|
9
|
+
export type ErrorKind =
|
|
10
|
+
| 'signed-out'
|
|
11
|
+
| 'expired'
|
|
12
|
+
| 'forbidden'
|
|
13
|
+
| 'not-found'
|
|
14
|
+
| 'too-large'
|
|
15
|
+
| 'network'
|
|
16
|
+
| 'invalid'
|
|
17
|
+
| 'unsupported'
|
|
18
|
+
| 'server';
|
|
19
|
+
|
|
20
|
+
const MESSAGES: Record<Exclude<ErrorKind, 'server'>, string> = {
|
|
21
|
+
'signed-out': 'Sign in to Oceanum.io to continue.',
|
|
22
|
+
expired: 'Your Oceanum session has expired, sign in again.',
|
|
23
|
+
forbidden:
|
|
24
|
+
'You have no access to this notebook. It may not exist, or was created under another organisation.',
|
|
25
|
+
'not-found': 'This notebook no longer exists on Oceanum.io.',
|
|
26
|
+
'too-large': 'The notebook is too large for Oceanum.io (32 MiB limit).',
|
|
27
|
+
network:
|
|
28
|
+
'Could not reach Oceanum.io. Check your connection; very large notebooks can also fail this way.',
|
|
29
|
+
invalid: 'Oceanum.io returned an unexpected response.',
|
|
30
|
+
unsupported: 'This Oceanum.io service does not offer that operation yet.'
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** A spec store failure with a message that is safe to show to the user. */
|
|
34
|
+
export class SpecStoreError extends Error {
|
|
35
|
+
constructor(
|
|
36
|
+
readonly kind: ErrorKind,
|
|
37
|
+
readonly status: number | null = null
|
|
38
|
+
) {
|
|
39
|
+
super(
|
|
40
|
+
kind === 'server'
|
|
41
|
+
? `Oceanum.io returned an error (HTTP ${status}).`
|
|
42
|
+
: MESSAGES[kind]
|
|
43
|
+
);
|
|
44
|
+
this.name = 'SpecStoreError';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Map a failed HTTP status to an error. */
|
|
49
|
+
export function errorForStatus(status: number): SpecStoreError {
|
|
50
|
+
switch (status) {
|
|
51
|
+
// The spec store's authentication middleware answers a bad or expired token with 400.
|
|
52
|
+
case 400:
|
|
53
|
+
return new SpecStoreError('expired', status);
|
|
54
|
+
// Casbin answers 403 both for no access and for a record that does not exist.
|
|
55
|
+
case 403:
|
|
56
|
+
return new SpecStoreError('forbidden', status);
|
|
57
|
+
case 404:
|
|
58
|
+
return new SpecStoreError('not-found', status);
|
|
59
|
+
case 405:
|
|
60
|
+
// The route is there but not this method: a store from before it had PATCH.
|
|
61
|
+
return new SpecStoreError('unsupported', status);
|
|
62
|
+
case 413:
|
|
63
|
+
return new SpecStoreError('too-large', status);
|
|
64
|
+
default:
|
|
65
|
+
return new SpecStoreError('server', status);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type SharePermission = 'read' | 'write';
|
|
70
|
+
|
|
71
|
+
/** A permission grant, as accepted by `POST /specs/notebook/{id}/permissions`. */
|
|
72
|
+
export type PermissionGrant =
|
|
73
|
+
| { type: 'user'; entity: string; permission: SharePermission }
|
|
74
|
+
| { type: 'public'; entity: ''; permission: 'read' };
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* What the share dialog asks for: add grants, or remove one (e.g. stop public access).
|
|
78
|
+
*
|
|
79
|
+
* Granting carries a list because the dialog shares with several people at once, while
|
|
80
|
+
* the spec store takes one grant per request. `invalid` carries back anything typed that
|
|
81
|
+
* is not a shareable address, so the caller can name it rather than failing with
|
|
82
|
+
* "invalid email".
|
|
83
|
+
*/
|
|
84
|
+
export type ShareChoice =
|
|
85
|
+
| { action: 'grant'; grants: PermissionGrant[]; invalid: string[] }
|
|
86
|
+
| { action: 'revoke'; grant: PermissionGrant };
|
|
87
|
+
|
|
88
|
+
export interface ISpecStoreClientOptions {
|
|
89
|
+
/** Spec store base URL, e.g. `https://specs.oceanum.io`. */
|
|
90
|
+
specsUrl: string;
|
|
91
|
+
/** Returns a current access token or `null`. Called for every request. */
|
|
92
|
+
getAccessToken: () => Promise<string | null>;
|
|
93
|
+
/** The spec type the client reads and writes; `notebook` unless given. */
|
|
94
|
+
specType?: string;
|
|
95
|
+
fetch?: typeof fetch;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isSummary(value: unknown): value is ISpecSummary {
|
|
99
|
+
if (typeof value !== 'object' || value === null) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
const v = value as Record<string, unknown>;
|
|
103
|
+
return (
|
|
104
|
+
isSpecId(v.id) &&
|
|
105
|
+
typeof v.name === 'string' &&
|
|
106
|
+
typeof v.modified === 'string'
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isRecord(value: unknown): value is ISpecRecord {
|
|
111
|
+
return isSummary(value) && 'spec' in value;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Client for one spec type of the Oceanum spec store, `notebook` by default. */
|
|
115
|
+
export class SpecStoreClient {
|
|
116
|
+
constructor(options: ISpecStoreClientOptions) {
|
|
117
|
+
const specType = options.specType ?? 'notebook';
|
|
118
|
+
this._base = `${options.specsUrl.replace(/\/+$/, '')}/specs/${specType}`;
|
|
119
|
+
this._getAccessToken = options.getAccessToken;
|
|
120
|
+
this._fetch = options.fetch ?? ((input, init) => fetch(input, init));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Every notebook the user may read, including public ones. */
|
|
124
|
+
async list(): Promise<ISpecSummary[]> {
|
|
125
|
+
const data = await this._request('GET', this._base, { auth: 'required' });
|
|
126
|
+
if (!Array.isArray(data) || !data.every(isSummary)) {
|
|
127
|
+
throw new SpecStoreError('invalid');
|
|
128
|
+
}
|
|
129
|
+
return data;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Read a record, anonymously when signed out. */
|
|
133
|
+
async get(id: string): Promise<ISpecRecord> {
|
|
134
|
+
return this._record(
|
|
135
|
+
await this._request('GET', this._url(id), { auth: 'optional' })
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Create a record. The server mints the id: use the returned record's. */
|
|
140
|
+
async create(body: ISpecBody): Promise<ISpecRecord> {
|
|
141
|
+
return this._record(
|
|
142
|
+
await this._request('POST', this._base, {
|
|
143
|
+
auth: 'required',
|
|
144
|
+
body: pick(body)
|
|
145
|
+
})
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Replace a record's name, description and notebook. */
|
|
150
|
+
async update(id: string, body: ISpecBody): Promise<ISpecRecord> {
|
|
151
|
+
return this._record(
|
|
152
|
+
await this._request('PUT', this._url(id), {
|
|
153
|
+
auth: 'required',
|
|
154
|
+
body: pick(body)
|
|
155
|
+
})
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Change some of a record's fields and leave the rest as they are. Needs the same
|
|
161
|
+
* write access as `update`, which the store enforces a patch as.
|
|
162
|
+
*/
|
|
163
|
+
async patch(id: string, changes: ISpecPatch): Promise<ISpecRecord> {
|
|
164
|
+
if (Object.keys(changes).length === 0) {
|
|
165
|
+
// The store answers an empty patch with 400; there is nothing to send anyway.
|
|
166
|
+
throw new SpecStoreError('invalid');
|
|
167
|
+
}
|
|
168
|
+
return this._record(
|
|
169
|
+
await this._request('PATCH', this._url(id), {
|
|
170
|
+
auth: 'required',
|
|
171
|
+
body: changes
|
|
172
|
+
})
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Give a record a new name, without sending the notebook back with it.
|
|
178
|
+
*
|
|
179
|
+
* Falls back to reading the record and putting it back where the store has no PATCH
|
|
180
|
+
* (production had none when this was written). That fallback races another writer's
|
|
181
|
+
* change to the notebook, which is the whole reason to prefer a patch.
|
|
182
|
+
*/
|
|
183
|
+
async rename(id: string, name: string): Promise<ISpecRecord> {
|
|
184
|
+
try {
|
|
185
|
+
return await this.patch(id, { name });
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (!(error instanceof SpecStoreError) || error.kind !== 'unsupported') {
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
const record = await this.get(id);
|
|
191
|
+
return this.update(id, {
|
|
192
|
+
name,
|
|
193
|
+
description: record.description,
|
|
194
|
+
spec: record.spec as ISpecBody['spec']
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Delete a record (requires admin access to it). */
|
|
200
|
+
async remove(id: string): Promise<void> {
|
|
201
|
+
await this._request('DELETE', this._url(id), { auth: 'required' });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Grant a permission on a record (requires admin access to it). */
|
|
205
|
+
async addPermission(id: string, grant: PermissionGrant): Promise<void> {
|
|
206
|
+
await this._request('POST', `${this._url(id)}/permissions`, {
|
|
207
|
+
auth: 'required',
|
|
208
|
+
body: {
|
|
209
|
+
type: grant.type,
|
|
210
|
+
entity: grant.entity,
|
|
211
|
+
permission: grant.permission
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Remove a permission (requires admin access). The server matches the exact grant and
|
|
218
|
+
* answers 404 when there is no such permission.
|
|
219
|
+
*/
|
|
220
|
+
async removePermission(id: string, grant: PermissionGrant): Promise<void> {
|
|
221
|
+
await this._request('DELETE', `${this._url(id)}/permissions`, {
|
|
222
|
+
auth: 'required',
|
|
223
|
+
body: {
|
|
224
|
+
type: grant.type,
|
|
225
|
+
entity: grant.entity,
|
|
226
|
+
permission: grant.permission
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private _url(id: string): string {
|
|
232
|
+
if (!isSpecId(id)) {
|
|
233
|
+
throw new SpecStoreError('invalid');
|
|
234
|
+
}
|
|
235
|
+
return `${this._base}/${id}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private _record(data: unknown): ISpecRecord {
|
|
239
|
+
if (!isRecord(data)) {
|
|
240
|
+
throw new SpecStoreError('invalid');
|
|
241
|
+
}
|
|
242
|
+
return data;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private async _request(
|
|
246
|
+
method: string,
|
|
247
|
+
url: string,
|
|
248
|
+
options: { auth: 'required' | 'optional'; body?: object }
|
|
249
|
+
): Promise<unknown> {
|
|
250
|
+
const token = await this._getAccessToken();
|
|
251
|
+
if (token === null && options.auth === 'required') {
|
|
252
|
+
throw new SpecStoreError('signed-out');
|
|
253
|
+
}
|
|
254
|
+
const headers: Record<string, string> = {};
|
|
255
|
+
if (token !== null) {
|
|
256
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
257
|
+
}
|
|
258
|
+
const init: RequestInit = { method, headers };
|
|
259
|
+
if (options.body !== undefined) {
|
|
260
|
+
headers['Content-Type'] = 'application/json';
|
|
261
|
+
init.body = JSON.stringify(options.body);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let response: Response;
|
|
265
|
+
try {
|
|
266
|
+
response = await this._fetch(url, init);
|
|
267
|
+
} catch {
|
|
268
|
+
throw new SpecStoreError('network');
|
|
269
|
+
}
|
|
270
|
+
if (!response.ok) {
|
|
271
|
+
throw errorForStatus(response.status);
|
|
272
|
+
}
|
|
273
|
+
if (response.status === 204) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
return await response.json();
|
|
278
|
+
} catch {
|
|
279
|
+
throw new SpecStoreError('invalid');
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private readonly _base: string;
|
|
284
|
+
private readonly _getAccessToken: () => Promise<string | null>;
|
|
285
|
+
private readonly _fetch: typeof fetch;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Only the fields the server accepts; in particular never send an `id`. */
|
|
289
|
+
function pick(body: ISpecBody): ISpecBody {
|
|
290
|
+
return { name: body.name, description: body.description, spec: body.spec };
|
|
291
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Signal } from '@lumino/signaling';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Emits after this extension creates, renames or deletes a notebook record, so the
|
|
5
|
+
* Notebooks tab can list again. A plain module-level signal: the tab and the commands
|
|
6
|
+
* live in different plugins, and the change is the only thing they need to share.
|
|
7
|
+
*/
|
|
8
|
+
// The sender is a throwaway object, not null: Lumino keys its receivers by sender in a
|
|
9
|
+
// WeakMap, which refuses null.
|
|
10
|
+
export const notebooksChanged = new Signal<object, void>({});
|