Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/ipynb/src/notebookAttachmentCleaner.ts
3292 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as vscode from 'vscode';
7
import { ATTACHMENT_CLEANUP_COMMANDID, JUPYTER_NOTEBOOK_MARKDOWN_SELECTOR } from './constants';
8
import { deepClone, objectEquals, Delayer } from './helper';
9
10
interface AttachmentCleanRequest {
11
notebook: vscode.NotebookDocument;
12
document: vscode.TextDocument;
13
cell: vscode.NotebookCell;
14
}
15
16
interface IAttachmentData {
17
[key: string /** mimetype */]: string;/** b64-encoded */
18
}
19
20
interface IAttachmentDiagnostic {
21
name: string;
22
ranges: vscode.Range[];
23
}
24
25
export enum DiagnosticCode {
26
missing_attachment = 'notebook.missing-attachment'
27
}
28
29
export class AttachmentCleaner implements vscode.CodeActionProvider {
30
private _attachmentCache:
31
Map<string /** uri */, Map<string /** cell fragment*/, Map<string /** attachment filename */, IAttachmentData>>> = new Map();
32
33
private _disposables: vscode.Disposable[];
34
private _imageDiagnosticCollection: vscode.DiagnosticCollection;
35
private readonly _delayer = new Delayer(750);
36
37
constructor() {
38
this._disposables = [];
39
this._imageDiagnosticCollection = vscode.languages.createDiagnosticCollection('Notebook Image Attachment');
40
this._disposables.push(this._imageDiagnosticCollection);
41
42
this._disposables.push(vscode.commands.registerCommand(ATTACHMENT_CLEANUP_COMMANDID, async (document: vscode.Uri, range: vscode.Range) => {
43
const workspaceEdit = new vscode.WorkspaceEdit();
44
workspaceEdit.delete(document, range);
45
await vscode.workspace.applyEdit(workspaceEdit);
46
}));
47
48
this._disposables.push(vscode.languages.registerCodeActionsProvider(JUPYTER_NOTEBOOK_MARKDOWN_SELECTOR, this, {
49
providedCodeActionKinds: [
50
vscode.CodeActionKind.QuickFix
51
],
52
}));
53
54
this._disposables.push(vscode.workspace.onDidChangeNotebookDocument(e => {
55
this._delayer.trigger(() => {
56
57
e.cellChanges.forEach(change => {
58
if (!change.document) {
59
return;
60
}
61
62
if (change.cell.kind !== vscode.NotebookCellKind.Markup) {
63
return;
64
}
65
66
const metadataEdit = this.cleanNotebookAttachments({
67
notebook: e.notebook,
68
cell: change.cell,
69
document: change.document
70
});
71
if (metadataEdit) {
72
const workspaceEdit = new vscode.WorkspaceEdit();
73
workspaceEdit.set(e.notebook.uri, [metadataEdit]);
74
vscode.workspace.applyEdit(workspaceEdit);
75
}
76
});
77
});
78
}));
79
80
81
this._disposables.push(vscode.workspace.onWillSaveNotebookDocument(e => {
82
if (e.reason === vscode.TextDocumentSaveReason.Manual) {
83
this._delayer.dispose();
84
if (e.notebook.getCells().length === 0) {
85
return;
86
}
87
const notebookEdits: vscode.NotebookEdit[] = [];
88
for (const cell of e.notebook.getCells()) {
89
if (cell.kind !== vscode.NotebookCellKind.Markup) {
90
continue;
91
}
92
93
const metadataEdit = this.cleanNotebookAttachments({
94
notebook: e.notebook,
95
cell: cell,
96
document: cell.document
97
});
98
99
if (metadataEdit) {
100
notebookEdits.push(metadataEdit);
101
}
102
}
103
if (!notebookEdits.length) {
104
return;
105
}
106
const workspaceEdit = new vscode.WorkspaceEdit();
107
workspaceEdit.set(e.notebook.uri, notebookEdits);
108
e.waitUntil(Promise.resolve(workspaceEdit));
109
}
110
}));
111
112
this._disposables.push(vscode.workspace.onDidCloseNotebookDocument(e => {
113
this._attachmentCache.delete(e.uri.toString());
114
}));
115
116
this._disposables.push(vscode.workspace.onWillRenameFiles(e => {
117
const re = /\.ipynb$/;
118
for (const file of e.files) {
119
if (!re.exec(file.oldUri.toString())) {
120
continue;
121
}
122
123
// transfer cache to new uri
124
if (this._attachmentCache.has(file.oldUri.toString())) {
125
this._attachmentCache.set(file.newUri.toString(), this._attachmentCache.get(file.oldUri.toString())!);
126
this._attachmentCache.delete(file.oldUri.toString());
127
}
128
}
129
}));
130
131
this._disposables.push(vscode.workspace.onDidOpenTextDocument(e => {
132
this.analyzeMissingAttachments(e);
133
}));
134
135
this._disposables.push(vscode.workspace.onDidCloseTextDocument(e => {
136
this.analyzeMissingAttachments(e);
137
}));
138
139
vscode.workspace.textDocuments.forEach(document => {
140
this.analyzeMissingAttachments(document);
141
});
142
}
143
144
provideCodeActions(document: vscode.TextDocument, _range: vscode.Range | vscode.Selection, context: vscode.CodeActionContext, _token: vscode.CancellationToken): vscode.ProviderResult<(vscode.CodeAction | vscode.Command)[]> {
145
const fixes: vscode.CodeAction[] = [];
146
147
for (const diagnostic of context.diagnostics) {
148
switch (diagnostic.code) {
149
case DiagnosticCode.missing_attachment:
150
{
151
const fix = new vscode.CodeAction(
152
'Remove invalid image attachment reference',
153
vscode.CodeActionKind.QuickFix);
154
155
fix.command = {
156
command: ATTACHMENT_CLEANUP_COMMANDID,
157
title: 'Remove invalid image attachment reference',
158
arguments: [document.uri, diagnostic.range],
159
};
160
fixes.push(fix);
161
}
162
break;
163
}
164
}
165
166
return fixes;
167
}
168
169
/**
170
* take in a NotebookDocumentChangeEvent, and clean the attachment data for the cell(s) that have had their markdown source code changed
171
* @param e NotebookDocumentChangeEvent from the onDidChangeNotebookDocument listener
172
* @returns vscode.NotebookEdit, the metadata alteration performed on the json behind the ipynb
173
*/
174
private cleanNotebookAttachments(e: AttachmentCleanRequest): vscode.NotebookEdit | undefined {
175
176
if (e.notebook.isClosed) {
177
return;
178
}
179
const document = e.document;
180
const cell = e.cell;
181
182
const markdownAttachmentsInUse: { [key: string /** filename */]: IAttachmentData } = {};
183
const cellFragment = cell.document.uri.fragment;
184
const notebookUri = e.notebook.uri.toString();
185
const diagnostics: IAttachmentDiagnostic[] = [];
186
const markdownAttachmentsRefedInCell = this.getAttachmentNames(document);
187
188
if (markdownAttachmentsRefedInCell.size === 0) {
189
// no attachments used in this cell, cache all images from cell metadata
190
this.saveAllAttachmentsToCache(cell.metadata, notebookUri, cellFragment);
191
}
192
193
if (this.checkMetadataHasAttachmentsField(cell.metadata)) {
194
// the cell metadata contains attachments, check if any are used in the markdown source
195
196
for (const [currFilename, attachment] of Object.entries(cell.metadata.attachments)) {
197
// means markdown reference is present in the metadata, rendering will work properly
198
// therefore, we don't need to check it in the next loop either
199
if (markdownAttachmentsRefedInCell.has(currFilename)) {
200
// attachment reference is present in the markdown source, no need to cache it
201
markdownAttachmentsRefedInCell.get(currFilename)!.valid = true;
202
markdownAttachmentsInUse[currFilename] = attachment as IAttachmentData;
203
} else {
204
// attachment reference is not present in the markdown source, cache it
205
this.saveAttachmentToCache(notebookUri, cellFragment, currFilename, cell.metadata);
206
}
207
}
208
}
209
210
for (const [currFilename, attachment] of markdownAttachmentsRefedInCell) {
211
if (attachment.valid) {
212
// attachment reference is present in both the markdown source and the metadata, no op
213
continue;
214
}
215
216
// if image is referenced in markdown source but not in metadata -> check if we have image in the cache
217
const cachedImageAttachment = this._attachmentCache.get(notebookUri)?.get(cellFragment)?.get(currFilename);
218
if (cachedImageAttachment) {
219
markdownAttachmentsInUse[currFilename] = cachedImageAttachment;
220
this._attachmentCache.get(notebookUri)?.get(cellFragment)?.delete(currFilename);
221
} else {
222
// if image is not in the cache, show warning
223
diagnostics.push({ name: currFilename, ranges: attachment.ranges });
224
}
225
}
226
227
this.updateDiagnostics(cell.document.uri, diagnostics);
228
229
if (cell.index > -1 && !objectEquals(markdownAttachmentsInUse || {}, cell.metadata.attachments || {})) {
230
const updateMetadata: { [key: string]: any } = deepClone(cell.metadata);
231
if (Object.keys(markdownAttachmentsInUse).length === 0) {
232
updateMetadata.attachments = undefined;
233
} else {
234
updateMetadata.attachments = markdownAttachmentsInUse;
235
}
236
const metadataEdit = vscode.NotebookEdit.updateCellMetadata(cell.index, updateMetadata);
237
return metadataEdit;
238
}
239
return;
240
}
241
242
private analyzeMissingAttachments(document: vscode.TextDocument): void {
243
if (document.uri.scheme !== 'vscode-notebook-cell') {
244
// not notebook
245
return;
246
}
247
248
if (document.isClosed) {
249
this.updateDiagnostics(document.uri, []);
250
return;
251
}
252
253
let notebook: vscode.NotebookDocument | undefined;
254
let activeCell: vscode.NotebookCell | undefined;
255
for (const notebookDocument of vscode.workspace.notebookDocuments) {
256
const cell = notebookDocument.getCells().find(cell => cell.document === document);
257
if (cell) {
258
notebook = notebookDocument;
259
activeCell = cell;
260
break;
261
}
262
}
263
264
if (!notebook || !activeCell) {
265
return;
266
}
267
268
const diagnostics: IAttachmentDiagnostic[] = [];
269
const markdownAttachments = this.getAttachmentNames(document);
270
if (this.checkMetadataHasAttachmentsField(activeCell.metadata)) {
271
for (const [currFilename, attachment] of markdownAttachments) {
272
if (!activeCell.metadata.attachments[currFilename]) {
273
// no attachment reference in the metadata
274
diagnostics.push({ name: currFilename, ranges: attachment.ranges });
275
}
276
}
277
}
278
279
this.updateDiagnostics(activeCell.document.uri, diagnostics);
280
}
281
282
private updateDiagnostics(cellUri: vscode.Uri, diagnostics: IAttachmentDiagnostic[]) {
283
const vscodeDiagnostics: vscode.Diagnostic[] = [];
284
for (const currDiagnostic of diagnostics) {
285
currDiagnostic.ranges.forEach(range => {
286
const diagnostic = new vscode.Diagnostic(range, `The image named: '${currDiagnostic.name}' is not present in cell metadata.`, vscode.DiagnosticSeverity.Warning);
287
diagnostic.code = DiagnosticCode.missing_attachment;
288
vscodeDiagnostics.push(diagnostic);
289
});
290
}
291
292
this._imageDiagnosticCollection.set(cellUri, vscodeDiagnostics);
293
}
294
295
/**
296
* remove attachment from metadata and add it to the cache
297
* @param notebookUri uri of the notebook currently being edited
298
* @param cellFragment fragment of the cell currently being edited
299
* @param currFilename filename of the image being pulled into the cell
300
* @param metadata metadata of the cell currently being edited
301
*/
302
private saveAttachmentToCache(notebookUri: string, cellFragment: string, currFilename: string, metadata: { [key: string]: any }): void {
303
const documentCache = this._attachmentCache.get(notebookUri);
304
if (!documentCache) {
305
// no cache for this notebook yet
306
const cellCache = new Map<string, IAttachmentData>();
307
cellCache.set(currFilename, this.getMetadataAttachment(metadata, currFilename));
308
const documentCache = new Map();
309
documentCache.set(cellFragment, cellCache);
310
this._attachmentCache.set(notebookUri, documentCache);
311
} else if (!documentCache.has(cellFragment)) {
312
// no cache for this cell yet
313
const cellCache = new Map<string, IAttachmentData>();
314
cellCache.set(currFilename, this.getMetadataAttachment(metadata, currFilename));
315
documentCache.set(cellFragment, cellCache);
316
} else {
317
// cache for this cell already exists
318
// add to cell cache
319
documentCache.get(cellFragment)?.set(currFilename, this.getMetadataAttachment(metadata, currFilename));
320
}
321
}
322
323
/**
324
* get an attachment entry from the given metadata
325
* @param metadata metadata to extract image data from
326
* @param currFilename filename of image being extracted
327
* @returns
328
*/
329
private getMetadataAttachment(metadata: { [key: string]: any }, currFilename: string): { [key: string]: any } {
330
return metadata.attachments[currFilename];
331
}
332
333
/**
334
* returns a boolean that represents if there are any images in the attachment field of a cell's metadata
335
* @param metadata metadata of cell
336
* @returns boolean representing the presence of any attachments
337
*/
338
private checkMetadataHasAttachmentsField(metadata: { [key: string]: unknown }): metadata is { readonly attachments: Record<string, unknown> } {
339
return !!metadata.attachments && typeof metadata.attachments === 'object';
340
}
341
342
/**
343
* given metadata from a cell, cache every image (used in cases with no image links in markdown source)
344
* @param metadata metadata for a cell with no images in markdown source
345
* @param notebookUri uri for the notebook being edited
346
* @param cellFragment fragment of cell being edited
347
*/
348
private saveAllAttachmentsToCache(metadata: { [key: string]: unknown }, notebookUri: string, cellFragment: string): void {
349
const documentCache = this._attachmentCache.get(notebookUri) ?? new Map();
350
this._attachmentCache.set(notebookUri, documentCache);
351
const cellCache = documentCache.get(cellFragment) ?? new Map<string, IAttachmentData>();
352
documentCache.set(cellFragment, cellCache);
353
354
if (metadata.attachments && typeof metadata.attachments === 'object') {
355
for (const [currFilename, attachment] of Object.entries(metadata.attachments)) {
356
cellCache.set(currFilename, attachment);
357
}
358
}
359
}
360
361
/**
362
* pass in all of the markdown source code, and get a dictionary of all images referenced in the markdown. keys are image filenames, values are render state
363
* @param document the text document for the cell, formatted as a string
364
*/
365
private getAttachmentNames(document: vscode.TextDocument) {
366
const source = document.getText();
367
const filenames: Map<string, { valid: boolean; ranges: vscode.Range[] }> = new Map();
368
const re = /!\[.*?\]\(<?attachment:(?<filename>.*?)>?\)/gm;
369
370
let match;
371
while ((match = re.exec(source))) {
372
if (match.groups?.filename) {
373
const index = match.index;
374
const length = match[0].length;
375
const startPosition = document.positionAt(index);
376
const endPosition = document.positionAt(index + length);
377
const range = new vscode.Range(startPosition, endPosition);
378
const filename = filenames.get(match.groups.filename) ?? { valid: false, ranges: [] };
379
filenames.set(match.groups.filename, filename);
380
filename.ranges.push(range);
381
}
382
}
383
return filenames;
384
}
385
386
dispose() {
387
this._disposables.forEach(d => d.dispose());
388
this._delayer.dispose();
389
}
390
}
391
392
393