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
This commit is contained in:
Barbara Valdez
2021-02-02 20:39:11 -08:00
committed by GitHub
parent 41915bda8d
commit 9ac180d772
13 changed files with 1296 additions and 595 deletions

View File

@@ -239,6 +239,10 @@
"command": "notebook.command.closeNotebook", "command": "notebook.command.closeNotebook",
"title": "%title.closeJupyterNotebook%" "title": "%title.closeJupyterNotebook%"
}, },
{
"command": "notebook.command.moveTo",
"title": "%title.moveTo%"
},
{ {
"command": "notebook.command.createBook", "command": "notebook.command.createBook",
"title": "%title.createJupyterBook%", "title": "%title.createJupyterBook%",
@@ -385,6 +389,10 @@
"command": "notebook.command.closeNotebook", "command": "notebook.command.closeNotebook",
"when": "false" "when": "false"
}, },
{
"command": "notebook.command.moveTo",
"when": "false"
},
{ {
"command": "notebook.command.revealInBooksViewlet", "command": "notebook.command.revealInBooksViewlet",
"when": "false" "when": "false"
@@ -457,6 +465,10 @@
"command": "notebook.command.closeNotebook", "command": "notebook.command.closeNotebook",
"when": "view == bookTreeView && viewItem == savedNotebook" "when": "view == bookTreeView && viewItem == savedNotebook"
}, },
{
"command": "notebook.command.moveTo",
"when": "view == bookTreeView && viewItem == savedNotebook || view == bookTreeView && viewItem == section"
},
{ {
"command": "notebook.command.pinNotebook", "command": "notebook.command.pinNotebook",
"when": "view == bookTreeView && viewItem == savedNotebook", "when": "view == bookTreeView && viewItem == savedNotebook",

View File

@@ -48,5 +48,6 @@
"title.openNotebookFolder": "Open Notebooks in Folder", "title.openNotebookFolder": "Open Notebooks in Folder",
"title.openRemoteJupyterBook": "Add Remote Jupyter Book", "title.openRemoteJupyterBook": "Add Remote Jupyter Book",
"title.pinNotebook": "Pin Notebook", "title.pinNotebook": "Pin Notebook",
"title.unpinNotebook": "Unpin Notebook" "title.unpinNotebook": "Unpin Notebook",
"title.moveTo": "Move to ..."
} }

View File

@@ -11,16 +11,12 @@ import * as path from 'path';
import * as fileServices from 'fs'; import * as fileServices from 'fs';
import * as fs from 'fs-extra'; import * as fs from 'fs-extra';
import * as loc from '../common/localizedConstants'; 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 fsPromises = fileServices.promises;
const content = 'content'; const content = 'content';
export enum BookVersion {
v1 = 'v1',
v2 = 'v2'
}
export class BookModel { export class BookModel {
private _bookItems: BookTreeItem[]; private _bookItems: BookTreeItem[];
private _allNotebooks = new Map<string, BookTreeItem>(); private _allNotebooks = new Map<string, BookTreeItem>();
@@ -124,7 +120,7 @@ export class BookModel {
if (this.openAsUntitled && !this._allNotebooks.get(pathDetails.base)) { if (this.openAsUntitled && !this._allNotebooks.get(pathDetails.base)) {
this._allNotebooks.set(pathDetails.base, notebookItem); this._allNotebooks.set(pathDetails.base, notebookItem);
} else { } 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); let uriToNotebook: vscode.Uri = vscode.Uri.file(this.bookPath);
if (!this._allNotebooks.get(uriToNotebook.fsPath)) { if (!this._allNotebooks.get(uriToNotebook.fsPath)) {
this._allNotebooks.set(uriToNotebook.fsPath, notebookItem); this._allNotebooks.set(uriToNotebook.fsPath, notebookItem);
@@ -155,7 +151,7 @@ export class BookModel {
title: config.title, title: config.title,
contentPath: this._tableOfContentsPath, contentPath: this._tableOfContentsPath,
root: this._rootPath, root: this._rootPath,
tableOfContents: { sections: this.parseJupyterSections(tableOfContents) }, tableOfContents: { sections: this.parseJupyterSections(this._bookVersion, tableOfContents) },
page: tableOfContents, page: tableOfContents,
type: BookTreeItemType.Book, type: BookTreeItemType.Book,
treeItemCollapsibleState: collapsibleState, treeItemCollapsibleState: collapsibleState,
@@ -169,7 +165,7 @@ export class BookModel {
this._bookItems.push(book); this._bookItems.push(book);
} catch (e) { } catch (e) {
this._errorMessage = loc.readBookError(this.bookPath, e instanceof Error ? e.message : 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; return this._bookItems;
@@ -182,95 +178,83 @@ export class BookModel {
public async getSections(tableOfContents: IJupyterBookToc, sections: JupyterBookSection[], root: string, book: BookTreeItemFormat): Promise<BookTreeItem[]> { public async getSections(tableOfContents: IJupyterBookToc, sections: JupyterBookSection[], root: string, book: BookTreeItemFormat): Promise<BookTreeItem[]> {
let notebooks: BookTreeItem[] = []; let notebooks: BookTreeItem[] = [];
for (let i = 0; i < sections.length; i++) { for (let i = 0; i < sections.length; i++) {
if (sections[i].url || (sections[i] as IJupyterBookSectionV2).file) { if (sections[i].url) {
if (sections[i].url && ((sections[i] as IJupyterBookSectionV1).external || book.version === BookVersion.v2)) { let externalLink: BookTreeItem = new BookTreeItem({
let externalLink: BookTreeItem = new BookTreeItem({ title: sections[i].title,
title: sections[i].title, contentPath: undefined,
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, root: root,
tableOfContents: tableOfContents, tableOfContents: tableOfContents,
page: sections[i], page: sections[i],
type: BookTreeItemType.ExternalLink, type: BookTreeItemType.Notebook,
treeItemCollapsibleState: vscode.TreeItemCollapsibleState.Collapsed, treeItemCollapsibleState: vscode.TreeItemCollapsibleState.Collapsed,
isUntitled: this.openAsUntitled, isUntitled: this.openAsUntitled,
version: book.version version: book.version
}, },
{ {
light: this._extensionContext.asAbsolutePath('resources/light/link.svg'), light: this._extensionContext.asAbsolutePath('resources/light/notebook.svg'),
dark: this._extensionContext.asAbsolutePath('resources/dark/link_inverse.svg') dark: this._extensionContext.asAbsolutePath('resources/dark/notebook_inverse.svg')
} }
); );
notebooks.push(externalLink); if (this.openAsUntitled) {
} else { if (!this._allNotebooks.get(path.basename(pathToNotebook))) {
let pathToNotebook: string; this._allNotebooks.set(path.basename(pathToNotebook), notebook);
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);
} }
} else if (await fs.pathExists(pathToMarkdown)) { notebooks.push(notebook);
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);
} else { } else {
this._errorMessage = loc.missingFileError(sections[i].title); // convert to URI to avoid causing issue with drive letters when getting navigation links
vscode.window.showErrorMessage(this._errorMessage); 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; return notebooks;
@@ -280,16 +264,16 @@ export class BookModel {
* Recursively parses out a section of a Jupyter Book. * Recursively parses out a section of a Jupyter Book.
* @param section The input data to parse * @param section The input data to parse
*/ */
private parseJupyterSections(section: any[]): JupyterBookSection[] { public parseJupyterSections(version: string, section: any[]): JupyterBookSection[] {
try { try {
return section.reduce((acc, val) => Array.isArray(val.sections) ? 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) { } catch (e) {
this._errorMessage = loc.invalidTocFileError(); this._errorMessage = loc.invalidTocFileError();
if (section.length > 0) { if (section.length > 0) {
this._errorMessage = loc.invalidTocError(section[0].title); this._errorMessage = loc.invalidTocError(section[0].title);
} }
throw this._errorMessage; throw new Error(this._errorMessage);
} }
} }

View File

@@ -6,14 +6,23 @@ import * as path from 'path';
import { BookTreeItem } from './bookTreeItem'; import { BookTreeItem } from './bookTreeItem';
import * as yaml from 'js-yaml'; import * as yaml from 'js-yaml';
import * as fs from 'fs-extra'; import * as fs from 'fs-extra';
import { IJupyterBookSectionV1, IJupyterBookSectionV2, JupyterBookSection } from '../contracts/content'; import { JupyterBookSection } from '../contracts/content';
import { BookVersion } from './bookModel'; import { BookVersion, convertTo } from './bookVersionHandler';
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import * as loc from '../common/localizedConstants';
import { BookModel } from './bookModel';
export interface IBookTocManager { export interface IBookTocManager {
updateBook(element: BookTreeItem, book: BookTreeItem): Promise<void>; updateBook(element: BookTreeItem, book: BookTreeItem, targetSection?: JupyterBookSection): Promise<void>;
createBook(bookContentPath: string, contentFolder: string): Promise<void> createBook(bookContentPath: string, contentFolder: string): Promise<void>;
recovery(): Promise<void>
} }
export interface quickPickResults {
quickPickSection?: vscode.QuickPickItem,
book?: BookTreeItem
}
const allowedFileExtensions: string[] = ['.md', '.ipynb']; const allowedFileExtensions: string[] = ['.md', '.ipynb'];
const initMarkdown: string[] = ['index.md', 'introduction.md', 'intro.md', 'readme.md']; 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 { export class BookTocManager implements IBookTocManager {
public tableofContents: IJupyterBookSectionV2[]; public tableofContents: JupyterBookSection[];
public newSection: JupyterBookSection = {}; public newSection: JupyterBookSection;
private _movedFiles = new Map<string, string>();
private _modifiedDirectory = new Set<string>();
private _tocFiles = new Map<string, JupyterBookSection[]>();
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<IJupyterBookSectionV2[]> { async getAllFiles(toc: JupyterBookSection[], directory: string, filesInDir: string[], rootDirectory: string): Promise<JupyterBookSection[]> {
await Promise.all(filesInDir.map(async file => { await Promise.all(filesInDir.map(async file => {
let isDirectory = (await fs.promises.stat(path.join(directory, file))).isDirectory(); let isDirectory = (await fs.promises.stat(path.join(directory, file))).isDirectory();
if (isDirectory) { if (isDirectory) {
@@ -41,7 +61,7 @@ export class BookTocManager implements IBookTocManager {
files.splice(index, 1); files.splice(index, 1);
} }
}); });
let jupyterSection: IJupyterBookSectionV2 = { let jupyterSection: JupyterBookSection = {
title: file, title: file,
file: path.join(file, initFile), file: path.join(file, initFile),
expand_sections: true, expand_sections: true,
@@ -53,7 +73,7 @@ export class BookTocManager implements IBookTocManager {
} else if (allowedFileExtensions.includes(path.extname(file))) { } else if (allowedFileExtensions.includes(path.extname(file))) {
// if the file is in the book root we don't include the directory. // 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 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, title: path.parse(file).name,
file: filePath file: filePath
}; };
@@ -74,21 +94,119 @@ export class BookTocManager implements IBookTocManager {
return toc; return toc;
} }
updateToc(tableOfContents: JupyterBookSection[], findSection: BookTreeItem, addSection: JupyterBookSection): JupyterBookSection[] { /**
for (const section of tableOfContents) { * Renames files if it already exists in the target book
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))) { * @param src The source file that will be moved.
if (tableOfContents[tableOfContents.length - 1].sections) { * @param dest The destination path of the file, that it's been moved.
tableOfContents[tableOfContents.length - 1].sections.push(addSection); * Returns a new file name that does not exist in destination folder.
} else { */
tableOfContents[tableOfContents.length - 1].sections = [addSection]; async renameFile(src: string, dest: string): Promise<string> {
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<void> {
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<void> {
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<void> {
const toc = yaml.safeLoad((await fs.readFile(tocPath, 'utf8')));
this._tocFiles.set(tocPath, toc);
let newToc = new Array<JupyterBookSection>(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); await vscode.commands.executeCommand('notebook.command.openNotebookFolder', bookContentPath, undefined, true);
} }
async addSection(section: BookTreeItem, book: BookTreeItem, isSection: boolean): Promise<void> { /**
this.newSection.title = section.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
//the book contentPath contains the first file of the section, we get the dirname to identify the section's root path * and move the files individually. The overwrite option is set to false to prevent any issues with duplicated file names.
const rootPath = isSection ? path.dirname(book.book.contentPath) : book.rootContentPath; * @param files Files in the section.
// 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 async traverseSections(files: JupyterBookSection[]): Promise<JupyterBookSection[]> {
const uri = isSection ? path.join(path.basename(rootPath), section.uri) : section.uri; let movedSections: JupyterBookSection[] = [];
if (section.book.version === BookVersion.v1) { for (const elem of files) {
this.newSection.url = uri; if (elem.file) {
let movedSections: IJupyterBookSectionV1[] = []; let fileName = undefined;
const files = section.sections as IJupyterBookSectionV1[]; try {
for (const elem of files) { await fs.move(path.join(this.sourceBookContentPath, elem.file).concat('.ipynb'), path.join(this.targetBookContentPath, elem.file).concat('.ipynb'), { overwrite: false });
await fs.promises.mkdir(path.join(rootPath, path.dirname(elem.url)), { recursive: true }); this._movedFiles.set(path.join(this.sourceBookContentPath, elem.file).concat('.ipynb'), path.join(this.targetBookContentPath, elem.file).concat('.ipynb'));
await fs.move(path.join(path.dirname(section.book.contentPath), path.basename(elem.url)), path.join(rootPath, elem.url)); } catch (error) {
movedSections.push({ url: isSection ? path.join(path.basename(rootPath), elem.url) : elem.url, title: elem.title }); 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) { movedSections.push(elem);
(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;
} }
return movedSections;
} }
async addNotebook(notebook: BookTreeItem, book: BookTreeItem, isSection: boolean): Promise<void> { /**
//the book's contentPath contains the first file of the section, we get the dirname to identify the section's root path * 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
const rootPath = isSection ? path.dirname(book.book.contentPath) : book.rootContentPath; * notebook's path. The overwrite option is set to false to prevent any issues with duplicated file names.
let notebookName = path.basename(notebook.book.contentPath); * @param section The section that's been moved.
await fs.move(notebook.book.contentPath, path.join(rootPath, notebookName)); * @param book The target book.
if (book.book.version === BookVersion.v1) { */
this.newSection = { url: notebookName, title: notebookName }; async addSection(section: BookTreeItem, book: BookTreeItem): Promise<void> {
} else if (book.book.version === BookVersion.v2) { const uri = path.sep.concat(path.relative(section.rootContentPath, section.book.contentPath));
this.newSection = { file: notebookName, title: notebookName }; 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 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<void> { async addNotebook(notebook: BookTreeItem, book: BookTreeItem): Promise<void> {
if (element.contextValue === 'section' && book.book.version === element.book.version) { const rootPath = book.rootContentPath;
if (book.contextValue === 'section') { const notebookPath = path.parse(notebook.book.contentPath);
await this.addSection(element, book, true); let fileName = undefined;
this.tableofContents = this.updateToc(book.tableOfContents.sections, book, this.newSection); try {
await fs.writeFile(book.tableOfContentsPath, yaml.safeDump(this.tableofContents, { lineWidth: Infinity })); await fs.move(notebook.book.contentPath, path.join(rootPath, notebookPath.base), { overwrite: false });
} else if (book.contextValue === 'savedBook') { } catch (error) {
await this.addSection(element, book, false); if (error.code === 'EEXIST') {
book.tableOfContents.sections.push(this.newSection); fileName = await this.renameFile(notebook.book.contentPath, path.join(rootPath, notebookPath.base));
await fs.writeFile(book.tableOfContentsPath, yaml.safeDump(book.tableOfContents, { lineWidth: Infinity })); }
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<void> {
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') { else if (element.contextValue === 'savedNotebook') {
if (book.contextValue === 'savedBook') { await this.addNotebook(element, targetBook);
await this.addNotebook(element, book, false); if (element.tableOfContentsPath) {
book.tableOfContents.sections.push(this.newSection); const elementVersion = element.book.version === BookVersion.v1 ? BookVersion.v1 : BookVersion.v2;
await fs.writeFile(book.tableOfContentsPath, yaml.safeDump(book.tableOfContents, { lineWidth: Infinity })); // the notebook is part of a book so we need to modify its toc as well
} else if (book.contextValue === 'section') { const findSection = { file: element.book.page.file?.replace(/\\/g, '/'), title: element.book.page.title };
await this.addNotebook(element, book, true); await this.updateTOC(elementVersion, element.tableOfContentsPath, findSection, undefined);
this.tableofContents = this.updateToc(book.tableOfContents.sections, book, this.newSection); } else {
await fs.writeFile(book.tableOfContentsPath, yaml.safeDump(this.tableofContents, { lineWidth: Infinity })); // 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<string, string> {
return this._movedFiles;
}
public get originalToc(): Map<string, JupyterBookSection[]> {
return this._tocFiles;
}
public get modifiedDir(): Set<string> {
return this._modifiedDirectory;
}
public set movedFiles(files: Map<string, string>) {
this._movedFiles = files;
}
public set originalToc(files: Map<string, JupyterBookSection[]>) {
this._tocFiles = files;
}
public set modifiedDir(files: Set<string>) {
this._modifiedDirectory = files;
}
} }

View File

@@ -4,14 +4,11 @@
*--------------------------------------------------------------------------------------------*/ *--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import * as path from 'path';
import * as fs from 'fs'; 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 * as loc from '../common/localizedConstants';
import { isBookItemPinned } from '../common/utils'; import { isBookItemPinned } from '../common/utils';
import { BookVersion } from './bookModel'; import { getContentPath, getTocPath } from './bookVersionHandler';
const content = 'content';
export enum BookTreeItemType { export enum BookTreeItemType {
Book = 'Book', Book = 'Book',
@@ -45,7 +42,6 @@ export class BookTreeItem extends vscode.TreeItem {
constructor(public book: BookTreeItemFormat, icons: any) { constructor(public book: BookTreeItemFormat, icons: any) {
super(book.title, book.treeItemCollapsibleState); super(book.title, book.treeItemCollapsibleState);
if (book.type === BookTreeItemType.Book) { if (book.type === BookTreeItemType.Book) {
this.collapsibleState = book.treeItemCollapsibleState; this.collapsibleState = book.treeItemCollapsibleState;
this._sections = book.page; this._sections = book.page;
@@ -64,6 +60,9 @@ export class BookTreeItem extends vscode.TreeItem {
} else { } else {
this.contextValue = isBookItemPinned(book.contentPath) ? 'pinnedNotebook' : 'savedNotebook'; this.contextValue = isBookItemPinned(book.contentPath) ? 'pinnedNotebook' : 'savedNotebook';
} }
} else if (book.type === BookTreeItemType.ExternalLink) {
this.contextValue = BookTreeItemType.ExternalLink;
} else { } else {
this.contextValue = book.type === BookTreeItemType.Notebook ? (isBookItemPinned(book.contentPath) ? 'pinnedNotebook' : 'savedNotebook') : 'section'; 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.setCommand();
} }
this.iconPath = icons; this.iconPath = icons;
this._tableOfContentsPath = undefined;
if (this.book.type === BookTreeItemType.ExternalLink) { if (this.book.type === BookTreeItemType.ExternalLink) {
this.tooltip = `${this._uri}`; this.tooltip = `${this._uri}`;
} }
else { 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; // if it's a section, book or a notebook's book then we set the table of contents path.
this._rootContentPath = this.book.version === BookVersion.v1 ? path.join(this.book.root, content) : this.book.root; 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.tooltip = this.book.type === BookTreeItemType.Book ? this._rootContentPath : this.book.contentPath;
this.resourceUri = vscode.Uri.file(this.book.root); 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 ? this.collapsibleState = (this.book.page.sections || this.book.page.subsections) && this.book.page.expand_sections ?
vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.Expanded :
this.book.page.sections || this.book.page.subsections ? this.book.page.sections || this.book.page.subsections ?
vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.Collapsed :
vscode.TreeItemCollapsibleState.None; vscode.TreeItemCollapsibleState.None;
this._sections = this.book.page.sections || this.book.page.subsections; 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) { if (this.book.tableOfContents.sections) {
let index = (this.book.tableOfContents.sections.indexOf(this.book.page)); 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) { 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) // 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], }; 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; let i = --index;
while (i > -1) { while (i > -1) {
let pathToNotebook: string; 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) // 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')); pathToNotebook = getContentPath(this.book.version, this.book.root, this.book.tableOfContents.sections[i].file);
} else if ((this.book.tableOfContents.sections[i] as IJupyterBookSectionV1).url) { pathToNotebook = pathToNotebook.concat('.ipynb');
pathToNotebook = path.posix.join(this.book.root, content, (this.book.tableOfContents.sections[i] as IJupyterBookSectionV1).url.concat('.ipynb'));
} }
// eslint-disable-next-line no-sync // eslint-disable-next-line no-sync
if (fs.existsSync(pathToNotebook)) { if (fs.existsSync(pathToNotebook)) {
this._previousUri = pathToNotebook; this._previousUri = pathToNotebook;
@@ -134,13 +135,11 @@ export class BookTreeItem extends vscode.TreeItem {
let i = ++index; let i = ++index;
while (i < this.book.tableOfContents.sections.length) { while (i < this.book.tableOfContents.sections.length) {
let pathToNotebook: string; 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) // 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')); pathToNotebook = getContentPath(this.book.version, this.book.root, this.book.tableOfContents.sections[i].file);
} else if ((this.book.tableOfContents.sections[i] as IJupyterBookSectionV1).url) { pathToNotebook = pathToNotebook.concat('.ipynb');
pathToNotebook = path.posix.join(this.book.root, content, (this.book.tableOfContents.sections[i] as IJupyterBookSectionV1).url.concat('.ipynb'));
} }
// eslint-disable-next-line no-sync // eslint-disable-next-line no-sync
if (fs.existsSync(pathToNotebook)) { if (fs.existsSync(pathToNotebook)) {
this._nextUri = pathToNotebook; this._nextUri = pathToNotebook;
@@ -174,7 +173,7 @@ export class BookTreeItem extends vscode.TreeItem {
return this.book.tableOfContents; return this.book.tableOfContents;
} }
public get sections(): any[] { public get sections(): JupyterBookSection[] {
return this._sections; return this._sections;
} }
@@ -192,6 +191,14 @@ export class BookTreeItem extends vscode.TreeItem {
this._uri = uri; 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 * Helper method to find a child section with a specified URL
* @param url The url of the section we're searching for * @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 { 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; return section;
} else if (section.sections) { } else if (section.sections) {
for (const childSection of section.sections) { for (const childSection of section.sections) {

View File

@@ -11,19 +11,17 @@ import * as constants from '../common/constants';
import { IPrompter, IQuestion, QuestionTypes } from '../prompts/question'; import { IPrompter, IQuestion, QuestionTypes } from '../prompts/question';
import CodeAdapter from '../prompts/adapter'; import CodeAdapter from '../prompts/adapter';
import { BookTreeItem, BookTreeItemType } from './bookTreeItem'; import { BookTreeItem, BookTreeItemType } from './bookTreeItem';
import { BookModel, BookVersion } from './bookModel'; import { BookModel } from './bookModel';
import { Deferred } from '../common/promise'; import { Deferred } from '../common/promise';
import { IBookTrustManager, BookTrustManager } from './bookTrustManager'; import { IBookTrustManager, BookTrustManager } from './bookTrustManager';
import * as loc from '../common/localizedConstants'; import * as loc from '../common/localizedConstants';
import * as glob from 'fast-glob'; import * as glob from 'fast-glob';
import { IJupyterBookSectionV2, IJupyterBookSectionV1 } from '../contracts/content';
import { debounce, getPinnedNotebooks } from '../common/utils'; import { debounce, getPinnedNotebooks } from '../common/utils';
import { IBookPinManager, BookPinManager } from './bookPinManager'; 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'; import { TelemetryReporter, BookTelemetryView, NbTelemetryActions } from '../telemetry';
const content = 'content';
interface BookSearchResults { interface BookSearchResults {
notebookPaths: string[]; notebookPaths: string[];
bookPaths: string[]; bookPaths: string[];
@@ -50,7 +48,6 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider<BookTreeIte
this._extensionContext = extensionContext; this._extensionContext = extensionContext;
this.books = []; this.books = [];
this.bookPinManager = new BookPinManager(); this.bookPinManager = new BookPinManager();
this.bookTocManager = new BookTocManager();
this.viewId = view; this.viewId = view;
this.initialize(workspaceFolders).catch(e => console.error(e)); this.initialize(workspaceFolders).catch(e => console.error(e));
this.prompter = new CodeAdapter(); this.prompter = new CodeAdapter();
@@ -93,6 +90,14 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider<BookTreeIte
this._extensionContext.globalState.update(constants.visitedNotebooksMementoKey, value); this._extensionContext.globalState.update(constants.visitedNotebooksMementoKey, value);
} }
setFileWatcher(book: BookModel): void {
fs.watchFile(book.tableOfContentsPath, async (curr, prev) => {
if (curr.mtime > prev.mtime) {
this.fireBookRefresh(book);
}
});
}
trustBook(bookTreeItem?: BookTreeItem): void { trustBook(bookTreeItem?: BookTreeItem): void {
let bookPathToTrust: string = bookTreeItem ? bookTreeItem.root : this.currentBook?.bookPath; let bookPathToTrust: string = bookTreeItem ? bookTreeItem.root : this.currentBook?.bookPath;
if (bookPathToTrust) { if (bookPathToTrust) {
@@ -144,8 +149,92 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider<BookTreeIte
TelemetryReporter.createActionEvent(BookTelemetryView, NbTelemetryActions.CreateBook).send(); TelemetryReporter.createActionEvent(BookTelemetryView, NbTelemetryActions.CreateBook).send();
} }
async editBook(book: BookTreeItem, section: BookTreeItem): Promise<void> { async getSelectionQuickPick(movingElement: BookTreeItem): Promise<quickPickResults> {
await this.bookTocManager.updateBook(section, book); 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<void> {
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<void> { async openBook(bookPath: string, urlToOpen?: string, showPreview?: boolean, isNotebook?: boolean): Promise<void> {
@@ -180,6 +269,11 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider<BookTreeIte
}); });
} }
} catch (e) { } catch (e) {
// if there is an error remove book from context
const index = this.books.findIndex(book => book.bookPath === bookPath);
if (index !== -1) {
this.books.splice(index, 1);
}
vscode.window.showErrorMessage(loc.openFileError(bookPath, e instanceof Error ? e.message : e)); vscode.window.showErrorMessage(loc.openFileError(bookPath, e instanceof Error ? e.message : e));
} }
} }
@@ -267,9 +361,9 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider<BookTreeIte
if (urlToOpen) { if (urlToOpen) {
const bookRoot = this.currentBook.bookItems[0]; const bookRoot = this.currentBook.bookItems[0];
const sectionToOpen = bookRoot.findChildSection(urlToOpen); const sectionToOpen = bookRoot.findChildSection(urlToOpen);
urlPath = sectionToOpen?.url; urlPath = sectionToOpen?.file;
} else { } else {
urlPath = this.currentBook.version === BookVersion.v1 ? (this.currentBook.bookItems[0].tableOfContents.sections[0] as IJupyterBookSectionV1).url : (this.currentBook.bookItems[0].tableOfContents.sections[0] as IJupyterBookSectionV2).file; urlPath = this.currentBook.bookItems[0].tableOfContents.sections[0].file;
} }
} }
if (urlPath) { if (urlPath) {
@@ -434,20 +528,7 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider<BookTreeIte
public async searchJupyterBooks(treeItem?: BookTreeItem): Promise<void> { public async searchJupyterBooks(treeItem?: BookTreeItem): Promise<void> {
let folderToSearch: string; let folderToSearch: string;
if (treeItem && treeItem.sections !== undefined) { if (treeItem && treeItem.sections !== undefined) {
if (treeItem.book.version === BookVersion.v1) { folderToSearch = treeItem.uri ? getContentPath(treeItem.version, treeItem.book.root, path.dirname(treeItem.uri)) : getContentPath(treeItem.version, treeItem.book.root, '');
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);
}
}
} else if (this.currentBook && !this.currentBook.isNotebook) { } else if (this.currentBook && !this.currentBook.isNotebook) {
folderToSearch = path.join(this.currentBook.contentFolderPath); folderToSearch = path.join(this.currentBook.contentFolderPath);
} else { } else {
@@ -573,19 +654,14 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider<BookTreeIte
} }
} }
/**
* Optional method on the vscode interface.
* Implementing getParent, due to reveal method in extHostTreeView.ts
* throwing error if it is not implemented.
*/
getParent(element?: BookTreeItem): vscode.ProviderResult<BookTreeItem> { getParent(element?: BookTreeItem): vscode.ProviderResult<BookTreeItem> {
if (element?.uri) { // Remove it for perf issues.
let parentPath: string; return undefined;
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;
}
} }
getUntitledNotebookUri(resource: string): vscode.Uri { getUntitledNotebookUri(resource: string): vscode.Uri {

View File

@@ -6,7 +6,8 @@ import * as path from 'path';
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import * as constants from './../common/constants'; import * as constants from './../common/constants';
import { BookTreeItem } from './bookTreeItem'; import { BookTreeItem } from './bookTreeItem';
import { BookModel, BookVersion } from './bookModel'; import { BookModel } from './bookModel';
import { BookVersion } from './bookVersionHandler';
export interface IBookTrustManager { export interface IBookTrustManager {
isNotebookTrustedByDefault(notebookUri: string): boolean; isNotebookTrustedByDefault(notebookUri: string): boolean;

View File

@@ -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 {};
}

View File

@@ -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 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 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 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 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 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 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); } 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 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 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 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 // Remote Book dialog constants
export const url = localize('url', "URL"); export const url = localize('url', "URL");

View File

@@ -86,11 +86,11 @@ export interface IJupyterBookSectionV1 {
/** /**
* Contains a list of more entries that make up the chapter's/section's sub-sections * 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 * 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. * 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 * 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 * 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 * If the section shouldn't have a number in the sidebar
*/ */
@@ -147,8 +147,23 @@ export interface IJupyterBookSectionV2 {
* External link * External link
*/ */
url?: string; 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 interface JupyterBookSection extends IJupyterBookSectionV1, IJupyterBookSectionV2 { }
export type JupyterBookSection = IJupyterBookSectionV1 | IJupyterBookSectionV2;

View File

@@ -19,6 +19,7 @@ import { RemoteBookDialog } from './dialog/remoteBookDialog';
import { RemoteBookDialogModel } from './dialog/remoteBookDialogModel'; import { RemoteBookDialogModel } from './dialog/remoteBookDialogModel';
import { IconPathHelper } from './common/iconHelper'; import { IconPathHelper } from './common/iconHelper';
import { ExtensionContextHelper } from './common/extensionContextHelper'; import { ExtensionContextHelper } from './common/extensionContextHelper';
import { BookTreeItem } from './book/bookTreeItem';
const localize = nls.loadMessageBundle(); 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 model = new RemoteBookDialogModel();
let remoteBookController = new RemoteBookController(model, appContext.outputChannel); let remoteBookController = new RemoteBookController(model, appContext.outputChannel);

View File

@@ -19,12 +19,13 @@ import { exists } from '../../common/utils';
import { BookModel } from '../../book/bookModel'; import { BookModel } from '../../book/bookModel';
import { BookTrustManager } from '../../book/bookTrustManager'; import { BookTrustManager } from '../../book/bookTrustManager';
import { NavigationProviders } from '../../common/constants'; import { NavigationProviders } from '../../common/constants';
import { readBookError } from '../../common/localizedConstants'; import { openFileError } from '../../common/localizedConstants';
import * as sinon from 'sinon'; import * as sinon from 'sinon';
import { AppContext } from '../../common/appContext'; import { AppContext } from '../../common/appContext';
export interface IExpectedBookItem { export interface IExpectedBookItem {
title: string; title: string;
file?: string;
url?: string; url?: string;
sections?: any[]; sections?: any[];
external?: boolean; external?: boolean;
@@ -34,7 +35,11 @@ export interface IExpectedBookItem {
export function equalBookItems(book: BookTreeItem, expectedBook: IExpectedBookItem, errorMsg?: string): void { 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(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) { if (expectedBook.previousUri || expectedBook.nextUri) {
let prevUri = book.previousUri ? book.previousUri.toLocaleLowerCase() : undefined; let prevUri = book.previousUri ? book.previousUri.toLocaleLowerCase() : undefined;
let expectedPrevUri = expectedBook.previousUri ? expectedBook.previousUri.replace(/\\/g, '/') : undefined; let expectedPrevUri = expectedBook.previousUri ? expectedBook.previousUri.replace(/\\/g, '/') : undefined;
@@ -75,25 +80,25 @@ describe('BooksTreeViewTests', function () {
expectedNotebook1 = { expectedNotebook1 = {
// tslint:disable-next-line: quotemark // tslint:disable-next-line: quotemark
title: 'Notebook1', title: 'Notebook1',
url: '/notebook1', file: '/notebook1',
previousUri: undefined, previousUri: undefined,
nextUri: notebook2File.toLocaleLowerCase() nextUri: notebook2File.toLocaleLowerCase()
}; };
expectedNotebook2 = { expectedNotebook2 = {
title: 'Notebook2', title: 'Notebook2',
url: '/notebook2', file: '/notebook2',
previousUri: notebook1File.toLocaleLowerCase(), previousUri: notebook1File.toLocaleLowerCase(),
nextUri: notebook3File.toLocaleLowerCase() nextUri: notebook3File.toLocaleLowerCase()
}; };
expectedNotebook3 = { expectedNotebook3 = {
title: 'Notebook3', title: 'Notebook3',
url: '/notebook3', file: '/notebook3',
previousUri: notebook2File.toLocaleLowerCase(), previousUri: notebook2File.toLocaleLowerCase(),
nextUri: undefined nextUri: undefined
}; };
expectedMarkdown = { expectedMarkdown = {
title: 'Markdown', title: 'Markdown',
url: '/markdown' file: '/markdown'
}; };
expectedExternalLink = { expectedExternalLink = {
title: 'GitHub', 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(); let parent = await bookTreeViewProvider.getParent();
should(parent).be.undefined(); 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 rootFolderPath = path.join(os.tmpdir(), `BookTestData_${uuid.v4()}`);
let bookTreeViewProvider: BookTreeViewProvider; let bookTreeViewProvider: BookTreeViewProvider;
let runs = [ let runs = [
{ {
it: 'v1', it: 'v1',
folderPaths : { folderPaths: {
'dataFolderPath' : path.join(rootFolderPath, '_data'), 'dataFolderPath': path.join(rootFolderPath, '_data'),
'contentFolderPath' : path.join(rootFolderPath, 'content'), 'contentFolderPath': path.join(rootFolderPath, 'content'),
'configFile' : path.join(rootFolderPath, '_config.yml'), 'configFile': path.join(rootFolderPath, '_config.yml'),
'tableOfContentsFile' : path.join(rootFolderPath,'_data', 'toc.yml'), 'tableOfContentsFile': path.join(rootFolderPath, '_data', 'toc.yml'),
'notebook2File' : path.join(rootFolderPath, 'content', 'notebook2.ipynb'), 'notebook2File': path.join(rootFolderPath, 'content', 'notebook2.ipynb'),
'tableOfContentsFileIgnore' : path.join(rootFolderPath, 'toc.yml') 'tableOfContentsFileIgnore': path.join(rootFolderPath, 'toc.yml')
}, },
contents : { contents: {
'config' : 'title: Test Book', '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' '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', it: 'v2',
folderPaths : { folderPaths: {
'dataFolderPath' : path.join(rootFolderPath, '_data'), 'dataFolderPath': path.join(rootFolderPath, '_data'),
'configFile' : path.join(rootFolderPath, '_config.yml'), 'configFile': path.join(rootFolderPath, '_config.yml'),
'tableOfContentsFile' : path.join(rootFolderPath, '_toc.yml'), 'tableOfContentsFile': path.join(rootFolderPath, '_toc.yml'),
'notebook2File' : path.join(rootFolderPath,'notebook2.ipynb'), 'notebook2File': path.join(rootFolderPath, 'notebook2.ipynb'),
'tableOfContentsFileIgnore' : path.join(rootFolderPath, '_data', 'toc.yml') 'tableOfContentsFileIgnore': path.join(rootFolderPath, '_data', 'toc.yml')
}, },
contents : { contents: {
'config' : 'title: Test Book', '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' '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 { describe('BookTreeViewProvider.getTableOfContentFiles on ' + run.it, function (): void {
let folder: vscode.WorkspaceFolder; let folder: vscode.WorkspaceFolder;
before(async () => { before(async () => {
await fs.mkdir(rootFolderPath); await fs.mkdir(rootFolderPath);
await fs.mkdir(run.folderPaths.dataFolderPath); await fs.mkdir(run.folderPaths.dataFolderPath);
if(run.it === 'v1') { if (run.it === 'v1') {
await fs.mkdir(run.folderPaths.contentFolderPath); await fs.mkdir(run.folderPaths.contentFolderPath);
} }
await fs.writeFile(run.folderPaths.tableOfContentsFile, run.contents.toc); await fs.writeFile(run.folderPaths.tableOfContentsFile, run.contents.toc);
await fs.writeFile(run.folderPaths.tableOfContentsFileIgnore, ''); await fs.writeFile(run.folderPaths.tableOfContentsFileIgnore, '');
await fs.writeFile(run.folderPaths.notebook2File, ''); await fs.writeFile(run.folderPaths.notebook2File, '');
await fs.writeFile(run.folderPaths.configFile, run.contents.config);
const mockExtensionContext = new MockExtensionContext(); const mockExtensionContext = new MockExtensionContext();
folder = { folder = {
@@ -474,16 +480,17 @@ describe('BooksTreeViewTests', function () {
bookTreeViewProvider = new BookTreeViewProvider([folder], mockExtensionContext, false, 'bookTreeView', NavigationProviders.NotebooksNavigator); bookTreeViewProvider = new BookTreeViewProvider([folder], mockExtensionContext, false, 'bookTreeView', NavigationProviders.NotebooksNavigator);
let errorCase = new Promise((resolve, reject) => setTimeout(() => resolve(), 5000)); 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 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 () => { it('should ignore toc.yml files not in _data folder', async () => {
await bookTreeViewProvider.currentBook.readBookStructure(rootFolderPath); await bookTreeViewProvider.currentBook.readBookStructure(rootFolderPath);
await bookTreeViewProvider.currentBook.loadTableOfContentFiles(); await bookTreeViewProvider.currentBook.loadTableOfContentFiles();
let path = bookTreeViewProvider.currentBook.tableOfContentsPath; let path = bookTreeViewProvider.currentBook.tableOfContentsPath;
should(vscode.Uri.file(path).fsPath).equal(vscode.Uri.file(run.folderPaths.tableOfContentsFile).fsPath); 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 () => { it('should ignore toc.yml files not under the root book folder', async () => {
await bookTreeViewProvider.currentBook.readBookStructure(rootFolderPath); await bookTreeViewProvider.currentBook.readBookStructure(rootFolderPath);
await bookTreeViewProvider.currentBook.loadTableOfContentFiles(); 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 rootFolderPath = path.join(os.tmpdir(), `BookTestData_${uuid.v4()}`);
let bookTreeViewProvider: BookTreeViewProvider; let bookTreeViewProvider: BookTreeViewProvider;
let runs = [ let runs = [
{ {
it: 'v1', it: 'v1',
folderPaths : { folderPaths: {
'dataFolderPath' : path.join(rootFolderPath, '_data'), 'dataFolderPath': path.join(rootFolderPath, '_data'),
'contentFolderPath' : path.join(rootFolderPath, 'content'), 'contentFolderPath': path.join(rootFolderPath, 'content'),
'configFile' : path.join(rootFolderPath, '_config.yml'), 'configFile': path.join(rootFolderPath, '_config.yml'),
'tableofContentsFile' : path.join(rootFolderPath,'_data', 'toc.yml'), 'tableofContentsFile': path.join(rootFolderPath, '_data', 'toc.yml'),
'notebook2File' : path.join(rootFolderPath, 'content', 'notebook2.ipynb'), 'notebook2File': path.join(rootFolderPath, 'content', 'notebook2.ipynb'),
}, },
contents : { contents: {
'config' : 'title: Test Book', 'config': 'title: Test Book',
'toc' : '- title: Notebook1\n url: /notebook1\n- title: Notebook2\n url: /notebook2' 'toc': '- title: Notebook1\n url: /notebook1\n- title: Notebook2\n url: /notebook2'
} }
}, { }, {
it: 'v2', it: 'v2',
folderPaths : { folderPaths: {
'configFile' : path.join(rootFolderPath, '_config.yml'), 'configFile': path.join(rootFolderPath, '_config.yml'),
'tableofContentsFile' : path.join(rootFolderPath, '_toc.yml'), 'tableofContentsFile': path.join(rootFolderPath, '_toc.yml'),
'notebook2File' : path.join(rootFolderPath, 'notebook2.ipynb'), 'notebook2File': path.join(rootFolderPath, 'notebook2.ipynb'),
}, },
contents : { contents: {
'config' : 'title: Test Book', 'config': 'title: Test Book',
'toc' : '- title: Notebook1\n file: /notebook1\n- title: Notebook2\n file: /notebook2' '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 { describe('BookTreeViewProvider.getBooks on ' + run.it, function (): void {
let folder: vscode.WorkspaceFolder; let folder: vscode.WorkspaceFolder;
before(async () => { before(async () => {
await fs.mkdir(rootFolderPath); await fs.mkdir(rootFolderPath);
if(run.it === 'v1'){ if (run.it === 'v1') {
await fs.mkdir(run.folderPaths.dataFolderPath); await fs.mkdir(run.folderPaths.dataFolderPath);
await fs.mkdir(run.folderPaths.contentFolderPath); await fs.mkdir(run.folderPaths.contentFolderPath);
} }
await fs.writeFile(run.folderPaths.tableofContentsFile, run.contents.config); await fs.writeFile(run.folderPaths.tableofContentsFile, run.contents.config);
await fs.writeFile(run.folderPaths.configFile, run.contents.config);
const mockExtensionContext = new MockExtensionContext(); const mockExtensionContext = new MockExtensionContext();
folder = { folder = {
uri: vscode.Uri.file(rootFolderPath), uri: vscode.Uri.file(rootFolderPath),
@@ -553,14 +561,22 @@ describe('BooksTreeViewTests', function () {
}); });
it('should show error message if config.yml file not found', async () => { it('should show error message if config.yml file not found', async () => {
await bookTreeViewProvider.currentBook.readBooks(); try {
should(bookTreeViewProvider.currentBook.errorMessage).equal(readBookError(bookTreeViewProvider.currentBook.bookPath, `ENOENT: no such file or directory, open '${run.folderPaths.configFile}'`)); 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<void> { it('should show error if toc.yml file format is invalid', async function (): Promise<void> {
await fs.writeFile(run.folderPaths.configFile, run.contents.config); try {
await bookTreeViewProvider.currentBook.readBooks(); await fs.writeFile(run.folderPaths.configFile, run.contents.config);
should(bookTreeViewProvider.currentBook.errorMessage).equal(readBookError(bookTreeViewProvider.currentBook.bookPath, `Invalid toc file`)); await bookTreeViewProvider.openBook(rootFolderPath, undefined, false, false);
} catch (error) {
should(error).equal(openFileError(bookTreeViewProvider.currentBook.bookPath, `Invalid toc file`));
}
}); });
after(async function (): Promise<void> { after(async function (): Promise<void> {
@@ -568,125 +584,127 @@ describe('BooksTreeViewTests', function () {
await promisify(rimraf)(rootFolderPath); await promisify(rimraf)(rootFolderPath);
} }
}); });
}); });
})}); })
});
describe('BookTreeViewProvider.getSections', function() { describe('BookTreeViewProvider.getSections', function () {
let rootFolderPath = path.join(os.tmpdir(), `BookTestData_${uuid.v4()}`); let rootFolderPath = path.join(os.tmpdir(), `BookTestData_${uuid.v4()}`);
let bookTreeViewProvider: BookTreeViewProvider; let bookTreeViewProvider: BookTreeViewProvider;
let runs = [ let runs = [
{ {
it: 'v1', it: 'v1',
folderPaths : { folderPaths: {
'dataFolderPath' : path.join(rootFolderPath, '_data'), 'dataFolderPath': path.join(rootFolderPath, '_data'),
'contentFolderPath' : path.join(rootFolderPath, 'content'), 'contentFolderPath': path.join(rootFolderPath, 'content'),
'configFile' : path.join(rootFolderPath, '_config.yml'), 'configFile': path.join(rootFolderPath, '_config.yml'),
'tableofContentsFile' : path.join(rootFolderPath,'_data', 'toc.yml'), 'tableofContentsFile': path.join(rootFolderPath, '_data', 'toc.yml'),
'notebook2File' : path.join(rootFolderPath, 'content', 'notebook2.ipynb'), 'notebook2File': path.join(rootFolderPath, 'content', 'notebook2.ipynb'),
}, },
contents : { contents: {
'config' : 'title: Test Book', 'config': 'title: Test Book',
'toc' : '- title: Notebook1\n url: /notebook1\n- title: Notebook2\n url: /notebook2' 'toc': '- title: Notebook1\n url: /notebook1\n- title: Notebook2\n url: /notebook2'
} }
},{ }, {
it: 'v2', it: 'v2',
folderPaths : { folderPaths: {
'configFile' : path.join(rootFolderPath, '_config.yml'), 'configFile': path.join(rootFolderPath, '_config.yml'),
'tableofContentsFile' : path.join(rootFolderPath,'_toc.yml'), 'tableofContentsFile': path.join(rootFolderPath, '_toc.yml'),
'notebook2File' : path.join(rootFolderPath,'notebook2.ipynb'), 'notebook2File': path.join(rootFolderPath, 'notebook2.ipynb'),
}, },
contents : { contents: {
'config' : 'title: Test Book', 'config': 'title: Test Book',
'toc' : '- title: Notebook1\n file: /notebook1\n- title: Notebook2\n file: /notebook2' '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 { describe('BookTreeViewProvider.getSections on ' + run.it, function (): void {
let folder: vscode.WorkspaceFolder[]; let folder: vscode.WorkspaceFolder[];
let expectedNotebook2: IExpectedBookItem; let expectedNotebook2: IExpectedBookItem;
before(async () => { before(async () => {
expectedNotebook2 = { expectedNotebook2 = {
title: 'Notebook2', title: 'Notebook2',
url: '/notebook2', file: '/notebook2',
previousUri: undefined, previousUri: undefined,
nextUri: undefined nextUri: undefined
}; };
await fs.mkdir(rootFolderPath); await fs.mkdir(rootFolderPath);
if(run.it === 'v1'){ if (run.it === 'v1') {
await fs.mkdir(run.folderPaths.dataFolderPath); await fs.mkdir(run.folderPaths.dataFolderPath);
await fs.mkdir(run.folderPaths.contentFolderPath); await fs.mkdir(run.folderPaths.contentFolderPath);
} }
await fs.writeFile(run.folderPaths.configFile, run.contents.config); await fs.writeFile(run.folderPaths.configFile, run.contents.config);
await fs.writeFile(run.folderPaths.tableofContentsFile, run.contents.toc); await fs.writeFile(run.folderPaths.tableofContentsFile, run.contents.toc);
await fs.writeFile(run.folderPaths.notebook2File, ''); await fs.writeFile(run.folderPaths.notebook2File, '');
const mockExtensionContext = new MockExtensionContext(); const mockExtensionContext = new MockExtensionContext();
folder = [{ folder = [{
uri: vscode.Uri.file(rootFolderPath), uri: vscode.Uri.file(rootFolderPath),
name: '', name: '',
index: 0 index: 0
}]; }];
bookTreeViewProvider = new BookTreeViewProvider(folder, mockExtensionContext, false, 'bookTreeView', NavigationProviders.NotebooksNavigator); bookTreeViewProvider = new BookTreeViewProvider(folder, mockExtensionContext, false, 'bookTreeView', NavigationProviders.NotebooksNavigator);
let errorCase = new Promise((resolve, reject) => setTimeout(() => resolve(), 5000)); 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 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<void> {
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<void> {
if (await exists(rootFolderPath)) await promisify(rimraf)(rootFolderPath);
});
}); });
})
});
it('should show error if notebook or markdown file is missing', async function (): Promise<void> { describe('BookTreeViewProvider.Commands', function () {
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<void> {
if (await exists(rootFolderPath)) await promisify(rimraf)(rootFolderPath);
});
});
})});
describe('BookTreeViewProvider.Commands', function() {
let rootFolderPath = path.join(os.tmpdir(), `BookTestData_${uuid.v4()}`); let rootFolderPath = path.join(os.tmpdir(), `BookTestData_${uuid.v4()}`);
let bookTreeViewProvider: BookTreeViewProvider; let bookTreeViewProvider: BookTreeViewProvider;
let runs = [ let runs = [
{ {
it: 'v1', it: 'v1',
folderPaths : { folderPaths: {
'dataFolderPath' : path.join(rootFolderPath, '_data'), 'dataFolderPath': path.join(rootFolderPath, '_data'),
'contentFolderPath' : path.join(rootFolderPath, 'content'), 'contentFolderPath': path.join(rootFolderPath, 'content'),
'configFile' : path.join(rootFolderPath, '_config.yml'), 'configFile': path.join(rootFolderPath, '_config.yml'),
'tableofContentsFile' : path.join(rootFolderPath,'_data', 'toc.yml'), 'tableofContentsFile': path.join(rootFolderPath, '_data', 'toc.yml'),
'notebook1File' : path.join(rootFolderPath, 'content', 'notebook1.ipynb'), 'notebook1File': path.join(rootFolderPath, 'content', 'notebook1.ipynb'),
'notebook2File' : path.join(rootFolderPath, 'content', 'notebook2.ipynb'), 'notebook2File': path.join(rootFolderPath, 'content', 'notebook2.ipynb'),
'markdownFile' : path.join(rootFolderPath, 'content', 'readme.md') 'markdownFile': path.join(rootFolderPath, 'content', 'readme.md')
}, },
contents : { contents: {
'config' : 'title: Test Book', 'config': 'title: Test Book',
'toc' : '- title: Home\n url: /readme\n- title: Notebook1\n url: /notebook1\n- title: Notebook2\n url: /notebook2' 'toc': '- title: Home\n url: /readme\n- title: Notebook1\n url: /notebook1\n- title: Notebook2\n url: /notebook2'
} }
}, },
{ {
it: 'v2', it: 'v2',
folderPaths : { folderPaths: {
'configFile' : path.join(rootFolderPath, '_config.yml'), 'configFile': path.join(rootFolderPath, '_config.yml'),
'tableofContentsFile' : path.join(rootFolderPath, '_toc.yml'), 'tableofContentsFile': path.join(rootFolderPath, '_toc.yml'),
'notebook1File' : path.join(rootFolderPath, 'notebook1.ipynb'), 'notebook1File': path.join(rootFolderPath, 'notebook1.ipynb'),
'notebook2File' : path.join(rootFolderPath, 'notebook2.ipynb'), 'notebook2File': path.join(rootFolderPath, 'notebook2.ipynb'),
'markdownFile' : path.join(rootFolderPath, 'readme.md') 'markdownFile': path.join(rootFolderPath, 'readme.md')
}, },
contents : { contents: {
'config' : 'title: Test Book', 'config': 'title: Test Book',
'toc' : '- title: Home\n file: /readme\n- title: Notebook1\n file: /notebook1\n- title: Notebook2\n file: /notebook2' '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 { describe('BookTreeViewProvider.Commands on ' + run.it, function (): void {
before(async () => { before(async () => {
await fs.mkdir(rootFolderPath); await fs.mkdir(rootFolderPath);
if(run.it === 'v1'){ if (run.it === 'v1') {
await fs.mkdir(run.folderPaths.dataFolderPath); await fs.mkdir(run.folderPaths.dataFolderPath);
await fs.mkdir(run.folderPaths.contentFolderPath); 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 rootFolderPath = path.join(os.tmpdir(), `BookFolderTest_${uuid.v4()}`);
let bookTreeViewProvider: BookTreeViewProvider; let bookTreeViewProvider: BookTreeViewProvider;
let runs = [ let runs = [
{ {
it: 'v1', it: 'v1',
folderPaths : { folderPaths: {
'bookFolderPath' : path.join(rootFolderPath, 'BookTestData'), 'bookFolderPath': path.join(rootFolderPath, 'BookTestData'),
'dataFolderPath' : path.join(rootFolderPath, 'BookTestData', '_data'), 'dataFolderPath': path.join(rootFolderPath, 'BookTestData', '_data'),
'contentFolderPath' : path.join(rootFolderPath, 'BookTestData', 'content'), 'contentFolderPath': path.join(rootFolderPath, 'BookTestData', 'content'),
'configFile' : path.join(rootFolderPath, 'BookTestData', '_config.yml'), 'configFile': path.join(rootFolderPath, 'BookTestData', '_config.yml'),
'tableOfContentsFile' : path.join(rootFolderPath,'BookTestData','_data', 'toc.yml'), 'tableOfContentsFile': path.join(rootFolderPath, 'BookTestData', '_data', 'toc.yml'),
'bookNotebookFile' : path.join(rootFolderPath, 'BookTestData', 'content', 'notebook1.ipynb'), 'bookNotebookFile': path.join(rootFolderPath, 'BookTestData', 'content', 'notebook1.ipynb'),
'notebookFolderPath' : path.join(rootFolderPath, 'NotebookTestData'), 'notebookFolderPath': path.join(rootFolderPath, 'NotebookTestData'),
'standaloneNotebookFile' : path.join(rootFolderPath, 'NotebookTestData','notebook2.ipynb') 'standaloneNotebookFile': path.join(rootFolderPath, 'NotebookTestData', 'notebook2.ipynb')
}, },
contents : { contents: {
'config' : 'title: Test Book', 'config': 'title: Test Book',
'toc' : '- title: Notebook1\n url: /notebook1', 'toc': '- title: Notebook1\n url: /notebook1',
'bookTitle' : 'Test Book', 'bookTitle': 'Test Book',
'standaloneNotebookTitle' : 'notebook2' 'standaloneNotebookTitle': 'notebook2'
} }
}, },
{ {
it: 'v2', it: 'v2',
folderPaths : { folderPaths: {
'bookFolderPath' : path.join(rootFolderPath, 'BookTestData'), 'bookFolderPath': path.join(rootFolderPath, 'BookTestData'),
'configFile' : path.join(rootFolderPath, 'BookTestData', '_config.yml'), 'configFile': path.join(rootFolderPath, 'BookTestData', '_config.yml'),
'tableOfContentsFile' : path.join(rootFolderPath,'BookTestData', '_toc.yml'), 'tableOfContentsFile': path.join(rootFolderPath, 'BookTestData', '_toc.yml'),
'bookNotebookFile' : path.join(rootFolderPath, 'BookTestData','notebook1.ipynb'), 'bookNotebookFile': path.join(rootFolderPath, 'BookTestData', 'notebook1.ipynb'),
'notebookFolderPath' : path.join(rootFolderPath, 'NotebookTestData'), 'notebookFolderPath': path.join(rootFolderPath, 'NotebookTestData'),
'standaloneNotebookFile' : path.join(rootFolderPath, 'NotebookTestData','notebook2.ipynb') 'standaloneNotebookFile': path.join(rootFolderPath, 'NotebookTestData', 'notebook2.ipynb')
}, },
contents : { contents: {
'config' : 'title: Test Book', 'config': 'title: Test Book',
'toc' : '- title: Notebook1\n file: /notebook1', 'toc': '- title: Notebook1\n file: /notebook1',
'bookTitle' : 'Test Book', 'bookTitle': 'Test Book',
'standaloneNotebookTitle' : 'notebook2' 'standaloneNotebookTitle': 'notebook2'
} }
}]; }];
runs.forEach(function (run){ runs.forEach(function (run) {
describe('BookTreeViewProvider.openNotebookFolder on ' + run.it, function (): void { describe('BookTreeViewProvider.openNotebookFolder on ' + run.it, function (): void {
before(async () => { before(async () => {
await fs.mkdir(rootFolderPath); await fs.mkdir(rootFolderPath);
await fs.mkdir(run.folderPaths.bookFolderPath); 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.dataFolderPath);
await fs.mkdir(run.folderPaths.contentFolderPath); await fs.mkdir(run.folderPaths.contentFolderPath);
} }

View File

@@ -2,17 +2,25 @@
* Copyright (c) Microsoft Corporation. All rights reserved. * Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information. * Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/ *--------------------------------------------------------------------------------------------*/
import * as should from 'should'; import * as should from 'should';
import * as path from 'path'; 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 { BookTreeItem, BookTreeItemFormat, BookTreeItemType } from '../../book/bookTreeItem';
import * as yaml from 'js-yaml';
import * as sinon from 'sinon'; import * as sinon from 'sinon';
import { IJupyterBookSectionV1, IJupyterBookSectionV2, JupyterBookSection } from '../../contracts/content'; import { IJupyterBookSectionV1, IJupyterBookSectionV2, JupyterBookSection } from '../../contracts/content';
import * as fs from 'fs-extra'; import * as fs from 'fs-extra';
import * as os from 'os'; import * as os from 'os';
import * as uuid from 'uuid'; 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 { export function equalTOC(actualToc: IJupyterBookSectionV2[], expectedToc: IJupyterBookSectionV2[]): boolean {
for (let [i, section] of actualToc.entries()) { for (let [i, section] of actualToc.entries()) {
@@ -89,7 +97,7 @@ describe('BookTocManagerTests', function () {
}]; }];
await bookTocManager.createBook(bookFolderPath, root2FolderPath); await bookTocManager.createBook(bookFolderPath, root2FolderPath);
should(equalTOC(bookTocManager.tableofContents[2].sections, expectedSection)).be.true; 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 () => { it('should ignore invalid file extensions', async () => {
@@ -103,267 +111,466 @@ describe('BookTocManagerTests', function () {
}); });
describe('EditingBooks', () => { 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 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 = [ let runs = [
{ {
it: 'using the jupyter-book legacy version < 0.7.0', it: 'using the jupyter-book legacy version < 0.7.0',
version: 'v1', version: 'v1',
url: 'file', sourceBook: {
book: { 'rootBookFolderPath': sourceBookFolderPath,
'rootBookFolderPath': rootBookFolderPath, 'bookContentFolderPath': path.join(sourceBookFolderPath, 'content'),
'bookContentFolderPath': path.join(rootBookFolderPath, 'content', 'sample'), 'tocPath': path.join(sourceBookFolderPath, '_data', 'toc.yml'),
'bookDataFolderPath': path.join(rootBookFolderPath, '_data'), 'readme': path.join(sourceBookFolderPath, 'content', 'readme.md'),
'notebook1': path.join(rootBookFolderPath, 'content', 'notebook'), 'toc': [
'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: [
{ {
'title': 'Notebook 5', 'title': 'Notebook 1',
'url': path.join(path.sep, 'test', 'notebook5') 'file': path.join(path.sep, 'sectionA', 'notebook1')
}, },
{ {
'title': 'Notebook 6', 'title': 'Notebook 2',
'url': path.join(path.sep, 'test', 'notebook6') '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', version: 'v2',
url: 'file', sourceBook: {
book: { 'rootBookFolderPath': sourceBookFolderPath,
'bookContentFolderPath': path.join(rootBookFolderPath, 'sample'), 'bookContentFolderPath': sourceBookFolderPath,
'rootBookFolderPath': rootBookFolderPath, 'tocPath': path.join(sourceBookFolderPath, '_toc.yml'),
'notebook1': path.join(rootBookFolderPath, 'notebook'), 'readme': path.join(sourceBookFolderPath, 'readme.md')
'notebook2': path.join(rootBookFolderPath, 'notebook2'),
'tocPath': path.join(rootBookFolderPath, '_toc.yml')
}, },
bookSection1: { sectionA: {
'bookContentFolderPath': path.join(rootSectionFolderPath, 'sample'), 'contentPath': path.join(sourceBookFolderPath, 'sectionA', 'readme.md'),
'contentPath': path.join(rootSectionFolderPath, 'sample', 'readme.md'), 'sectionRoot': path.join(sourceBookFolderPath, 'sectionA'),
'sectionRoot': rootSectionFolderPath, 'sectionName': 'Section A',
'sectionName': 'Sample', 'notebook1': path.join(sourceBookFolderPath, 'sectionA', 'notebook1.ipynb'),
'notebook3': path.join(rootSectionFolderPath, 'sample', 'notebook3'), 'notebook2': path.join(sourceBookFolderPath, 'sectionA', 'notebook2.ipynb'),
'notebook4': path.join(rootSectionFolderPath, 'sample', 'notebook4'), 'sectionFormat': [
'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: [
{ {
'title': 'Notebook 5', 'title': 'Notebook 1',
'file': path.join(path.sep, 'test', 'notebook5') 'file': path.join(path.sep, 'sectionA', 'notebook1')
}, },
{ {
'title': 'Notebook 6', 'title': 'Notebook 2',
'file': path.join(path.sep, 'test', 'notebook6') '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) { runs.forEach(function (run) {
describe('Editing Books ' + run.it, function (): void { describe('Editing Books ' + run.it, function (): void {
beforeEach(async () => { beforeEach(async () => {
let bookTreeItemFormat1: BookTreeItemFormat = { let targetBookTreeItemFormat: BookTreeItemFormat = {
contentPath: run.version === 'v1' ? path.join(run.book.rootBookFolderPath, 'content', 'index.md') : path.join(run.book.rootBookFolderPath, 'index.md'), contentPath: run.targetBook.readme,
root: run.book.rootBookFolderPath, root: run.targetBook.rootBookFolderPath,
tableOfContents: { tableOfContents: {
sections: run.section sections: run.targetBook.toc
}, },
isUntitled: undefined, isUntitled: undefined,
title: undefined, title: 'Target Book',
treeItemCollapsibleState: undefined, treeItemCollapsibleState: undefined,
type: BookTreeItemType.Book, type: BookTreeItemType.Book,
version: run.version, version: run.version,
page: run.section page: run.targetBook.toc
}; };
let bookTreeItemFormat2: BookTreeItemFormat = { let sectionCTreeItemFormat: BookTreeItemFormat = {
title: run.bookSection1.sectionName, title: run.sectionC.sectionName,
contentPath: run.bookSection1.contentPath, contentPath: run.sectionC.contentPath,
root: run.bookSection1.sectionRoot, root: run.targetBook.rootBookFolderPath,
tableOfContents: { tableOfContents: {
sections: run.section1 sections: run.sectionC.sectionFormat
}, },
isUntitled: undefined, isUntitled: undefined,
treeItemCollapsibleState: undefined, treeItemCollapsibleState: undefined,
type: BookTreeItemType.Book, type: BookTreeItemType.Markdown,
version: run.version, version: run.version,
page: run.section1 page: run.sectionC.sectionFormat
}; };
let bookTreeItemFormat3: BookTreeItemFormat = { // section A is from source book
title: run.bookSection2.sectionName, let sectionATreeItemFormat: BookTreeItemFormat = {
contentPath: run.bookSection2.contentPath, title: run.sectionA.sectionName,
root: run.bookSection2.sectionRoot, contentPath: run.sectionA.contentPath,
root: run.sourceBook.rootBookFolderPath,
tableOfContents: { tableOfContents: {
sections: run.section2 sections: run.sectionA.sectionFormat
}, },
isUntitled: undefined, isUntitled: undefined,
treeItemCollapsibleState: undefined, treeItemCollapsibleState: undefined,
type: BookTreeItemType.Book, type: BookTreeItemType.Markdown,
version: run.version, version: run.version,
page: run.section2 page: run.sectionA.sectionFormat
}; };
let bookTreeItemFormat4: BookTreeItemFormat = { // section B is from source book
title: run.bookSection2.sectionName, let sectionBTreeItemFormat: BookTreeItemFormat = {
contentPath: run.notebook.contentPath, title: run.sectionB.sectionName,
root: run.bookSection2.sectionRoot, contentPath: run.sectionB.contentPath,
root: run.sourceBook.rootBookFolderPath,
tableOfContents: { 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, isUntitled: undefined,
treeItemCollapsibleState: undefined, treeItemCollapsibleState: undefined,
type: BookTreeItemType.Notebook, type: BookTreeItemType.Notebook,
version: run.version,
page: { page: {
sections: undefined sections: [
{
'title': 'Notebook 5',
'file': path.join(path.sep, 'notebook5')
}
]
} }
}; };
book = new BookTreeItem(bookTreeItemFormat1, undefined); let duplicatedNbTreeItemFormat: BookTreeItemFormat = {
bookSection = new BookTreeItem(bookTreeItemFormat2, undefined); title: 'Duplicated Notebook',
bookSection2 = new BookTreeItem(bookTreeItemFormat3, undefined); contentPath: path.join(duplicatedNotebookPath, 'notebook5.ipynb'),
notebook = new BookTreeItem(bookTreeItemFormat4, undefined); root: duplicatedNotebookPath,
bookTocManager = new BookTocManager(); 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'); targetBook = new BookTreeItem(targetBookTreeItemFormat, undefined);
bookSection2.uri = path.join('test', 'readme'); 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'; sectionC.uri = path.join('sectionC', 'readme');
bookSection2.contextValue = 'section'; 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'; 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 }); if (run.version === 'v1') {
await fs.promises.mkdir(run.bookSection1.bookContentFolderPath, { recursive: true }); await fs.promises.mkdir(path.dirname(run.targetBook.tocPath), { recursive: true });
await fs.promises.mkdir(run.bookSection2.bookContentFolderPath, { recursive: true }); await fs.promises.mkdir(path.dirname(run.sourceBook.tocPath), { 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 });
} }
await fs.writeFile(run.book.notebook1, '');
await fs.writeFile(run.book.notebook2, ''); // target book
await fs.writeFile(run.bookSection1.notebook3, ''); 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');
await fs.writeFile(run.bookSection1.notebook4, ''); // source book
await fs.writeFile(run.bookSection2.notebook5, ''); 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');
await fs.writeFile(run.bookSection2.notebook6, '');
await fs.writeFile(run.notebook.contentPath, ''); 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 () => { it('Add section to book', async () => {
await bookTocManager.updateBook(bookSection, book); bookTocManager = new BookTocManager(targetBookModel, sourceBookModel);
const listFiles = await fs.promises.readdir(run.book.bookContentFolderPath); await bookTocManager.updateBook(sectionA, targetBook, undefined);
const tocFile = await fs.promises.readFile(run.book.tocPath, 'utf8'); const listFiles = await fs.promises.readdir(path.join(run.targetBook.bookContentFolderPath, 'sectionA'));
let toc = yaml.safeLoad(tocFile); const listSourceFiles = await fs.promises.readdir(path.join(run.sourceBook.bookContentFolderPath));
should(JSON.stringify(listFiles)).be.equal(JSON.stringify(['notebook3', 'notebook4']), 'The files of the section should be moved to the books folder'); should(JSON.stringify(listSourceFiles).includes('sectionA')).be.false('The source book files should not contain the section A files');
should(equalSections(toc.sections[2], bookTocManager.newSection)).be.true; 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 () => { it('Add section to section', async () => {
await bookTocManager.updateBook(bookSection, bookSection2); bookTocManager = new BookTocManager(targetBookModel, sourceBookModel);
let listFiles = await fs.promises.readdir(path.join(run.bookSection2.bookContentFolderPath, 'sample')); await bookTocManager.updateBook(sectionB, sectionC, {
const tocFile = await fs.promises.readFile(path.join(run.bookSection2.tocPath), 'utf8'); 'title': 'Notebook 6',
let toc = yaml.safeLoad(tocFile); 'file': path.join(path.sep, 'sectionC', 'notebook6')
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; 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 () => { it('Add notebook to book', async () => {
await bookTocManager.updateBook(notebook, book); bookTocManager = new BookTocManager(targetBookModel);
const folder = run.version === 'v1' ? path.join(run.book.rootBookFolderPath, 'content') : path.join(run.book.rootBookFolderPath); await bookTocManager.updateBook(notebook, targetBook);
let listFiles = await fs.promises.readdir(folder); const listFiles = await fs.promises.readdir(run.targetBook.bookContentFolderPath);
const tocFile = await fs.promises.readFile(run.book.tocPath, 'utf8'); should(JSON.stringify(listFiles).includes('notebook5.ipynb')).be.true('Notebook 5 should be under the target book content folder');
let toc = yaml.safeLoad(tocFile); });
should(listFiles.findIndex(f => f === 'readme.md')).not.equal(-1);
should(equalSections(toc.sections[2], bookTocManager.newSection)).be.true; 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<void> {
sinon.restore();
if (await exists(sourceBookFolderPath)) {
await promisify(rimraf)(sourceBookFolderPath);
}
if (await exists(targetBookFolderPath)) {
await promisify(rimraf)(targetBookFolderPath);
}
}); });
}); });
}); });