diff --git a/extensions/notebook/package.json b/extensions/notebook/package.json index 43a900f75b..9e348aa69a 100644 --- a/extensions/notebook/package.json +++ b/extensions/notebook/package.json @@ -49,6 +49,11 @@ "items": { "type": "string" } + }, + "notebook.remoteBookDownloadTimeout": { + "type": "number", + "default": 60000, + "description": "%notebook.remoteBookDownloadTimeout.description%" } } }, @@ -228,6 +233,15 @@ "dark": "resources/dark/open_notebook_inverse.svg", "light": "resources/light/open_notebook.svg" } + }, + { + "command": "notebook.command.openRemoteBook", + "title": "%title.openRemoteJupyterBook%", + "category": "%books-preview-category%", + "icon": { + "dark": "resources/dark/open_notebook_inverse.svg", + "light": "resources/light/open_notebook.svg" + } } ], "languages": [ @@ -421,6 +435,9 @@ "command": "notebook.command.openNotebookFolder", "when": "view == bookTreeView", "group": "navigation" + }, { + "command": "notebook.command.openRemoteBook", + "when": "view == bookTreeView" } ], "notebook/toolbar": [ diff --git a/extensions/notebook/package.nls.json b/extensions/notebook/package.nls.json index ecc0747485..c3ef7cce3f 100644 --- a/extensions/notebook/package.nls.json +++ b/extensions/notebook/package.nls.json @@ -8,6 +8,7 @@ "notebook.maxTableRows.description": "Maximum number of rows returned per table in the Notebook editor", "notebook.trustedBooks.description": "Notebooks contained in these books will automatically be trusted.", "notebook.maxBookSearchDepth.description": "Maximum depth of subdirectories to search for Books (Enter 0 for infinite)", + "notebook.remoteBookDownloadTimeout.description": "Download timeout in milliseconds for GitHub books", "notebook.command.new": "New Notebook", "notebook.command.open": "Open Notebook", "notebook.analyzeJupyterNotebook": "Analyze in Notebook", @@ -41,5 +42,6 @@ "title.closeJupyterNotebook": "Close Jupyter Notebook", "title.revealInBooksViewlet": "Reveal in Books", "title.createJupyterBook": "Create Book (Preview)", - "title.openNotebookFolder": "Open Notebooks in Folder" + "title.openNotebookFolder": "Open Notebooks in Folder", + "title.openRemoteJupyterBook": "Add Remote Jupyter Book" } diff --git a/extensions/notebook/src/book/githubRemoteBook.ts b/extensions/notebook/src/book/githubRemoteBook.ts new file mode 100644 index 0000000000..1463079154 --- /dev/null +++ b/extensions/notebook/src/book/githubRemoteBook.ts @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as request from 'request'; +import * as fs from 'fs-extra'; +import * as loc from '../common/localizedConstants'; +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as zip from 'adm-zip'; +import * as tar from 'tar'; +import * as utils from '../common/utils'; +import { RemoteBook } from './remoteBook'; +import { IAsset } from './remoteBookController'; +import * as constants from '../common/constants'; + +export class GitHubRemoteBook extends RemoteBook { + constructor(public remotePath: URL, public outputChannel: vscode.OutputChannel, protected _asset: IAsset) { + super(remotePath, outputChannel, _asset); + } + + public async createLocalCopy(): Promise { + this.outputChannel.show(true); + this.setLocalPath(); + this.outputChannel.appendLine(loc.msgDownloadLocation(this._localPath.href)); + this.outputChannel.appendLine(loc.msgRemoteBookDownloadProgress); + this.createDirectory(); + let notebookConfig = vscode.workspace.getConfiguration(constants.notebookConfigKey); + let downloadTimeout = notebookConfig[constants.remoteBookDownloadTimeout]; + + return new Promise((resolve, reject) => { + let options = { + headers: { + 'User-Agent': 'request', + 'timeout': downloadTimeout + } + }; + let downloadRequest = request.get(this._asset.browserDownloadUrl.href, options) + .on('error', (error) => { + this.outputChannel.appendLine(loc.msgRemoteBookDownloadError); + this.outputChannel.appendLine(error.message); + reject(error); + }) + .on('response', (response) => { + if (response.statusCode !== 200) { + this.outputChannel.appendLine(loc.msgRemoteBookDownloadError); + return reject(new Error(loc.httpRequestError(response.statusCode, response.statusMessage))); + } + }); + let remoteBookFullPath = new URL(this._localPath.href.concat('.zip')); + downloadRequest.pipe(fs.createWriteStream(remoteBookFullPath.href)) + .on('close', async () => { + resolve(this.extractFiles(remoteBookFullPath)); + }) + .on('error', (error) => { + this.outputChannel.appendLine(loc.msgRemoteBookDownloadError); + this.outputChannel.appendLine(error.message); + reject(error); + downloadRequest.abort(); + }); + }); + } + public async createDirectory(): Promise { + let fileName = this._asset.book.concat('-').concat(this._asset.version).concat('-').concat(this._asset.language); + this._localPath = new URL(path.join(this._localPath.href, fileName)); + try { + let exists = await fs.pathExists(this._localPath.href); + if (exists) { + await fs.remove(this._localPath.href); + } + await fs.promises.mkdir(this._localPath.href); + } catch (error) { + this.outputChannel.appendLine(loc.msgRemoteBookDirectoryError); + this.outputChannel.appendLine(error.message); + } + } + public async extractFiles(remoteBookFullPath: URL): Promise { + try { + if (utils.getOSPlatform() === utils.Platform.Windows || utils.getOSPlatform() === utils.Platform.Mac) { + let zippedFile = new zip(remoteBookFullPath.href); + zippedFile.extractAllTo(this._localPath.href); + } else { + tar.extract({ file: remoteBookFullPath.href, cwd: this._localPath.href }).catch(error => { + this.outputChannel.appendLine(loc.msgRemoteBookUnpackingError); + this.outputChannel.appendLine(error.message); + }); + } + await fs.promises.unlink(remoteBookFullPath.href); + this.outputChannel.appendLine(loc.msgRemoteBookDownloadComplete); + vscode.commands.executeCommand('notebook.command.openNotebookFolder', this._localPath.href, undefined, true); + } + catch (err) { + this.outputChannel.appendLine(err.message); + } + } +} diff --git a/extensions/notebook/src/book/remoteBook.ts b/extensions/notebook/src/book/remoteBook.ts new file mode 100644 index 0000000000..1bea796282 --- /dev/null +++ b/extensions/notebook/src/book/remoteBook.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as utils from '../common/utils'; +import { IAsset } from './remoteBookController'; + +export abstract class RemoteBook { + protected _localPath: URL; + + constructor(public remotePath: URL, public outputChannel: vscode.OutputChannel, protected _asset?: IAsset) { + this.remotePath = remotePath; + } + + public async abstract createLocalCopy(): Promise; + + public setLocalPath(): void { + // Save directory on User directory + if (vscode.workspace.workspaceFolders !== undefined) { + // Get workspace root path + let folders = vscode.workspace.workspaceFolders; + this._localPath = new URL(folders[0].uri.fsPath); + } else { + //If no workspace folder is opened then path is Users directory + this._localPath = new URL(utils.getUserHome()); + } + } +} diff --git a/extensions/notebook/src/book/remoteBookController.ts b/extensions/notebook/src/book/remoteBookController.ts new file mode 100644 index 0000000000..63340a47eb --- /dev/null +++ b/extensions/notebook/src/book/remoteBookController.ts @@ -0,0 +1,142 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as request from 'request'; +import * as loc from '../common/localizedConstants'; +import * as utils from '../common/utils'; +import * as vscode from 'vscode'; +import { RemoteBookDialogModel } from '../dialog/remoteBookDialogModel'; +import { GitHubRemoteBook } from '../book/githubRemoteBook'; +import { SharedRemoteBook } from '../book/sharedRemoteBook'; + +const assetNameRE = /([a-zA-Z0-9]+)(?:-|_)([a-zA-Z0-9.]+)(?:-|_)([a-zA-Z0-9]+).(zip|tar.gz|tgz)/; + +export class RemoteBookController { + constructor(public model: RemoteBookDialogModel, public outputChannel: vscode.OutputChannel) { + } + + public async setRemoteBook(url: URL, remoteLocation: string, asset?: IAsset): Promise { + if (remoteLocation === 'GitHub') { + this.model.remoteBook = new GitHubRemoteBook(url, this.outputChannel, asset); + } else { + this.model.remoteBook = new SharedRemoteBook(url, this.outputChannel); + } + return await this.model.remoteBook.createLocalCopy(); + } + + public async getReleases(url?: URL): Promise { + if (url) { + this.model.releases = []; + let options = { + headers: { + 'User-Agent': 'request' + } + }; + return new Promise((resolve, reject) => { + request.get(url.href, options, (error, response, body) => { + if (error) { + return reject(error); + } + + if (response.statusCode !== 200) { + return reject(new Error(loc.httpRequestError(response.statusCode, response.statusMessage))); + } + + let releases = JSON.parse(body); + let bookReleases: IRelease[] = []; + if (releases?.length > 0) { + let keys = Object.keys(releases); + keys.forEach(key => { + try { + bookReleases.push({ name: releases[key].name, assetsUrl: new URL(releases[key].assets_url) }); + } + catch (error) { + return reject(error); + } + }); + } + if (bookReleases.length > 0) { + this.model.releases = bookReleases; + resolve(bookReleases); + } else { + return reject(new Error(loc.msgReleaseNotFound)); + } + }); + }); + } else { + return this.model.releases; + } + } + + public async getAssets(release?: IRelease): Promise { + if (release) { + let format: string[] = []; + if (utils.getOSPlatform() === utils.Platform.Windows || utils.getOSPlatform() === utils.Platform.Mac) { + format = ['zip']; + } else { + format = ['tar.gz', 'tgz']; + } + let options = { + headers: { + 'User-Agent': 'request' + } + }; + return new Promise((resolve, reject) => { + request.get(release.assetsUrl.href, options, (error, response, body) => { + if (error) { + return reject(error); + } + + if (response.statusCode !== 200) { + return reject(new Error(loc.httpRequestError(response.statusCode, response.statusMessage))); + } + let assets = JSON.parse(body); + let githubAssets: IAsset[] = []; + if (assets) { + let keys = Object.keys(assets); + keys.forEach(key => { + let asset = {} as IAsset; + asset.url = new URL(assets[key].url); + asset.name = assets[key].name; + asset.browserDownloadUrl = new URL(assets[key].browser_download_url); + let groupsRe = asset.name.match(assetNameRE); + if (groupsRe) { + asset.book = groupsRe[1]; + asset.version = groupsRe[2]; + asset.language = groupsRe[3]; + asset.format = groupsRe[4]; + if (format.includes(asset.format)) { + githubAssets.push(asset); + } + } + }); + } + this.model.assets = githubAssets; + if (githubAssets.length > 0) { + resolve(githubAssets); + } + return reject(new Error(loc.msgBookNotFound)); + }); + }); + } else { + return this.model.assets; + } + } +} + +export interface IRelease { + name: string; + assetsUrl: URL; +} + +export interface IAsset { + name: string; + book: string; + version: string; + language: string; + format: string; + url: URL; + browserDownloadUrl: URL; +} diff --git a/extensions/notebook/src/book/sharedRemoteBook.ts b/extensions/notebook/src/book/sharedRemoteBook.ts new file mode 100644 index 0000000000..161ac5850c --- /dev/null +++ b/extensions/notebook/src/book/sharedRemoteBook.ts @@ -0,0 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { RemoteBook } from '../book/remoteBook'; +import * as vscode from 'vscode'; + + +export class SharedRemoteBook extends RemoteBook { + constructor(public remotePath: URL, public outputChannel: vscode.OutputChannel) { + super(remotePath, outputChannel); + } + public async createLocalCopy(): Promise { + throw new Error('Not yet supported'); + } +} diff --git a/extensions/notebook/src/common/appContext.ts b/extensions/notebook/src/common/appContext.ts index a7407d4599..f1017970c2 100644 --- a/extensions/notebook/src/common/appContext.ts +++ b/extensions/notebook/src/common/appContext.ts @@ -6,7 +6,7 @@ import * as vscode from 'vscode'; import { NotebookUtils } from './notebookUtils'; import { BookTreeViewProvider } from '../book/bookTreeView'; -import { NavigationProviders, BOOKS_VIEWID, PROVIDED_BOOKS_VIEWID } from './constants'; +import { NavigationProviders, BOOKS_VIEWID, PROVIDED_BOOKS_VIEWID, extensionOutputChannelName } from './constants'; /** * Global context for the application @@ -16,6 +16,7 @@ export class AppContext { public readonly notebookUtils: NotebookUtils; public readonly bookTreeViewProvider: BookTreeViewProvider; public readonly providedBookTreeViewProvider: BookTreeViewProvider; + public readonly outputChannel: vscode.OutputChannel; constructor(public readonly extensionContext: vscode.ExtensionContext) { this.notebookUtils = new NotebookUtils(); @@ -23,5 +24,6 @@ export class AppContext { let workspaceFolders = vscode.workspace.workspaceFolders?.slice() ?? []; this.bookTreeViewProvider = new BookTreeViewProvider(workspaceFolders, extensionContext, false, BOOKS_VIEWID, NavigationProviders.NotebooksNavigator); this.providedBookTreeViewProvider = new BookTreeViewProvider([], extensionContext, true, PROVIDED_BOOKS_VIEWID, NavigationProviders.ProvidedBooksNavigator); + this.outputChannel = vscode.window.createOutputChannel(extensionOutputChannelName); } } diff --git a/extensions/notebook/src/common/constants.ts b/extensions/notebook/src/common/constants.ts index 6a81479f0f..c1e6fc80ff 100644 --- a/extensions/notebook/src/common/constants.ts +++ b/extensions/notebook/src/common/constants.ts @@ -8,7 +8,7 @@ import * as nls from 'vscode-nls'; const localize = nls.loadMessageBundle(); // CONFIG VALUES /////////////////////////////////////////////////////////// -export const extensionOutputChannel = 'Notebooks'; +export const extensionOutputChannelName = 'Notebooks'; // JUPYTER CONFIG ////////////////////////////////////////////////////////// export const pythonBundleVersion = '0.0.1'; @@ -18,6 +18,7 @@ export const existingPythonConfigKey = 'useExistingPython'; export const notebookConfigKey = 'notebook'; export const trustedBooksConfigKey = 'trustedBooks'; export const maxBookSearchDepth = 'maxBookSearchDepth'; +export const remoteBookDownloadTimeout = 'remoteBookDownloadTimeout'; export const winPlatform = 'win32'; diff --git a/extensions/notebook/src/common/localizedConstants.ts b/extensions/notebook/src/common/localizedConstants.ts index 89ca7def69..e8ff782c4d 100644 --- a/extensions/notebook/src/common/localizedConstants.ts +++ b/extensions/notebook/src/common/localizedConstants.ts @@ -39,3 +39,35 @@ export function openMarkdownError(resource: string, error: string): string { ret export function openUntitledNotebookError(resource: string, error: string): string { return localize('openUntitledNotebookError', "Open untitled notebook {0} as untitled failed: {1}", resource, error); } export function openExternalLinkError(resource: string, error: string): string { return localize('openExternalLinkError', "Open link {0} failed: {1}", resource, error); } export function closeBookError(resource: string, error: string): string { return localize('closeBookError', "Close book {0} failed: {1}", resource, error); } + +// Remote Book dialog constants +export const url = localize('url', "URL"); +export const repoUrl = localize('repoUrl', "Repository URL"); +export const location = localize('location', "Location"); +export const addRemoteBook = localize('addRemoteBook', "Add Remote Book"); +export const onGitHub = localize('onGitHub', "GitHub"); +export const onSharedFile = localize('onsharedFile', "Shared File"); +export const releases = localize('releases', "Releases"); +export const book = localize('book', "Book"); +export const version = localize('version', "Version"); +export const language = localize('language', "Language"); +export const booksNotFound = localize('booksNotFound', "No books are currently available on the provided link"); +export const urlGithubError = localize('urlGithubError', "The url provided is not a Github release url"); +export const search = localize('search', "Search"); +export const add = localize('add', "Add"); +export const close = localize('close', "Close"); +export const invalidTextPlaceholder = localize('invalidTextPlaceholder', "-"); + +// Remote Book Controller constants +export const msgRemoteBookDownloadProgress = localize('msgRemoteBookDownloadProgress', "Remote Book download is in progress"); +export const msgRemoteBookDownloadComplete = localize('msgRemoteBookDownloadComplete', "Remote Book download is complete"); +export const msgRemoteBookDownloadError = localize('msgRemoteBookDownloadError', "Error while downloading remote Book"); +export const msgRemoteBookUnpackingError = localize('msgRemoteBookUnpackingError', "Error while decompressing remote Book"); +export const msgRemoteBookDirectoryError = localize('msgRemoteBookDirectoryError', "Error while creating remote Book directory"); +export const msgTaskName = localize('msgTaskName', "Downloading Remote Book"); +export const msgResourceNotFound = localize('msgResourceNotFound', "Resource not Found"); +export const msgBookNotFound = localize('msgBookNotFound', "Books not Found"); +export const msgReleaseNotFound = localize('msgReleaseNotFound', "Releases not Found"); +export const msgUndefinedAssetError = localize('msgUndefinedAssetError', "The selected book is not valid"); +export function httpRequestError(code: number, message: string): string { return localize('httpRequestError', "Http Request failed with error: {0} {1}", code, message); } +export function msgDownloadLocation(downloadLocation: string): string { return localize('msgDownloadLocation', "Downloading to {0}", downloadLocation); } diff --git a/extensions/notebook/src/common/utils.ts b/extensions/notebook/src/common/utils.ts index 65f668d247..7c2353b283 100644 --- a/extensions/notebook/src/common/utils.ts +++ b/extensions/notebook/src/common/utils.ts @@ -269,6 +269,25 @@ export function debounce(delay: number): Function { }); } +export function generateGuid(): string { + let hexValues: string[] = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F']; + let oct: string = ''; + let tmp: number; + for (let a: number = 0; a < 4; a++) { + tmp = (4294967296 * Math.random()) | 0; + oct += hexValues[tmp & 0xF] + + hexValues[tmp >> 4 & 0xF] + + hexValues[tmp >> 8 & 0xF] + + hexValues[tmp >> 12 & 0xF] + + hexValues[tmp >> 16 & 0xF] + + hexValues[tmp >> 20 & 0xF] + + hexValues[tmp >> 24 & 0xF] + + hexValues[tmp >> 28 & 0xF]; + } + let clockSequenceHi: string = hexValues[8 + (Math.random() * 4) | 0]; + return oct.substr(0, 8) + '-' + oct.substr(9, 4) + '-4' + oct.substr(13, 3) + '-' + clockSequenceHi + oct.substr(16, 3) + '-' + oct.substr(19, 12); +} + // PRIVATE HELPERS ///////////////////////////////////////////////////////// function outputDataChunk(data: string | Buffer, outputChannel: vscode.OutputChannel, header: string): void { data.toString().split(/\r?\n/) diff --git a/extensions/notebook/src/dialog/remoteBookDialog.ts b/extensions/notebook/src/dialog/remoteBookDialog.ts new file mode 100644 index 0000000000..5109eff2a4 --- /dev/null +++ b/extensions/notebook/src/dialog/remoteBookDialog.ts @@ -0,0 +1,292 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import * as loc from '../common/localizedConstants'; +import { RemoteBookController, IAsset } from '../book/remoteBookController'; +import * as utils from '../common/utils'; + +const tigerToolboxRepo = 'repos/microsoft/tigertoolbox'; +const urlGithubRE = /^(?:https:\/\/(?:github\.com|api\.github\.com\/repos)|(?:\/)?(?:\/)?repos)([\w-.?!=&%*+:@\/]*)/g; + +function apiGitHub(url: string): string { + return `https://api.github.com/${url}/releases`; +} + +function getRemoteLocationCategory(name: string): azdata.CategoryValue { + if (name === loc.onGitHub) { + return { name: name, displayName: loc.onGitHub }; + } + return { name: name, displayName: loc.onSharedFile }; +} + +export class RemoteBookDialog { + + private dialog: azdata.window.Dialog; + public view: azdata.ModelView; + private formModel: azdata.FormContainer; + private githubRepoDropdown: azdata.DropDownComponent; + private remoteLocationDropdown: azdata.DropDownComponent; + public releaseDropdown: azdata.DropDownComponent; + private searchButton: azdata.ButtonComponent; + public bookDropdown: azdata.DropDownComponent; + public versionDropdown: azdata.DropDownComponent; + public languageDropdown: azdata.DropDownComponent; + private _remoteTypes: azdata.CategoryValue[]; + + constructor(public controller: RemoteBookController) { + } + + public async createDialog(): Promise { + this.dialog = azdata.window.createModelViewDialog(loc.addRemoteBook); + this.dialog.registerContent(async view => { + this.view = view; + + this.remoteLocationDropdown = this.view.modelBuilder.dropDown().withProperties({ + values: this.remoteLocationCategories, + value: '', + editable: false, + }).component(); + + this.remoteLocationDropdown.onValueChanged(e => this.onRemoteLocationChanged()); + + this.githubRepoDropdown = this.view.modelBuilder.dropDown().withProperties({ + values: [tigerToolboxRepo], + value: '', + editable: true, + fireOnTextChange: true, + }).component(); + + this.searchButton = this.view.modelBuilder.button().withProperties({ + label: loc.search, + title: loc.search, + width: '200px' + }).component(); + this.searchButton.onDidClick(async () => await this.validate()); + + this.releaseDropdown = this.view.modelBuilder.dropDown() + .withProperties({ + values: [], + value: '', + enabled: false + }).component(); + + this.releaseDropdown.onValueChanged(async () => await this.getAssets()); + + this.bookDropdown = this.view.modelBuilder.dropDown().withProperties({ + values: [], + value: '', + editable: false, + }).component(); + + this.bookDropdown.onValueChanged(async () => await this.fillVersionDropdown()); + + this.versionDropdown = this.view.modelBuilder.dropDown().withProperties({ + values: [], + value: '', + editable: false, + }).component(); + + this.versionDropdown.onValueChanged(async () => await this.fillLanguageDropdown()); + + this.languageDropdown = this.view.modelBuilder.dropDown().withProperties({ + values: [], + value: '', + editable: false, + }).component(); + + this.languageDropdown.onValueChanged(async () => this.checkValues()); + this.setFieldsToEmpty(); + + this.formModel = this.view.modelBuilder.formContainer() + .withFormItems([{ + components: [ + { + component: this.remoteLocationDropdown, + title: loc.location, + required: true + }, + { + component: this.githubRepoDropdown, + title: loc.repoUrl, + required: true + }, + { + component: this.searchButton, + title: '' + }, + { + component: this.releaseDropdown, + title: loc.releases, + }, + { + component: this.bookDropdown, + title: loc.book, + required: true + }, + { + component: this.versionDropdown, + title: loc.version, + required: true + }, + { + component: this.languageDropdown, + title: loc.language, + required: true + }, + ], + title: '' + }]).withLayout({ width: '100%' }).component(); + await this.view.initializeModel(this.formModel); + }); + this.dialog.okButton.enabled = false; + this.dialog.okButton.label = loc.add; + this.dialog.cancelButton.label = loc.close; + this.dialog.registerCloseValidator(async () => await this.download()); + azdata.window.openDialog(this.dialog); + } + + private async setFieldsToEmpty(): Promise { + await this.bookDropdown.updateProperties({ + values: [loc.invalidTextPlaceholder], + value: loc.invalidTextPlaceholder + }); + await this.versionDropdown.updateProperties({ + values: [loc.invalidTextPlaceholder], + value: loc.invalidTextPlaceholder + }); + await this.languageDropdown.updateProperties({ + values: [loc.invalidTextPlaceholder], + value: loc.invalidTextPlaceholder + }); + this.dialog.okButton.enabled = false; + } + + private get remoteLocationValue(): string { + return (this.remoteLocationDropdown.value).name; + } + + public onRemoteLocationChanged(): void { + if (this.controller.getReleases() !== undefined && this.remoteLocationValue === loc.onGitHub) { + this.releaseDropdown.enabled = true; + } else { + this.releaseDropdown.enabled = false; + } + } + + public async validate(): Promise { + try { + let url = utils.getDropdownValue(this.githubRepoDropdown); + url = url.trim().toLowerCase(); + if (this.remoteLocationValue === loc.onGitHub && url.length > 0) { + //get the first group to extract /owner/repo/releases format + let groupsRe = url.match(urlGithubRE); + if (groupsRe?.length > 0) { + url = apiGitHub(groupsRe[0]); + let releases = await this.controller.getReleases(new URL(url)); + if (releases) { + this.releaseDropdown.enabled = true; + await this.fillReleasesDropdown(); + this.setFieldsToEmpty(); + } + } else { + throw new Error(loc.urlGithubError); + } + } + } + catch (error) { + await this.fillReleasesDropdown(); + this.setFieldsToEmpty(); + this.showErrorMessage(error.message); + } + } + + public async getAssets(): Promise { + try { + if (this.remoteLocationValue === loc.onGitHub) { + let releases = await this.controller.getReleases(); + let selected_release = releases.filter(release => + release.name === this.releaseDropdown.value); + let assets = await this.controller.getAssets(selected_release[0]); + if (assets?.length > 0) { + this.bookDropdown.values = ['-'].concat([...new Set(assets.map(asset => asset.book))]); + } + this.checkValues(); + } + } + catch (error) { + this.setFieldsToEmpty(); + this.showErrorMessage(error.message); + } + } + + public async download(): Promise { + try { + if (this.remoteLocationValue === loc.onGitHub) { + let selected_asset = await this.getSelectedAsset(); + if (!selected_asset) { + throw new Error(loc.msgUndefinedAssetError); + } + await this.controller.setRemoteBook(selected_asset.url, this.remoteLocationValue, selected_asset); + } else { + let url = utils.getDropdownValue(this.githubRepoDropdown); + let newUrl = new URL(url); + await this.controller.setRemoteBook(newUrl, this.remoteLocationValue); + } + return true; + } + catch (error) { + this.showErrorMessage(error.message); + return false; + } + } + + public async fillReleasesDropdown(): Promise { + this.releaseDropdown.values = ['-'].concat((await this.controller.getReleases()).map(release => release.name)); + } + + public async fillVersionDropdown(): Promise { + let filtered_assets = (await this.controller.getAssets()).filter(asset => asset.book === this.bookDropdown.value); + this.versionDropdown.values = ['-'].concat(filtered_assets.map(asset => asset.version)); + this.checkValues(); + } + + public async fillLanguageDropdown(): Promise { + let filtered_assets = (await this.controller.getAssets()).filter(asset => asset.book === this.bookDropdown.value && + asset.version === this.versionDropdown.value); + this.languageDropdown.values = ['-'].concat(filtered_assets.map(asset => asset.language)); + this.checkValues(); + } + + public async getSelectedAsset(): Promise { + let lang = this.languageDropdown.value; + let book = this.bookDropdown.value; + let version = this.versionDropdown.value; + return (await this.controller.getAssets()).filter(asset => asset.book === book && asset.version === version && asset.language === lang)[0]; + } + + public checkValues(): void { + if (this.languageDropdown.value !== loc.invalidTextPlaceholder && this.versionDropdown.value !== loc.invalidTextPlaceholder && + this.bookDropdown.value !== loc.invalidTextPlaceholder) { + this.dialog.okButton.enabled = true; + } else { + this.dialog.okButton.enabled = false; + } + } + + public get remoteLocationCategories(): azdata.CategoryValue[] { + if (!this._remoteTypes) { + this._remoteTypes = [getRemoteLocationCategory(loc.onGitHub)]; + } + return this._remoteTypes; + } + + public showErrorMessage(message: string): void { + this.dialog.message = { + text: message, + level: azdata.window.MessageLevel.Error + }; + } +} diff --git a/extensions/notebook/src/dialog/remoteBookDialogModel.ts b/extensions/notebook/src/dialog/remoteBookDialogModel.ts new file mode 100644 index 0000000000..f56eaef324 --- /dev/null +++ b/extensions/notebook/src/dialog/remoteBookDialogModel.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { RemoteBook } from '../book/remoteBook'; +import { IRelease, IAsset } from '../book/remoteBookController'; + +export class RemoteBookDialogModel { + private _remoteLocation: string; + private _releases: IRelease[]; + private _assets: IAsset[]; + private _book: RemoteBook; + + constructor() { + } + + public get remoteLocation(): string { + return this._remoteLocation; + } + + public set remoteLocation(location: string) { + this._remoteLocation = location; + } + + public get releases(): IRelease[] { + return this._releases; + } + + public set releases(newReleases: IRelease[]) { + this._releases = newReleases; + } + + public get assets(): IAsset[] { + return this._assets; + } + + public set assets(newAssets: IAsset[]) { + this._assets = newAssets; + } + + public get remoteBook(): RemoteBook { + return this._book; + } + + public set remoteBook(newBook: RemoteBook) { + this._book = newBook; + } + +} diff --git a/extensions/notebook/src/extension.ts b/extensions/notebook/src/extension.ts index 4c2241d0fa..ee185daaad 100644 --- a/extensions/notebook/src/extension.ts +++ b/extensions/notebook/src/extension.ts @@ -14,6 +14,9 @@ import { IExtensionApi, IPackageManageProvider } from './types'; import { CellType } from './contracts/content'; import { NotebookUriHandler } from './protocol/notebookUriHandler'; import { BuiltInCommands, unsavedBooksContextKey } from './common/constants'; +import { RemoteBookController } from './book/remoteBookController'; +import { RemoteBookDialog } from './dialog/remoteBookDialog'; +import { RemoteBookDialogModel } from './dialog/remoteBookDialogModel'; const localize = nls.loadMessageBundle(); @@ -36,7 +39,6 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.closeBook', (book: any) => bookTreeViewProvider.closeBook(book))); extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.closeNotebook', (book: any) => bookTreeViewProvider.closeBook(book))); extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.openNotebookFolder', (folderPath?: string, urlToOpen?: string, showPreview?: boolean,) => bookTreeViewProvider.openNotebookFolder(folderPath, urlToOpen, showPreview))); - extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.createBook', async () => { let untitledFileName: vscode.Uri = vscode.Uri.parse(`untitled:${createBookPath}`); await vscode.workspace.openTextDocument(createBookPath).then((document) => { @@ -47,6 +49,15 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi }); }); })); + + let model = new RemoteBookDialogModel(); + let remoteBookController = new RemoteBookController(model, appContext.outputChannel); + + extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.openRemoteBook', async () => { + let dialog = new RemoteBookDialog(remoteBookController); + dialog.createDialog(); + })); + extensionContext.subscriptions.push(vscode.commands.registerCommand('_notebook.command.new', async (context?: azdata.ConnectedContext) => { let connectionProfile: azdata.IConnectionProfile = undefined; if (context && context.connectionProfile) { diff --git a/extensions/notebook/src/jupyter/jupyterController.ts b/extensions/notebook/src/jupyter/jupyterController.ts index 1dd735ef3c..cbbfebb1c0 100644 --- a/extensions/notebook/src/jupyter/jupyterController.ts +++ b/extensions/notebook/src/jupyter/jupyterController.ts @@ -40,13 +40,11 @@ export class JupyterController implements vscode.Disposable { private _serverInstanceFactory: ServerInstanceFactory = new ServerInstanceFactory(); private _packageManageProviders = new Map(); - private outputChannel: vscode.OutputChannel; private prompter: IPrompter; private _notebookProvider: JupyterNotebookProvider; constructor(private appContext: AppContext) { this.prompter = new CodeAdapter(); - this.outputChannel = vscode.window.createOutputChannel(constants.extensionOutputChannel); } public get extensionContext(): vscode.ExtensionContext { @@ -65,7 +63,7 @@ export class JupyterController implements vscode.Disposable { public async activate(): Promise { this._jupyterInstallation = new JupyterServerInstallation( this.extensionContext.extensionPath, - this.outputChannel); + this.appContext.outputChannel); await this._jupyterInstallation.configurePackagePaths(); IconPathHelper.setExtensionContext(this.extensionContext); diff --git a/extensions/notebook/src/test/book/remoteBookController.test.ts b/extensions/notebook/src/test/book/remoteBookController.test.ts new file mode 100644 index 0000000000..498e88085b --- /dev/null +++ b/extensions/notebook/src/test/book/remoteBookController.test.ts @@ -0,0 +1,121 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { RemoteBookDialogModel } from '../../dialog/remoteBookDialogModel'; +import { IRelease, RemoteBookController } from '../../book/remoteBookController'; +import * as should from 'should'; +import * as request from 'request'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { MockExtensionContext } from '../common/stubs'; +import { AppContext } from '../../common/appContext'; +import * as loc from '../../common/localizedConstants'; + +describe('Remote Book Controller', function () { + let mockExtensionContext: vscode.ExtensionContext = new MockExtensionContext(); + let appContext = new AppContext(mockExtensionContext); + let model = new RemoteBookDialogModel(); + let controller = new RemoteBookController(model, appContext.outputChannel); + let getStub : sinon.SinonStub; + + beforeEach(function (): void { + getStub = sinon.stub(request, 'get'); + }); + + afterEach(function (): void { + sinon.restore(); + }); + + it('Verify that errorMessage is thrown, when fetchReleases call returns empty', async function (): Promise { + let expectedBody = JSON.stringify([]); + let expectedURL = new URL('https://api.github.com/repos/microsoft/test/releases'); + getStub.yields(null, { statusCode: 200 }, expectedBody); + + try { + await controller.getReleases(expectedURL); + } + catch (err) { + should(err.message).be.equals(loc.msgReleaseNotFound); + should(model.releases.length).be.equal(0); + } + }); + + it('Should get the books', async function (): Promise { + let expectedBody = JSON.stringify([ + { + url: 'https://api.github.com/repos/microsoft/test/releases/1/assets/1', + name: 'test-1.1-EN.zip', + browser_download_url: 'https://api.github.com/repos/microsoft/test/releases/download/1/test-1.1-EN.zip', + + }, + { + url: 'https://api.github.com/repos/microsoft/test/releases/1/assets/2', + name: 'test-1.1-ES.zip', + browser_download_url: 'https://api.github.com/repos/microsoft/test/releases/download/2/test-1.1-ES.zip', + }, + { + url: 'https://api.github.com/repos/microsoft/test/releases/1/assets/1', + name: 'test-1.1-EN.tgz', + browser_download_url: 'https://api.github.com/repos/microsoft/test/releases/download/1/test-1.1-EN.tgz', + + }, + { + url: 'https://api.github.com/repos/microsoft/test/releases/1/assets/2', + name: 'test-1.1-ES.tar.gz', + browser_download_url: 'https://api.github.com/repos/microsoft/test/releases/download/2/test-1.1-ES.tar.gz', + }, + { + url: 'https://api.github.com/repos/microsoft/test/releases/1/assets/3', + name: 'test-1.1-FR.tgz', + browser_download_url: 'https://api.github.com/repos/microsoft/test/releases/download/1/test-1.1-FR.tgz', + } + ]); + let expectedURL = new URL('https://api.github.com/repos/microsoft/test/releases/1/assets'); + let expectedRelease: IRelease = { + name: 'Test Release', + assetsUrl: expectedURL + }; + getStub.yields(null, { statusCode: 200 }, expectedBody); + + let result = await controller.getAssets(expectedRelease); + should(result.length).be.above(0, 'Result should contain assets'); + result.forEach(asset => { + should(asset).have.property('name'); + should(asset).have.property('url'); + should(asset).have.property('browserDownloadUrl'); + }); + }); + + it('Should throw an error if the book object does not follow the name-version-lang format', async function (): Promise { + let expectedBody = JSON.stringify([ + { + url: 'https://api.github.com/repos/microsoft/test/releases/1/assets/1', + name: 'test-1.1.zip', + browser_download_url: 'https://api.github.com/repos/microsoft/test/releases/download/1/test-1.1.zip', + + }, + { + url: 'https://api.github.com/repos/microsoft/test/releases/1/assets/2', + name: 'test-1.2.zip', + browser_download_url: 'https://api.github.com/repos/microsoft/test/releases/download/1/test-1.2.zip', + }, + ]); + let expectedURL = new URL('https://api.github.com/repos/microsoft/test/releases/1/assets'); + let expectedRelease: IRelease = { + name: 'Test Release', + assetsUrl: expectedURL + }; + getStub.yields(null, { statusCode: 200 }, expectedBody); + + try { + await controller.getAssets(expectedRelease); + } + catch (err) { + should(err.message).be.equals(loc.msgBookNotFound); + should(model.releases.length).be.equal(0); + } + }); +}); + diff --git a/extensions/notebook/src/test/book/remoteBookDialog.test.ts b/extensions/notebook/src/test/book/remoteBookDialog.test.ts new file mode 100644 index 0000000000..1e44f7bf08 --- /dev/null +++ b/extensions/notebook/src/test/book/remoteBookDialog.test.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { RemoteBookDialog } from '../../dialog/remoteBookDialog'; +import { RemoteBookDialogModel } from '../../dialog/remoteBookDialogModel'; +import { RemoteBookController } from '../../book/remoteBookController'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { MockExtensionContext } from '../common/stubs'; +import { AppContext } from '../../common/appContext'; +import * as azdata from 'azdata'; +import * as should from 'should'; + +describe('Add Remote Book Dialog', function () { + let mockExtensionContext: vscode.ExtensionContext = new MockExtensionContext(); + let appContext = new AppContext(mockExtensionContext); + let model = new RemoteBookDialogModel(); + let controller = new RemoteBookController(model, appContext.outputChannel); + let dialog = new RemoteBookDialog(controller); + + it('Should open dialog successfully ', async function (): Promise { + const spy = sinon.spy(azdata.window, 'openDialog'); + await dialog.createDialog(); + should(spy.calledOnce).be.true(); + }); +}); + diff --git a/extensions/notebook/src/test/common.ts b/extensions/notebook/src/test/common.ts index a9ff4c0e45..85b0fb3b44 100644 --- a/extensions/notebook/src/test/common.ts +++ b/extensions/notebook/src/test/common.ts @@ -334,7 +334,7 @@ class TestComponentBase implements azdata.Component { } } -class TestDropdownComponent extends TestComponentBase implements azdata.DropDownComponent { +export class TestDropdownComponent extends TestComponentBase implements azdata.DropDownComponent { constructor(private onClick: vscode.EventEmitter) { super(); }