@oceanum/oceanumlab 4.7.1 → 4.7.2
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.js +14 -5
- package/lib/StoredNotebooks.d.ts +9 -5
- package/lib/StoredNotebooks.js +49 -21
- 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 +7 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +8 -6
- package/lib/share/client.d.ts +84 -0
- package/lib/share/client.js +205 -0
- package/lib/share/events.d.ts +7 -0
- package/lib/share/events.js +9 -0
- package/lib/share/notebook.d.ts +184 -0
- package/lib/share/notebook.js +277 -0
- package/lib/share/oceanumOnly.d.ts +49 -0
- package/lib/share/oceanumOnly.js +90 -0
- package/lib/share/plugin.d.ts +21 -0
- package/lib/share/plugin.js +883 -0
- package/lib/share/widgets.d.ts +25 -0
- package/lib/share/widgets.js +154 -0
- package/package.json +5 -1
- package/src/DatameshWidget.tsx +21 -2
- package/src/StoredNotebooks.tsx +60 -20
- package/src/__tests__/OceanumPanel.spec.tsx +60 -4
- package/src/__tests__/StoredNotebooks.spec.tsx +157 -0
- package/src/__tests__/oceanumOnly.spec.ts +193 -0
- package/src/__tests__/shareClient.spec.ts +314 -0
- package/src/__tests__/shareNotebook.spec.ts +451 -0
- package/src/__tests__/sharePlugin.spec.ts +426 -0
- package/src/auth/serverAuth.ts +1 -0
- package/src/auth/startSignIn.ts +23 -0
- package/src/auth/tokens.ts +7 -0
- package/src/index.ts +8 -6
- package/src/share/client.ts +288 -0
- package/src/share/events.ts +10 -0
- package/src/share/notebook.ts +416 -0
- package/src/share/oceanumOnly.ts +126 -0
- package/src/share/plugin.ts +1096 -0
- package/src/share/widgets.ts +217 -0
- package/style/index.css +111 -16
- 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,217 @@
|
|
|
1
|
+
import { Clipboard, Dialog } from '@jupyterlab/apputils';
|
|
2
|
+
import { Widget } from '@lumino/widgets';
|
|
3
|
+
|
|
4
|
+
import type { PermissionGrant, ShareChoice } from './client';
|
|
5
|
+
import { ISpecSummary, parseShareEmails, parseTimestamp } from './notebook';
|
|
6
|
+
|
|
7
|
+
function element<K extends keyof HTMLElementTagNameMap>(
|
|
8
|
+
tag: K,
|
|
9
|
+
className: string,
|
|
10
|
+
text?: string
|
|
11
|
+
): HTMLElementTagNameMap[K] {
|
|
12
|
+
const node = document.createElement(tag);
|
|
13
|
+
node.className = className;
|
|
14
|
+
if (text !== undefined) {
|
|
15
|
+
// Names and emails come from other users: always set text, never HTML.
|
|
16
|
+
node.textContent = text;
|
|
17
|
+
}
|
|
18
|
+
return node;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Dialog body listing notebooks to open; its value is the selected record id. */
|
|
22
|
+
export class NotebookListBody
|
|
23
|
+
extends Widget
|
|
24
|
+
implements Dialog.IBodyWidget<string | null>
|
|
25
|
+
{
|
|
26
|
+
constructor(mine: readonly ISpecSummary[], shared: readonly ISpecSummary[]) {
|
|
27
|
+
super();
|
|
28
|
+
this.addClass('jp-OceanumShare-body');
|
|
29
|
+
this._addSection('My notebooks', mine, false);
|
|
30
|
+
this._addSection('Shared with me', shared, true);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
getValue(): string | null {
|
|
34
|
+
return this._selected;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private _addSection(
|
|
38
|
+
title: string,
|
|
39
|
+
items: readonly ISpecSummary[],
|
|
40
|
+
showCreator: boolean
|
|
41
|
+
): void {
|
|
42
|
+
this.node.appendChild(element('h3', 'jp-OceanumShare-heading', title));
|
|
43
|
+
if (items.length === 0) {
|
|
44
|
+
this.node.appendChild(element('p', 'jp-OceanumShare-empty', 'None'));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const list = element('ul', 'jp-OceanumShare-list');
|
|
48
|
+
list.setAttribute('role', 'listbox');
|
|
49
|
+
list.setAttribute('aria-label', title);
|
|
50
|
+
for (const item of items) {
|
|
51
|
+
const option = element('li', 'jp-OceanumShare-item');
|
|
52
|
+
option.setAttribute('role', 'option');
|
|
53
|
+
option.setAttribute('aria-selected', 'false');
|
|
54
|
+
option.tabIndex = 0;
|
|
55
|
+
option.appendChild(element('span', 'jp-OceanumShare-name', item.name));
|
|
56
|
+
const modified = parseTimestamp(item.modified);
|
|
57
|
+
const details = [
|
|
58
|
+
modified ? `Modified ${modified.toLocaleDateString()}` : '',
|
|
59
|
+
showCreator && item.creator ? item.creator : ''
|
|
60
|
+
].filter(Boolean);
|
|
61
|
+
option.appendChild(
|
|
62
|
+
element('span', 'jp-OceanumShare-meta', details.join(' · '))
|
|
63
|
+
);
|
|
64
|
+
const select = () => this._select(item.id, option);
|
|
65
|
+
option.addEventListener('click', select);
|
|
66
|
+
option.addEventListener('focus', select);
|
|
67
|
+
list.appendChild(option);
|
|
68
|
+
}
|
|
69
|
+
this.node.appendChild(list);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private _select(id: string, option: HTMLElement): void {
|
|
73
|
+
this._selected = id;
|
|
74
|
+
for (const node of this.node.querySelectorAll('.jp-OceanumShare-item')) {
|
|
75
|
+
const selected = node === option;
|
|
76
|
+
node.classList.toggle('jp-mod-selected', selected);
|
|
77
|
+
node.setAttribute('aria-selected', String(selected));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private _selected: string | null = null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const EVERYONE_WARNING =
|
|
85
|
+
'Anyone with the link can read this notebook, including people without an Oceanum ' +
|
|
86
|
+
'account. This cannot be limited to your organisation. Its name and your email ' +
|
|
87
|
+
'address also become visible to anyone who lists Oceanum notebooks.';
|
|
88
|
+
|
|
89
|
+
const PUBLIC_GRANT: PermissionGrant = {
|
|
90
|
+
type: 'public',
|
|
91
|
+
entity: '',
|
|
92
|
+
permission: 'read'
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Dialog body for sharing; its value is the change to make, or `null` if incomplete. */
|
|
96
|
+
export class ShareBody
|
|
97
|
+
extends Widget
|
|
98
|
+
implements Dialog.IBodyWidget<ShareChoice | null>
|
|
99
|
+
{
|
|
100
|
+
constructor(link: string) {
|
|
101
|
+
super();
|
|
102
|
+
this.addClass('jp-OceanumShare-body');
|
|
103
|
+
|
|
104
|
+
this.node.appendChild(element('h3', 'jp-OceanumShare-heading', 'Link'));
|
|
105
|
+
const row = element('div', 'jp-OceanumShare-linkRow');
|
|
106
|
+
const linkInput = element('input', 'jp-OceanumShare-link');
|
|
107
|
+
linkInput.readOnly = true;
|
|
108
|
+
linkInput.value = link;
|
|
109
|
+
linkInput.setAttribute('aria-label', 'Notebook link');
|
|
110
|
+
const copy = element(
|
|
111
|
+
'button',
|
|
112
|
+
'jp-mod-styled jp-OceanumShare-copy',
|
|
113
|
+
'Copy link'
|
|
114
|
+
);
|
|
115
|
+
copy.type = 'button';
|
|
116
|
+
copy.addEventListener('click', () => {
|
|
117
|
+
Clipboard.copyToSystem(link);
|
|
118
|
+
copy.textContent = 'Copied';
|
|
119
|
+
});
|
|
120
|
+
row.append(linkInput, copy);
|
|
121
|
+
this.node.appendChild(row);
|
|
122
|
+
this.node.appendChild(
|
|
123
|
+
element(
|
|
124
|
+
'p',
|
|
125
|
+
'jp-OceanumShare-hint',
|
|
126
|
+
'The link opens the notebook for anyone it is shared with.'
|
|
127
|
+
)
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
this.node.appendChild(
|
|
131
|
+
element('h3', 'jp-OceanumShare-heading', 'Share with')
|
|
132
|
+
);
|
|
133
|
+
const name = `jp-OceanumShare-who-${++ShareBody._count}`;
|
|
134
|
+
this._person = this._radio(name, 'A specific person', true);
|
|
135
|
+
const personRow = element('div', 'jp-OceanumShare-personRow');
|
|
136
|
+
this._email = element('textarea', 'jp-OceanumShare-email');
|
|
137
|
+
this._email.rows = 2;
|
|
138
|
+
this._email.placeholder = 'name@example.com, another@example.com';
|
|
139
|
+
this._email.setAttribute('aria-label', 'Email addresses');
|
|
140
|
+
this._level = element('select', 'jp-OceanumShare-level');
|
|
141
|
+
this._level.setAttribute('aria-label', 'Access');
|
|
142
|
+
for (const [value, label] of [
|
|
143
|
+
['read', 'can view'],
|
|
144
|
+
['write', 'can edit']
|
|
145
|
+
]) {
|
|
146
|
+
const option = element('option', '', label);
|
|
147
|
+
option.value = value;
|
|
148
|
+
this._level.appendChild(option);
|
|
149
|
+
}
|
|
150
|
+
personRow.append(this._email, this._level);
|
|
151
|
+
this.node.appendChild(personRow);
|
|
152
|
+
this.node.appendChild(
|
|
153
|
+
element(
|
|
154
|
+
'p',
|
|
155
|
+
'jp-OceanumShare-hint',
|
|
156
|
+
// Not "new lines": JupyterLab's dialog swallows Enter before it reaches the
|
|
157
|
+
// textarea, so one cannot be typed here. Pasted ones still separate addresses.
|
|
158
|
+
'Separate several addresses with commas or spaces. Everyone you add gets the access chosen here.'
|
|
159
|
+
)
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
this._everyone = this._radio(name, 'Everyone with the link', false);
|
|
163
|
+
this.node.appendChild(
|
|
164
|
+
element('p', 'jp-OceanumShare-warning', EVERYONE_WARNING)
|
|
165
|
+
);
|
|
166
|
+
this._stopEveryone = this._radio(name, 'Stop sharing with everyone', false);
|
|
167
|
+
|
|
168
|
+
const update = () => {
|
|
169
|
+
this._email.disabled = !this._person.checked;
|
|
170
|
+
this._level.disabled = !this._person.checked;
|
|
171
|
+
};
|
|
172
|
+
for (const radio of [this._person, this._everyone, this._stopEveryone]) {
|
|
173
|
+
radio.addEventListener('change', update);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
getValue(): ShareChoice | null {
|
|
178
|
+
if (this._everyone.checked) {
|
|
179
|
+
return { action: 'grant', grants: [PUBLIC_GRANT], invalid: [] };
|
|
180
|
+
}
|
|
181
|
+
if (this._stopEveryone.checked) {
|
|
182
|
+
return { action: 'revoke', grant: PUBLIC_GRANT };
|
|
183
|
+
}
|
|
184
|
+
const { emails, invalid } = parseShareEmails(this._email.value);
|
|
185
|
+
if (emails.length === 0 && invalid.length === 0) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
const permission = this._level.value === 'write' ? 'write' : 'read';
|
|
189
|
+
return {
|
|
190
|
+
action: 'grant',
|
|
191
|
+
grants: emails.map(entity => ({ type: 'user', entity, permission })),
|
|
192
|
+
invalid
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private _radio(
|
|
197
|
+
name: string,
|
|
198
|
+
text: string,
|
|
199
|
+
checked: boolean
|
|
200
|
+
): HTMLInputElement {
|
|
201
|
+
const label = element('label', 'jp-OceanumShare-choice');
|
|
202
|
+
const input = element('input', 'jp-OceanumShare-radio');
|
|
203
|
+
input.type = 'radio';
|
|
204
|
+
input.name = name;
|
|
205
|
+
input.checked = checked;
|
|
206
|
+
label.append(input, document.createTextNode(` ${text}`));
|
|
207
|
+
this.node.appendChild(label);
|
|
208
|
+
return input;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private static _count = 0;
|
|
212
|
+
private readonly _person: HTMLInputElement;
|
|
213
|
+
private readonly _everyone: HTMLInputElement;
|
|
214
|
+
private readonly _stopEveryone: HTMLInputElement;
|
|
215
|
+
private readonly _email: HTMLTextAreaElement;
|
|
216
|
+
private readonly _level: HTMLSelectElement;
|
|
217
|
+
}
|
package/style/index.css
CHANGED
|
@@ -43,17 +43,26 @@
|
|
|
43
43
|
color: var(--jp-ui-font-color1);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/* Headers within the panel's tabs (Datamesh Workspace and the workspace name, Oceanum
|
|
47
|
+
* AI, My notebooks / Shared with me) share one text style: bold, standard case. */
|
|
48
|
+
.datamesh-workspace-header,
|
|
49
|
+
.datamesh-workspace-name,
|
|
50
|
+
.oceanum-ai-chat-header,
|
|
51
|
+
.oceanum-notebooks-heading {
|
|
52
|
+
font-weight: 600;
|
|
53
|
+
font-size: var(--jp-ui-font-size1);
|
|
54
|
+
color: var(--jp-ui-font-color1);
|
|
55
|
+
letter-spacing: 0.5px;
|
|
56
|
+
text-transform: none;
|
|
57
|
+
}
|
|
58
|
+
|
|
46
59
|
/* Datamesh Workspace section header */
|
|
47
60
|
.datamesh-workspace-header {
|
|
48
61
|
display: flex;
|
|
49
62
|
align-items: center;
|
|
50
63
|
justify-content: space-between;
|
|
51
64
|
padding: 8px 12px;
|
|
52
|
-
font-weight: 600;
|
|
53
|
-
font-size: var(--jp-ui-font-size0);
|
|
54
|
-
color: var(--jp-ui-font-color2);
|
|
55
65
|
background: var(--jp-layout-color1);
|
|
56
|
-
letter-spacing: 0.5px;
|
|
57
66
|
}
|
|
58
67
|
|
|
59
68
|
.add-connection {
|
|
@@ -78,13 +87,9 @@
|
|
|
78
87
|
|
|
79
88
|
.datamesh-workspace-name {
|
|
80
89
|
flex: 0 0 auto;
|
|
81
|
-
font-weight: 600;
|
|
82
|
-
text-transform: uppercase;
|
|
83
|
-
letter-spacing: 1px;
|
|
84
90
|
padding: 8px 8px 8px 12px;
|
|
85
91
|
margin: 0;
|
|
86
92
|
margin-bottom: 10px;
|
|
87
|
-
font-size: var(--jp-ui-font-size1);
|
|
88
93
|
}
|
|
89
94
|
|
|
90
95
|
.datasource-item-expand,
|
|
@@ -224,12 +229,8 @@
|
|
|
224
229
|
display: flex;
|
|
225
230
|
align-items: center;
|
|
226
231
|
gap: 8px;
|
|
227
|
-
font-weight: bold;
|
|
228
|
-
font-size: var(--jp-ui-font-size1);
|
|
229
232
|
padding: 6px 10px;
|
|
230
|
-
color: var(--jp-ui-font-color1);
|
|
231
233
|
background: var(--jp-layout-color2);
|
|
232
|
-
letter-spacing: 0.5px;
|
|
233
234
|
}
|
|
234
235
|
|
|
235
236
|
.oceanum-ai-chat-title {
|
|
@@ -257,8 +258,13 @@
|
|
|
257
258
|
white-space: nowrap;
|
|
258
259
|
}
|
|
259
260
|
|
|
261
|
+
/* The thread takes only the height it needs, so the input sits under the last message
|
|
262
|
+
* rather than at the foot of the tab with a gap between them. `min-height: 0` is what
|
|
263
|
+
* lets it shrink past its content once the thread is taller than the space there is:
|
|
264
|
+
* without it a flex item refuses to, and the input would be pushed out of the panel. */
|
|
260
265
|
.oceanum-ai-chat-messages {
|
|
261
|
-
flex:
|
|
266
|
+
flex: 0 1 auto;
|
|
267
|
+
min-height: 0;
|
|
262
268
|
overflow-y: auto;
|
|
263
269
|
padding: 6px 8px;
|
|
264
270
|
display: flex;
|
|
@@ -586,9 +592,6 @@
|
|
|
586
592
|
display: flex;
|
|
587
593
|
justify-content: space-between;
|
|
588
594
|
padding: 6px 8px 2px;
|
|
589
|
-
color: var(--jp-ui-font-color2);
|
|
590
|
-
font-size: var(--jp-ui-font-size0);
|
|
591
|
-
text-transform: uppercase;
|
|
592
595
|
}
|
|
593
596
|
|
|
594
597
|
.oceanum-notebooks-list {
|
|
@@ -633,3 +636,95 @@
|
|
|
633
636
|
color: var(--jp-error-color1);
|
|
634
637
|
font-size: var(--jp-ui-font-size1);
|
|
635
638
|
}
|
|
639
|
+
|
|
640
|
+
/* Open from Oceanum and Share on Oceanum dialogs (src/share/widgets.ts). */
|
|
641
|
+
.jp-OceanumShare-body {
|
|
642
|
+
min-width: 320px;
|
|
643
|
+
max-width: 560px;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
.jp-OceanumShare-heading {
|
|
647
|
+
margin: 12px 0 6px;
|
|
648
|
+
font-size: var(--jp-ui-font-size1);
|
|
649
|
+
font-weight: 600;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
.jp-OceanumShare-heading:first-child {
|
|
653
|
+
margin-top: 0;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
.jp-OceanumShare-empty,
|
|
657
|
+
.jp-OceanumShare-hint {
|
|
658
|
+
margin: 0 0 6px;
|
|
659
|
+
color: var(--jp-ui-font-color2);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
.jp-OceanumShare-list {
|
|
663
|
+
max-height: 240px;
|
|
664
|
+
margin: 0;
|
|
665
|
+
padding: 0;
|
|
666
|
+
overflow-y: auto;
|
|
667
|
+
list-style: none;
|
|
668
|
+
border: var(--jp-border-width) solid var(--jp-border-color1);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
.jp-OceanumShare-item {
|
|
672
|
+
display: flex;
|
|
673
|
+
flex-direction: column;
|
|
674
|
+
padding: 6px 8px;
|
|
675
|
+
cursor: pointer;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
.jp-OceanumShare-item:hover {
|
|
679
|
+
background: var(--jp-layout-color2);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
.jp-OceanumShare-item.jp-mod-selected {
|
|
683
|
+
background: var(--jp-brand-color3);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
.jp-OceanumShare-meta {
|
|
687
|
+
color: var(--jp-ui-font-color2);
|
|
688
|
+
font-size: var(--jp-ui-font-size0);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
.jp-OceanumShare-linkRow,
|
|
692
|
+
.jp-OceanumShare-personRow {
|
|
693
|
+
display: flex;
|
|
694
|
+
gap: 6px;
|
|
695
|
+
align-items: center;
|
|
696
|
+
margin-bottom: 6px;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
.jp-OceanumShare-linkRow > :first-child,
|
|
700
|
+
.jp-OceanumShare-personRow > :first-child {
|
|
701
|
+
flex: 1;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/* A textarea so several addresses fit; without this it would default to monospace. */
|
|
705
|
+
.jp-OceanumShare-email {
|
|
706
|
+
font: inherit;
|
|
707
|
+
resize: vertical;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/* Keep the access select beside the first line of addresses, not centred against them. */
|
|
711
|
+
.jp-OceanumShare-personRow {
|
|
712
|
+
align-items: flex-start;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
.jp-OceanumShare-choice {
|
|
716
|
+
display: block;
|
|
717
|
+
margin: 6px 0;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
.jp-OceanumShare-choice input[type='radio'] {
|
|
721
|
+
width: auto;
|
|
722
|
+
height: auto;
|
|
723
|
+
margin: 0 4px 0 0;
|
|
724
|
+
appearance: auto;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
.jp-OceanumShare-warning {
|
|
728
|
+
margin: 0 0 0 20px;
|
|
729
|
+
color: var(--jp-warn-color0);
|
|
730
|
+
}
|
package/lib/specStore.d.ts
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reading the Oceanum spec store's `notebook` records.
|
|
3
|
-
*
|
|
4
|
-
* Deliberately small: only what the Stored notebooks tab needs. oceanum-notebook's
|
|
5
|
-
* share-oceanum package has a fuller client (create, update, permissions), and when
|
|
6
|
-
* sharing moves here the two should become one rather than sitting side by side.
|
|
7
|
-
*/
|
|
8
|
-
/** A spec store list entry. The list omits the notebook body. */
|
|
9
|
-
export interface ISpecSummary {
|
|
10
|
-
id: string;
|
|
11
|
-
name: string;
|
|
12
|
-
description: string | null;
|
|
13
|
-
/** Set when the record is created and never updated, so this is a creation time. */
|
|
14
|
-
modified: string;
|
|
15
|
-
/** Null unless the caller created the record: the store hides other creators. */
|
|
16
|
-
creator: string | null;
|
|
17
|
-
}
|
|
18
|
-
/**
|
|
19
|
-
* Every notebook the signed-in user may read: their own and those shared with them.
|
|
20
|
-
*
|
|
21
|
-
* The store answers an unauthenticated caller with an empty list rather than an error,
|
|
22
|
-
* so a missing token is indistinguishable from an empty account — callers should check
|
|
23
|
-
* they are signed in before showing "no notebooks".
|
|
24
|
-
*/
|
|
25
|
-
export declare function listNotebooks(specsUrl: string, token: string, fetcher?: typeof fetch): Promise<ISpecSummary[]>;
|
|
26
|
-
/**
|
|
27
|
-
* Split a listing into the caller's own notebooks and the rest, newest first.
|
|
28
|
-
*
|
|
29
|
-
* The store only sends `creator` on records the caller created, so anything else it
|
|
30
|
-
* returns is shared with them — directly, or because it is public. Addresses are
|
|
31
|
-
* compared case-insensitively: the store matches entities as case-sensitive globs, so
|
|
32
|
-
* the two sides can differ in case.
|
|
33
|
-
*/
|
|
34
|
-
export declare function partitionNotebooks(items: readonly ISpecSummary[], email: string | null): {
|
|
35
|
-
mine: ISpecSummary[];
|
|
36
|
-
shared: ISpecSummary[];
|
|
37
|
-
};
|
package/lib/specStore.js
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reading the Oceanum spec store's `notebook` records.
|
|
3
|
-
*
|
|
4
|
-
* Deliberately small: only what the Stored notebooks tab needs. oceanum-notebook's
|
|
5
|
-
* share-oceanum package has a fuller client (create, update, permissions), and when
|
|
6
|
-
* sharing moves here the two should become one rather than sitting side by side.
|
|
7
|
-
*/
|
|
8
|
-
function isSummary(value) {
|
|
9
|
-
if (typeof value !== 'object' || value === null) {
|
|
10
|
-
return false;
|
|
11
|
-
}
|
|
12
|
-
const v = value;
|
|
13
|
-
return (typeof v.id === 'string' &&
|
|
14
|
-
typeof v.name === 'string' &&
|
|
15
|
-
typeof v.modified === 'string');
|
|
16
|
-
}
|
|
17
|
-
/**
|
|
18
|
-
* Every notebook the signed-in user may read: their own and those shared with them.
|
|
19
|
-
*
|
|
20
|
-
* The store answers an unauthenticated caller with an empty list rather than an error,
|
|
21
|
-
* so a missing token is indistinguishable from an empty account — callers should check
|
|
22
|
-
* they are signed in before showing "no notebooks".
|
|
23
|
-
*/
|
|
24
|
-
export async function listNotebooks(specsUrl, token, fetcher = fetch) {
|
|
25
|
-
const url = `${specsUrl.replace(/\/+$/, '')}/specs/notebook`;
|
|
26
|
-
const response = await fetcher(url, {
|
|
27
|
-
headers: { Authorization: `Bearer ${token}` }
|
|
28
|
-
});
|
|
29
|
-
if (!response.ok) {
|
|
30
|
-
// The store answers a bad or expired token with 400, and 403 both for no access
|
|
31
|
-
// and for a record that does not exist.
|
|
32
|
-
throw new Error(`Oceanum.io returned HTTP ${response.status}.`);
|
|
33
|
-
}
|
|
34
|
-
const data = await response.json();
|
|
35
|
-
if (!Array.isArray(data)) {
|
|
36
|
-
throw new Error('Oceanum.io returned an unexpected response.');
|
|
37
|
-
}
|
|
38
|
-
return data.filter(isSummary);
|
|
39
|
-
}
|
|
40
|
-
/**
|
|
41
|
-
* Split a listing into the caller's own notebooks and the rest, newest first.
|
|
42
|
-
*
|
|
43
|
-
* The store only sends `creator` on records the caller created, so anything else it
|
|
44
|
-
* returns is shared with them — directly, or because it is public. Addresses are
|
|
45
|
-
* compared case-insensitively: the store matches entities as case-sensitive globs, so
|
|
46
|
-
* the two sides can differ in case.
|
|
47
|
-
*/
|
|
48
|
-
export function partitionNotebooks(items, email) {
|
|
49
|
-
var _a;
|
|
50
|
-
const me = (_a = email === null || email === void 0 ? void 0 : email.toLowerCase()) !== null && _a !== void 0 ? _a : null;
|
|
51
|
-
const sorted = [...items].sort((a, b) => b.modified.localeCompare(a.modified));
|
|
52
|
-
const isMine = (item) => { var _a; return me !== null && ((_a = item.creator) === null || _a === void 0 ? void 0 : _a.toLowerCase()) === me; };
|
|
53
|
-
return {
|
|
54
|
-
mine: sorted.filter(isMine),
|
|
55
|
-
shared: sorted.filter(item => !isMine(item))
|
|
56
|
-
};
|
|
57
|
-
}
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
import { ISpecSummary, listNotebooks, partitionNotebooks } from '../specStore';
|
|
2
|
-
|
|
3
|
-
function summary(overrides: Partial<ISpecSummary> = {}): ISpecSummary {
|
|
4
|
-
return {
|
|
5
|
-
id: 'nb-1',
|
|
6
|
-
name: 'Wave analysis',
|
|
7
|
-
description: null,
|
|
8
|
-
modified: '2026-05-17T12:00:00',
|
|
9
|
-
creator: null,
|
|
10
|
-
...overrides
|
|
11
|
-
};
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function response(body: unknown, status = 200): Response {
|
|
15
|
-
return {
|
|
16
|
-
ok: status >= 200 && status < 300,
|
|
17
|
-
status,
|
|
18
|
-
json: async () => body
|
|
19
|
-
} as unknown as Response;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
describe('listNotebooks', () => {
|
|
23
|
-
it('asks the notebook spec type with the token as a bearer credential', async () => {
|
|
24
|
-
const calls: [string, RequestInit | undefined][] = [];
|
|
25
|
-
const fetcher = (async (url: string, init?: RequestInit) => {
|
|
26
|
-
calls.push([url, init]);
|
|
27
|
-
return response([]);
|
|
28
|
-
}) as unknown as typeof fetch;
|
|
29
|
-
|
|
30
|
-
await listNotebooks('https://specs.example.com', 'jwt', fetcher);
|
|
31
|
-
|
|
32
|
-
expect(calls[0][0]).toBe('https://specs.example.com/specs/notebook');
|
|
33
|
-
expect((calls[0][1]?.headers as Record<string, string>).Authorization).toBe(
|
|
34
|
-
'Bearer jwt'
|
|
35
|
-
);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it('tolerates a trailing slash on the service url', async () => {
|
|
39
|
-
const calls: string[] = [];
|
|
40
|
-
const fetcher = (async (url: string) => {
|
|
41
|
-
calls.push(url);
|
|
42
|
-
return response([]);
|
|
43
|
-
}) as unknown as typeof fetch;
|
|
44
|
-
|
|
45
|
-
await listNotebooks('https://specs.example.com//', 'jwt', fetcher);
|
|
46
|
-
|
|
47
|
-
expect(calls[0]).toBe('https://specs.example.com/specs/notebook');
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('reports the status rather than returning an empty list', async () => {
|
|
51
|
-
// The store answers a bad or expired token with 400, so silently returning []
|
|
52
|
-
// would look exactly like an account with no notebooks.
|
|
53
|
-
const fetcher = (async () =>
|
|
54
|
-
response({ detail: 'bad' }, 400)) as unknown as typeof fetch;
|
|
55
|
-
await expect(
|
|
56
|
-
listNotebooks('https://specs.example.com', 'jwt', fetcher)
|
|
57
|
-
).rejects.toThrow('HTTP 400');
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it('rejects a body that is not a list', async () => {
|
|
61
|
-
const fetcher = (async () =>
|
|
62
|
-
response({ detail: 'nope' })) as unknown as typeof fetch;
|
|
63
|
-
await expect(
|
|
64
|
-
listNotebooks('https://specs.example.com', 'jwt', fetcher)
|
|
65
|
-
).rejects.toThrow('unexpected response');
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('drops entries that are not summaries rather than rendering undefined', async () => {
|
|
69
|
-
const fetcher = (async () =>
|
|
70
|
-
response([
|
|
71
|
-
summary({ id: 'good' }),
|
|
72
|
-
{ id: 'bad' },
|
|
73
|
-
null
|
|
74
|
-
])) as unknown as typeof fetch;
|
|
75
|
-
const items = await listNotebooks(
|
|
76
|
-
'https://specs.example.com',
|
|
77
|
-
'jwt',
|
|
78
|
-
fetcher
|
|
79
|
-
);
|
|
80
|
-
expect(items.map(item => item.id)).toEqual(['good']);
|
|
81
|
-
});
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
describe('partitionNotebooks', () => {
|
|
85
|
-
it('splits the caller own records from the rest', () => {
|
|
86
|
-
const mine = summary({ id: 'mine', creator: 'dave@oceanum.science' });
|
|
87
|
-
const theirs = summary({ id: 'theirs', creator: null });
|
|
88
|
-
const { mine: own, shared } = partitionNotebooks(
|
|
89
|
-
[mine, theirs],
|
|
90
|
-
'dave@oceanum.science'
|
|
91
|
-
);
|
|
92
|
-
expect(own.map(i => i.id)).toEqual(['mine']);
|
|
93
|
-
expect(shared.map(i => i.id)).toEqual(['theirs']);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('compares addresses case-insensitively', () => {
|
|
97
|
-
// The store matches entities as case-sensitive globs, so the claim and the stored
|
|
98
|
-
// creator can differ in case for the same person.
|
|
99
|
-
const { mine } = partitionNotebooks(
|
|
100
|
-
[summary({ creator: 'Dave@Oceanum.Science' })],
|
|
101
|
-
'dave@oceanum.science'
|
|
102
|
-
);
|
|
103
|
-
expect(mine).toHaveLength(1);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it('treats everything as shared when nobody is signed in', () => {
|
|
107
|
-
const { mine, shared } = partitionNotebooks(
|
|
108
|
-
[summary({ creator: null }), summary({ id: 'b', creator: 'a@b.com' })],
|
|
109
|
-
null
|
|
110
|
-
);
|
|
111
|
-
expect(mine).toEqual([]);
|
|
112
|
-
expect(shared).toHaveLength(2);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it('orders each group newest first without mutating the input', () => {
|
|
116
|
-
const input = [
|
|
117
|
-
summary({ id: 'old', modified: '2026-01-01T00:00:00' }),
|
|
118
|
-
summary({ id: 'new', modified: '2026-05-17T00:00:00' })
|
|
119
|
-
];
|
|
120
|
-
expect(partitionNotebooks(input, null).shared.map(i => i.id)).toEqual([
|
|
121
|
-
'new',
|
|
122
|
-
'old'
|
|
123
|
-
]);
|
|
124
|
-
expect(input.map(i => i.id)).toEqual(['old', 'new']);
|
|
125
|
-
});
|
|
126
|
-
});
|
package/src/specStore.ts
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reading the Oceanum spec store's `notebook` records.
|
|
3
|
-
*
|
|
4
|
-
* Deliberately small: only what the Stored notebooks tab needs. oceanum-notebook's
|
|
5
|
-
* share-oceanum package has a fuller client (create, update, permissions), and when
|
|
6
|
-
* sharing moves here the two should become one rather than sitting side by side.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/** A spec store list entry. The list omits the notebook body. */
|
|
10
|
-
export interface ISpecSummary {
|
|
11
|
-
id: string;
|
|
12
|
-
name: string;
|
|
13
|
-
description: string | null;
|
|
14
|
-
/** Set when the record is created and never updated, so this is a creation time. */
|
|
15
|
-
modified: string;
|
|
16
|
-
/** Null unless the caller created the record: the store hides other creators. */
|
|
17
|
-
creator: string | null;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function isSummary(value: unknown): value is ISpecSummary {
|
|
21
|
-
if (typeof value !== 'object' || value === null) {
|
|
22
|
-
return false;
|
|
23
|
-
}
|
|
24
|
-
const v = value as Record<string, unknown>;
|
|
25
|
-
return (
|
|
26
|
-
typeof v.id === 'string' &&
|
|
27
|
-
typeof v.name === 'string' &&
|
|
28
|
-
typeof v.modified === 'string'
|
|
29
|
-
);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Every notebook the signed-in user may read: their own and those shared with them.
|
|
34
|
-
*
|
|
35
|
-
* The store answers an unauthenticated caller with an empty list rather than an error,
|
|
36
|
-
* so a missing token is indistinguishable from an empty account — callers should check
|
|
37
|
-
* they are signed in before showing "no notebooks".
|
|
38
|
-
*/
|
|
39
|
-
export async function listNotebooks(
|
|
40
|
-
specsUrl: string,
|
|
41
|
-
token: string,
|
|
42
|
-
fetcher: typeof fetch = fetch
|
|
43
|
-
): Promise<ISpecSummary[]> {
|
|
44
|
-
const url = `${specsUrl.replace(/\/+$/, '')}/specs/notebook`;
|
|
45
|
-
const response = await fetcher(url, {
|
|
46
|
-
headers: { Authorization: `Bearer ${token}` }
|
|
47
|
-
});
|
|
48
|
-
if (!response.ok) {
|
|
49
|
-
// The store answers a bad or expired token with 400, and 403 both for no access
|
|
50
|
-
// and for a record that does not exist.
|
|
51
|
-
throw new Error(`Oceanum.io returned HTTP ${response.status}.`);
|
|
52
|
-
}
|
|
53
|
-
const data = await response.json();
|
|
54
|
-
if (!Array.isArray(data)) {
|
|
55
|
-
throw new Error('Oceanum.io returned an unexpected response.');
|
|
56
|
-
}
|
|
57
|
-
return data.filter(isSummary);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Split a listing into the caller's own notebooks and the rest, newest first.
|
|
62
|
-
*
|
|
63
|
-
* The store only sends `creator` on records the caller created, so anything else it
|
|
64
|
-
* returns is shared with them — directly, or because it is public. Addresses are
|
|
65
|
-
* compared case-insensitively: the store matches entities as case-sensitive globs, so
|
|
66
|
-
* the two sides can differ in case.
|
|
67
|
-
*/
|
|
68
|
-
export function partitionNotebooks(
|
|
69
|
-
items: readonly ISpecSummary[],
|
|
70
|
-
email: string | null
|
|
71
|
-
): { mine: ISpecSummary[]; shared: ISpecSummary[] } {
|
|
72
|
-
const me = email?.toLowerCase() ?? null;
|
|
73
|
-
const sorted = [...items].sort((a, b) =>
|
|
74
|
-
b.modified.localeCompare(a.modified)
|
|
75
|
-
);
|
|
76
|
-
const isMine = (item: ISpecSummary): boolean =>
|
|
77
|
-
me !== null && item.creator?.toLowerCase() === me;
|
|
78
|
-
return {
|
|
79
|
-
mine: sorted.filter(isMine),
|
|
80
|
-
shared: sorted.filter(item => !isMine(item))
|
|
81
|
-
};
|
|
82
|
-
}
|