From 9ac180d772b9e20050d5d030c4886b6c86cf48fd Mon Sep 17 00:00:00 2001 From: Barbara Valdez <34872381+barbaravaldez@users.noreply.github.com> Date: Tue, 2 Feb 2021 20:39:11 -0800 Subject: [PATCH] Editing Books (#13535) * start work on ui * Move notebooks complete * Simplify version handling * fix issues with add section method * fix issues with the edit experience and add the quick pick for editing * add version handler for re-writing tocs * fix book toc manager tests * add notebook test * fix localize constant * normalize paths on windows * check that a section has sections before attempting to mve files * Implement method for renaming duplicated files * moving last notebook from section converts the section into notebook * Add recovery method restore original state of file system * Add clean up, for files that are copied instead of renamed * remove dir complexity * divide edit book in methods for easier testing and remove promise chain * Keep structure of toc * normalize paths on windows * fix external link * Add other fields for versions * fix paths in uri of findSection * add section to section test * Add error messages * address pr comments and add tests * check that the new path of a notebook is different from the original path before deleting --- extensions/notebook/package.json | 12 + extensions/notebook/package.nls.json | 3 +- extensions/notebook/src/book/bookModel.ts | 156 ++--- .../notebook/src/book/bookTocManager.ts | 395 +++++++++--- extensions/notebook/src/book/bookTreeItem.ts | 53 +- extensions/notebook/src/book/bookTreeView.ts | 148 +++-- .../notebook/src/book/bookTrustManager.ts | 3 +- .../notebook/src/book/bookVersionHandler.ts | 128 ++++ .../notebook/src/common/localizedConstants.ts | 8 +- extensions/notebook/src/contracts/content.ts | 27 +- extensions/notebook/src/extension.ts | 5 + .../notebook/src/test/book/book.test.ts | 356 ++++++----- .../src/test/book/bookTocManager.test.ts | 597 ++++++++++++------ 13 files changed, 1296 insertions(+), 595 deletions(-) create mode 100644 extensions/notebook/src/book/bookVersionHandler.ts diff --git a/extensions/notebook/package.json b/extensions/notebook/package.json index fd690e662a..8c64429d47 100644 --- a/extensions/notebook/package.json +++ b/extensions/notebook/package.json @@ -239,6 +239,10 @@ "command": "notebook.command.closeNotebook", "title": "%title.closeJupyterNotebook%" }, + { + "command": "notebook.command.moveTo", + "title": "%title.moveTo%" + }, { "command": "notebook.command.createBook", "title": "%title.createJupyterBook%", @@ -385,6 +389,10 @@ "command": "notebook.command.closeNotebook", "when": "false" }, + { + "command": "notebook.command.moveTo", + "when": "false" + }, { "command": "notebook.command.revealInBooksViewlet", "when": "false" @@ -457,6 +465,10 @@ "command": "notebook.command.closeNotebook", "when": "view == bookTreeView && viewItem == savedNotebook" }, + { + "command": "notebook.command.moveTo", + "when": "view == bookTreeView && viewItem == savedNotebook || view == bookTreeView && viewItem == section" + }, { "command": "notebook.command.pinNotebook", "when": "view == bookTreeView && viewItem == savedNotebook", diff --git a/extensions/notebook/package.nls.json b/extensions/notebook/package.nls.json index 1f998c5727..eab780df70 100644 --- a/extensions/notebook/package.nls.json +++ b/extensions/notebook/package.nls.json @@ -48,5 +48,6 @@ "title.openNotebookFolder": "Open Notebooks in Folder", "title.openRemoteJupyterBook": "Add Remote Jupyter Book", "title.pinNotebook": "Pin Notebook", - "title.unpinNotebook": "Unpin Notebook" + "title.unpinNotebook": "Unpin Notebook", + "title.moveTo": "Move to ..." } diff --git a/extensions/notebook/src/book/bookModel.ts b/extensions/notebook/src/book/bookModel.ts index bb2de38192..c148645f59 100644 --- a/extensions/notebook/src/book/bookModel.ts +++ b/extensions/notebook/src/book/bookModel.ts @@ -11,16 +11,12 @@ import * as path from 'path'; import * as fileServices from 'fs'; import * as fs from 'fs-extra'; import * as loc from '../common/localizedConstants'; -import { IJupyterBookToc, JupyterBookSection, IJupyterBookSectionV2, IJupyterBookSectionV1 } from '../contracts/content'; +import { IJupyterBookToc, JupyterBookSection } from '../contracts/content'; +import { convertFrom, getContentPath, BookVersion } from './bookVersionHandler'; const fsPromises = fileServices.promises; const content = 'content'; -export enum BookVersion { - v1 = 'v1', - v2 = 'v2' -} - export class BookModel { private _bookItems: BookTreeItem[]; private _allNotebooks = new Map(); @@ -124,7 +120,7 @@ export class BookModel { if (this.openAsUntitled && !this._allNotebooks.get(pathDetails.base)) { this._allNotebooks.set(pathDetails.base, notebookItem); } else { - // convert to URI to avoid casing issue with drive letters when getting navigation links + // convert to URI to avoid causing issue with drive letters when getting navigation links let uriToNotebook: vscode.Uri = vscode.Uri.file(this.bookPath); if (!this._allNotebooks.get(uriToNotebook.fsPath)) { this._allNotebooks.set(uriToNotebook.fsPath, notebookItem); @@ -155,7 +151,7 @@ export class BookModel { title: config.title, contentPath: this._tableOfContentsPath, root: this._rootPath, - tableOfContents: { sections: this.parseJupyterSections(tableOfContents) }, + tableOfContents: { sections: this.parseJupyterSections(this._bookVersion, tableOfContents) }, page: tableOfContents, type: BookTreeItemType.Book, treeItemCollapsibleState: collapsibleState, @@ -169,7 +165,7 @@ export class BookModel { this._bookItems.push(book); } catch (e) { this._errorMessage = loc.readBookError(this.bookPath, e instanceof Error ? e.message : e); - vscode.window.showErrorMessage(this._errorMessage); + throw new Error(this._errorMessage); } } return this._bookItems; @@ -182,95 +178,83 @@ export class BookModel { public async getSections(tableOfContents: IJupyterBookToc, sections: JupyterBookSection[], root: string, book: BookTreeItemFormat): Promise { let notebooks: BookTreeItem[] = []; for (let i = 0; i < sections.length; i++) { - if (sections[i].url || (sections[i] as IJupyterBookSectionV2).file) { - if (sections[i].url && ((sections[i] as IJupyterBookSectionV1).external || book.version === BookVersion.v2)) { - let externalLink: BookTreeItem = new BookTreeItem({ - title: sections[i].title, - contentPath: undefined, + if (sections[i].url) { + let externalLink: BookTreeItem = new BookTreeItem({ + title: sections[i].title, + contentPath: undefined, + root: root, + tableOfContents: tableOfContents, + page: sections[i], + type: BookTreeItemType.ExternalLink, + treeItemCollapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + isUntitled: this.openAsUntitled, + version: book.version + }, + { + light: this._extensionContext.asAbsolutePath('resources/light/link.svg'), + dark: this._extensionContext.asAbsolutePath('resources/dark/link_inverse.svg') + } + ); + + notebooks.push(externalLink); + } else if (sections[i].file) { + const pathToNotebook: string = getContentPath(book.version, book.root, sections[i].file.concat('.ipynb')); + const pathToMarkdown: string = getContentPath(book.version, book.root, sections[i].file.concat('.md')); + + // Note: Currently, if there is an ipynb and a md file with the same name, Jupyter Books only shows the notebook. + // Following Jupyter Books behavior for now + if (await fs.pathExists(pathToNotebook)) { + let notebook = new BookTreeItem({ + title: sections[i].title ? sections[i].title : sections[i].file, + contentPath: pathToNotebook, root: root, tableOfContents: tableOfContents, page: sections[i], - type: BookTreeItemType.ExternalLink, + type: BookTreeItemType.Notebook, treeItemCollapsibleState: vscode.TreeItemCollapsibleState.Collapsed, isUntitled: this.openAsUntitled, version: book.version }, { - light: this._extensionContext.asAbsolutePath('resources/light/link.svg'), - dark: this._extensionContext.asAbsolutePath('resources/dark/link_inverse.svg') + light: this._extensionContext.asAbsolutePath('resources/light/notebook.svg'), + dark: this._extensionContext.asAbsolutePath('resources/dark/notebook_inverse.svg') } ); - notebooks.push(externalLink); - } else { - let pathToNotebook: string; - let pathToMarkdown: string; - if (book.version === BookVersion.v2) { - pathToNotebook = path.join(book.root, (sections[i] as IJupyterBookSectionV2).file.concat('.ipynb')); - pathToMarkdown = path.join(book.root, (sections[i] as IJupyterBookSectionV2).file.concat('.md')); - } else if (sections[i].url) { - pathToNotebook = path.join(book.root, content, (sections[i] as IJupyterBookSectionV1).url.concat('.ipynb')); - pathToMarkdown = path.join(book.root, content, (sections[i] as IJupyterBookSectionV1).url.concat('.md')); - } - - // Note: Currently, if there is an ipynb and a md file with the same name, Jupyter Books only shows the notebook. - // Following Jupyter Books behavior for now - if (await fs.pathExists(pathToNotebook)) { - let notebook = new BookTreeItem({ - title: sections[i].title ? sections[i].title : (sections[i] as IJupyterBookSectionV2).file, - contentPath: pathToNotebook, - root: root, - tableOfContents: tableOfContents, - page: sections[i], - type: BookTreeItemType.Notebook, - treeItemCollapsibleState: vscode.TreeItemCollapsibleState.Collapsed, - isUntitled: this.openAsUntitled, - version: book.version - }, - { - light: this._extensionContext.asAbsolutePath('resources/light/notebook.svg'), - dark: this._extensionContext.asAbsolutePath('resources/dark/notebook_inverse.svg') - } - ); - - if (this.openAsUntitled) { - if (!this._allNotebooks.get(path.basename(pathToNotebook))) { - this._allNotebooks.set(path.basename(pathToNotebook), notebook); - } - notebooks.push(notebook); - } else { - // convert to URI to avoid casing issue with drive letters when getting navigation links - let uriToNotebook: vscode.Uri = vscode.Uri.file(pathToNotebook); - if (!this._allNotebooks.get(uriToNotebook.fsPath)) { - this._allNotebooks.set(uriToNotebook.fsPath, notebook); - } - notebooks.push(notebook); + if (this.openAsUntitled) { + if (!this._allNotebooks.get(path.basename(pathToNotebook))) { + this._allNotebooks.set(path.basename(pathToNotebook), notebook); } - } else if (await fs.pathExists(pathToMarkdown)) { - let markdown: BookTreeItem = new BookTreeItem({ - title: sections[i].title ? sections[i].title : (sections[i] as IJupyterBookSectionV2).file, - contentPath: pathToMarkdown, - root: root, - tableOfContents: tableOfContents, - page: sections[i], - type: BookTreeItemType.Markdown, - treeItemCollapsibleState: vscode.TreeItemCollapsibleState.Collapsed, - isUntitled: this.openAsUntitled, - version: book.version - }, - { - light: this._extensionContext.asAbsolutePath('resources/light/markdown.svg'), - dark: this._extensionContext.asAbsolutePath('resources/dark/markdown_inverse.svg') - } - ); - notebooks.push(markdown); + notebooks.push(notebook); } else { - this._errorMessage = loc.missingFileError(sections[i].title); - vscode.window.showErrorMessage(this._errorMessage); + // convert to URI to avoid causing issue with drive letters when getting navigation links + let uriToNotebook: vscode.Uri = vscode.Uri.file(pathToNotebook); + if (!this._allNotebooks.get(uriToNotebook.fsPath)) { + this._allNotebooks.set(uriToNotebook.fsPath, notebook); + } + notebooks.push(notebook); } + } else if (await fs.pathExists(pathToMarkdown)) { + let markdown: BookTreeItem = new BookTreeItem({ + title: sections[i].title ? sections[i].title : sections[i].file, + contentPath: pathToMarkdown, + root: root, + tableOfContents: tableOfContents, + page: sections[i], + type: BookTreeItemType.Markdown, + treeItemCollapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + isUntitled: this.openAsUntitled, + version: book.version + }, + { + light: this._extensionContext.asAbsolutePath('resources/light/markdown.svg'), + dark: this._extensionContext.asAbsolutePath('resources/dark/markdown_inverse.svg') + } + ); + notebooks.push(markdown); + } else { + this._errorMessage = loc.missingFileError(sections[i].title, root); } - } else { - // TODO: search functionality (#6160) } } return notebooks; @@ -280,16 +264,16 @@ export class BookModel { * Recursively parses out a section of a Jupyter Book. * @param section The input data to parse */ - private parseJupyterSections(section: any[]): JupyterBookSection[] { + public parseJupyterSections(version: string, section: any[]): JupyterBookSection[] { try { return section.reduce((acc, val) => Array.isArray(val.sections) ? - acc.concat(val).concat(this.parseJupyterSections(val.sections)) : acc.concat(val), []); + acc.concat(convertFrom(version, val)).concat(this.parseJupyterSections(version, val.sections)) : acc.concat(convertFrom(version, val)), []); } catch (e) { this._errorMessage = loc.invalidTocFileError(); if (section.length > 0) { this._errorMessage = loc.invalidTocError(section[0].title); } - throw this._errorMessage; + throw new Error(this._errorMessage); } } diff --git a/extensions/notebook/src/book/bookTocManager.ts b/extensions/notebook/src/book/bookTocManager.ts index 8573560b7a..66d50e04f3 100644 --- a/extensions/notebook/src/book/bookTocManager.ts +++ b/extensions/notebook/src/book/bookTocManager.ts @@ -6,14 +6,23 @@ import * as path from 'path'; import { BookTreeItem } from './bookTreeItem'; import * as yaml from 'js-yaml'; import * as fs from 'fs-extra'; -import { IJupyterBookSectionV1, IJupyterBookSectionV2, JupyterBookSection } from '../contracts/content'; -import { BookVersion } from './bookModel'; +import { JupyterBookSection } from '../contracts/content'; +import { BookVersion, convertTo } from './bookVersionHandler'; import * as vscode from 'vscode'; +import * as loc from '../common/localizedConstants'; +import { BookModel } from './bookModel'; export interface IBookTocManager { - updateBook(element: BookTreeItem, book: BookTreeItem): Promise; - createBook(bookContentPath: string, contentFolder: string): Promise + updateBook(element: BookTreeItem, book: BookTreeItem, targetSection?: JupyterBookSection): Promise; + createBook(bookContentPath: string, contentFolder: string): Promise; + recovery(): Promise } + +export interface quickPickResults { + quickPickSection?: vscode.QuickPickItem, + book?: BookTreeItem +} + const allowedFileExtensions: string[] = ['.md', '.ipynb']; const initMarkdown: string[] = ['index.md', 'introduction.md', 'intro.md', 'readme.md']; @@ -22,13 +31,24 @@ export function hasSections(node: JupyterBookSection): boolean { } export class BookTocManager implements IBookTocManager { - public tableofContents: IJupyterBookSectionV2[]; - public newSection: JupyterBookSection = {}; + public tableofContents: JupyterBookSection[]; + public newSection: JupyterBookSection; + private _movedFiles = new Map(); + private _modifiedDirectory = new Set(); + private _tocFiles = new Map(); + private sourceBookContentPath: string; + private targetBookContentPath: string; + private _sourceBook: BookModel; - constructor() { + constructor(targetBook?: BookModel, sourceBook?: BookModel) { + this._sourceBook = sourceBook; + this.newSection = {}; + this.tableofContents = []; + this.sourceBookContentPath = sourceBook?.bookItems[0].rootContentPath; + this.targetBookContentPath = targetBook?.bookItems[0].rootContentPath; } - async getAllFiles(toc: IJupyterBookSectionV2[], directory: string, filesInDir: string[], rootDirectory: string): Promise { + async getAllFiles(toc: JupyterBookSection[], directory: string, filesInDir: string[], rootDirectory: string): Promise { await Promise.all(filesInDir.map(async file => { let isDirectory = (await fs.promises.stat(path.join(directory, file))).isDirectory(); if (isDirectory) { @@ -41,7 +61,7 @@ export class BookTocManager implements IBookTocManager { files.splice(index, 1); } }); - let jupyterSection: IJupyterBookSectionV2 = { + let jupyterSection: JupyterBookSection = { title: file, file: path.join(file, initFile), expand_sections: true, @@ -53,7 +73,7 @@ export class BookTocManager implements IBookTocManager { } else if (allowedFileExtensions.includes(path.extname(file))) { // if the file is in the book root we don't include the directory. const filePath = directory === rootDirectory ? path.parse(file).name : path.join(path.basename(directory), path.parse(file).name); - const addFile: IJupyterBookSectionV2 = { + const addFile: JupyterBookSection = { title: path.parse(file).name, file: filePath }; @@ -74,21 +94,119 @@ export class BookTocManager implements IBookTocManager { return toc; } - updateToc(tableOfContents: JupyterBookSection[], findSection: BookTreeItem, addSection: JupyterBookSection): JupyterBookSection[] { - for (const section of tableOfContents) { - if ((section as IJupyterBookSectionV1).url && path.dirname(section.url) === path.join(path.sep, path.dirname(findSection.uri)) || (section as IJupyterBookSectionV2).file && path.dirname((section as IJupyterBookSectionV2).file) === path.join(path.sep, path.dirname(findSection.uri))) { - if (tableOfContents[tableOfContents.length - 1].sections) { - tableOfContents[tableOfContents.length - 1].sections.push(addSection); - } else { - tableOfContents[tableOfContents.length - 1].sections = [addSection]; + /** + * Renames files if it already exists in the target book + * @param src The source file that will be moved. + * @param dest The destination path of the file, that it's been moved. + * Returns a new file name that does not exist in destination folder. + */ + async renameFile(src: string, dest: string): Promise { + let newFileName = path.join(path.parse(dest).dir, path.parse(dest).name); + let counter = 2; + while (await fs.pathExists(path.join(newFileName.concat(' - ', counter.toString())).concat(path.parse(dest).ext))) { + counter++; + } + await fs.move(src, path.join(newFileName.concat(' - ', counter.toString())).concat(path.parse(dest).ext), { overwrite: true }); + this._movedFiles.set(src, path.join(newFileName.concat(' - ', counter.toString())).concat(path.parse(dest).ext)); + vscode.window.showInformationMessage(loc.duplicateFileError(path.parse(dest).base, src, newFileName.concat(' - ', counter.toString()))); + return newFileName.concat(' - ', counter.toString()); + } + + /** + * Restore user's original state in case of error, when trying to move files. + * We keep track of all the moved files in the _movedFiles. The value of the map contains the current path of the file, + * while the key contains the original path. + * + * Rewrite the original table of contents of the book, in case of error as well. + */ + async recovery(): Promise { + this._movedFiles.forEach(async (value, key) => { + await fs.move(value, key); + }); + + this._tocFiles.forEach(async (value, key) => { + await fs.writeFile(key, yaml.safeDump(value, { lineWidth: Infinity, noRefs: true, skipInvalid: true })); + }); + } + + async cleanUp(directory: string): Promise { + let contents = await fs.readdir(directory); + if (contents.length === 0 && this._modifiedDirectory.has(directory)) { + // remove empty folders + await fs.rmdir(directory); + } else { + contents.forEach(async (content) => { + if ((await fs.stat(path.join(directory, content))).isFile) { + //check if the file is in the moved files + let isCopy = this._movedFiles.get(path.join(directory, content)); + if (isCopy && this._movedFiles.get(path.join(directory, content)) !== path.join(directory, content)) { + // the file could not be renamed, so a copy was created. + // remove file only if the new path and old path are different + await fs.unlink(path.join(directory, content)); + } + } else if ((await fs.stat(path.join(directory, content))).isDirectory) { + await this.cleanUp(path.join(directory, content)); } - break; - } - else if (hasSections(section)) { - return this.updateToc(section.sections, findSection, addSection); + }); + } + } + + /** + * Reads and modifies the table of contents file of the target book. + * @param version the version of the target book + * @param tocPath Path to the table of contents + * @param findSection The section that will be modified. + * @param addSection The section that'll be added to the target section. If it's undefined then the target section (findSection) is removed from the table of contents. + */ + async updateTOC(version: BookVersion, tocPath: string, findSection: JupyterBookSection, addSection?: JupyterBookSection): Promise { + const toc = yaml.safeLoad((await fs.readFile(tocPath, 'utf8'))); + this._tocFiles.set(tocPath, toc); + let newToc = new Array(toc.length); + for (const [index, section] of toc.entries()) { + let newSection = this.buildTOC(version, section, findSection, addSection); + if (newSection) { + newToc[index] = newSection; } } - return tableOfContents; + await fs.writeFile(tocPath, yaml.safeDump(newToc, { lineWidth: Infinity, noRefs: true, skipInvalid: true })); + this.tableofContents = newToc; + } + + /** + * Creates a new table of contents structure containing the added section. This method is only called when we move a section to another section. + * Since the sections can be arranged in a tree structure we need to look for the section that will be modified in a recursively. + * @param version Version of the book + * @param section The current section that we are iterating + * @param findSection The section that will be modified. + * @param addSection The section that'll be added to the target section. If it's undefined then the target section (findSection) is removed from the table of contents. + */ + private buildTOC(version: BookVersion, section: JupyterBookSection, findSection: JupyterBookSection, addSection: JupyterBookSection): JupyterBookSection { + // condition to find the section to be modified + if (section.title === findSection.title && (section.file && section.file === findSection.file || section.url && section.url === findSection.file)) { + if (addSection) { + //if addSection is not undefined, then we added to the table of contents. + section.sections !== undefined && section.sections.length > 0 ? section.sections.push(addSection) : section.sections = [addSection]; + return section; + } + // if addSection is undefined then we remove the whole section from the table of contents. + return addSection; + } else { + let newSection = convertTo(version, section); + if (section.sections && section.sections.length > 0) { + newSection.sections = [] as JupyterBookSection[]; + for (let s of section.sections) { + const child = this.buildTOC(version, s, findSection, addSection); + if (child) { + newSection.sections.push(convertTo(version, child)); + } + } + } + if (newSection.sections?.length === 0) { + // if sections is an empty array then assign it to undefined, so it's converted into a markdown file. + newSection.sections = undefined; + } + return newSection; + } } /** @@ -107,75 +225,198 @@ export class BookTocManager implements IBookTocManager { await vscode.commands.executeCommand('notebook.command.openNotebookFolder', bookContentPath, undefined, true); } - async addSection(section: BookTreeItem, book: BookTreeItem, isSection: boolean): Promise { - this.newSection.title = section.title; - //the book contentPath contains the first file of the section, we get the dirname to identify the section's root path - const rootPath = isSection ? path.dirname(book.book.contentPath) : book.rootContentPath; - // TODO: the uri contains the first notebook or markdown file in the TOC format. If we are in a section, - // we want to include the intermediary directories between the book's root and the section - const uri = isSection ? path.join(path.basename(rootPath), section.uri) : section.uri; - if (section.book.version === BookVersion.v1) { - this.newSection.url = uri; - let movedSections: IJupyterBookSectionV1[] = []; - const files = section.sections as IJupyterBookSectionV1[]; - for (const elem of files) { - await fs.promises.mkdir(path.join(rootPath, path.dirname(elem.url)), { recursive: true }); - await fs.move(path.join(path.dirname(section.book.contentPath), path.basename(elem.url)), path.join(rootPath, elem.url)); - movedSections.push({ url: isSection ? path.join(path.basename(rootPath), elem.url) : elem.url, title: elem.title }); + /** + * When moving a section, we need to modify every file path that it's within the section. Since sections is a tree like structure, we need to modify each of the file paths + * and move the files individually. The overwrite option is set to false to prevent any issues with duplicated file names. + * @param files Files in the section. + */ + async traverseSections(files: JupyterBookSection[]): Promise { + let movedSections: JupyterBookSection[] = []; + for (const elem of files) { + if (elem.file) { + let fileName = undefined; + try { + await fs.move(path.join(this.sourceBookContentPath, elem.file).concat('.ipynb'), path.join(this.targetBookContentPath, elem.file).concat('.ipynb'), { overwrite: false }); + this._movedFiles.set(path.join(this.sourceBookContentPath, elem.file).concat('.ipynb'), path.join(this.targetBookContentPath, elem.file).concat('.ipynb')); + } catch (error) { + if (error.code === 'EEXIST') { + fileName = await this.renameFile(path.join(this.sourceBookContentPath, elem.file).concat('.ipynb'), path.join(this.targetBookContentPath, elem.file).concat('.ipynb')); + } + else if (error.code !== 'ENOENT') { + throw (error); + } + } + try { + await fs.move(path.join(this.sourceBookContentPath, elem.file).concat('.md'), path.join(this.targetBookContentPath, elem.file).concat('.md'), { overwrite: false }); + this._movedFiles.set(path.join(this.sourceBookContentPath, elem.file).concat('.md'), path.join(this.targetBookContentPath, elem.file).concat('.md')); + } catch (error) { + if (error.code === 'EEXIST') { + fileName = await this.renameFile(path.join(this.sourceBookContentPath, elem.file).concat('.md'), path.join(this.targetBookContentPath, elem.file).concat('.md')); + } + else if (error.code !== 'ENOENT') { + throw (error); + } + } + elem.file = fileName === undefined ? elem.file : path.join(path.dirname(elem.file), path.parse(fileName).name); + elem.sections = elem.sections ? await this.traverseSections(elem.sections) : undefined; } - this.newSection.sections = movedSections; - } else if (section.book.version === BookVersion.v2) { - (this.newSection as IJupyterBookSectionV2).file = uri; - let movedSections: IJupyterBookSectionV2[] = []; - const files = section.sections as IJupyterBookSectionV2[]; - for (const elem of files) { - await fs.promises.mkdir(path.join(rootPath, path.dirname(elem.file)), { recursive: true }); - await fs.move(path.join(path.dirname(section.book.contentPath), path.basename(elem.file)), path.join(rootPath, elem.file)); - movedSections.push({ file: isSection ? path.join(path.basename(rootPath), elem.file) : elem.file, title: elem.title }); - } - this.newSection.sections = movedSections; + + movedSections.push(elem); } + return movedSections; } - async addNotebook(notebook: BookTreeItem, book: BookTreeItem, isSection: boolean): Promise { - //the book's contentPath contains the first file of the section, we get the dirname to identify the section's root path - const rootPath = isSection ? path.dirname(book.book.contentPath) : book.rootContentPath; - let notebookName = path.basename(notebook.book.contentPath); - await fs.move(notebook.book.contentPath, path.join(rootPath, notebookName)); - if (book.book.version === BookVersion.v1) { - this.newSection = { url: notebookName, title: notebookName }; - } else if (book.book.version === BookVersion.v2) { - this.newSection = { file: notebookName, title: notebookName }; + /** + * Moves a section to a book top level or another book's section. If there's a target section we add the the targetSection directory if it has one and append it to the + * notebook's path. The overwrite option is set to false to prevent any issues with duplicated file names. + * @param section The section that's been moved. + * @param book The target book. + */ + async addSection(section: BookTreeItem, book: BookTreeItem): Promise { + const uri = path.sep.concat(path.relative(section.rootContentPath, section.book.contentPath)); + let moveFile = path.join(path.parse(uri).dir, path.parse(uri).name); + let fileName = undefined; + try { + await fs.move(section.book.contentPath, path.join(this.targetBookContentPath, moveFile).concat(path.parse(uri).ext), { overwrite: false }); + this._movedFiles.set(section.book.contentPath, path.join(this.targetBookContentPath, moveFile).concat(path.parse(uri).ext)); + } catch (error) { + if (error.code === 'EEXIST') { + fileName = await this.renameFile(section.book.contentPath, path.join(this.targetBookContentPath, moveFile).concat(path.parse(uri).ext)); + } + else if (error.code !== 'ENOENT') { + throw (error); + } + } + fileName = fileName === undefined ? path.parse(uri).name : path.parse(fileName).name; + + if (this._sourceBook) { + const sectionTOC = this._sourceBook.bookItems[0].findChildSection(section.uri); + if (sectionTOC) { + this.newSection = sectionTOC; + } + } + this.newSection.title = section.title; + this.newSection.file = path.join(path.parse(uri).dir, fileName)?.replace(/\\/g, '/'); + if (section.sections) { + const files = section.sections as JupyterBookSection[]; + const movedSections = await this.traverseSections(files); + this.newSection.sections = movedSections; + this._modifiedDirectory.add(path.dirname(section.book.contentPath)); + this.cleanUp(path.dirname(section.book.contentPath)); + } + + if (book.version === BookVersion.v1) { + // here we only convert if is v1 because we are already using the v2 notation for every book that we read. + this.newSection = convertTo(book.version, this.newSection); } } /** - * Moves the element to the book's folder and adds it to the table of contents. + * Moves a notebook to a book top level or a book's section. If there's a target section we add the the targetSection directory if it has one and append it to the + * notebook's path. The overwrite option is set to false to prevent any issues with duplicated file names. * @param element Notebook, Markdown File, or section that will be added to the book. - * @param book Book or a BookSection that will be modified. + * @param targetBook Book that will be modified. */ - public async updateBook(element: BookTreeItem, book: BookTreeItem): Promise { - if (element.contextValue === 'section' && book.book.version === element.book.version) { - if (book.contextValue === 'section') { - await this.addSection(element, book, true); - this.tableofContents = this.updateToc(book.tableOfContents.sections, book, this.newSection); - await fs.writeFile(book.tableOfContentsPath, yaml.safeDump(this.tableofContents, { lineWidth: Infinity })); - } else if (book.contextValue === 'savedBook') { - await this.addSection(element, book, false); - book.tableOfContents.sections.push(this.newSection); - await fs.writeFile(book.tableOfContentsPath, yaml.safeDump(book.tableOfContents, { lineWidth: Infinity })); + async addNotebook(notebook: BookTreeItem, book: BookTreeItem): Promise { + const rootPath = book.rootContentPath; + const notebookPath = path.parse(notebook.book.contentPath); + let fileName = undefined; + try { + await fs.move(notebook.book.contentPath, path.join(rootPath, notebookPath.base), { overwrite: false }); + } catch (error) { + if (error.code === 'EEXIST') { + fileName = await this.renameFile(notebook.book.contentPath, path.join(rootPath, notebookPath.base)); + } + else { + throw (error); + } + } + + if (this._sourceBook) { + const sectionTOC = this._sourceBook.bookItems[0].findChildSection(notebook.uri); + this.newSection = sectionTOC; + } + fileName = fileName === undefined ? notebookPath.name : path.parse(fileName).name; + this.newSection.file = path.sep.concat(fileName); + this.newSection.title = notebook.book.title; + if (book.version === BookVersion.v1) { + // here we only convert if is v1 because we are already using the v2 notation for every book that we read. + this.newSection = convertTo(book.version, this.newSection); + } + } + + /** + * Moves the element to the target book's folder and adds it to the table of contents. + * @param element Notebook, Markdown File, or section that will be added to the book. + * @param targetBook Book that will be modified. + * @param targetSection Book section that'll be modified. + */ + public async updateBook(element: BookTreeItem, targetBook: BookTreeItem, targetSection?: JupyterBookSection): Promise { + const targetBookVersion = targetBook.book.version === BookVersion.v1 ? BookVersion.v1 : BookVersion.v2; + if (element.contextValue === 'section') { + await this.addSection(element, targetBook); + const elementVersion = element.book.version === BookVersion.v1 ? BookVersion.v1 : BookVersion.v2; + // modify the sourceBook toc and remove the section + const findSection: JupyterBookSection = { file: element.book.page.file?.replace(/\\/g, '/'), title: element.book.page.title }; + await this.updateTOC(elementVersion, element.tableOfContentsPath, findSection, undefined); + if (targetSection) { + // adding new section to the target book toc file + await this.updateTOC(targetBookVersion, targetBook.tableOfContentsPath, targetSection, this.newSection); + } + else { + //since there's not a target section, we just append the section at the end of the file + if (this.targetBookContentPath !== this.sourceBookContentPath) { + this.tableofContents = targetBook.sections.map(section => convertTo(targetBook.version, section)); + } + this.tableofContents.push(this.newSection); + await fs.writeFile(targetBook.tableOfContentsPath, yaml.safeDump(this.tableofContents, { lineWidth: Infinity, noRefs: true, skipInvalid: true })); } } else if (element.contextValue === 'savedNotebook') { - if (book.contextValue === 'savedBook') { - await this.addNotebook(element, book, false); - book.tableOfContents.sections.push(this.newSection); - await fs.writeFile(book.tableOfContentsPath, yaml.safeDump(book.tableOfContents, { lineWidth: Infinity })); - } else if (book.contextValue === 'section') { - await this.addNotebook(element, book, true); - this.tableofContents = this.updateToc(book.tableOfContents.sections, book, this.newSection); - await fs.writeFile(book.tableOfContentsPath, yaml.safeDump(this.tableofContents, { lineWidth: Infinity })); + await this.addNotebook(element, targetBook); + if (element.tableOfContentsPath) { + const elementVersion = element.book.version === BookVersion.v1 ? BookVersion.v1 : BookVersion.v2; + // the notebook is part of a book so we need to modify its toc as well + const findSection = { file: element.book.page.file?.replace(/\\/g, '/'), title: element.book.page.title }; + await this.updateTOC(elementVersion, element.tableOfContentsPath, findSection, undefined); + } else { + // close the standalone notebook, so it doesn't throw an error when we move the notebook to new location. + await vscode.commands.executeCommand('notebook.command.closeNotebook', element); + } + if (!targetSection) { + if (this.targetBookContentPath !== this.sourceBookContentPath) { + this.tableofContents = targetBook.sections.map(section => convertTo(targetBook.version, section)); + } + this.tableofContents.push(this.newSection); + await fs.writeFile(targetBook.tableOfContentsPath, yaml.safeDump(this.tableofContents, { lineWidth: Infinity, noRefs: true, skipInvalid: true })); + } else { + await this.updateTOC(targetBookVersion, targetBook.tableOfContentsPath, targetSection, this.newSection); } } } + + public get movedFiles(): Map { + return this._movedFiles; + } + + public get originalToc(): Map { + return this._tocFiles; + } + + public get modifiedDir(): Set { + return this._modifiedDirectory; + } + + public set movedFiles(files: Map) { + this._movedFiles = files; + } + + public set originalToc(files: Map) { + this._tocFiles = files; + } + + public set modifiedDir(files: Set) { + this._modifiedDirectory = files; + } + } diff --git a/extensions/notebook/src/book/bookTreeItem.ts b/extensions/notebook/src/book/bookTreeItem.ts index 3ef362e191..6fc19bdcce 100644 --- a/extensions/notebook/src/book/bookTreeItem.ts +++ b/extensions/notebook/src/book/bookTreeItem.ts @@ -4,14 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import * as path from 'path'; import * as fs from 'fs'; -import { JupyterBookSection, IJupyterBookToc, IJupyterBookSectionV2, IJupyterBookSectionV1 } from '../contracts/content'; +import { JupyterBookSection, IJupyterBookToc } from '../contracts/content'; import * as loc from '../common/localizedConstants'; import { isBookItemPinned } from '../common/utils'; -import { BookVersion } from './bookModel'; - -const content = 'content'; +import { getContentPath, getTocPath } from './bookVersionHandler'; export enum BookTreeItemType { Book = 'Book', @@ -45,7 +42,6 @@ export class BookTreeItem extends vscode.TreeItem { constructor(public book: BookTreeItemFormat, icons: any) { super(book.title, book.treeItemCollapsibleState); - if (book.type === BookTreeItemType.Book) { this.collapsibleState = book.treeItemCollapsibleState; this._sections = book.page; @@ -64,6 +60,9 @@ export class BookTreeItem extends vscode.TreeItem { } else { this.contextValue = isBookItemPinned(book.contentPath) ? 'pinnedNotebook' : 'savedNotebook'; } + } else if (book.type === BookTreeItemType.ExternalLink) { + this.contextValue = BookTreeItemType.ExternalLink; + } else { this.contextValue = book.type === BookTreeItemType.Notebook ? (isBookItemPinned(book.contentPath) ? 'pinnedNotebook' : 'savedNotebook') : 'section'; } @@ -71,26 +70,30 @@ export class BookTreeItem extends vscode.TreeItem { this.setCommand(); } this.iconPath = icons; + this._tableOfContentsPath = undefined; if (this.book.type === BookTreeItemType.ExternalLink) { this.tooltip = `${this._uri}`; } else { - this._tableOfContentsPath = (this.book.type === BookTreeItemType.Book || this.contextValue === 'section') ? (this.book.version === BookVersion.v1 ? path.join(this.book.root, '_data', 'toc.yml') : path.join(this.book.root, '_toc.yml')) : undefined; - this._rootContentPath = this.book.version === BookVersion.v1 ? path.join(this.book.root, content) : this.book.root; + // if it's a section, book or a notebook's book then we set the table of contents path. + if (this.book.type === BookTreeItemType.Book || this.contextValue === 'section' || (book.tableOfContents.sections && book.type === BookTreeItemType.Notebook)) { + this._tableOfContentsPath = getTocPath(this.book.version, this.book.root); + } + this._rootContentPath = getContentPath(this.book.version, this.book.root, ''); this.tooltip = this.book.type === BookTreeItemType.Book ? this._rootContentPath : this.book.contentPath; this.resourceUri = vscode.Uri.file(this.book.root); } } - private setPageVariables() { + private setPageVariables(): void { this.collapsibleState = (this.book.page.sections || this.book.page.subsections) && this.book.page.expand_sections ? vscode.TreeItemCollapsibleState.Expanded : this.book.page.sections || this.book.page.subsections ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None; this._sections = this.book.page.sections || this.book.page.subsections; - this._uri = this.book.version === BookVersion.v1 ? this.book.page.url : this.book.page.file; + this._uri = this.book.page.file ? this.book.page.file : this.book.page.url; if (this.book.tableOfContents.sections) { let index = (this.book.tableOfContents.sections.indexOf(this.book.page)); @@ -99,7 +102,7 @@ export class BookTreeItem extends vscode.TreeItem { } } - private setCommand() { + private setCommand(): void { if (this.book.type === BookTreeItemType.Notebook) { // The Notebook editor expects a posix path for the resource (it will still resolve to the correct fsPath based on OS) this.command = { command: this.book.isUntitled ? 'bookTreeView.openUntitledNotebook' : 'bookTreeView.openNotebook', title: loc.openNotebookCommand, arguments: [this.book.contentPath], }; @@ -114,13 +117,11 @@ export class BookTreeItem extends vscode.TreeItem { let i = --index; while (i > -1) { let pathToNotebook: string; - if (this.book.version === BookVersion.v2 && (this.book.tableOfContents.sections[i] as IJupyterBookSectionV2).file) { + if (this.book.tableOfContents.sections[i].file) { // The Notebook editor expects a posix path for the resource (it will still resolve to the correct fsPath based on OS) - pathToNotebook = path.posix.join(this.book.root, (this.book.tableOfContents.sections[i] as IJupyterBookSectionV2).file.concat('.ipynb')); - } else if ((this.book.tableOfContents.sections[i] as IJupyterBookSectionV1).url) { - pathToNotebook = path.posix.join(this.book.root, content, (this.book.tableOfContents.sections[i] as IJupyterBookSectionV1).url.concat('.ipynb')); + pathToNotebook = getContentPath(this.book.version, this.book.root, this.book.tableOfContents.sections[i].file); + pathToNotebook = pathToNotebook.concat('.ipynb'); } - // eslint-disable-next-line no-sync if (fs.existsSync(pathToNotebook)) { this._previousUri = pathToNotebook; @@ -134,13 +135,11 @@ export class BookTreeItem extends vscode.TreeItem { let i = ++index; while (i < this.book.tableOfContents.sections.length) { let pathToNotebook: string; - if (this.book.version === BookVersion.v2 && (this.book.tableOfContents.sections[i] as IJupyterBookSectionV2).file) { + if (this.book.tableOfContents.sections[i].file) { // The Notebook editor expects a posix path for the resource (it will still resolve to the correct fsPath based on OS) - pathToNotebook = path.posix.join(this.book.root, (this.book.tableOfContents.sections[i] as IJupyterBookSectionV2).file.concat('.ipynb')); - } else if ((this.book.tableOfContents.sections[i] as IJupyterBookSectionV1).url) { - pathToNotebook = path.posix.join(this.book.root, content, (this.book.tableOfContents.sections[i] as IJupyterBookSectionV1).url.concat('.ipynb')); + pathToNotebook = getContentPath(this.book.version, this.book.root, this.book.tableOfContents.sections[i].file); + pathToNotebook = pathToNotebook.concat('.ipynb'); } - // eslint-disable-next-line no-sync if (fs.existsSync(pathToNotebook)) { this._nextUri = pathToNotebook; @@ -174,7 +173,7 @@ export class BookTreeItem extends vscode.TreeItem { return this.book.tableOfContents; } - public get sections(): any[] { + public get sections(): JupyterBookSection[] { return this._sections; } @@ -192,6 +191,14 @@ export class BookTreeItem extends vscode.TreeItem { this._uri = uri; } + public set sections(sections: JupyterBookSection[]) { + this._sections = sections; + } + + public set tableOfContentsPath(tocPath: string) { + this._tableOfContentsPath = tocPath; + } + /** * Helper method to find a child section with a specified URL * @param url The url of the section we're searching for @@ -204,7 +211,7 @@ export class BookTreeItem extends vscode.TreeItem { } private findChildSectionRecur(section: JupyterBookSection, url: string): JupyterBookSection | undefined { - if ((section as IJupyterBookSectionV1).url && (section as IJupyterBookSectionV1).url === url || (section as IJupyterBookSectionV2).file && (section as IJupyterBookSectionV2).file === url) { + if (section.file && section.file === url) { return section; } else if (section.sections) { for (const childSection of section.sections) { diff --git a/extensions/notebook/src/book/bookTreeView.ts b/extensions/notebook/src/book/bookTreeView.ts index 1ed49aaf5b..d3ee953aa8 100644 --- a/extensions/notebook/src/book/bookTreeView.ts +++ b/extensions/notebook/src/book/bookTreeView.ts @@ -11,19 +11,17 @@ import * as constants from '../common/constants'; import { IPrompter, IQuestion, QuestionTypes } from '../prompts/question'; import CodeAdapter from '../prompts/adapter'; import { BookTreeItem, BookTreeItemType } from './bookTreeItem'; -import { BookModel, BookVersion } from './bookModel'; +import { BookModel } from './bookModel'; import { Deferred } from '../common/promise'; import { IBookTrustManager, BookTrustManager } from './bookTrustManager'; import * as loc from '../common/localizedConstants'; import * as glob from 'fast-glob'; -import { IJupyterBookSectionV2, IJupyterBookSectionV1 } from '../contracts/content'; import { debounce, getPinnedNotebooks } from '../common/utils'; import { IBookPinManager, BookPinManager } from './bookPinManager'; -import { BookTocManager, IBookTocManager } from './bookTocManager'; +import { BookTocManager, IBookTocManager, quickPickResults } from './bookTocManager'; +import { getContentPath } from './bookVersionHandler'; import { TelemetryReporter, BookTelemetryView, NbTelemetryActions } from '../telemetry'; -const content = 'content'; - interface BookSearchResults { notebookPaths: string[]; bookPaths: string[]; @@ -50,7 +48,6 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider console.error(e)); this.prompter = new CodeAdapter(); @@ -93,6 +90,14 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider { + if (curr.mtime > prev.mtime) { + this.fireBookRefresh(book); + } + }); + } + trustBook(bookTreeItem?: BookTreeItem): void { let bookPathToTrust: string = bookTreeItem ? bookTreeItem.root : this.currentBook?.bookPath; if (bookPathToTrust) { @@ -144,8 +149,92 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider { - await this.bookTocManager.updateBook(section, book); + async getSelectionQuickPick(movingElement: BookTreeItem): Promise { + let bookOptions: vscode.QuickPickItem[] = []; + let pickedSection: vscode.QuickPickItem; + this.books.forEach(book => { + if (!book.isNotebook) { + bookOptions.push({ label: book.bookItems[0].title, detail: book.bookPath }); + } + }); + let pickedBook = await vscode.window.showQuickPick(bookOptions, { + canPickMany: false, + placeHolder: loc.labelBookFolder + }); + + if (pickedBook && movingElement) { + const updateBook = this.books.find(book => book.bookPath === pickedBook.detail).bookItems[0]; + if (updateBook) { + let bookSections = updateBook.sections; + while (bookSections?.length > 0) { + bookOptions = [{ label: loc.labelAddToLevel, detail: pickedSection ? pickedSection.detail : '' }]; + bookSections.forEach(section => { + if (section.sections) { + bookOptions.push({ label: section.title ? section.title : section.file, detail: section.file }); + } + }); + bookSections = []; + if (bookOptions.length > 1) { + pickedSection = await vscode.window.showQuickPick(bookOptions, { + canPickMany: false, + placeHolder: loc.labelBookSection + }); + + if (pickedSection && pickedSection.label === loc.labelAddToLevel) { + break; + } + else if (pickedSection && pickedSection.detail) { + if (updateBook.root === movingElement.root && pickedSection.detail === movingElement.uri) { + pickedSection = undefined; + } else { + bookSections = updateBook.findChildSection(pickedSection.detail).sections; + } + } + } + } + } + return { quickPickSection: pickedSection, book: updateBook }; + } + return undefined; + } + + async editBook(movingElement: BookTreeItem): Promise { + const selectionResults = await this.getSelectionQuickPick(movingElement); + const pickedSection = selectionResults.quickPickSection; + const updateBook = selectionResults.book; + if (pickedSection && updateBook) { + const targetSection = pickedSection.detail !== undefined ? updateBook.findChildSection(pickedSection.detail) : undefined; + if (movingElement.tableOfContents.sections) { + if (movingElement.contextValue === 'savedNotebook') { + let sourceBook = this.books.find(book => book.getNotebook(path.normalize(movingElement.book.contentPath))); + movingElement.tableOfContents.sections = sourceBook?.bookItems[0].sections; + } + } + const sourceBook = this.books.find(book => book.bookPath === movingElement.book.root); + const targetBook = this.books.find(book => book.bookPath === updateBook.book.root); + this.bookTocManager = new BookTocManager(targetBook, sourceBook); + // remove watch on toc file from both books. + if (sourceBook) { + fs.unwatchFile(movingElement.tableOfContentsPath); + } + try { + await this.bookTocManager.updateBook(movingElement, updateBook, targetSection); + } catch (e) { + await this.bookTocManager.recovery(); + vscode.window.showErrorMessage(loc.editBookError(updateBook.book.contentPath, e instanceof Error ? e.message : e)); + } finally { + this.fireBookRefresh(targetBook); + if (sourceBook) { + // refresh source book model to pick up latest changes + this.fireBookRefresh(sourceBook); + } + // even if it fails, we still need to watch the toc file again. + if (sourceBook) { + this.setFileWatcher(sourceBook); + } + this.setFileWatcher(targetBook); + } + } } async openBook(bookPath: string, urlToOpen?: string, showPreview?: boolean, isNotebook?: boolean): Promise { @@ -180,6 +269,11 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider book.bookPath === bookPath); + if (index !== -1) { + this.books.splice(index, 1); + } vscode.window.showErrorMessage(loc.openFileError(bookPath, e instanceof Error ? e.message : e)); } } @@ -267,9 +361,9 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider { let folderToSearch: string; if (treeItem && treeItem.sections !== undefined) { - if (treeItem.book.version === BookVersion.v1) { - if (treeItem.uri) { - folderToSearch = path.join(treeItem.book.root, content, path.dirname(treeItem.uri)); - } else { - folderToSearch = path.join(treeItem.root, content); - } - } else if (treeItem.book.version === BookVersion.v2) { - if (treeItem.uri) { - folderToSearch = path.join(treeItem.book.root, path.dirname(treeItem.uri)); - } else { - folderToSearch = path.join(treeItem.root); - } - } - + folderToSearch = treeItem.uri ? getContentPath(treeItem.version, treeItem.book.root, path.dirname(treeItem.uri)) : getContentPath(treeItem.version, treeItem.book.root, ''); } else if (this.currentBook && !this.currentBook.isNotebook) { folderToSearch = path.join(this.currentBook.contentFolderPath); } else { @@ -573,19 +654,14 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider { - if (element?.uri) { - let parentPath: string; - let contentFolder = element.book.version === BookVersion.v1 ? path.join(element.book.root, content) : element.book.root; - parentPath = path.join(contentFolder, element.uri.substring(0, element.uri.lastIndexOf(path.posix.sep))); - if (parentPath === element.root) { - return undefined; - } - let parentPaths = Array.from(this.currentBook.getAllNotebooks()?.keys()).filter(x => x.indexOf(parentPath) > -1); - return parentPaths.length > 0 ? this.currentBook.getAllNotebooks().get(parentPaths[0]) : undefined; - } else { - return undefined; - } + // Remove it for perf issues. + return undefined; } getUntitledNotebookUri(resource: string): vscode.Uri { diff --git a/extensions/notebook/src/book/bookTrustManager.ts b/extensions/notebook/src/book/bookTrustManager.ts index 58c7088d46..de24683a85 100644 --- a/extensions/notebook/src/book/bookTrustManager.ts +++ b/extensions/notebook/src/book/bookTrustManager.ts @@ -6,7 +6,8 @@ import * as path from 'path'; import * as vscode from 'vscode'; import * as constants from './../common/constants'; import { BookTreeItem } from './bookTreeItem'; -import { BookModel, BookVersion } from './bookModel'; +import { BookModel } from './bookModel'; +import { BookVersion } from './bookVersionHandler'; export interface IBookTrustManager { isNotebookTrustedByDefault(notebookUri: string): boolean; diff --git a/extensions/notebook/src/book/bookVersionHandler.ts b/extensions/notebook/src/book/bookVersionHandler.ts new file mode 100644 index 0000000000..011e3aa6e5 --- /dev/null +++ b/extensions/notebook/src/book/bookVersionHandler.ts @@ -0,0 +1,128 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { JupyterBookSection, IJupyterBookSectionV1, IJupyterBookSectionV2 } from '../contracts/content'; +import * as path from 'path'; + +export enum BookVersion { + v1 = 'v1', + v2 = 'v2' +} + +export function getContentPath(version: string, bookPath: string, filePath: string): string { + return BookVersion.v1 === version ? path.posix.join(bookPath, 'content', filePath) : path.posix.join(bookPath, filePath); +} + +export function getTocPath(version: string, bookPath: string): string { + return BookVersion.v1 === version ? path.posix.join(bookPath, '_data', 'toc.yml') : path.posix.join(bookPath, '_toc.yml'); +} + +/** + * Parses a section to JupyterSection, which is the union of Jupyter Book v1 and v2 interfaces. + * There are conflicting properties between v1 and v2 Jupyter Book toc properties, + * this method converts v1 to v2 while keeping the v1 properties that do not exist in v2. + * @param version Version of the section that will be converted + * @param section The section that'll be converted. +*/ +export function convertFrom(version: string, section: JupyterBookSection): JupyterBookSection { + if (version === BookVersion.v1) { + return Object.assign(section, { + title: section.title, + file: (section as IJupyterBookSectionV1).external ? undefined : section.url, + url: (section as IJupyterBookSectionV1).external ? section.url : undefined, + sections: section.sections, + expand_sections: section.expand_sections, + search: (section as IJupyterBookSectionV1).search, + divider: (section as IJupyterBookSectionV1).divider, + header: (section as IJupyterBookSectionV1).header, + external: (section as IJupyterBookSectionV1).external, + numbered: !(section as IJupyterBookSectionV1).not_numbered, + not_numbered: undefined + }); + } else { + return Object.assign(section, { + title: section.title, + file: (section as IJupyterBookSectionV2).file, + url: section.url, + sections: section.sections, + expand_sections: section.expand_sections, + numbered: (section as IJupyterBookSectionV2).numbered, + header: (section as IJupyterBookSectionV2).header, + chapters: (section as IJupyterBookSectionV2).chapters, + part: (section as IJupyterBookSectionV2).part + }); + } +} + +/** + * Converts the JupyterSection to either Jupyter Book v1 or v2. + * @param version Version of the section that will be converted + * @param section The section that'll be converted. +*/ +export function convertTo(version: string, section: JupyterBookSection): JupyterBookSection { + if (version === BookVersion.v1) { + if (section.sections && section.sections.length > 0) { + let temp: JupyterBookSection = {}; + temp.title = section.title; + temp.url = section.url ? section.url : section.file; + temp.expand_sections = section.expand_sections; + temp.not_numbered = !section.numbered; + temp.search = section.search; + temp.divider = section.divider; + temp.header = section.header; + temp.external = section.external; + temp.sections = []; + for (let s of section.sections) { + const child = this.convertTo(version, s); + temp.sections.push(child); + } + return temp; + } else { + let newSection: JupyterBookSection = {}; + newSection.title = section.title; + newSection.url = section.url ? section.url : section.file; + newSection.sections = section.sections; + newSection.not_numbered = !section.numbered; + newSection.expand_sections = section.expand_sections; + newSection.search = section.search; + newSection.divider = section.divider; + newSection.header = section.header; + newSection.external = section.external; + return newSection; + } + } + else if (version === BookVersion.v2) { + if (section.sections && section.sections.length > 0) { + let temp: JupyterBookSection = {}; + temp.title = section.title; + temp.file = section.file; + temp.expand_sections = section.expand_sections; + temp.header = section.header; + temp.numbered = section.numbered; + temp.part = section.part; + temp.chapters = section.chapters; + temp.url = section.url; + temp.sections = []; + for (let s of section.sections) { + const child = this.convertTo(version, s); + temp.sections.push(child); + } + return temp; + } else { + let newSection: JupyterBookSection = {}; + newSection.title = section.title; + newSection.file = section.file; + newSection.sections = section.sections; + newSection.expand_sections = section.expand_sections; + newSection.header = section.header; + newSection.numbered = section.numbered; + newSection.part = section.part; + newSection.chapters = section.chapters; + newSection.url = section.url; + return newSection; + } + } + return {}; +} diff --git a/extensions/notebook/src/common/localizedConstants.ts b/extensions/notebook/src/common/localizedConstants.ts index cf282585b6..f1ab7a3643 100644 --- a/extensions/notebook/src/common/localizedConstants.ts +++ b/extensions/notebook/src/common/localizedConstants.ts @@ -31,10 +31,13 @@ export function msgBookPinned(book: string): string { return localize('msgBookPi export function msgBookUnpinned(book: string): string { return localize('msgBookUnpinned', "Book {0} is no longer pinned in this workspace", book); } export const missingTocError = localize('bookInitializeFailed', "Failed to find a Table of Contents file in the specified book."); export const noBooksSelectedError = localize('noBooksSelected', "No books are currently selected in the viewlet."); +export const labelBookSection = localize('labelBookSection', "Select Book Section"); +export const labelAddToLevel = localize('labelAddToLevel', "Add to this level"); -export function missingFileError(title: string): string { return localize('missingFileError', "Missing file : {0}", title); } +export function missingFileError(title: string, path: string): string { return localize('missingFileError', "Missing file : {0} from {1}", title, path); } export function invalidTocFileError(): string { return localize('InvalidError.tocFile', "Invalid toc file"); } export function invalidTocError(title: string): string { return localize('Invalid toc.yml', "Error: {0} has an incorrect toc.yml file", title); } +export function configFileError(): string { return localize('configFileError', "Configuration file missing"); } export function openFileError(path: string, error: string): string { return localize('openBookError', "Open book {0} failed: {1}", path, error); } export function readBookError(path: string, error: string): string { return localize('readBookError', "Failed to read book {0}: {1}", path, error); } @@ -43,6 +46,9 @@ 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); } +export function duplicateFileError(title: string, path: string, newPath: string): string { return localize('duplicateFileError', "File {0} already exists in the destination folder {1} \n The file has been renamed to {2} to prevent data loss.", title, path, newPath); } +export function editBookError(path: string, error: string): string { return localize('editBookError', "Error while editing book {0}: {1}", path, error); } +export function selectBookError(error: string): string { return localize('selectBookError', "Error while selecting a book or a section to edit: {0}", error); } // Remote Book dialog constants export const url = localize('url', "URL"); diff --git a/extensions/notebook/src/contracts/content.ts b/extensions/notebook/src/contracts/content.ts index aae3080775..bd43bafc92 100644 --- a/extensions/notebook/src/contracts/content.ts +++ b/extensions/notebook/src/contracts/content.ts @@ -86,11 +86,11 @@ export interface IJupyterBookSectionV1 { /** * Contains a list of more entries that make up the chapter's/section's sub-sections */ - sections?: IJupyterBookSectionV1[]; + sections?: JupyterBookSection[]; /** * If the section shouldn't have a number in the sidebar */ - not_numbered?: string; + not_numbered?: boolean; /** * If you'd like the sections of this chapter to always be expanded in the sidebar. */ @@ -113,7 +113,7 @@ export interface IJupyterBookSectionV1 { /** * Will insert a header with no link in the sidebar */ - header?: boolean; + header?: string; } /** @@ -134,7 +134,7 @@ export interface IJupyterBookSectionV2 { /** * Contains a list of more entries that make up the chapter's/section's sub-sections */ - sections?: IJupyterBookSectionV2[]; + sections?: JupyterBookSection[]; /** * If the section shouldn't have a number in the sidebar */ @@ -147,8 +147,23 @@ export interface IJupyterBookSectionV2 { * External link */ url?: string; + + // Below are some special values that trigger specific behavior: + + /** + * Will insert a header with no link in the sidebar + */ + header?: string; + /** + * If a book is divided into groups then part is the title of the group + */ + part?: string; + /** + * the equivalent of sections in a group. + */ + chapters?: string[]; + } -// type that supports new and old version -export type JupyterBookSection = IJupyterBookSectionV1 | IJupyterBookSectionV2; +export interface JupyterBookSection extends IJupyterBookSectionV1, IJupyterBookSectionV2 { } diff --git a/extensions/notebook/src/extension.ts b/extensions/notebook/src/extension.ts index 1e2f859e16..c787376227 100644 --- a/extensions/notebook/src/extension.ts +++ b/extensions/notebook/src/extension.ts @@ -19,6 +19,7 @@ import { RemoteBookDialog } from './dialog/remoteBookDialog'; import { RemoteBookDialogModel } from './dialog/remoteBookDialogModel'; import { IconPathHelper } from './common/iconHelper'; import { ExtensionContextHelper } from './common/extensionContextHelper'; +import { BookTreeItem } from './book/bookTreeItem'; const localize = nls.loadMessageBundle(); @@ -69,6 +70,10 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi }); })); + extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.moveTo', async (book: BookTreeItem) => { + await bookTreeViewProvider.editBook(book); + })); + let model = new RemoteBookDialogModel(); let remoteBookController = new RemoteBookController(model, appContext.outputChannel); diff --git a/extensions/notebook/src/test/book/book.test.ts b/extensions/notebook/src/test/book/book.test.ts index cc0ac55be1..53bae78071 100644 --- a/extensions/notebook/src/test/book/book.test.ts +++ b/extensions/notebook/src/test/book/book.test.ts @@ -19,12 +19,13 @@ import { exists } from '../../common/utils'; import { BookModel } from '../../book/bookModel'; import { BookTrustManager } from '../../book/bookTrustManager'; import { NavigationProviders } from '../../common/constants'; -import { readBookError } from '../../common/localizedConstants'; +import { openFileError } from '../../common/localizedConstants'; import * as sinon from 'sinon'; import { AppContext } from '../../common/appContext'; export interface IExpectedBookItem { title: string; + file?: string; url?: string; sections?: any[]; external?: boolean; @@ -34,7 +35,11 @@ export interface IExpectedBookItem { export function equalBookItems(book: BookTreeItem, expectedBook: IExpectedBookItem, errorMsg?: string): void { should(book.title).equal(expectedBook.title, `Book titles do not match, expected ${expectedBook?.title} and got ${book?.title}`); - should(path.posix.parse(book.uri)).deepEqual(path.posix.parse(expectedBook.url)); + if (expectedBook.file) { + should(path.posix.parse(book.uri)).deepEqual(path.posix.parse(expectedBook.file)); + } else { + should(path.posix.parse(book.uri)).deepEqual(path.posix.parse(expectedBook.url)); + } if (expectedBook.previousUri || expectedBook.nextUri) { let prevUri = book.previousUri ? book.previousUri.toLocaleLowerCase() : undefined; let expectedPrevUri = expectedBook.previousUri ? expectedBook.previousUri.replace(/\\/g, '/') : undefined; @@ -75,25 +80,25 @@ describe('BooksTreeViewTests', function () { expectedNotebook1 = { // tslint:disable-next-line: quotemark title: 'Notebook1', - url: '/notebook1', + file: '/notebook1', previousUri: undefined, nextUri: notebook2File.toLocaleLowerCase() }; expectedNotebook2 = { title: 'Notebook2', - url: '/notebook2', + file: '/notebook2', previousUri: notebook1File.toLocaleLowerCase(), nextUri: notebook3File.toLocaleLowerCase() }; expectedNotebook3 = { title: 'Notebook3', - url: '/notebook3', + file: '/notebook3', previousUri: notebook2File.toLocaleLowerCase(), nextUri: undefined }; expectedMarkdown = { title: 'Markdown', - url: '/markdown' + file: '/markdown' }; expectedExternalLink = { title: 'GitHub', @@ -227,7 +232,7 @@ describe('BooksTreeViewTests', function () { }); - it('getParent should return when element is a valid child notebook', async () => { + it.skip('getParent should return when element is a valid child notebook', async () => { let parent = await bookTreeViewProvider.getParent(); should(parent).be.undefined(); @@ -417,53 +422,54 @@ describe('BooksTreeViewTests', function () { }); - describe('BookTreeViewProvider.getTableOfContentFiles', function() { + describe('BookTreeViewProvider.getTableOfContentFiles', function () { let rootFolderPath = path.join(os.tmpdir(), `BookTestData_${uuid.v4()}`); let bookTreeViewProvider: BookTreeViewProvider; let runs = [ { it: 'v1', - folderPaths : { - 'dataFolderPath' : path.join(rootFolderPath, '_data'), - 'contentFolderPath' : path.join(rootFolderPath, 'content'), - 'configFile' : path.join(rootFolderPath, '_config.yml'), - 'tableOfContentsFile' : path.join(rootFolderPath,'_data', 'toc.yml'), - 'notebook2File' : path.join(rootFolderPath, 'content', 'notebook2.ipynb'), - 'tableOfContentsFileIgnore' : path.join(rootFolderPath, 'toc.yml') + folderPaths: { + 'dataFolderPath': path.join(rootFolderPath, '_data'), + 'contentFolderPath': path.join(rootFolderPath, 'content'), + 'configFile': path.join(rootFolderPath, '_config.yml'), + 'tableOfContentsFile': path.join(rootFolderPath, '_data', 'toc.yml'), + 'notebook2File': path.join(rootFolderPath, 'content', 'notebook2.ipynb'), + 'tableOfContentsFileIgnore': path.join(rootFolderPath, 'toc.yml') }, - contents : { - 'config' : 'title: Test Book', - 'toc' : '- title: Notebook1\n url: /notebook1\n sections:\n - title: Notebook2\n url: /notebook2\n - title: Notebook3\n url: /notebook3\n- title: Markdown\n url: /markdown\n- title: GitHub\n url: https://github.com/\n external: true' + contents: { + 'config': 'title: Test Book', + 'toc': '- title: Notebook1\n url: /notebook1\n sections:\n - title: Notebook2\n url: /notebook2\n - title: Notebook3\n url: /notebook3\n- title: Markdown\n url: /markdown\n- title: GitHub\n url: https://github.com/\n external: true' } }, { it: 'v2', - folderPaths : { - 'dataFolderPath' : path.join(rootFolderPath, '_data'), - 'configFile' : path.join(rootFolderPath, '_config.yml'), - 'tableOfContentsFile' : path.join(rootFolderPath, '_toc.yml'), - 'notebook2File' : path.join(rootFolderPath,'notebook2.ipynb'), - 'tableOfContentsFileIgnore' : path.join(rootFolderPath, '_data', 'toc.yml') + folderPaths: { + 'dataFolderPath': path.join(rootFolderPath, '_data'), + 'configFile': path.join(rootFolderPath, '_config.yml'), + 'tableOfContentsFile': path.join(rootFolderPath, '_toc.yml'), + 'notebook2File': path.join(rootFolderPath, 'notebook2.ipynb'), + 'tableOfContentsFileIgnore': path.join(rootFolderPath, '_data', 'toc.yml') }, - contents : { - 'config' : 'title: Test Book', - 'toc' : '- title: Notebook1\n file: /notebook1\n sections:\n - title: Notebook2\n file: /notebook2\n - title: Notebook3\n file: /notebook3\n- title: Markdown\n file: /markdown\n- title: GitHub\n url: https://github.com/\n' + contents: { + 'config': 'title: Test Book', + 'toc': '- title: Notebook1\n file: /notebook1\n sections:\n - title: Notebook2\n file: /notebook2\n - title: Notebook3\n file: /notebook3\n- title: Markdown\n file: /markdown\n- title: GitHub\n url: https://github.com/\n' } } ]; - runs.forEach(function (run){ + runs.forEach(function (run) { describe('BookTreeViewProvider.getTableOfContentFiles on ' + run.it, function (): void { let folder: vscode.WorkspaceFolder; before(async () => { await fs.mkdir(rootFolderPath); await fs.mkdir(run.folderPaths.dataFolderPath); - if(run.it === 'v1') { + if (run.it === 'v1') { await fs.mkdir(run.folderPaths.contentFolderPath); } await fs.writeFile(run.folderPaths.tableOfContentsFile, run.contents.toc); await fs.writeFile(run.folderPaths.tableOfContentsFileIgnore, ''); await fs.writeFile(run.folderPaths.notebook2File, ''); + await fs.writeFile(run.folderPaths.configFile, run.contents.config); const mockExtensionContext = new MockExtensionContext(); folder = { @@ -474,16 +480,17 @@ describe('BooksTreeViewTests', function () { bookTreeViewProvider = new BookTreeViewProvider([folder], mockExtensionContext, false, 'bookTreeView', NavigationProviders.NotebooksNavigator); let errorCase = new Promise((resolve, reject) => setTimeout(() => resolve(), 5000)); await Promise.race([bookTreeViewProvider.initialized, errorCase.then(() => { throw new Error('BookTreeViewProvider did not initialize in time'); })]); + await bookTreeViewProvider.openBook(rootFolderPath, undefined, false, false); }); - if(run.it === 'v1') { + if (run.it === 'v1') { it('should ignore toc.yml files not in _data folder', async () => { await bookTreeViewProvider.currentBook.readBookStructure(rootFolderPath); await bookTreeViewProvider.currentBook.loadTableOfContentFiles(); let path = bookTreeViewProvider.currentBook.tableOfContentsPath; should(vscode.Uri.file(path).fsPath).equal(vscode.Uri.file(run.folderPaths.tableOfContentsFile).fsPath); }); - } else if (run.it === 'v2'){ + } else if (run.it === 'v2') { it('should ignore toc.yml files not under the root book folder', async () => { await bookTreeViewProvider.currentBook.readBookStructure(rootFolderPath); await bookTreeViewProvider.currentBook.loadTableOfContentFiles(); @@ -501,46 +508,47 @@ describe('BooksTreeViewTests', function () { }); }); - describe('BookTreeViewProvider.getBooks', function() { + describe('BookTreeViewProvider.getBooks', function () { let rootFolderPath = path.join(os.tmpdir(), `BookTestData_${uuid.v4()}`); let bookTreeViewProvider: BookTreeViewProvider; let runs = [ { it: 'v1', - folderPaths : { - 'dataFolderPath' : path.join(rootFolderPath, '_data'), - 'contentFolderPath' : path.join(rootFolderPath, 'content'), - 'configFile' : path.join(rootFolderPath, '_config.yml'), - 'tableofContentsFile' : path.join(rootFolderPath,'_data', 'toc.yml'), - 'notebook2File' : path.join(rootFolderPath, 'content', 'notebook2.ipynb'), + folderPaths: { + 'dataFolderPath': path.join(rootFolderPath, '_data'), + 'contentFolderPath': path.join(rootFolderPath, 'content'), + 'configFile': path.join(rootFolderPath, '_config.yml'), + 'tableofContentsFile': path.join(rootFolderPath, '_data', 'toc.yml'), + 'notebook2File': path.join(rootFolderPath, 'content', 'notebook2.ipynb'), }, - contents : { - 'config' : 'title: Test Book', - 'toc' : '- title: Notebook1\n url: /notebook1\n- title: Notebook2\n url: /notebook2' + contents: { + 'config': 'title: Test Book', + 'toc': '- title: Notebook1\n url: /notebook1\n- title: Notebook2\n url: /notebook2' } }, { it: 'v2', - folderPaths : { - 'configFile' : path.join(rootFolderPath, '_config.yml'), - 'tableofContentsFile' : path.join(rootFolderPath, '_toc.yml'), - 'notebook2File' : path.join(rootFolderPath, 'notebook2.ipynb'), + folderPaths: { + 'configFile': path.join(rootFolderPath, '_config.yml'), + 'tableofContentsFile': path.join(rootFolderPath, '_toc.yml'), + 'notebook2File': path.join(rootFolderPath, 'notebook2.ipynb'), }, - contents : { - 'config' : 'title: Test Book', - 'toc' : '- title: Notebook1\n file: /notebook1\n- title: Notebook2\n file: /notebook2' + contents: { + 'config': 'title: Test Book', + 'toc': '- title: Notebook1\n file: /notebook1\n- title: Notebook2\n file: /notebook2' } } ]; - runs.forEach(function (run){ + runs.forEach(function (run) { describe('BookTreeViewProvider.getBooks on ' + run.it, function (): void { let folder: vscode.WorkspaceFolder; before(async () => { await fs.mkdir(rootFolderPath); - if(run.it === 'v1'){ + if (run.it === 'v1') { await fs.mkdir(run.folderPaths.dataFolderPath); await fs.mkdir(run.folderPaths.contentFolderPath); } await fs.writeFile(run.folderPaths.tableofContentsFile, run.contents.config); + await fs.writeFile(run.folderPaths.configFile, run.contents.config); const mockExtensionContext = new MockExtensionContext(); folder = { uri: vscode.Uri.file(rootFolderPath), @@ -553,14 +561,22 @@ describe('BooksTreeViewTests', function () { }); it('should show error message if config.yml file not found', async () => { - await bookTreeViewProvider.currentBook.readBooks(); - should(bookTreeViewProvider.currentBook.errorMessage).equal(readBookError(bookTreeViewProvider.currentBook.bookPath, `ENOENT: no such file or directory, open '${run.folderPaths.configFile}'`)); + try { + await bookTreeViewProvider.openBook(rootFolderPath, undefined, false, false); + + } catch (error) { + should(error).equal(openFileError(bookTreeViewProvider.currentBook.bookPath, `ENOENT: no such file or directory, open '${run.folderPaths.configFile}'`)); + + } }); it('should show error if toc.yml file format is invalid', async function (): Promise { - await fs.writeFile(run.folderPaths.configFile, run.contents.config); - await bookTreeViewProvider.currentBook.readBooks(); - should(bookTreeViewProvider.currentBook.errorMessage).equal(readBookError(bookTreeViewProvider.currentBook.bookPath, `Invalid toc file`)); + try { + await fs.writeFile(run.folderPaths.configFile, run.contents.config); + await bookTreeViewProvider.openBook(rootFolderPath, undefined, false, false); + } catch (error) { + should(error).equal(openFileError(bookTreeViewProvider.currentBook.bookPath, `Invalid toc file`)); + } }); after(async function (): Promise { @@ -568,125 +584,127 @@ describe('BooksTreeViewTests', function () { await promisify(rimraf)(rootFolderPath); } }); - }); - })}); + }); + }) + }); - describe('BookTreeViewProvider.getSections', function() { + describe('BookTreeViewProvider.getSections', function () { let rootFolderPath = path.join(os.tmpdir(), `BookTestData_${uuid.v4()}`); let bookTreeViewProvider: BookTreeViewProvider; let runs = [ { it: 'v1', - folderPaths : { - 'dataFolderPath' : path.join(rootFolderPath, '_data'), - 'contentFolderPath' : path.join(rootFolderPath, 'content'), - 'configFile' : path.join(rootFolderPath, '_config.yml'), - 'tableofContentsFile' : path.join(rootFolderPath,'_data', 'toc.yml'), - 'notebook2File' : path.join(rootFolderPath, 'content', 'notebook2.ipynb'), + folderPaths: { + 'dataFolderPath': path.join(rootFolderPath, '_data'), + 'contentFolderPath': path.join(rootFolderPath, 'content'), + 'configFile': path.join(rootFolderPath, '_config.yml'), + 'tableofContentsFile': path.join(rootFolderPath, '_data', 'toc.yml'), + 'notebook2File': path.join(rootFolderPath, 'content', 'notebook2.ipynb'), }, - contents : { - 'config' : 'title: Test Book', - 'toc' : '- title: Notebook1\n url: /notebook1\n- title: Notebook2\n url: /notebook2' + contents: { + 'config': 'title: Test Book', + 'toc': '- title: Notebook1\n url: /notebook1\n- title: Notebook2\n url: /notebook2' } - },{ + }, { it: 'v2', - folderPaths : { - 'configFile' : path.join(rootFolderPath, '_config.yml'), - 'tableofContentsFile' : path.join(rootFolderPath,'_toc.yml'), - 'notebook2File' : path.join(rootFolderPath,'notebook2.ipynb'), + folderPaths: { + 'configFile': path.join(rootFolderPath, '_config.yml'), + 'tableofContentsFile': path.join(rootFolderPath, '_toc.yml'), + 'notebook2File': path.join(rootFolderPath, 'notebook2.ipynb'), }, - contents : { - 'config' : 'title: Test Book', - 'toc' : '- title: Notebook1\n file: /notebook1\n- title: Notebook2\n file: /notebook2' + contents: { + 'config': 'title: Test Book', + 'toc': '- title: Notebook1\n file: /notebook1\n- title: Notebook2\n file: /notebook2' } } ]; - runs.forEach(function (run){ + runs.forEach(function (run) { describe('BookTreeViewProvider.getSections on ' + run.it, function (): void { - let folder: vscode.WorkspaceFolder[]; - let expectedNotebook2: IExpectedBookItem; + let folder: vscode.WorkspaceFolder[]; + let expectedNotebook2: IExpectedBookItem; - before(async () => { - expectedNotebook2 = { - title: 'Notebook2', - url: '/notebook2', - previousUri: undefined, - nextUri: undefined - }; - await fs.mkdir(rootFolderPath); - if(run.it === 'v1'){ - await fs.mkdir(run.folderPaths.dataFolderPath); - await fs.mkdir(run.folderPaths.contentFolderPath); - } - await fs.writeFile(run.folderPaths.configFile, run.contents.config); - await fs.writeFile(run.folderPaths.tableofContentsFile, run.contents.toc); - await fs.writeFile(run.folderPaths.notebook2File, ''); + before(async () => { + expectedNotebook2 = { + title: 'Notebook2', + file: '/notebook2', + previousUri: undefined, + nextUri: undefined + }; + await fs.mkdir(rootFolderPath); + if (run.it === 'v1') { + await fs.mkdir(run.folderPaths.dataFolderPath); + await fs.mkdir(run.folderPaths.contentFolderPath); + } + await fs.writeFile(run.folderPaths.configFile, run.contents.config); + await fs.writeFile(run.folderPaths.tableofContentsFile, run.contents.toc); + await fs.writeFile(run.folderPaths.notebook2File, ''); - const mockExtensionContext = new MockExtensionContext(); - folder = [{ - uri: vscode.Uri.file(rootFolderPath), - name: '', - index: 0 - }]; - bookTreeViewProvider = new BookTreeViewProvider(folder, mockExtensionContext, false, 'bookTreeView', NavigationProviders.NotebooksNavigator); - let errorCase = new Promise((resolve, reject) => setTimeout(() => resolve(), 5000)); - await Promise.race([bookTreeViewProvider.initialized, errorCase.then(() => { throw new Error('BookTreeViewProvider did not initialize in time'); })]); + const mockExtensionContext = new MockExtensionContext(); + folder = [{ + uri: vscode.Uri.file(rootFolderPath), + name: '', + index: 0 + }]; + bookTreeViewProvider = new BookTreeViewProvider(folder, mockExtensionContext, false, 'bookTreeView', NavigationProviders.NotebooksNavigator); + let errorCase = new Promise((resolve, reject) => setTimeout(() => resolve(), 5000)); + await Promise.race([bookTreeViewProvider.initialized, errorCase.then(() => { throw new Error('BookTreeViewProvider did not initialize in time'); })]); + }); + + it('should show error if notebook or markdown file is missing', async function (): Promise { + let books: BookTreeItem[] = bookTreeViewProvider.currentBook.bookItems; + let children = await bookTreeViewProvider.currentBook.getSections({ sections: [] }, books[0].sections, rootFolderPath, books[0].book); + should(bookTreeViewProvider.currentBook.errorMessage).equal('Missing file : Notebook1 from '.concat(bookTreeViewProvider.currentBook.bookPath)); + // rest of book should be detected correctly even with a missing file + equalBookItems(children[0], expectedNotebook2); + }); + + after(async function (): Promise { + if (await exists(rootFolderPath)) await promisify(rimraf)(rootFolderPath); + }); }); + }) + }); - it('should show error if notebook or markdown file is missing', async function (): Promise { - let books: BookTreeItem[] = bookTreeViewProvider.currentBook.bookItems; - let children = await bookTreeViewProvider.currentBook.getSections({ sections: [] }, books[0].sections, rootFolderPath, books[0].book); - should(bookTreeViewProvider.currentBook.errorMessage).equal('Missing file : Notebook1'); - // rest of book should be detected correctly even with a missing file - equalBookItems(children[0], expectedNotebook2); - }); - - after(async function (): Promise { - if (await exists(rootFolderPath)) await promisify(rimraf)(rootFolderPath); - }); - }); - })}); - - describe('BookTreeViewProvider.Commands', function() { + describe('BookTreeViewProvider.Commands', function () { let rootFolderPath = path.join(os.tmpdir(), `BookTestData_${uuid.v4()}`); let bookTreeViewProvider: BookTreeViewProvider; let runs = [ { it: 'v1', - folderPaths : { - 'dataFolderPath' : path.join(rootFolderPath, '_data'), - 'contentFolderPath' : path.join(rootFolderPath, 'content'), - 'configFile' : path.join(rootFolderPath, '_config.yml'), - 'tableofContentsFile' : path.join(rootFolderPath,'_data', 'toc.yml'), - 'notebook1File' : path.join(rootFolderPath, 'content', 'notebook1.ipynb'), - 'notebook2File' : path.join(rootFolderPath, 'content', 'notebook2.ipynb'), - 'markdownFile' : path.join(rootFolderPath, 'content', 'readme.md') + folderPaths: { + 'dataFolderPath': path.join(rootFolderPath, '_data'), + 'contentFolderPath': path.join(rootFolderPath, 'content'), + 'configFile': path.join(rootFolderPath, '_config.yml'), + 'tableofContentsFile': path.join(rootFolderPath, '_data', 'toc.yml'), + 'notebook1File': path.join(rootFolderPath, 'content', 'notebook1.ipynb'), + 'notebook2File': path.join(rootFolderPath, 'content', 'notebook2.ipynb'), + 'markdownFile': path.join(rootFolderPath, 'content', 'readme.md') }, - contents : { - 'config' : 'title: Test Book', - 'toc' : '- title: Home\n url: /readme\n- title: Notebook1\n url: /notebook1\n- title: Notebook2\n url: /notebook2' + contents: { + 'config': 'title: Test Book', + 'toc': '- title: Home\n url: /readme\n- title: Notebook1\n url: /notebook1\n- title: Notebook2\n url: /notebook2' } }, { it: 'v2', - folderPaths : { - 'configFile' : path.join(rootFolderPath, '_config.yml'), - 'tableofContentsFile' : path.join(rootFolderPath, '_toc.yml'), - 'notebook1File' : path.join(rootFolderPath, 'notebook1.ipynb'), - 'notebook2File' : path.join(rootFolderPath, 'notebook2.ipynb'), - 'markdownFile' : path.join(rootFolderPath, 'readme.md') + folderPaths: { + 'configFile': path.join(rootFolderPath, '_config.yml'), + 'tableofContentsFile': path.join(rootFolderPath, '_toc.yml'), + 'notebook1File': path.join(rootFolderPath, 'notebook1.ipynb'), + 'notebook2File': path.join(rootFolderPath, 'notebook2.ipynb'), + 'markdownFile': path.join(rootFolderPath, 'readme.md') }, - contents : { - 'config' : 'title: Test Book', - 'toc' : '- title: Home\n file: /readme\n- title: Notebook1\n file: /notebook1\n- title: Notebook2\n file: /notebook2' + contents: { + 'config': 'title: Test Book', + 'toc': '- title: Home\n file: /readme\n- title: Notebook1\n file: /notebook1\n- title: Notebook2\n file: /notebook2' } } ]; - runs.forEach(function (run){ + runs.forEach(function (run) { describe('BookTreeViewProvider.Commands on ' + run.it, function (): void { before(async () => { await fs.mkdir(rootFolderPath); - if(run.it === 'v1'){ + if (run.it === 'v1') { await fs.mkdir(run.folderPaths.dataFolderPath); await fs.mkdir(run.folderPaths.contentFolderPath); } @@ -836,53 +854,53 @@ describe('BooksTreeViewTests', function () { }); }); - describe('BookTreeViewProvider.openNotebookFolder', function() { + describe('BookTreeViewProvider.openNotebookFolder', function () { let rootFolderPath = path.join(os.tmpdir(), `BookFolderTest_${uuid.v4()}`); let bookTreeViewProvider: BookTreeViewProvider; let runs = [ { it: 'v1', - folderPaths : { - 'bookFolderPath' : path.join(rootFolderPath, 'BookTestData'), - 'dataFolderPath' : path.join(rootFolderPath, 'BookTestData', '_data'), - 'contentFolderPath' : path.join(rootFolderPath, 'BookTestData', 'content'), - 'configFile' : path.join(rootFolderPath, 'BookTestData', '_config.yml'), - 'tableOfContentsFile' : path.join(rootFolderPath,'BookTestData','_data', 'toc.yml'), - 'bookNotebookFile' : path.join(rootFolderPath, 'BookTestData', 'content', 'notebook1.ipynb'), - 'notebookFolderPath' : path.join(rootFolderPath, 'NotebookTestData'), - 'standaloneNotebookFile' : path.join(rootFolderPath, 'NotebookTestData','notebook2.ipynb') + folderPaths: { + 'bookFolderPath': path.join(rootFolderPath, 'BookTestData'), + 'dataFolderPath': path.join(rootFolderPath, 'BookTestData', '_data'), + 'contentFolderPath': path.join(rootFolderPath, 'BookTestData', 'content'), + 'configFile': path.join(rootFolderPath, 'BookTestData', '_config.yml'), + 'tableOfContentsFile': path.join(rootFolderPath, 'BookTestData', '_data', 'toc.yml'), + 'bookNotebookFile': path.join(rootFolderPath, 'BookTestData', 'content', 'notebook1.ipynb'), + 'notebookFolderPath': path.join(rootFolderPath, 'NotebookTestData'), + 'standaloneNotebookFile': path.join(rootFolderPath, 'NotebookTestData', 'notebook2.ipynb') }, - contents : { - 'config' : 'title: Test Book', - 'toc' : '- title: Notebook1\n url: /notebook1', - 'bookTitle' : 'Test Book', - 'standaloneNotebookTitle' : 'notebook2' + contents: { + 'config': 'title: Test Book', + 'toc': '- title: Notebook1\n url: /notebook1', + 'bookTitle': 'Test Book', + 'standaloneNotebookTitle': 'notebook2' } }, { it: 'v2', - folderPaths : { - 'bookFolderPath' : path.join(rootFolderPath, 'BookTestData'), - 'configFile' : path.join(rootFolderPath, 'BookTestData', '_config.yml'), - 'tableOfContentsFile' : path.join(rootFolderPath,'BookTestData', '_toc.yml'), - 'bookNotebookFile' : path.join(rootFolderPath, 'BookTestData','notebook1.ipynb'), - 'notebookFolderPath' : path.join(rootFolderPath, 'NotebookTestData'), - 'standaloneNotebookFile' : path.join(rootFolderPath, 'NotebookTestData','notebook2.ipynb') + folderPaths: { + 'bookFolderPath': path.join(rootFolderPath, 'BookTestData'), + 'configFile': path.join(rootFolderPath, 'BookTestData', '_config.yml'), + 'tableOfContentsFile': path.join(rootFolderPath, 'BookTestData', '_toc.yml'), + 'bookNotebookFile': path.join(rootFolderPath, 'BookTestData', 'notebook1.ipynb'), + 'notebookFolderPath': path.join(rootFolderPath, 'NotebookTestData'), + 'standaloneNotebookFile': path.join(rootFolderPath, 'NotebookTestData', 'notebook2.ipynb') }, - contents : { - 'config' : 'title: Test Book', - 'toc' : '- title: Notebook1\n file: /notebook1', - 'bookTitle' : 'Test Book', - 'standaloneNotebookTitle' : 'notebook2' + contents: { + 'config': 'title: Test Book', + 'toc': '- title: Notebook1\n file: /notebook1', + 'bookTitle': 'Test Book', + 'standaloneNotebookTitle': 'notebook2' } - }]; - runs.forEach(function (run){ + }]; + runs.forEach(function (run) { describe('BookTreeViewProvider.openNotebookFolder on ' + run.it, function (): void { before(async () => { await fs.mkdir(rootFolderPath); await fs.mkdir(run.folderPaths.bookFolderPath); - if(run.it === 'v1') { + if (run.it === 'v1') { await fs.mkdir(run.folderPaths.dataFolderPath); await fs.mkdir(run.folderPaths.contentFolderPath); } diff --git a/extensions/notebook/src/test/book/bookTocManager.test.ts b/extensions/notebook/src/test/book/bookTocManager.test.ts index e5550de735..0e32e1150c 100644 --- a/extensions/notebook/src/test/book/bookTocManager.test.ts +++ b/extensions/notebook/src/test/book/bookTocManager.test.ts @@ -2,17 +2,25 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ - import * as should from 'should'; import * as path from 'path'; -import { BookTocManager, hasSections } from '../../book/bookTocManager'; +import { BookTocManager, hasSections, quickPickResults } from '../../book/bookTocManager'; import { BookTreeItem, BookTreeItemFormat, BookTreeItemType } from '../../book/bookTreeItem'; -import * as yaml from 'js-yaml'; import * as sinon from 'sinon'; import { IJupyterBookSectionV1, IJupyterBookSectionV2, JupyterBookSection } from '../../contracts/content'; import * as fs from 'fs-extra'; import * as os from 'os'; import * as uuid from 'uuid'; +import { exists } from '../../common/utils'; +import * as rimraf from 'rimraf'; +import { promisify } from 'util'; +import { BookModel } from '../../book/bookModel'; +import { MockExtensionContext } from '../common/stubs'; +import { BookVersion } from '../../book/bookVersionHandler'; +import { BookTreeViewProvider } from '../../book/bookTreeView'; +import { NavigationProviders } from '../../common/constants'; +import * as loc from '../../common/localizedConstants'; + export function equalTOC(actualToc: IJupyterBookSectionV2[], expectedToc: IJupyterBookSectionV2[]): boolean { for (let [i, section] of actualToc.entries()) { @@ -89,7 +97,7 @@ describe('BookTocManagerTests', function () { }]; await bookTocManager.createBook(bookFolderPath, root2FolderPath); should(equalTOC(bookTocManager.tableofContents[2].sections, expectedSection)).be.true; - should(bookTocManager.tableofContents[2].file).be.equal(path.join(subfolder, 'readme')); + should((bookTocManager.tableofContents[2] as IJupyterBookSectionV2).file).be.equal(path.join(subfolder, 'readme')); }); it('should ignore invalid file extensions', async () => { @@ -103,267 +111,466 @@ describe('BookTocManagerTests', function () { }); describe('EditingBooks', () => { - let book: BookTreeItem; - let bookSection: BookTreeItem; - let bookSection2: BookTreeItem; - let notebook: BookTreeItem; - let rootBookFolderPath: string = path.join(os.tmpdir(), uuid.v4(), 'Book'); - let rootSectionFolderPath: string = path.join(os.tmpdir(), uuid.v4(), 'BookSection'); - let rootSection2FolderPath: string = path.join(os.tmpdir(), uuid.v4(), 'BookSection2'); - let notebookFolder: string = path.join(os.tmpdir(), uuid.v4(), 'Notebook'); let bookTocManager: BookTocManager; - + let sourceBookModel: BookModel; + let targetBookModel: BookModel; + let targetBook: BookTreeItem; + let sectionC: BookTreeItem; + let sectionA: BookTreeItem; + let sectionB: BookTreeItem; + let notebook: BookTreeItem; + let duplicatedNotebook: BookTreeItem; + let sourceBookFolderPath: string = path.join(os.tmpdir(), uuid.v4(), 'sourceBook'); + let targetBookFolderPath: string = path.join(os.tmpdir(), uuid.v4(), 'targetBook'); + let duplicatedNotebookPath: string = path.join(os.tmpdir(), uuid.v4(), 'duplicatedNotebook'); let runs = [ { it: 'using the jupyter-book legacy version < 0.7.0', version: 'v1', - url: 'file', - book: { - 'rootBookFolderPath': rootBookFolderPath, - 'bookContentFolderPath': path.join(rootBookFolderPath, 'content', 'sample'), - 'bookDataFolderPath': path.join(rootBookFolderPath, '_data'), - 'notebook1': path.join(rootBookFolderPath, 'content', 'notebook'), - 'notebook2': path.join(rootBookFolderPath, 'content', 'notebook2'), - 'tocPath': path.join(rootBookFolderPath, '_data', 'toc.yml') - }, - bookSection1: { - 'contentPath': path.join(rootSectionFolderPath, 'content', 'sample', 'readme.md'), - 'sectionRoot': rootSectionFolderPath, - 'sectionName': 'Sample', - 'bookContentFolderPath': path.join(rootSectionFolderPath, 'content', 'sample'), - 'bookDataFolderPath': path.join(rootSectionFolderPath, '_data'), - 'notebook3': path.join(rootSectionFolderPath, 'content', 'sample', 'notebook3'), - 'notebook4': path.join(rootSectionFolderPath, 'content', 'sample', 'notebook4'), - 'tocPath': path.join(rootSectionFolderPath, '_data', 'toc.yml') - }, - bookSection2: { - 'contentPath': path.join(rootSection2FolderPath, 'content', 'test', 'readme.md'), - 'sectionRoot': rootSection2FolderPath, - 'sectionName': 'Test', - 'bookContentFolderPath': path.join(rootSection2FolderPath, 'content', 'test'), - 'bookDataFolderPath': path.join(rootSection2FolderPath, '_data'), - 'notebook5': path.join(rootSection2FolderPath, 'content', 'test', 'notebook5'), - 'notebook6': path.join(rootSection2FolderPath, 'content', 'test', 'notebook6'), - 'tocPath': path.join(rootSection2FolderPath, '_data', 'toc.yml') - }, - notebook: { - 'contentPath': path.join(notebookFolder, 'test', 'readme.md') - }, - section: [ - { - 'title': 'Notebook', - 'url': path.join(path.sep, 'notebook') - }, - { - 'title': 'Notebook 2', - 'url': path.join(path.sep, 'notebook2') - } - ], - section1: [ - { - 'title': 'Notebook 3', - 'url': path.join('sample', 'notebook3') - }, - { - 'title': 'Notebook 4', - 'url': path.join('sample', 'notebook4') - } - ], - section2: [ + sourceBook: { + 'rootBookFolderPath': sourceBookFolderPath, + 'bookContentFolderPath': path.join(sourceBookFolderPath, 'content'), + 'tocPath': path.join(sourceBookFolderPath, '_data', 'toc.yml'), + 'readme': path.join(sourceBookFolderPath, 'content', 'readme.md'), + 'toc': [ { - 'title': 'Notebook 5', - 'url': path.join(path.sep, 'test', 'notebook5') + 'title': 'Notebook 1', + 'file': path.join(path.sep, 'sectionA', 'notebook1') }, { - 'title': 'Notebook 6', - 'url': path.join(path.sep, 'test', 'notebook6') + 'title': 'Notebook 2', + 'file': path.join(path.sep, 'sectionA', 'notebook2') } ] + }, + sectionA: { + 'contentPath': path.join(sourceBookFolderPath, 'content', 'sectionA', 'readme.md'), + 'sectionRoot': path.join(sourceBookFolderPath, 'content', 'sectionA'), + 'sectionName': 'Section A', + 'notebook1': path.join(sourceBookFolderPath, 'content', 'sectionA', 'notebook1.ipynb'), + 'notebook2': path.join(sourceBookFolderPath, 'content', 'sectionA', 'notebook2.ipynb'), + 'sectionFormat': [ + { + 'title': 'Notebook 1', + 'file': path.join(path.sep, 'sectionA', 'notebook1') + }, + { + 'title': 'Notebook 2', + 'file': path.join(path.sep, 'sectionA', 'notebook2') + } + ] + }, + sectionB: { + 'contentPath': path.join(sourceBookFolderPath, 'content', 'sectionB', 'readme.md'), + 'sectionRoot': path.join(sourceBookFolderPath, 'content', 'sectionB'), + 'sectionName': 'Section B', + 'notebook3': path.join(sourceBookFolderPath, 'content', 'sectionB', 'notebook3.ipynb'), + 'notebook4': path.join(sourceBookFolderPath, 'content', 'sectionB', 'notebook4.ipynb'), + 'sectionFormat': [ + { + 'title': 'Notebook 3', + 'file': path.join(path.sep, 'sectionB', 'notebook3') + }, + { + 'title': 'Notebook 4', + 'file': path.join(path.sep, 'sectionB', 'notebook4') + } + ] + }, + notebook5: { + 'contentPath': path.join(sourceBookFolderPath, 'content', 'notebook5.ipynb') + }, + targetBook: { + 'rootBookFolderPath': targetBookFolderPath, + 'bookContentFolderPath': path.join(targetBookFolderPath, 'content'), + 'tocPath': path.join(targetBookFolderPath, '_data', 'toc.yml'), + 'readme': path.join(targetBookFolderPath, 'content', 'readme.md'), + 'toc': [ + { + 'title': 'Welcome page', + 'file': path.join(path.sep, 'readme'), + }, + { + 'title': 'Section C', + 'file': path.join(path.sep, 'sectionC', 'readme'), + 'sections': [ + { + 'title': 'Notebook 6', + 'file': path.join(path.sep, 'sectionC', 'notebook6') + } + ] + } + ] + }, + sectionC: { + 'contentPath': path.join(targetBookFolderPath, 'content', 'sectionC', 'readme.md'), + 'sectionRoot': path.join(targetBookFolderPath, 'content', 'sectionC'), + 'sectionName': 'Section C', + 'notebook6': path.join(targetBookFolderPath, 'content', 'sectionC', 'notebook6.ipynb'), + 'sectionFormat': [ + { + 'title': 'Notebook 6', + 'file': path.join(path.sep, 'sectionC', 'notebook6') + } + ] + } }, { - it: 'using jupyter-book versions >= 0.7.0', + it: 'using the jupyter-book legacy version >= 0.7.0', version: 'v2', - url: 'file', - book: { - 'bookContentFolderPath': path.join(rootBookFolderPath, 'sample'), - 'rootBookFolderPath': rootBookFolderPath, - 'notebook1': path.join(rootBookFolderPath, 'notebook'), - 'notebook2': path.join(rootBookFolderPath, 'notebook2'), - 'tocPath': path.join(rootBookFolderPath, '_toc.yml') + sourceBook: { + 'rootBookFolderPath': sourceBookFolderPath, + 'bookContentFolderPath': sourceBookFolderPath, + 'tocPath': path.join(sourceBookFolderPath, '_toc.yml'), + 'readme': path.join(sourceBookFolderPath, 'readme.md') }, - bookSection1: { - 'bookContentFolderPath': path.join(rootSectionFolderPath, 'sample'), - 'contentPath': path.join(rootSectionFolderPath, 'sample', 'readme.md'), - 'sectionRoot': rootSectionFolderPath, - 'sectionName': 'Sample', - 'notebook3': path.join(rootSectionFolderPath, 'sample', 'notebook3'), - 'notebook4': path.join(rootSectionFolderPath, 'sample', 'notebook4'), - 'tocPath': path.join(rootSectionFolderPath, '_toc.yml') - }, - bookSection2: { - 'bookContentFolderPath': path.join(rootSection2FolderPath, 'test'), - 'contentPath': path.join(rootSection2FolderPath, 'test', 'readme.md'), - 'sectionRoot': rootSection2FolderPath, - 'sectionName': 'Test', - 'notebook5': path.join(rootSection2FolderPath, 'test', 'notebook5'), - 'notebook6': path.join(rootSection2FolderPath, 'test', 'notebook6'), - 'tocPath': path.join(rootSection2FolderPath, '_toc.yml') - }, - notebook: { - 'contentPath': path.join(notebookFolder, 'test', 'readme.md') - }, - section: [ - { - 'title': 'Notebook', - 'file': path.join(path.sep, 'notebook') - }, - { - 'title': 'Notebook 2', - 'file': path.join(path.sep, 'notebook2') - } - ], - section1: [ - { - 'title': 'Notebook 3', - 'file': path.join('sample', 'notebook3') - }, - { - 'title': 'Notebook 4', - 'file': path.join('sample', 'notebook4') - } - ], - section2: [ + sectionA: { + 'contentPath': path.join(sourceBookFolderPath, 'sectionA', 'readme.md'), + 'sectionRoot': path.join(sourceBookFolderPath, 'sectionA'), + 'sectionName': 'Section A', + 'notebook1': path.join(sourceBookFolderPath, 'sectionA', 'notebook1.ipynb'), + 'notebook2': path.join(sourceBookFolderPath, 'sectionA', 'notebook2.ipynb'), + 'sectionFormat': [ { - 'title': 'Notebook 5', - 'file': path.join(path.sep, 'test', 'notebook5') + 'title': 'Notebook 1', + 'file': path.join(path.sep, 'sectionA', 'notebook1') }, { - 'title': 'Notebook 6', - 'file': path.join(path.sep, 'test', 'notebook6') + 'title': 'Notebook 2', + 'file': path.join(path.sep, 'sectionA', 'notebook2') } ] + }, + sectionB: { + 'contentPath': path.join(sourceBookFolderPath, 'sectionB', 'readme.md'), + 'sectionRoot': path.join(sourceBookFolderPath, 'sectionB'), + 'sectionName': 'Section B', + 'notebook3': path.join(sourceBookFolderPath, 'sectionB', 'notebook3.ipynb'), + 'notebook4': path.join(sourceBookFolderPath, 'sectionB', 'notebook4.ipynb'), + 'sectionFormat': [ + { + 'title': 'Notebook 3', + 'file': path.join(path.sep, 'sectionB', 'notebook3') + }, + { + 'title': 'Notebook 4', + 'file': path.join(path.sep, 'sectionB', 'notebook4') + } + ] + }, + notebook5: { + 'contentPath': path.join(sourceBookFolderPath, 'notebook5.ipynb') + }, + targetBook: { + 'rootBookFolderPath': targetBookFolderPath, + 'bookContentFolderPath': targetBookFolderPath, + 'tocPath': path.join(targetBookFolderPath, '_toc.yml'), + 'readme': path.join(targetBookFolderPath, 'readme.md'), + 'toc': [ + { + 'title': 'Welcome', + 'file': path.join(path.sep, 'readme'), + }, + { + 'title': 'Section C', + 'file': path.join(path.sep, 'sectionC', 'readme'), + 'sections': [ + { + 'title': 'Notebook 6', + 'file': path.join(path.sep, 'sectionC', 'notebook6') + } + ] + } + ] + }, + sectionC: { + 'contentPath': path.join(targetBookFolderPath, 'sectionC', 'readme.md'), + 'sectionRoot': path.join(targetBookFolderPath, 'sectionC'), + 'sectionName': 'Section C', + 'notebook6': path.join(targetBookFolderPath, 'sectionC', 'notebook6.ipynb'), + 'sectionFormat': [ + { + 'title': 'Notebook 6', + 'file': path.join(path.sep, 'sectionC', 'notebook6') + } + ] + } } ]; runs.forEach(function (run) { describe('Editing Books ' + run.it, function (): void { beforeEach(async () => { - let bookTreeItemFormat1: BookTreeItemFormat = { - contentPath: run.version === 'v1' ? path.join(run.book.rootBookFolderPath, 'content', 'index.md') : path.join(run.book.rootBookFolderPath, 'index.md'), - root: run.book.rootBookFolderPath, + let targetBookTreeItemFormat: BookTreeItemFormat = { + contentPath: run.targetBook.readme, + root: run.targetBook.rootBookFolderPath, tableOfContents: { - sections: run.section + sections: run.targetBook.toc }, isUntitled: undefined, - title: undefined, + title: 'Target Book', treeItemCollapsibleState: undefined, type: BookTreeItemType.Book, version: run.version, - page: run.section + page: run.targetBook.toc }; - let bookTreeItemFormat2: BookTreeItemFormat = { - title: run.bookSection1.sectionName, - contentPath: run.bookSection1.contentPath, - root: run.bookSection1.sectionRoot, + let sectionCTreeItemFormat: BookTreeItemFormat = { + title: run.sectionC.sectionName, + contentPath: run.sectionC.contentPath, + root: run.targetBook.rootBookFolderPath, tableOfContents: { - sections: run.section1 + sections: run.sectionC.sectionFormat }, isUntitled: undefined, treeItemCollapsibleState: undefined, - type: BookTreeItemType.Book, + type: BookTreeItemType.Markdown, version: run.version, - page: run.section1 + page: run.sectionC.sectionFormat }; - let bookTreeItemFormat3: BookTreeItemFormat = { - title: run.bookSection2.sectionName, - contentPath: run.bookSection2.contentPath, - root: run.bookSection2.sectionRoot, + // section A is from source book + let sectionATreeItemFormat: BookTreeItemFormat = { + title: run.sectionA.sectionName, + contentPath: run.sectionA.contentPath, + root: run.sourceBook.rootBookFolderPath, tableOfContents: { - sections: run.section2 + sections: run.sectionA.sectionFormat }, isUntitled: undefined, treeItemCollapsibleState: undefined, - type: BookTreeItemType.Book, + type: BookTreeItemType.Markdown, version: run.version, - page: run.section2 + page: run.sectionA.sectionFormat }; - let bookTreeItemFormat4: BookTreeItemFormat = { - title: run.bookSection2.sectionName, - contentPath: run.notebook.contentPath, - root: run.bookSection2.sectionRoot, + // section B is from source book + let sectionBTreeItemFormat: BookTreeItemFormat = { + title: run.sectionB.sectionName, + contentPath: run.sectionB.contentPath, + root: run.sourceBook.rootBookFolderPath, tableOfContents: { - sections: undefined + sections: run.sectionB.sectionFormat + }, + isUntitled: undefined, + treeItemCollapsibleState: undefined, + type: BookTreeItemType.Markdown, + version: run.version, + page: run.sectionB.sectionFormat + }; + + // notebook5 is from source book + let notebookTreeItemFormat: BookTreeItemFormat = { + title: '', + contentPath: run.notebook5.contentPath, + root: run.sourceBook.rootBookFolderPath, + tableOfContents: { + sections: [ + { + 'title': 'Notebook 5', + 'file': path.join(path.sep, 'notebook5') + } + ] }, isUntitled: undefined, treeItemCollapsibleState: undefined, type: BookTreeItemType.Notebook, + version: run.version, page: { - sections: undefined + sections: [ + { + 'title': 'Notebook 5', + 'file': path.join(path.sep, 'notebook5') + } + ] } }; - book = new BookTreeItem(bookTreeItemFormat1, undefined); - bookSection = new BookTreeItem(bookTreeItemFormat2, undefined); - bookSection2 = new BookTreeItem(bookTreeItemFormat3, undefined); - notebook = new BookTreeItem(bookTreeItemFormat4, undefined); - bookTocManager = new BookTocManager(); + let duplicatedNbTreeItemFormat: BookTreeItemFormat = { + title: 'Duplicated Notebook', + contentPath: path.join(duplicatedNotebookPath, 'notebook5.ipynb'), + root: duplicatedNotebookPath, + tableOfContents: { + sections: [ + { + 'title': 'Notebook 5', + 'file': path.join(path.sep, 'notebook5') + } + ] + }, + isUntitled: undefined, + treeItemCollapsibleState: undefined, + type: BookTreeItemType.Notebook, + version: run.version, + page: { + sections: [ + { + 'title': 'Notebook 5', + 'file': path.join(path.sep, 'notebook5') + } + ] + } + }; - bookSection.uri = path.join('sample', 'readme'); - bookSection2.uri = path.join('test', 'readme'); + targetBook = new BookTreeItem(targetBookTreeItemFormat, undefined); + sectionC = new BookTreeItem(sectionCTreeItemFormat, undefined); + sectionA = new BookTreeItem(sectionATreeItemFormat, undefined); + sectionB = new BookTreeItem(sectionBTreeItemFormat, undefined); + notebook = new BookTreeItem(notebookTreeItemFormat, undefined); + duplicatedNotebook = new BookTreeItem(duplicatedNbTreeItemFormat, undefined); - book.contextValue = 'savedBook'; - bookSection.contextValue = 'section'; - bookSection2.contextValue = 'section'; + + sectionC.uri = path.join('sectionC', 'readme'); + sectionA.uri = path.join('sectionA', 'readme'); + sectionB.uri = path.join('sectionB', 'readme'); + + targetBook.contextValue = 'savedBook'; + sectionA.contextValue = 'section'; + sectionB.contextValue = 'section'; + sectionC.contextValue = 'section'; notebook.contextValue = 'savedNotebook'; + duplicatedNotebook.contextValue = 'savedNotebook'; + + sectionC.tableOfContentsPath = run.targetBook.tocPath; + sectionA.tableOfContentsPath = run.sourceBook.tocPath; + sectionB.tableOfContentsPath = run.sourceBook.tocPath; + notebook.tableOfContentsPath = run.sourceBook.tocPath; + duplicatedNotebook.tableOfContentsPath = run.sourceBook.tocPath; + + sectionA.sections = run.sectionA.sectionFormat; + sectionB.sections = run.sectionB.sectionFormat; + sectionC.sections = run.sectionC.sectionFormat; + notebook.sections = [ + { + 'title': 'Notebook 5', + 'file': path.join(path.sep, 'notebook5') + } + ]; + duplicatedNotebook.sections = notebook.sections; + + await fs.promises.mkdir(run.targetBook.bookContentFolderPath, { recursive: true }); + await fs.promises.mkdir(run.sectionA.contentPath, { recursive: true }); + await fs.promises.mkdir(run.sectionB.contentPath, { recursive: true }); + await fs.promises.mkdir(run.sectionC.contentPath, { recursive: true }); + await fs.promises.mkdir(duplicatedNotebookPath, { recursive: true }); + + await fs.writeFile(run.sectionA.notebook1, ''); + await fs.writeFile(run.sectionA.notebook2, ''); + await fs.writeFile(run.sectionB.notebook3, ''); + await fs.writeFile(run.sectionB.notebook4, ''); + await fs.writeFile(run.sectionC.notebook6, ''); + await fs.writeFile(run.notebook5.contentPath, ''); + await fs.writeFile(duplicatedNotebook.book.contentPath, ''); + await fs.writeFile(path.join(run.targetBook.rootBookFolderPath, '_config.yml'), 'title: Target Book'); + await fs.writeFile(path.join(run.sourceBook.rootBookFolderPath, '_config.yml'), 'title: Source Book'); - await fs.promises.mkdir(run.book.bookContentFolderPath, { recursive: true }); - await fs.promises.mkdir(run.bookSection1.bookContentFolderPath, { recursive: true }); - await fs.promises.mkdir(run.bookSection2.bookContentFolderPath, { recursive: true }); - await fs.promises.mkdir(path.dirname(run.notebook.contentPath), { recursive: true }); - - if (run.book.bookDataFolderPath && run.bookSection1.bookDataFolderPath && run.bookSection2.bookDataFolderPath) { - await fs.promises.mkdir(run.book.bookDataFolderPath, { recursive: true }); - await fs.promises.mkdir(run.bookSection1.bookDataFolderPath, { recursive: true }); - await fs.promises.mkdir(run.bookSection2.bookDataFolderPath, { recursive: true }); + if (run.version === 'v1') { + await fs.promises.mkdir(path.dirname(run.targetBook.tocPath), { recursive: true }); + await fs.promises.mkdir(path.dirname(run.sourceBook.tocPath), { recursive: true }); } - await fs.writeFile(run.book.notebook1, ''); - await fs.writeFile(run.book.notebook2, ''); - await fs.writeFile(run.bookSection1.notebook3, ''); - await fs.writeFile(run.bookSection1.notebook4, ''); - await fs.writeFile(run.bookSection2.notebook5, ''); - await fs.writeFile(run.bookSection2.notebook6, ''); - await fs.writeFile(run.notebook.contentPath, ''); + + // target book + await fs.writeFile(run.targetBook.tocPath, '- title: Welcome\n file: /readme\n- title: Section C\n file: /sectionC/readme\n sections:\n - title: Notebook6\n file: /sectionC/notebook6'); + // source book + await fs.writeFile(run.sourceBook.tocPath, '- title: Notebook 5\n file: /notebook5\n- title: Section A\n file: /sectionA/readme\n sections:\n - title: Notebook1\n file: /sectionA/notebook1\n - title: Notebook2\n file: /sectionA/notebook2'); + + const mockExtensionContext = new MockExtensionContext(); + + sourceBookModel = new BookModel(run.sourceBook.rootBookFolderPath, false, false, mockExtensionContext); + targetBookModel = new BookModel(run.targetBook.rootBookFolderPath, false, false, mockExtensionContext); + // create book model mock objects + sinon.stub(sourceBookModel, 'bookItems').value([sectionA]); + sinon.stub(targetBookModel, 'bookItems').value([targetBook]); }); + it('Add section to book', async () => { - await bookTocManager.updateBook(bookSection, book); - const listFiles = await fs.promises.readdir(run.book.bookContentFolderPath); - const tocFile = await fs.promises.readFile(run.book.tocPath, 'utf8'); - let toc = yaml.safeLoad(tocFile); - should(JSON.stringify(listFiles)).be.equal(JSON.stringify(['notebook3', 'notebook4']), 'The files of the section should be moved to the books folder'); - should(equalSections(toc.sections[2], bookTocManager.newSection)).be.true; + bookTocManager = new BookTocManager(targetBookModel, sourceBookModel); + await bookTocManager.updateBook(sectionA, targetBook, undefined); + const listFiles = await fs.promises.readdir(path.join(run.targetBook.bookContentFolderPath, 'sectionA')); + const listSourceFiles = await fs.promises.readdir(path.join(run.sourceBook.bookContentFolderPath)); + should(JSON.stringify(listSourceFiles).includes('sectionA')).be.false('The source book files should not contain the section A files'); + should(JSON.stringify(listFiles)).be.equal(JSON.stringify(['notebook1.ipynb', 'notebook2.ipynb', 'readme.md']), 'The files of the section should be moved to the target book folder'); }); it('Add section to section', async () => { - await bookTocManager.updateBook(bookSection, bookSection2); - let listFiles = await fs.promises.readdir(path.join(run.bookSection2.bookContentFolderPath, 'sample')); - const tocFile = await fs.promises.readFile(path.join(run.bookSection2.tocPath), 'utf8'); - let toc = yaml.safeLoad(tocFile); - should(JSON.stringify(listFiles)).be.equal(JSON.stringify(['notebook3', 'notebook4']), 'The files of the section should be moved to the books folder'); - should(equalSections(toc[1].sections, bookTocManager.newSection)).be.true; + bookTocManager = new BookTocManager(targetBookModel, sourceBookModel); + await bookTocManager.updateBook(sectionB, sectionC, { + 'title': 'Notebook 6', + 'file': path.join(path.sep, 'sectionC', 'notebook6') + }); + const sectionCFiles = await fs.promises.readdir(path.join(run.targetBook.bookContentFolderPath, 'sectionC')); + const sectionBFiles = await fs.promises.readdir(path.join(run.targetBook.bookContentFolderPath, 'sectionB')); + should(JSON.stringify(sectionCFiles)).be.equal(JSON.stringify(['notebook6.ipynb', 'readme.md']), 'sectionB has been moved under target book content directory'); + should(JSON.stringify(sectionBFiles)).be.equal(JSON.stringify(['notebook3.ipynb', 'notebook4.ipynb', 'readme.md']), ' Verify that the files on sectionB had been moved to the targetBook'); }); it('Add notebook to book', async () => { - await bookTocManager.updateBook(notebook, book); - const folder = run.version === 'v1' ? path.join(run.book.rootBookFolderPath, 'content') : path.join(run.book.rootBookFolderPath); - let listFiles = await fs.promises.readdir(folder); - const tocFile = await fs.promises.readFile(run.book.tocPath, 'utf8'); - let toc = yaml.safeLoad(tocFile); - should(listFiles.findIndex(f => f === 'readme.md')).not.equal(-1); - should(equalSections(toc.sections[2], bookTocManager.newSection)).be.true; + bookTocManager = new BookTocManager(targetBookModel); + await bookTocManager.updateBook(notebook, targetBook); + const listFiles = await fs.promises.readdir(run.targetBook.bookContentFolderPath); + should(JSON.stringify(listFiles).includes('notebook5.ipynb')).be.true('Notebook 5 should be under the target book content folder'); + }); + + it('Add duplicated notebook to book', async () => { + bookTocManager = new BookTocManager(targetBookModel); + await bookTocManager.updateBook(notebook, targetBook); + await bookTocManager.updateBook(duplicatedNotebook, targetBook); + const listFiles = await fs.promises.readdir(run.targetBook.bookContentFolderPath); + if (run.version === BookVersion.v1) { + should(JSON.stringify(listFiles)).be.equal(JSON.stringify(['notebook5 - 2.ipynb', 'notebook5.ipynb', 'sectionC']), 'Should modify the name of the file'); + } else { + should(JSON.stringify(listFiles)).be.equal(JSON.stringify(['_config.yml', '_toc.yml', 'notebook5 - 2.ipynb', 'notebook5.ipynb', 'sectionC']), 'Should modify the name of the file'); + } + }); + + it('Recovery method is called after error', async () => { + const mockExtensionContext = new MockExtensionContext(); + const recoverySpy = sinon.spy(BookTocManager.prototype, 'recovery'); + sinon.stub(BookTocManager.prototype, 'updateBook').throws(new Error('Unexpected error.')); + const bookTreeViewProvider = new BookTreeViewProvider([], mockExtensionContext, false, 'bookTreeView', NavigationProviders.NotebooksNavigator); + const results: quickPickResults = { + book: targetBook, + quickPickSection: { + label: loc.labelAddToLevel, + description: undefined + } + } + bookTocManager = new BookTocManager(targetBookModel); + sinon.stub(bookTreeViewProvider, 'getSelectionQuickPick').returns(Promise.resolve(results)); + try { + await bookTreeViewProvider.editBook(notebook); + } catch (error) { + should(recoverySpy.calledOnce).be.true('If unexpected error then recovery method is called.'); + } + }); + + it('Clean up folder with files didnt move', async () => { + bookTocManager = new BookTocManager(targetBookModel); + bookTocManager.movedFiles.set(notebook.book.contentPath, 'movedtest'); + await fs.writeFile(path.join(run.sourceBook.bookContentFolderPath, 'test.ipynb'), ''); + await bookTocManager.cleanUp(path.dirname(notebook.book.contentPath)); + const listFiles = await fs.promises.readdir(path.dirname(notebook.book.contentPath)); + should(JSON.stringify(listFiles).includes('test.ipynb')).be.true('Notebook test.ipynb should not be removed'); + }); + + it('Clean up folder when there is an empty folder within the modified directory', async () => { + await fs.promises.mkdir(path.join(run.sourceBook.bookContentFolderPath, 'test')); + bookTocManager.modifiedDir.add(path.join(run.sourceBook.bookContentFolderPath, 'test')); + bookTocManager.movedFiles.set(notebook.book.contentPath, 'movedtest'); + await bookTocManager.cleanUp(path.dirname(notebook.book.contentPath)); + const listFiles = await fs.promises.readdir(run.sourceBook.bookContentFolderPath); + should(JSON.stringify(listFiles).includes('test')).be.true('Empty directories within the moving element directory are not deleted'); + }); + + afterEach(async function (): Promise { + sinon.restore(); + if (await exists(sourceBookFolderPath)) { + await promisify(rimraf)(sourceBookFolderPath); + } + if (await exists(targetBookFolderPath)) { + await promisify(rimraf)(targetBookFolderPath); + } }); }); });