@oceanum/oceanumlab 4.9.0 → 4.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/share/fromDatamesh.d.ts +82 -0
- package/lib/share/fromDatamesh.js +379 -0
- package/lib/share/plugin.js +51 -0
- package/package.json +1 -1
- package/src/__tests__/fromDatamesh.spec.ts +301 -0
- package/src/__tests__/sharePlugin.spec.ts +48 -0
- package/src/share/fromDatamesh.ts +477 -0
- package/src/share/plugin.ts +60 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decodePayload,
|
|
3
|
+
encodePayload,
|
|
4
|
+
FRAGMENT_KEY,
|
|
5
|
+
hashWithoutPayload,
|
|
6
|
+
IDatameshNotebookRequest,
|
|
7
|
+
MAX_DEPTH,
|
|
8
|
+
MAX_ITEMS,
|
|
9
|
+
notebookFromDatamesh,
|
|
10
|
+
notebookName,
|
|
11
|
+
payloadFromHash,
|
|
12
|
+
pyIdentifier,
|
|
13
|
+
pyLiteral,
|
|
14
|
+
pyString,
|
|
15
|
+
queryCellSource,
|
|
16
|
+
queryFields
|
|
17
|
+
} from '../share/fromDatamesh';
|
|
18
|
+
import { removeMath, replaceMath } from '@jupyterlab/rendermime/lib/latex';
|
|
19
|
+
import { marked } from 'marked';
|
|
20
|
+
|
|
21
|
+
const QUERY = {
|
|
22
|
+
id: 'abc123',
|
|
23
|
+
label: 'era5_wind10m_abc123',
|
|
24
|
+
datasource: 'era5_wind10m',
|
|
25
|
+
description: 'ERA5 10m wind',
|
|
26
|
+
variables: ['u10', 'v10'],
|
|
27
|
+
timefilter: { times: ['2024-01-01T00:00:00Z', null] },
|
|
28
|
+
geofilter: { type: 'bbox', geom: [170, -45, 175, -40] }
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function request(items: unknown[], name?: string): IDatameshNotebookRequest {
|
|
32
|
+
return { v: 1, name, items } as IDatameshNotebookRequest;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('payload encoding', () => {
|
|
36
|
+
it('round-trips through base64url, including non-ASCII text', () => {
|
|
37
|
+
const req = request(
|
|
38
|
+
[{ ...QUERY, description: 'Vent à 10 m — 風' }],
|
|
39
|
+
'Ōtaki'
|
|
40
|
+
);
|
|
41
|
+
const encoded = encodePayload(req);
|
|
42
|
+
expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
43
|
+
expect(decodePayload(encoded)).toEqual(req);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('rejects text that is not a payload', () => {
|
|
47
|
+
expect(() => decodePayload('!!!')).toThrow('not valid');
|
|
48
|
+
expect(() => decodePayload(btoa('not json'))).toThrow('not valid');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('rejects other versions, empty and oversized workspaces, and bad items', () => {
|
|
52
|
+
const enc = (v: unknown) =>
|
|
53
|
+
encodePayload(v as unknown as IDatameshNotebookRequest);
|
|
54
|
+
expect(() => decodePayload(enc({ v: 2, items: [QUERY] }))).toThrow(
|
|
55
|
+
'newer Datamesh UI'
|
|
56
|
+
);
|
|
57
|
+
expect(() => decodePayload(enc({ v: 1, items: [] }))).toThrow('no queries');
|
|
58
|
+
expect(() =>
|
|
59
|
+
decodePayload(enc({ v: 1, items: Array(MAX_ITEMS + 1).fill(QUERY) }))
|
|
60
|
+
).toThrow('too many');
|
|
61
|
+
expect(() =>
|
|
62
|
+
decodePayload(enc({ v: 1, items: [{ datasource: 3 }] }))
|
|
63
|
+
).toThrow('not valid');
|
|
64
|
+
expect(() =>
|
|
65
|
+
decodePayload(enc({ v: 1, items: [{ datasource: ' ' }] }))
|
|
66
|
+
).toThrow('not valid');
|
|
67
|
+
expect(() => decodePayload(enc({ v: 1, name: 5, items: [QUERY] }))).toThrow(
|
|
68
|
+
'not valid'
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('fragment handling', () => {
|
|
74
|
+
it('finds the payload among other hash keys and removes only it', () => {
|
|
75
|
+
const hash = `#foo=1&${FRAGMENT_KEY}=abc_-`;
|
|
76
|
+
expect(payloadFromHash(hash)).toBe('abc_-');
|
|
77
|
+
expect(hashWithoutPayload(hash)).toBe('#foo=1');
|
|
78
|
+
expect(hashWithoutPayload(`#${FRAGMENT_KEY}=x`)).toBe('');
|
|
79
|
+
expect(payloadFromHash('')).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('keeps every other part of the hash exactly as it was', () => {
|
|
83
|
+
expect(hashWithoutPayload(`#my-heading&${FRAGMENT_KEY}=x`)).toBe(
|
|
84
|
+
'#my-heading'
|
|
85
|
+
);
|
|
86
|
+
expect(hashWithoutPayload(`#a=b%20c&${FRAGMENT_KEY}=x&d`)).toBe(
|
|
87
|
+
'#a=b%20c&d'
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('payload limits', () => {
|
|
93
|
+
const nested = (depth: number): unknown => {
|
|
94
|
+
let v: unknown = 'x'.repeat(80);
|
|
95
|
+
for (let i = 0; i < depth; i++) {
|
|
96
|
+
v = [v];
|
|
97
|
+
}
|
|
98
|
+
return v;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
it('rejects nesting deeper than real queries use', () => {
|
|
102
|
+
const enc = (depth: number) =>
|
|
103
|
+
encodePayload({
|
|
104
|
+
v: 1,
|
|
105
|
+
items: [{ datasource: 'abc', variables: nested(depth) }]
|
|
106
|
+
} as unknown as IDatameshNotebookRequest);
|
|
107
|
+
expect(() => decodePayload(enc(20))).not.toThrow();
|
|
108
|
+
expect(() => decodePayload(enc(MAX_DEPTH))).toThrow('not valid');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('renders deep values in linear time (was 2^depth)', () => {
|
|
112
|
+
const started = Date.now();
|
|
113
|
+
pyLiteral(nested(MAX_DEPTH));
|
|
114
|
+
expect(Date.now() - started).toBeLessThan(1000);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('pyLiteral', () => {
|
|
119
|
+
it('writes Python, not JSON, constants', () => {
|
|
120
|
+
expect(pyLiteral({ a: null, b: true, c: false, d: 1.5 })).toBe(
|
|
121
|
+
'{"a": None, "b": True, "c": False, "d": 1.5}'
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('keeps "null" and "true" inside strings as text', () => {
|
|
126
|
+
expect(pyLiteral(['null', 'true'])).toBe('["null", "true"]');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('escapes strings so they cannot end the literal', () => {
|
|
130
|
+
const nasty = '"); import os; os.system("x") #\n\'\'\'\\';
|
|
131
|
+
const lit = pyLiteral(nasty);
|
|
132
|
+
expect(lit).toBe(JSON.stringify(nasty));
|
|
133
|
+
expect(lit).not.toContain('\n');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('wraps long values over several indented lines', () => {
|
|
137
|
+
const lit = pyLiteral({
|
|
138
|
+
geofilter: { type: 'feature', geom: { coordinates: Array(30).fill(1) } }
|
|
139
|
+
});
|
|
140
|
+
expect(lit.split('\n').length).toBeGreaterThan(3);
|
|
141
|
+
expect(lit).toContain('\n "geofilter": {');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('pyIdentifier', () => {
|
|
146
|
+
it('makes valid, unique names', () => {
|
|
147
|
+
const taken = new Set<string>();
|
|
148
|
+
expect(pyIdentifier('ERA5 wind-10m.v2', taken)).toBe('era5_wind_10m_v2');
|
|
149
|
+
expect(pyIdentifier('ERA5 wind-10m.v2', taken)).toBe('era5_wind_10m_v2_2');
|
|
150
|
+
expect(pyIdentifier('2024 swell', taken)).toBe('ds_2024_swell');
|
|
151
|
+
expect(pyIdentifier('import', taken)).toBe('import_');
|
|
152
|
+
expect(pyIdentifier('datamesh', taken)).toBe('datamesh_');
|
|
153
|
+
expect(pyIdentifier('—', taken)).toBe('data');
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('query cells', () => {
|
|
158
|
+
it('passes only fields oceanum-python Query accepts', () => {
|
|
159
|
+
expect(Object.keys(queryFields(QUERY)).sort()).toEqual(
|
|
160
|
+
[
|
|
161
|
+
'datasource',
|
|
162
|
+
'description',
|
|
163
|
+
'geofilter',
|
|
164
|
+
'timefilter',
|
|
165
|
+
'variables'
|
|
166
|
+
].sort()
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('queries a filtered selection', () => {
|
|
171
|
+
const src = queryCellSource(QUERY, 'wind');
|
|
172
|
+
expect(src).toContain('# ERA5 10m wind');
|
|
173
|
+
expect(src).toContain('wind = datamesh.query({');
|
|
174
|
+
expect(src).toContain('"times": ["2024-01-01T00:00:00Z", None]');
|
|
175
|
+
expect(src).not.toContain('"label"');
|
|
176
|
+
expect(src.endsWith('\nwind')).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('opens an unfiltered datasource lazily', () => {
|
|
180
|
+
const src = queryCellSource(
|
|
181
|
+
{ datasource: 'era5_wind10m', variables: [], functions: [] },
|
|
182
|
+
'wind'
|
|
183
|
+
);
|
|
184
|
+
expect(src).toBe('wind = datamesh.load_datasource("era5_wind10m")\nwind');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('keeps a multi-line description on one comment line', () => {
|
|
188
|
+
const src = queryCellSource(
|
|
189
|
+
{ ...QUERY, description: 'a\nprint("pwned")' },
|
|
190
|
+
'x'
|
|
191
|
+
);
|
|
192
|
+
expect(src.split('\n')[0]).toBe('# a print("pwned")');
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Every character Python's str.splitlines() breaks on, which IPython uses to split
|
|
197
|
+
// a cell into lines (reviewer-found injection through the comment line).
|
|
198
|
+
const SPLITLINES = [
|
|
199
|
+
0x0a, 0x0b, 0x0c, 0x0d, 0x1c, 0x1d, 0x1e, 0x85, 0x2028, 0x2029
|
|
200
|
+
].map(c => String.fromCharCode(c));
|
|
201
|
+
|
|
202
|
+
describe('line breaks cannot escape a comment or a string', () => {
|
|
203
|
+
it.each(SPLITLINES.map(c => [c.charCodeAt(0).toString(16), c]))(
|
|
204
|
+
'U+%s in a description or label stays inside the comment',
|
|
205
|
+
(_hex, sep) => {
|
|
206
|
+
for (const item of [
|
|
207
|
+
{ ...QUERY, description: `wind${sep}import os` },
|
|
208
|
+
{ ...QUERY, description: undefined, label: `wind${sep}import os` }
|
|
209
|
+
]) {
|
|
210
|
+
const src = queryCellSource(item, 'x');
|
|
211
|
+
const comment = src.split('\n')[0];
|
|
212
|
+
expect(comment).toBe('# wind import os');
|
|
213
|
+
for (const c of SPLITLINES.filter(s => s !== '\n')) {
|
|
214
|
+
expect(src).not.toContain(c);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
it('escapes C1 controls and U+2028/9 inside string literals and keys', () => {
|
|
221
|
+
const text = SPLITLINES.join('') + String.fromCharCode(0, 0x7f, 0x9f);
|
|
222
|
+
const lit = pyLiteral({ [text]: text });
|
|
223
|
+
// Only printable ASCII is left: every control and separator is a \u escape.
|
|
224
|
+
expect(pyString(text)).toMatch(/^[\x20-\x7e]*$/);
|
|
225
|
+
// pyLiteral's own wrapping adds plain newlines, and nothing else.
|
|
226
|
+
expect(lit).toMatch(/^[\x20-\x7e\n]*$/);
|
|
227
|
+
expect(lit).toContain('\\u0085');
|
|
228
|
+
expect(lit).toContain('\\u2028');
|
|
229
|
+
// Still the same value once parsed (JSON reads \u escapes as Python does).
|
|
230
|
+
expect(JSON.parse(pyString(text))).toBe(text);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('strips them from the Markdown heading too', () => {
|
|
234
|
+
const nb = notebookFromDatamesh(
|
|
235
|
+
request([QUERY], `a${SPLITLINES.join('')}b`)
|
|
236
|
+
);
|
|
237
|
+
expect(String(nb.cells[0].source).split('\n')[0]).toBe('# a b');
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('the Markdown heading, rendered as JupyterLab renders it', () => {
|
|
242
|
+
it.each([
|
|
243
|
+
'[x](https://e.example) ',
|
|
244
|
+
'https://2130706433/login',
|
|
245
|
+
'www.evil.example',
|
|
246
|
+
'me@evil.example',
|
|
247
|
+
'<img src=x onerror=alert(1)>',
|
|
248
|
+
'[x](javascript:alert(1))',
|
|
249
|
+
'`code` **bold** _em_ ~~s~~',
|
|
250
|
+
'@@0@@',
|
|
251
|
+
'run @@12@@ of 20',
|
|
252
|
+
'Cost $5 and $6'
|
|
253
|
+
])('%s renders as plain text', async name => {
|
|
254
|
+
const nb = notebookFromDatamesh(request([QUERY], name));
|
|
255
|
+
// JupyterLab's own Markdown path: math placeholders, marked, math restored.
|
|
256
|
+
const { text, math } = removeMath(String(nb.cells[0].source));
|
|
257
|
+
const html = replaceMath(await marked.parse(text, { gfm: true }), math);
|
|
258
|
+
const heading = html.split('\n')[0];
|
|
259
|
+
expect(heading).toMatch(/^<h1>[^<]*<\/h1>$/);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe('optional text fields', () => {
|
|
264
|
+
it('accepts null name, label and description', () => {
|
|
265
|
+
const enc = encodePayload({
|
|
266
|
+
v: 1,
|
|
267
|
+
name: null,
|
|
268
|
+
items: [{ ...QUERY, label: null, description: null }]
|
|
269
|
+
} as unknown as IDatameshNotebookRequest);
|
|
270
|
+
const req = decodePayload(enc);
|
|
271
|
+
expect(req.name).toBeUndefined();
|
|
272
|
+
const nb = notebookFromDatamesh(req);
|
|
273
|
+
expect(nb.cells[2].source).toContain('era5_wind10m = datamesh.query(');
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe('notebookFromDatamesh', () => {
|
|
278
|
+
it('builds an untrusted, unlinked notebook with one cell per query', () => {
|
|
279
|
+
const nb = notebookFromDatamesh(
|
|
280
|
+
request([QUERY, { ...QUERY, label: undefined }], 'My <b>work</b>space')
|
|
281
|
+
);
|
|
282
|
+
expect(nb.nbformat).toBe(4);
|
|
283
|
+
expect(nb.metadata).toEqual({ language_info: { name: 'python' } });
|
|
284
|
+
expect(nb.cells).toHaveLength(4);
|
|
285
|
+
expect(nb.cells[0].source).toContain('# My <b>work<\\/b>space');
|
|
286
|
+
expect(nb.cells[1].source).toContain('datamesh = Connector()');
|
|
287
|
+
expect(nb.cells[2].source).toContain(
|
|
288
|
+
'era5_wind10m_abc123 = datamesh.query'
|
|
289
|
+
);
|
|
290
|
+
expect(nb.cells[3].source).toContain('era5_wind10m = datamesh.query');
|
|
291
|
+
for (const cell of nb.cells) {
|
|
292
|
+
expect(cell.metadata).toEqual({});
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('names the notebook after the workspace, else the single query', () => {
|
|
297
|
+
expect(notebookName(request([QUERY], ' Coast '))).toBe('Coast');
|
|
298
|
+
expect(notebookName(request([QUERY]))).toBe('era5_wind10m_abc123');
|
|
299
|
+
expect(notebookName(request([QUERY, QUERY]))).toBe('Datamesh workspace');
|
|
300
|
+
});
|
|
301
|
+
});
|
|
@@ -14,6 +14,7 @@ import { Signal } from '@lumino/signaling';
|
|
|
14
14
|
|
|
15
15
|
import type { IOceanumAuth, IOceanumUser } from '../auth/tokens';
|
|
16
16
|
import type { ISpecRecord } from '../share/notebook';
|
|
17
|
+
import { encodePayload, FRAGMENT_KEY } from '../share/fromDatamesh';
|
|
17
18
|
import { CommandIDs, sharePlugin } from '../share/plugin';
|
|
18
19
|
|
|
19
20
|
/** What every dialog answers. Accepting with no value is "chose nothing". */
|
|
@@ -669,3 +670,50 @@ describe('uploading a notebook', () => {
|
|
|
669
670
|
expect(harness.commands.isEnabled(CommandIDs.upload)).toBe(false);
|
|
670
671
|
});
|
|
671
672
|
});
|
|
673
|
+
|
|
674
|
+
describe('opening a Datamesh UI workspace from the address', () => {
|
|
675
|
+
const settle = () => new Promise(resolve => setTimeout(resolve, 0));
|
|
676
|
+
const payload = encodePayload({
|
|
677
|
+
v: 1,
|
|
678
|
+
name: 'Coast',
|
|
679
|
+
items: [{ datasource: 'era5_wind10m', variables: ['u10'] }]
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
afterEach(() => window.history.replaceState(null, '', '/'));
|
|
683
|
+
|
|
684
|
+
it('writes a new untrusted notebook to the Oceanum folder, opens it and clears the hash', async () => {
|
|
685
|
+
window.history.replaceState(
|
|
686
|
+
null,
|
|
687
|
+
'',
|
|
688
|
+
`/lab/#other=1&${FRAGMENT_KEY}=${payload}`
|
|
689
|
+
);
|
|
690
|
+
const harness = activate({ user: null, folder: { 'Coast.ipynb': {} } });
|
|
691
|
+
// Read at activation but left in the address until the notebook is written,
|
|
692
|
+
// so a sign-in redirect before then still carries it.
|
|
693
|
+
expect(window.location.hash).toContain(FRAGMENT_KEY);
|
|
694
|
+
await settle();
|
|
695
|
+
expect(window.location.hash).toBe('#other=1');
|
|
696
|
+
|
|
697
|
+
const path = 'Oceanum/Coast (1).ipynb';
|
|
698
|
+
expect(harness.saved).toEqual([path]);
|
|
699
|
+
expect(harness.opened).toEqual([path]);
|
|
700
|
+
const content = harness.written[path] as INotebookContent;
|
|
701
|
+
expect(content.metadata).not.toHaveProperty('oceanum');
|
|
702
|
+
expect(content.cells[2].source).toContain('datamesh.query(');
|
|
703
|
+
expect(harness.calls).toEqual([]);
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it('writes nothing for a payload that is not valid', async () => {
|
|
707
|
+
window.history.replaceState(null, '', `/lab/#${FRAGMENT_KEY}=bad`);
|
|
708
|
+
const harness = activate();
|
|
709
|
+
await settle();
|
|
710
|
+
expect(window.location.hash).toBe('');
|
|
711
|
+
expect(harness.saved).toEqual([]);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it('does nothing without a payload', async () => {
|
|
715
|
+
const harness = activate();
|
|
716
|
+
await settle();
|
|
717
|
+
expect(harness.saved).toEqual([]);
|
|
718
|
+
});
|
|
719
|
+
});
|