Skip to content

Commit d6c40a5

Browse files
agriyakhetarpalkrassowskigithub-actions[bot]peytondmurray
authored
Connect saving and sharing notebooks with CKHub API (#46)
* Drop `loadingIndicator`, it's not used * New draft * Subsequent saving * Don't use readable ID for now * Lint share dialog divs * Fix token issue - need to use the same token for save as for create * Fix update notebook API call * Update Playwright Snapshots * Save operation should open share command * Lint * Swap save and share tests order * Use `docmanager:save` * Update file names for snapshots * Update Playwright Snapshots * Add a simpler test for now * Drop snapshot that was previously added * Some suggestions * Fix share button snapshot test * Remove problematic test; it requires the sharing service to run * Improve docstring for `createErrorDialog` Co-Authored-By: Peyton Murray <[email protected]> --------- Co-authored-by: krassowski <[email protected]> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: pdmurray <[email protected]>
1 parent 2245b0d commit d6c40a5

File tree

5 files changed

+214
-161
lines changed

5 files changed

+214
-161
lines changed

src/index.ts

Lines changed: 167 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,17 @@ import { Commands } from './commands';
2121
import { competitions } from './pages/competitions';
2222
import { notebookPlugin } from './pages/notebook';
2323

24+
function generatePassword(): string {
25+
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
26+
const array = new Uint8Array(16);
27+
crypto.getRandomValues(array);
28+
let password = '';
29+
for (let i = 0; i < array.length; i++) {
30+
password += chars.charAt(array[i] % chars.length);
31+
}
32+
return password;
33+
}
34+
2435
/**
2536
* Get the current notebook panel
2637
*/
@@ -59,8 +70,112 @@ const plugin: JupyterFrontEndPlugin<void> = {
5970
const apiUrl =
6071
PageConfig.getOption('sharing_service_api_url') || 'http://localhost:8080/api/v1';
6172

73+
const notebookPasswords = new Map();
6274
const sharingService = new SharingService(apiUrl);
6375

76+
async function handleNotebookSave(
77+
notebookPanel: NotebookPanel,
78+
isManualShare: boolean = false
79+
) {
80+
const notebookContent = notebookPanel.context.model.toJSON() as INotebookContent;
81+
82+
// Check if notebook has already been shared
83+
const isAlreadyShared =
84+
notebookContent.metadata &&
85+
typeof notebookContent.metadata === 'object' &&
86+
'sharedId' in notebookContent.metadata;
87+
88+
if (isAlreadyShared && !isManualShare) {
89+
try {
90+
const sharedId = notebookContent.metadata.sharedId as string;
91+
console.log('Updating notebook:', sharedId);
92+
93+
await sharingService.update(sharedId, notebookContent);
94+
95+
console.log('Notebook automatically synced to CKHub');
96+
} catch (error) {
97+
console.warn('Failed to sync notebook to CKHub:', error);
98+
await showDialog({
99+
title: 'Sync Failed',
100+
body: ReactWidget.create(createErrorDialog(error)),
101+
buttons: [Dialog.okButton()]
102+
});
103+
}
104+
return;
105+
}
106+
107+
if (!isAlreadyShared && !isManualShare) {
108+
// First save/share; displays a shareable link and shows a password in a dialog
109+
const password = generatePassword();
110+
const defaultName = `Notebook_${new Date().getFullYear()}-${(new Date().getMonth() + 1).toString().padStart(2, '0')}-${new Date().getDate().toString().padStart(2, '0')}`;
111+
112+
try {
113+
const shareResponse = await sharingService.share(notebookContent, password);
114+
115+
if (shareResponse && shareResponse.notebook) {
116+
notebookContent.metadata = {
117+
...notebookContent.metadata,
118+
sharedId: shareResponse.notebook.id,
119+
readableId: shareResponse.notebook.readable_id,
120+
sharedName: defaultName,
121+
isPasswordProtected: true,
122+
lastShared: new Date().toISOString()
123+
};
124+
125+
notebookPasswords.set(shareResponse.notebook.id, password);
126+
127+
notebookPanel.context.model.fromJSON(notebookContent);
128+
await notebookPanel.context.save();
129+
}
130+
} catch (error) {
131+
console.error('Failed to share notebook:', error);
132+
await showDialog({
133+
title: 'Error Sharing Notebook',
134+
body: ReactWidget.create(createErrorDialog(error)),
135+
buttons: [Dialog.okButton()]
136+
});
137+
}
138+
}
139+
140+
if (isManualShare) {
141+
// Manual share button pressed - show link and password
142+
const readableId = notebookContent.metadata.readableId as string;
143+
const sharedId = notebookContent.metadata.sharedId as string;
144+
const shareableLink = sharingService.makeRetrieveURL(readableId || sharedId).toString();
145+
146+
const dialogResult = await showDialog({
147+
title: '',
148+
body: ReactWidget.create(
149+
createSuccessDialog(shareableLink, notebookPasswords.get(sharedId))
150+
),
151+
buttons: [
152+
Dialog.okButton({ label: 'Copy Link!' }),
153+
Dialog.cancelButton({ label: 'Close' })
154+
]
155+
});
156+
157+
if (dialogResult.button.label === 'Copy Link!') {
158+
try {
159+
await navigator.clipboard.writeText(shareableLink);
160+
} catch (err) {
161+
console.error('Failed to copy link:', err);
162+
}
163+
}
164+
}
165+
}
166+
167+
/**
168+
* Hook into notebook saves using the saveState signal to handle CKHub sharing
169+
*/
170+
tracker.widgetAdded.connect((sender, widget) => {
171+
widget.context.saveState.connect(async (sender, saveState) => {
172+
// Only trigger when save is completed (not dirty and not saving)
173+
if (saveState === 'completed') {
174+
await handleNotebookSave(widget, false);
175+
}
176+
});
177+
});
178+
64179
/**
65180
* 1. A "Download as IPyNB" command.
66181
*/
@@ -114,102 +229,72 @@ const plugin: JupyterFrontEndPlugin<void> = {
114229

115230
const notebookContent = notebookPanel.context.model.toJSON() as INotebookContent;
116231

117-
// Check if notebook has already been shared; access metadata using notebook content
118-
let notebookId: string | undefined;
119-
if (
120-
notebookContent.metadata &&
121-
typeof notebookContent.metadata === 'object' &&
122-
'sharedId' in notebookContent.metadata
123-
) {
124-
notebookId = notebookContent.metadata.sharedId as string;
125-
}
232+
// Check if notebook has already been shared
233+
const notebookId = notebookContent?.metadata?.sharedId as string;
126234

127235
const isNewShare = !notebookId;
128236

129-
const result = await showDialog({
130-
title: isNewShare ? 'Share Notebook' : 'Update Shared Notebook',
131-
body: new ShareDialog(),
132-
buttons: [Dialog.cancelButton(), Dialog.okButton()]
133-
});
237+
if (isNewShare) {
238+
// First time sharing - show dialog to get notebook name
239+
const result = await showDialog({
240+
title: 'Share Notebook',
241+
body: new ShareDialog(),
242+
buttons: [Dialog.cancelButton(), Dialog.okButton()]
243+
});
134244

135-
if (result.button.accept) {
136-
const { notebookName, password } = result.value as IShareDialogData;
137-
138-
try {
139-
// Show loading indicator
140-
// TODO: this doesn't show up in the dialog properly, we could
141-
// even remove it as loading doesn't take long at all
142-
const loadingIndicator = document.createElement('div');
143-
loadingIndicator.textContent = 'Sharing notebook...';
144-
loadingIndicator.style.position = 'fixed';
145-
loadingIndicator.style.bottom = '20px';
146-
loadingIndicator.style.right = '20px';
147-
loadingIndicator.style.padding = '10px';
148-
loadingIndicator.style.backgroundColor = '#f0f0f0';
149-
loadingIndicator.style.borderRadius = '5px';
150-
loadingIndicator.style.zIndex = '1000';
151-
document.body.appendChild(loadingIndicator);
152-
153-
await sharingService.authenticate();
154-
155-
let shareResponse;
156-
if (isNewShare) {
157-
shareResponse = await sharingService.share(notebookContent, password);
158-
} else if (notebookId) {
159-
shareResponse = await sharingService.update(notebookId, notebookContent, password);
160-
}
245+
if (result.button.accept) {
246+
const { notebookName } = result.value as IShareDialogData;
247+
const password = generatePassword();
161248

162-
if (shareResponse && shareResponse.notebook) {
163-
// We need to update the metadata in the notebookContent first
164-
// to do this, and we need to ensure that the metadata object exists
165-
if (!notebookContent.metadata) {
166-
notebookContent.metadata = {};
167-
}
249+
try {
250+
const shareResponse = await sharingService.share(notebookContent, password);
168251

169-
notebookContent.metadata.sharedId = shareResponse.notebook.id;
170-
notebookContent.metadata.readableId = shareResponse.notebook.readable_id;
171-
notebookContent.metadata.sharedName = notebookName;
172-
notebookContent.metadata.isPasswordProtected = true;
252+
let shareableLink = '';
253+
if (shareResponse && shareResponse.notebook) {
254+
notebookContent.metadata = {
255+
...notebookContent.metadata,
256+
sharedId: shareResponse.notebook.id,
257+
readable_id: shareResponse.notebook.readable_id,
258+
sharedName: notebookName,
259+
isPasswordProtected: true
260+
};
173261

174-
notebookPanel.context.model.fromJSON(notebookContent);
175-
}
262+
notebookPanel.context.model.fromJSON(notebookContent);
263+
await notebookPanel.context.save();
176264

177-
let shareableLink = '';
178-
if (shareResponse && shareResponse.notebook) {
179-
const id = shareResponse.notebook.readable_id || shareResponse.notebook.id;
180-
shareableLink = sharingService.makeRetrieveURL(id).toString();
181-
}
265+
const id = shareResponse.notebook.readable_id || shareResponse.notebook.id;
266+
shareableLink = sharingService.makeRetrieveURL(id).toString();
267+
notebookPasswords.set(shareResponse.notebook.id, password);
268+
}
182269

183-
// Remove loading indicator
184-
document.body.removeChild(loadingIndicator);
185-
186-
if (shareableLink) {
187-
const dialogResult = await showDialog({
188-
title: isNewShare
189-
? 'Notebook Shared Successfully'
190-
: 'Notebook Updated Successfully',
191-
body: ReactWidget.create(createSuccessDialog(shareableLink, isNewShare, true)),
192-
buttons: [
193-
Dialog.okButton({ label: 'Copy Link' }),
194-
Dialog.cancelButton({ label: 'Close' })
195-
]
196-
});
270+
if (shareableLink) {
271+
const dialogResult = await showDialog({
272+
title: '',
273+
body: ReactWidget.create(createSuccessDialog(shareableLink, password)),
274+
buttons: [
275+
Dialog.okButton({ label: 'Done' }),
276+
Dialog.cancelButton({ label: 'Copy Link' })
277+
]
278+
});
197279

198-
if (dialogResult.button.label === 'Copy Link') {
199-
try {
200-
await navigator.clipboard.writeText(shareableLink);
201-
} catch (err) {
202-
console.error('Failed to copy link:', err);
280+
if (dialogResult.button.label === 'Copy Link') {
281+
try {
282+
await navigator.clipboard.writeText(shareableLink);
283+
} catch (err) {
284+
console.error('Failed to copy link:', err);
285+
}
203286
}
204287
}
288+
} catch (error) {
289+
await showDialog({
290+
title: 'Error',
291+
body: ReactWidget.create(createErrorDialog(error)),
292+
buttons: [Dialog.okButton()]
293+
});
205294
}
206-
} catch (error) {
207-
await showDialog({
208-
title: 'Error',
209-
body: ReactWidget.create(createErrorDialog(error)),
210-
buttons: [Dialog.okButton()]
211-
});
212295
}
296+
} else {
297+
await handleNotebookSave(notebookPanel, true);
213298
}
214299
} catch (error) {
215300
console.error('Error in share command:', error);

src/sharing-service.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -294,15 +294,12 @@ export class SharingService {
294294
* @param password - Password if notebook is protected
295295
* @returns API response with updated notebook details
296296
*/
297-
async update(id: string, notebook: INotebookContent, password?: string): Promise<IShareResponse> {
297+
async update(id: string, notebook: INotebookContent): Promise<IShareResponse> {
298298
if (!validateNotebookContent(notebook)) {
299299
throw new Error('Invalid notebook content');
300300
}
301301

302-
const requestData: Record<string, any> = { notebook };
303-
if (password) {
304-
requestData.password = password;
305-
}
302+
const requestData: Record<string, any> = notebook;
306303

307304
const endpoint = new URL(`notebooks/${id}`, this.api_url);
308305

0 commit comments

Comments
 (0)