From ef8b26b7ae633b12cfcebdf08d412f175e2ed5ac Mon Sep 17 00:00:00 2001 From: Maddy <12754347+MaddyDev@users.noreply.github.com> Date: Fri, 30 Apr 2021 19:43:55 -0700 Subject: [PATCH] Insert local/online images using image call out (#15238) * changes from Chris's branch and cell model updates * get base64 value * handle spaces in image names * add comments * add tests for imageCallOut dialog * format document for hygiene errors * address comments * check base64 validity using regex * replace space with regex * add parameter and return type * split into two functions * move functions to fileUtilities * correct import * fix for layering issue * revert file function changes --- .../calloutDialog/imageCalloutDialog.ts | 50 ++++-- .../cellViews/markdownToolbar.component.ts | 42 +++++ .../calloutDialog/imageCalloutDialog.test.ts | 147 ++++++++++++++++++ .../notebook/test/resources/extension 2.png | Bin 0 -> 3338 bytes .../notebook/test/resources/extension.png | Bin 0 -> 3338 bytes .../services/notebook/browser/models/cell.ts | 22 ++- .../browser/models/modelInterfaces.ts | 1 + 7 files changed, 244 insertions(+), 18 deletions(-) create mode 100644 src/sql/workbench/contrib/notebook/test/calloutDialog/imageCalloutDialog.test.ts create mode 100644 src/sql/workbench/contrib/notebook/test/resources/extension 2.png create mode 100644 src/sql/workbench/contrib/notebook/test/resources/extension.png diff --git a/src/sql/workbench/contrib/notebook/browser/calloutDialog/imageCalloutDialog.ts b/src/sql/workbench/contrib/notebook/browser/calloutDialog/imageCalloutDialog.ts index fe6ae17f42..1bd7af32d6 100644 --- a/src/sql/workbench/contrib/notebook/browser/calloutDialog/imageCalloutDialog.ts +++ b/src/sql/workbench/contrib/notebook/browser/calloutDialog/imageCalloutDialog.ts @@ -9,7 +9,7 @@ import * as styler from 'vs/platform/theme/common/styler'; import * as TelemetryKeys from 'sql/platform/telemetry/common/telemetryKeys'; import * as constants from 'sql/workbench/contrib/notebook/browser/calloutDialog/common/constants'; import { URI } from 'vs/base/common/uri'; -import { Modal, IDialogProperties } from 'sql/workbench/browser/modal/modal'; +import { Modal, IDialogProperties, DialogPosition, DialogWidth } from 'sql/workbench/browser/modal/modal'; import { IFileDialogService, IOpenDialogOptions } from 'vs/platform/dialogs/common/dialogs'; import { IContextViewService } from 'vs/platform/contextview/browser/contextView'; import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; @@ -24,9 +24,8 @@ import { Deferred } from 'sql/base/common/promise'; import { InputBox } from 'sql/base/browser/ui/inputBox/inputBox'; import { Checkbox } from 'sql/base/browser/ui/checkbox/checkbox'; import { RadioButton } from 'sql/base/browser/ui/radioButton/radioButton'; -import { DialogPosition, DialogWidth } from 'sql/workbench/api/common/sqlExtHostTypes'; import { attachCalloutDialogStyler } from 'sql/workbench/common/styler'; -import { escapeUrl } from 'sql/workbench/contrib/notebook/browser/calloutDialog/common/utils'; +import * as path from 'vs/base/common/path'; export interface IImageCalloutDialogOptions { insertTitle?: string, @@ -37,6 +36,7 @@ export interface IImageCalloutDialogOptions { const DEFAULT_DIALOG_WIDTH: DialogWidth = 452; +const IMAGE_Extensions: string[] = ['jpg', 'jpeg', 'png', 'gif']; export class ImageCalloutDialog extends Modal { private _selectionComplete: Deferred = new Deferred(); private _imageLocationLabel: HTMLElement; @@ -163,16 +163,6 @@ export class ImageCalloutDialog extends Modal { } }, true)); - this._register(this._imageRemoteRadioButton.onClicked(e => { - this._imageBrowseButton.style.display = 'none'; - this._imageUrlLabel.innerText = constants.urlPlaceholder; - this._imageUrlInputBox.setPlaceHolder(constants.urlPlaceholder); - })); - this._register(this._imageLocalRadioButton.onClicked(e => { - this._imageBrowseButton.style.display = 'block'; - this._imageUrlLabel.innerText = constants.pathPlaceholder; - this._imageUrlInputBox.setPlaceHolder(constants.pathPlaceholder); - })); DOM.append(pathRow, inputContainer); let embedRow = DOM.$('.row'); @@ -187,6 +177,19 @@ export class ImageCalloutDialog extends Modal { ariaLabel: constants.embedImageLabel }); DOM.append(embedRow, this._imageEmbedLabel); + + this._register(this._imageRemoteRadioButton.onClicked(e => { + this._imageBrowseButton.style.display = 'none'; + this._imageEmbedCheckbox.enabled = false; + this._imageUrlLabel.innerText = constants.urlPlaceholder; + this._imageUrlInputBox.setPlaceHolder(constants.urlPlaceholder); + })); + this._register(this._imageLocalRadioButton.onClicked(e => { + this._imageBrowseButton.style.display = 'block'; + this._imageEmbedCheckbox.enabled = true; + this._imageUrlLabel.innerText = constants.pathPlaceholder; + this._imageUrlInputBox.setPlaceHolder(constants.pathPlaceholder); + })); } private registerListeners(): void { @@ -196,10 +199,14 @@ export class ImageCalloutDialog extends Modal { public insert(): void { this.hide('ok'); + let imgPath = this._imageUrlInputBox.value; + let imageName = path.basename(imgPath); this._selectionComplete.resolve({ - insertEscapedMarkdown: `![](${escapeUrl(this._imageUrlInputBox.value)})`, - imagePath: this._imageUrlInputBox.value, - embedImage: this._imageEmbedCheckbox.checked + embedImage: this._imageEmbedCheckbox.checked, + // check for spaces and remove them in imageName. + // if spaces in image path replace with as per https://github.com/microsoft/vscode/issues/11933#issuecomment-249987377 + insertEscapedMarkdown: this._imageEmbedCheckbox.checked ? `![${imageName}](attachment:${imageName.replace(/\s/g, '')})` : `![](${imgPath.replace(/\s/g, ' ')})`, + imagePath: imgPath }); this.dispose(); } @@ -225,7 +232,8 @@ export class ImageCalloutDialog extends Modal { canSelectFolders: false, canSelectMany: false, defaultUri: URI.file(await this.getUserHome()), - title: undefined + title: undefined, + filters: [{ extensions: IMAGE_Extensions, name: 'images' }] }; let imageUri: URI[] = await this._fileDialogService.showOpenDialog(options); if (imageUri.length > 0) { @@ -234,4 +242,12 @@ export class ImageCalloutDialog extends Modal { return undefined; } } + + public set imagePath(val: string) { + this._imageUrlInputBox.value = val; + } + + public set embedImage(val: boolean) { + this._imageEmbedCheckbox.checked = val; + } } diff --git a/src/sql/workbench/contrib/notebook/browser/cellViews/markdownToolbar.component.ts b/src/sql/workbench/contrib/notebook/browser/cellViews/markdownToolbar.component.ts index b24a3bd907..e9a549ab7c 100644 --- a/src/sql/workbench/contrib/notebook/browser/cellViews/markdownToolbar.component.ts +++ b/src/sql/workbench/contrib/notebook/browser/cellViews/markdownToolbar.component.ts @@ -22,6 +22,7 @@ import { IEditor } from 'vs/editor/common/editorCommon'; import * as path from 'vs/base/common/path'; import { URI } from 'vs/base/common/uri'; import { escape } from 'vs/base/common/strings'; +import { IImageCalloutDialogOptions, ImageCalloutDialog } from 'sql/workbench/contrib/notebook/browser/calloutDialog/imageCalloutDialog'; export const MARKDOWN_TOOLBAR_SELECTOR: string = 'markdown-toolbar-component'; const linksRegex = /\[(?.+)\]\((?[^ ]+)(?: "(?.+)")?\)/; @@ -225,6 +226,7 @@ export class MarkdownToolbarComponent extends AngularDisposable { let triggerElement = event.target as HTMLElement; let needsTransform = true; let linkCalloutResult: ILinkCalloutDialogOptions; + let imageCalloutResult: IImageCalloutDialogOptions; if (type === MarkdownButtonType.LINK_PREVIEW) { linkCalloutResult = await this.createCallout(type, triggerElement); @@ -251,6 +253,12 @@ export class MarkdownToolbarComponent extends AngularDisposable { document.execCommand('insertHTML', false, `<a href="${escape(linkUrl)}">${escape(linkCalloutResult?.insertUnescapedLinkLabel)}</a>`); return; } + } else if (type === MarkdownButtonType.IMAGE_PREVIEW) { + imageCalloutResult = await this.createCallout(type, triggerElement); + // If cell edit mode isn't WYSIWYG, use result from callout. No need for further transformation. + if (this.cellModel.currentMode !== CellEditModes.WYSIWYG) { + needsTransform = false; + } } const transformer = new MarkdownTextTransformer(this._notebookService, this.cellModel); @@ -259,6 +267,14 @@ export class MarkdownToolbarComponent extends AngularDisposable { } else if (!needsTransform) { if (type === MarkdownButtonType.LINK_PREVIEW) { await insertFormattedMarkdown(linkCalloutResult?.insertEscapedMarkdown, this.getCellEditorControl()); + } else if (type === MarkdownButtonType.IMAGE_PREVIEW) { + if (imageCalloutResult.embedImage) { + let base64String = await this.getFileContentBase64(URI.file(imageCalloutResult.imagePath)); + let mimeType = await this.getFileMimeType(URI.file(imageCalloutResult.imagePath)); + this.cellModel.addAttachment(mimeType, base64String, path.basename(imageCalloutResult.imagePath).replace(' ', '')); + await insertFormattedMarkdown(imageCalloutResult.insertEscapedMarkdown, this.getCellEditorControl()); + } + await insertFormattedMarkdown(imageCalloutResult.insertEscapedMarkdown, this.getCellEditorControl()); } } } @@ -304,6 +320,10 @@ export class MarkdownToolbarComponent extends AngularDisposable { this._linkCallout = this._instantiationService.createInstance(LinkCalloutDialog, this.insertLinkHeading, dialogPosition, dialogProperties, defaultLabel, defaultLinkUrl); this._linkCallout.render(); calloutOptions = await this._linkCallout.open(); + } else if (type === MarkdownButtonType.IMAGE_PREVIEW) { + const imageCallout = this._instantiationService.createInstance(ImageCalloutDialog, this.insertImageHeading, dialogPosition, dialogProperties); + imageCallout.render(); + calloutOptions = await imageCallout.open(); } return calloutOptions; } @@ -354,4 +374,26 @@ export class MarkdownToolbarComponent extends AngularDisposable { } return undefined; } + + public async getFileContentBase64(fileUri: URI): Promise<string> { + return new Promise<string>(async resolve => { + let response = await fetch(fileUri.toString()); + let blob = await response.blob(); + + let file = new File([blob], fileUri.toString()); + let reader = new FileReader(); + // Read file content on file loaded event + reader.onload = function (event) { + resolve(event.target.result.toString()); + }; + // Convert data to base64 + reader.readAsDataURL(file); + }); + } + + public async getFileMimeType(fileUri: URI): Promise<string> { + let response = await fetch(fileUri.toString()); + let blob = await response.blob(); + return blob.type; + } } diff --git a/src/sql/workbench/contrib/notebook/test/calloutDialog/imageCalloutDialog.test.ts b/src/sql/workbench/contrib/notebook/test/calloutDialog/imageCalloutDialog.test.ts new file mode 100644 index 0000000000..e5d0f4c796 --- /dev/null +++ b/src/sql/workbench/contrib/notebook/test/calloutDialog/imageCalloutDialog.test.ts @@ -0,0 +1,147 @@ +import * as assert from 'assert'; + +import { TestFileDialogService, TestLayoutService, TestPathService } from 'vs/workbench/test/browser/workbenchTestServices'; +import { ILayoutService } from 'vs/platform/layout/browser/layoutService'; +import { IThemeService } from 'vs/platform/theme/common/themeService'; +import { TestThemeService } from 'vs/platform/theme/test/common/testThemeService'; +import { NullAdsTelemetryService } from 'sql/platform/telemetry/common/adsTelemetryService'; +import { IAdsTelemetryService } from 'sql/platform/telemetry/common/telemetry'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { MockContextKeyService } from 'vs/platform/keybinding/test/common/mockKeybindingService'; +import { Deferred } from 'sql/base/common/promise'; +import { IDialogProperties } from 'sql/workbench/browser/modal/modal'; +import { IImageCalloutDialogOptions, ImageCalloutDialog } from 'sql/workbench/contrib/notebook/browser/calloutDialog/imageCalloutDialog'; +import { IPathService } from 'vs/workbench/services/path/common/pathService'; +import { IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; +import * as path from 'vs/base/common/path'; + +suite('Image Callout Dialog', function (): void { + let pathService: IPathService; + let fileDialogService: IFileDialogService; + let layoutService: ILayoutService; + let themeService: IThemeService; + let telemetryService: IAdsTelemetryService; + let contextKeyService: IContextKeyService; + + const defaultDialogProperties: IDialogProperties = { xPos: 0, yPos: 0, height: 250, width: 100 }; + + setup(() => { + pathService = new TestPathService(); + fileDialogService = new TestFileDialogService(pathService); + layoutService = new TestLayoutService(); + themeService = new TestThemeService(); + telemetryService = new NullAdsTelemetryService(); + contextKeyService = new MockContextKeyService(); + }); + + test('Should return empty markdown on cancel', async function (): Promise<void> { + let imageCalloutDialog = new ImageCalloutDialog('Title', 'below', defaultDialogProperties, pathService, fileDialogService, + undefined, themeService, layoutService, telemetryService, contextKeyService, undefined, undefined, undefined); + imageCalloutDialog.render(); + + let deferred = new Deferred<IImageCalloutDialogOptions>(); + // When I first open the callout dialog + imageCalloutDialog.open().then(value => { + deferred.resolve(value); + }); + // And cancel the dialog + imageCalloutDialog.cancel(); + let result = await deferred.promise; + + assert.equal(result.imagePath, undefined, 'ImagePath must be undefined'); + assert.equal(result.embedImage, undefined, 'EmbedImage must be undefined'); + assert.equal(result.insertEscapedMarkdown, '', 'Markdown not returned correctly'); + }); + + test('Should return expected values on insert', async function (): Promise<void> { + const sampleImageFileUrl = await pathService.fileURI('../resources/extension.png'); + let imageCalloutDialog = new ImageCalloutDialog('Title', 'below', defaultDialogProperties, pathService, fileDialogService, + undefined, themeService, layoutService, telemetryService, contextKeyService, undefined, undefined, undefined); + imageCalloutDialog.render(); + + let deferred = new Deferred<IImageCalloutDialogOptions>(); + // When I first open the callout dialog + imageCalloutDialog.open().then(value => { + deferred.resolve(value); + }); + + imageCalloutDialog.imagePath = sampleImageFileUrl.fsPath; + // And insert the dialog + imageCalloutDialog.insert(); + let result = await deferred.promise; + assert.equal(result.imagePath, sampleImageFileUrl.fsPath, 'ImagePath not returned correctly'); + assert.equal(result.embedImage, false, 'EmbedImage not returned correctly'); + assert.equal(result.insertEscapedMarkdown, `![](${result.imagePath})`, 'Markdown not returned correctly'); + }); + + test('Should return expected values on insert when imageName has space', async function (): Promise<void> { + const sampleImageFileUrl = await pathService.fileURI('../resources/extension 2.png'); + let imageCalloutDialog = new ImageCalloutDialog('Title', 'below', defaultDialogProperties, pathService, fileDialogService, + undefined, themeService, layoutService, telemetryService, contextKeyService, undefined, undefined, undefined); + imageCalloutDialog.render(); + + let deferred = new Deferred<IImageCalloutDialogOptions>(); + // When I first open the callout dialog + imageCalloutDialog.open().then(value => { + deferred.resolve(value); + }); + + imageCalloutDialog.imagePath = sampleImageFileUrl.fsPath; + + // And insert the dialog + imageCalloutDialog.insert(); + let result = await deferred.promise; + assert.equal(result.imagePath, sampleImageFileUrl.fsPath, 'imagePath not returned correctly'); + assert.equal(result.embedImage, false, 'embedImage not returned correctly'); + assert.equal(result.insertEscapedMarkdown, `![](${result.imagePath.replace(' ', ' ')})`, 'Markdown not returned correctly'); + }); + + test('Should return expected values on insert when add as attachment is set', async function (): Promise<void> { + const sampleImageFileUrl = await pathService.fileURI('../resources/extension.png'); + let imageCalloutDialog = new ImageCalloutDialog('Title', 'below', defaultDialogProperties, pathService, fileDialogService, + undefined, themeService, layoutService, telemetryService, contextKeyService, undefined, undefined, undefined); + imageCalloutDialog.render(); + + let deferred = new Deferred<IImageCalloutDialogOptions>(); + // When I first open the callout dialog + imageCalloutDialog.open().then(value => { + deferred.resolve(value); + }); + + imageCalloutDialog.imagePath = sampleImageFileUrl.fsPath; + imageCalloutDialog.embedImage = true; + let imageName = path.basename(sampleImageFileUrl.fsPath); + + // And insert the dialog + imageCalloutDialog.insert(); + let result = await deferred.promise; + assert.equal(result.imagePath, sampleImageFileUrl.fsPath, 'imagePath not returned correctly'); + assert.equal(result.embedImage, true, 'embedImage not returned correctly'); + assert.equal(result.insertEscapedMarkdown, `![${imageName}](attachment:${imageName})`, 'Markdown not returned correctly'); + }); + + test('Should return expected values on insert when imageName has space and add attachment is set', async function (): Promise<void> { + const sampleImageFileUrl = await pathService.fileURI('../resources/extension 2.png'); + let imageCalloutDialog = new ImageCalloutDialog('Title', 'below', defaultDialogProperties, pathService, fileDialogService, + undefined, themeService, layoutService, telemetryService, contextKeyService, undefined, undefined, undefined); + imageCalloutDialog.render(); + + let deferred = new Deferred<IImageCalloutDialogOptions>(); + // When I first open the callout dialog + imageCalloutDialog.open().then(value => { + deferred.resolve(value); + }); + + imageCalloutDialog.imagePath = sampleImageFileUrl.fsPath; + imageCalloutDialog.embedImage = true; + let imageName = path.basename(sampleImageFileUrl.fsPath); + + // And insert the dialog + imageCalloutDialog.insert(); + let result = await deferred.promise; + assert.equal(result.imagePath, sampleImageFileUrl.fsPath, 'imagePath not returned correctly'); + assert.equal(result.embedImage, true, 'embedImage not returned correctly'); + assert.equal(result.insertEscapedMarkdown, `![${imageName}](attachment:${imageName.replace(' ', '')})`, 'Markdown not returned correctly'); + }); + +}); diff --git a/src/sql/workbench/contrib/notebook/test/resources/extension 2.png b/src/sql/workbench/contrib/notebook/test/resources/extension 2.png new file mode 100644 index 0000000000000000000000000000000000000000..c86d6d1e009f12f1ccb246db45303707052175e1 GIT binary patch literal 3338 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 literal 0 HcmV?d00001 diff --git a/src/sql/workbench/contrib/notebook/test/resources/extension.png b/src/sql/workbench/contrib/notebook/test/resources/extension.png new file mode 100644 index 0000000000000000000000000000000000000000..c86d6d1e009f12f1ccb246db45303707052175e1 GIT binary patch literal 3338 zcmai%cQhRSvcSK)7OO>HqC`oEUQ$F^K_WpybkV}Hf~=?^HqmwwZV*IIh~7o7(N`Cu zMz^e<q7ydJUViVdcmH_z&YYRgnK@_9%$zg-%xk>|8Vs~tv;Y7GElqWUzr_7xaH_w) z{iaRdUr^cJ*H8y%|7dzsUMv9gEn4boM%d|%WI9j7U%cICnsg7=JTU`uUW-Z8%C{dX zUx-@MUQ5}lR+!{O?``K#Cxm5W<P`7AQd5?TF)HBBJ<_Kw$&nmH+@qt6x#pK0Ri!oR z9b2XIewHnSzliP0LhbxnZYSbSOiWzz{-M&Ek7oOqvojyx&9l?9!&7Or-Tw+64P;6h z8jdu&qxjMm-Q+J21_#qm1K)>+@nlXy7*1T~TFVEw=Xd3>7L7WeKdOIhYC8FY?v7Nx zrk9UZV>(^&RNT|A7NIU=q_i@bSDk5$@?Af?ZN!725(_?3kKe(2d3g~-DE*n^1E*hO z2+;kb^wx(({+rxwWfax)DrW@yO5#2C{h{stQ(0j*DKWqFNm6$`&i&d_OIoJ=-hCQl z@=W+Cldz-E=hoQOwL51I`1M|XwK%Sj%?*!a{iuZvDQ&f@dLd5uFyJC1e@4f(0ON5> z4cusZLarU6*rIblNs1YNd&M4UWh$KV>e7O^rdwTy%H9_FYzIv)KfZBX+sH77a2@66 z;W*kBWGYSyT1g6T=<Z+lCB3x55z_dyM%G2FmZ1xmo-&_aMrbd{QwMlk;#8DSMjc0s ze!I=1!_=E}q3t(4n6Og2veGS#%a5nD>C5adQI>k9;);_T{c{JnI&b+DzRSM@?-J%w zh-+IzaT6TzFA7)1!*OQMcb#|<R!dqe{R8Oe4J5;TH|%LqFrB}U`}_-fB!aGMSpbgt zDt+?%Bj4a<rA?`+b!^SHlVl5GaDOS&+TXJdh;nkbSYq7@zAg1xr`~U|Xvo^T=N;Ig zBp!Xy7h)56iWC2{!iF#3>`3C>407vm-4x|7ufVU&vQrchdU;yxBjekw3mqdqthMUE z_v_5-9{p(dmr*SAx}4rV5Syj=EV0n4jfaOola<UAXB!!m?R$_{=d)_K6Dlq4MC74y zv#m3V>Fmz4zmXASUB}wtkwUM(5E?!o?D33E-fywvHJ_&YCypPRESJB!r-u2zw(3g3 zmZ_(RD}bSLBX0`RZ{M;qtusY324jz4;zHE8oYCevA|StE7W?D2lT+64yK<7}rx3NO z)r-GtQ84L=HzFCyXbnE(yeC&`iNZPExJgpEAS*J(g3++DaG}|GB{MfWv8^dNTpF|6 zeNX>(%ThP}1(oX!=1+raJ7<=i_ikdX=l1=CZW=Nn>+aVzRDE~ug}6lt`tT}F{Onae z{neHz)81}(Qt%VnIJ7H-G+qhTptB%xyo;B4vLrz6E%eJOY#3*6i<8U|<mZWW_^F%W zn)<~VyUr~`ec`6MqK|8Tq}PZARX_2-J+<b}#`ku{>b+;C+q};LzmNN43+uTr3>0w; z4k?W<N4+d3j(u?2yTkXXbk<?>Xk{@5-QPJdsO)cARr^uv%QHzW-~#XaRz6J`o~O36 z;F{(}YfCxtNX<Xw>>g1xm53}bkR-=@cE3Ma6d_0p_y&_4e0jQ(q6U${%t*PKY2J++ zAr1$c2*7qMVV_vsCt#{kueIoA!O<^%t?lFqrbY5Nr{1-a%S!BHXi)K^i*ra@j+6r* z^r2_Pr!;&v1hsccp%O(zZ&WGtKdiI}NfV%!fJ%&1wUv4s5tJuLiYf*=f70Qpvt|s# zLdAMGS@^?-(SrLjI*~-Dl~2Fuz+@wRoxA_@D05e-o$&+F(`AX$v2#0*g|V`_jEpYB zt-2HJmV36(`0Au*^KJ~)&L)%;Ns;wLf7yRw*n9TU)KQqs4l>)N9SBv`8eZd4g!OQp z1mrL#mSL_`4u9-O(ce?_>hcx$-wC;az4ErRuO~=FsgpL`jl~7Y#<nw!jB^u;AI^uB zyAm|GHJzVpbkED>i%*G9C3SC*YQZ)T9+;nMP#jG6K-Jv!x$KfWc8=ZIM1Euha>P9& z(2fczzAmg;R87x%7J0^}62jLP&>o;PeL5|xwPjNxtiG<;XGMfSAi(E3HCBf8qW7WH zj9FHVqVH8fa^<$7t5r>riL!vlmKmakd#{=mdJZ5$)Lw0cxHJ1mA#2TZq~*-XJQ)rj z5xjh9hxrV=LM;}Lo}%^+A>lop$Ln|;wJQA;+fvT$U%Mv`R#TSxB?Vg|?69?hR%zeE z-o-xyF+;kA)e{=LitScPK*B>M!VF9vBeek)-#f-7Yw2$R9>xVbS?ymwuRd1_2wK$! zY$tuf5sya~OjcN(s(vkHMebkyEr2TJgVm%1I8FfktCyr@rY9mSV(LzwMQ<fsml|}| z6Myr8%a?Y1xpyzbDg7#<XbAh&aV&dTu7K~6@e~_d@g`dT9!g&WFNuU|`*Xs<4G3yi z@L?Nv5et=lyOcF{a@dkn9qdvtS|a6Wyz*Ufj4HM%OgHoH3d;!5FXk}khLYHxr^&Cv zAm_p%XNKt`L$8e`bl;Jgr)b$X7F2kcJ_be0&IGJrs@nNUmT$y|kqfeqTM<tB>@x0J zkGHbf>r3XJ%5XXOPaCnP4qGOqIEh(V*y-myQH(pk!dn>OQIrpDDo%3Rb}hN~#`sfx z{-gNDK}(+IrsAjeFYk}Va&y1&Zh2g$tNrz`0?S)Q#-QAdTW!Vl*(v-I8;<!wi6pR9 z!`fi+i)DJY1U=WU=@`7^a29<lOWhB>yQtT{M6!NXk{^Be$+=_N^wT>Ng9H2k4xs-P zxN-{B9x-Kzj0q&#q4r-<q3lySuvSwFR2ezQLroP#(17Ks;`w@g^|wS#08<3JDdStS zlG#0&O909aUOY&)u;^klV}PlqLr5+=p?^!T95|Zcd(lFUstKmhifjX;^F1+r`sUFm zSx`GylZ-;O16?WJo`msqj9QJhiaHh9s)BG4ASF-lz-uO0!5j=V9Xe(;HP*o2@WmP2 zH9syDkYNF*v^Zq<a{Q?}91hN)s?F`oW8O4u@4R5R7C_oqWDLTAXgRRUYoFF^oZWMc z2^dh4>MvdNH6QtI2yQBlK$j3KAchwb82i(YO6`3Gdmx*}6Ik>g9)9_$ql<qN41oyI zC+A4gMSvNq6Uq@@nrVz{AyUI16gFVsBSxmjj@OCQ+Y?H7f>sFg?daSqYI@qTM4EjE zK(55SRV9fvhon-%pg<7z`em(vrr|pQM|V+W2Kq~YjJD%|ZMd+tL>y4S1c4MBBTc9F zo-3Ts;(<~i^gL)TxFnyk%FUErAj!m*m!phFLqKzbh`d@TlW7QBOW<?LBQBiA{MfDf zdWMnkPudh$8nPEE==GJd6X^@lNvUUbz2aMxFzJGj-{*Ch*sQg-D2Iw{U;v^Dn85!9 zmJgS_WX!btTp>6cBe48|)<}qUemEeH>(<<uT9425d~;ui7874r6}xI1RWGSx*~}km z2?pL~z^;#*fBEEY;<%iz<nf35UHAj4>D80%(~f2@DV3qLWY*7c5YWh6#ow)zd@Ij+ zg!SLr{`(OBIeG{s;o~1i*SGZM9NQc9F;(CCfv*v`qJ|-)_e-&Rn2~ngyh)@a?J$-R z+YSUOEHm*nR5Pn5mg|*cE7X7XR59!jRUatL|8x2LI7m{ZWIW&$0;}1jas0qyjRj=E zTG#g#Mkr7w1Bc8wX*fuuaC`sC&x18K`^!{K9N3lxdo@kHmz;D(gNAwypsf#qZ!Y2O zb7X8)Q^g7bmL3DP6~T20&!5iy(h|rulLL=c0cfie;IXZ|>fdwx*}$OWHb}4qYj7Mh z1@2RyzhQsQyt(5z8;ryRA&G(pqog8gQxs<e+D2iN87BrMg#n|o?WR%FR&(+uhv{Uh zA6!)h;>L{g?^nC9x@0D2FX1sy<aDEj4qXd@INH<g8k%<C8zz%#dXA}gBuoib;`v?K zDU2vEfJQ0`t0c8$hF!3;o3{<4xN`7v;VBGt)xz{Ejz(##8>DCia3|e**M$(Xwtf3~ zrl2*|JYp9SOrosWc4UD@cV-&BNt(S$19dxhlCKd^F;r@otEbW}819k_{bL`O)PqRW z?o`+9&h9Sje9UbYA;&?dk7t@JlUefxtq&(d7-mK)$c4ytv~qY+gnhVpUPf+3TnLR3 zr>L1hr}I<yF*`(_4=nuP8ioqtj_|q7*Ol<~PKCPBEh39VeV~wB-RXmmQLO<EHamIf pg@EuhJ~bW0jNrw8gihi}<@+?f$o^n(?ce7HXx(|BUUCZ+_&;3T2k8I+ literal 0 HcmV?d00001 diff --git a/src/sql/workbench/services/notebook/browser/models/cell.ts b/src/sql/workbench/services/notebook/browser/models/cell.ts index 9c74c30c18..a78040814e 100644 --- a/src/sql/workbench/services/notebook/browser/models/cell.ts +++ b/src/sql/workbench/services/notebook/browser/models/cell.ts @@ -34,7 +34,7 @@ import { IInsightOptions } from 'sql/workbench/common/editor/query/chartState'; let modelId = 0; const ads_execute_command = 'ads_execute_command'; - +const validBase64OctetStreamRegex = /^data:application\/octet-stream;base64,(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=|[A-Za-z0-9+\/]{4})/; export interface QueryResultId { batchId: number; id: number; @@ -149,6 +149,26 @@ export class CellModel extends Disposable implements ICellModel { return this._attachments; } + addAttachment(mimeType: string, base64Encoding: string, name: string): void { + // base64Encoded value looks like: data:application/octet-stream;base64,<base64Value> + // get the <base64Value> from the string + let index = base64Encoding.indexOf('base64,'); + if (this.isValidBase64OctetStream(base64Encoding)) { + base64Encoding = base64Encoding.substring(index + 7); + let attachment: nb.ICellAttachment = {}; + attachment[mimeType] = base64Encoding; + if (!this._attachments) { + this._attachments = {}; + } + // TO DO: Check if name already exists and message the user? + this._attachments[name] = attachment; + } + } + + private isValidBase64OctetStream(base64Image: string): boolean { + return base64Image && validBase64OctetStreamRegex.test(base64Image); + } + public get isEditMode(): boolean { return this._isEditMode; } diff --git a/src/sql/workbench/services/notebook/browser/models/modelInterfaces.ts b/src/sql/workbench/services/notebook/browser/models/modelInterfaces.ts index 48a1d36d4a..55aeaec619 100644 --- a/src/sql/workbench/services/notebook/browser/models/modelInterfaces.ts +++ b/src/sql/workbench/services/notebook/browser/models/modelInterfaces.ts @@ -535,6 +535,7 @@ export interface ICellModel { readonly savedConnectionName: string | undefined; readonly attachments: nb.ICellAttachments; readonly currentMode: CellEditModes; + addAttachment(mimeType: string, base64Encoding: string, name: string): void; } export interface IModelFactory {