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

View File

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

View File

@@ -11,16 +11,12 @@ import * as path from 'path';
import * as fileServices from 'fs';
import * as fs from 'fs-extra';
import * as loc from '../common/localizedConstants';
import { IJupyterBookToc, JupyterBookSection, IJupyterBookSectionV2, IJupyterBookSectionV1 } from '../contracts/content';
import { IJupyterBookToc, JupyterBookSection } from '../contracts/content';
import { convertFrom, getContentPath, BookVersion } from './bookVersionHandler';
const fsPromises = fileServices.promises;
const content = 'content';
export enum BookVersion {
v1 = 'v1',
v2 = 'v2'
}
export class BookModel {
private _bookItems: BookTreeItem[];
private _allNotebooks = new Map<string, BookTreeItem>();
@@ -124,7 +120,7 @@ export class BookModel {
if (this.openAsUntitled && !this._allNotebooks.get(pathDetails.base)) {
this._allNotebooks.set(pathDetails.base, notebookItem);
} else {
// convert to URI to avoid casing issue with drive letters when getting navigation links
// convert to URI to avoid causing issue with drive letters when getting navigation links
let uriToNotebook: vscode.Uri = vscode.Uri.file(this.bookPath);
if (!this._allNotebooks.get(uriToNotebook.fsPath)) {
this._allNotebooks.set(uriToNotebook.fsPath, notebookItem);
@@ -155,7 +151,7 @@ export class BookModel {
title: config.title,
contentPath: this._tableOfContentsPath,
root: this._rootPath,
tableOfContents: { sections: this.parseJupyterSections(tableOfContents) },
tableOfContents: { sections: this.parseJupyterSections(this._bookVersion, tableOfContents) },
page: tableOfContents,
type: BookTreeItemType.Book,
treeItemCollapsibleState: collapsibleState,
@@ -169,7 +165,7 @@ export class BookModel {
this._bookItems.push(book);
} catch (e) {
this._errorMessage = loc.readBookError(this.bookPath, e instanceof Error ? e.message : e);
vscode.window.showErrorMessage(this._errorMessage);
throw new Error(this._errorMessage);
}
}
return this._bookItems;
@@ -182,8 +178,7 @@ export class BookModel {
public async getSections(tableOfContents: IJupyterBookToc, sections: JupyterBookSection[], root: string, book: BookTreeItemFormat): Promise<BookTreeItem[]> {
let notebooks: BookTreeItem[] = [];
for (let i = 0; i < sections.length; i++) {
if (sections[i].url || (sections[i] as IJupyterBookSectionV2).file) {
if (sections[i].url && ((sections[i] as IJupyterBookSectionV1).external || book.version === BookVersion.v2)) {
if (sections[i].url) {
let externalLink: BookTreeItem = new BookTreeItem({
title: sections[i].title,
contentPath: undefined,
@@ -202,22 +197,15 @@ export class BookModel {
);
notebooks.push(externalLink);
} else {
let pathToNotebook: string;
let pathToMarkdown: string;
if (book.version === BookVersion.v2) {
pathToNotebook = path.join(book.root, (sections[i] as IJupyterBookSectionV2).file.concat('.ipynb'));
pathToMarkdown = path.join(book.root, (sections[i] as IJupyterBookSectionV2).file.concat('.md'));
} else if (sections[i].url) {
pathToNotebook = path.join(book.root, content, (sections[i] as IJupyterBookSectionV1).url.concat('.ipynb'));
pathToMarkdown = path.join(book.root, content, (sections[i] as IJupyterBookSectionV1).url.concat('.md'));
}
} 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] as IJupyterBookSectionV2).file,
title: sections[i].title ? sections[i].title : sections[i].file,
contentPath: pathToNotebook,
root: root,
tableOfContents: tableOfContents,
@@ -239,7 +227,7 @@ export class BookModel {
}
notebooks.push(notebook);
} 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(pathToNotebook);
if (!this._allNotebooks.get(uriToNotebook.fsPath)) {
this._allNotebooks.set(uriToNotebook.fsPath, notebook);
@@ -248,7 +236,7 @@ export class BookModel {
}
} else if (await fs.pathExists(pathToMarkdown)) {
let markdown: BookTreeItem = new BookTreeItem({
title: sections[i].title ? sections[i].title : (sections[i] as IJupyterBookSectionV2).file,
title: sections[i].title ? sections[i].title : sections[i].file,
contentPath: pathToMarkdown,
root: root,
tableOfContents: tableOfContents,
@@ -265,13 +253,9 @@ export class BookModel {
);
notebooks.push(markdown);
} else {
this._errorMessage = loc.missingFileError(sections[i].title);
vscode.window.showErrorMessage(this._errorMessage);
this._errorMessage = loc.missingFileError(sections[i].title, root);
}
}
} else {
// TODO: search functionality (#6160)
}
}
return notebooks;
}
@@ -280,16 +264,16 @@ export class BookModel {
* Recursively parses out a section of a Jupyter Book.
* @param section The input data to parse
*/
private parseJupyterSections(section: any[]): JupyterBookSection[] {
public parseJupyterSections(version: string, section: any[]): JupyterBookSection[] {
try {
return section.reduce((acc, val) => Array.isArray(val.sections) ?
acc.concat(val).concat(this.parseJupyterSections(val.sections)) : acc.concat(val), []);
acc.concat(convertFrom(version, val)).concat(this.parseJupyterSections(version, val.sections)) : acc.concat(convertFrom(version, val)), []);
} catch (e) {
this._errorMessage = loc.invalidTocFileError();
if (section.length > 0) {
this._errorMessage = loc.invalidTocError(section[0].title);
}
throw this._errorMessage;
throw new Error(this._errorMessage);
}
}

View File

@@ -6,14 +6,23 @@ import * as path from 'path';
import { BookTreeItem } from './bookTreeItem';
import * as yaml from 'js-yaml';
import * as fs from 'fs-extra';
import { IJupyterBookSectionV1, IJupyterBookSectionV2, JupyterBookSection } from '../contracts/content';
import { BookVersion } from './bookModel';
import { JupyterBookSection } from '../contracts/content';
import { BookVersion, convertTo } from './bookVersionHandler';
import * as vscode from 'vscode';
import * as loc from '../common/localizedConstants';
import { BookModel } from './bookModel';
export interface IBookTocManager {
updateBook(element: BookTreeItem, book: BookTreeItem): Promise<void>;
createBook(bookContentPath: string, contentFolder: string): Promise<void>
updateBook(element: BookTreeItem, book: BookTreeItem, targetSection?: JupyterBookSection): 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 initMarkdown: string[] = ['index.md', 'introduction.md', 'intro.md', 'readme.md'];
@@ -22,13 +31,24 @@ export function hasSections(node: JupyterBookSection): boolean {
}
export class BookTocManager implements IBookTocManager {
public tableofContents: IJupyterBookSectionV2[];
public newSection: JupyterBookSection = {};
public tableofContents: JupyterBookSection[];
public newSection: JupyterBookSection;
private _movedFiles = new Map<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 => {
let isDirectory = (await fs.promises.stat(path.join(directory, file))).isDirectory();
if (isDirectory) {
@@ -41,7 +61,7 @@ export class BookTocManager implements IBookTocManager {
files.splice(index, 1);
}
});
let jupyterSection: IJupyterBookSectionV2 = {
let jupyterSection: JupyterBookSection = {
title: file,
file: path.join(file, initFile),
expand_sections: true,
@@ -53,7 +73,7 @@ export class BookTocManager implements IBookTocManager {
} else if (allowedFileExtensions.includes(path.extname(file))) {
// if the file is in the book root we don't include the directory.
const filePath = directory === rootDirectory ? path.parse(file).name : path.join(path.basename(directory), path.parse(file).name);
const addFile: IJupyterBookSectionV2 = {
const addFile: JupyterBookSection = {
title: path.parse(file).name,
file: filePath
};
@@ -74,21 +94,119 @@ export class BookTocManager implements IBookTocManager {
return toc;
}
updateToc(tableOfContents: JupyterBookSection[], findSection: BookTreeItem, addSection: JupyterBookSection): JupyterBookSection[] {
for (const section of tableOfContents) {
if ((section as IJupyterBookSectionV1).url && path.dirname(section.url) === path.join(path.sep, path.dirname(findSection.uri)) || (section as IJupyterBookSectionV2).file && path.dirname((section as IJupyterBookSectionV2).file) === path.join(path.sep, path.dirname(findSection.uri))) {
if (tableOfContents[tableOfContents.length - 1].sections) {
tableOfContents[tableOfContents.length - 1].sections.push(addSection);
/**
* Renames files if it already exists in the target book
* @param src The source file that will be moved.
* @param dest The destination path of the file, that it's been moved.
* Returns a new file name that does not exist in destination folder.
*/
async renameFile(src: string, dest: string): Promise<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 {
tableOfContents[tableOfContents.length - 1].sections = [addSection];
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));
}
break;
} else if ((await fs.stat(path.join(directory, content))).isDirectory) {
await this.cleanUp(path.join(directory, content));
}
else if (hasSections(section)) {
return this.updateToc(section.sections, findSection, addSection);
});
}
}
return tableOfContents;
/**
* 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;
}
}
await fs.writeFile(tocPath, yaml.safeDump(newToc, { lineWidth: Infinity, noRefs: true, skipInvalid: true }));
this.tableofContents = newToc;
}
/**
* Creates a new table of contents structure containing the added section. This method is only called when we move a section to another section.
* Since the sections can be arranged in a tree structure we need to look for the section that will be modified in a recursively.
* @param version Version of the book
* @param section The current section that we are iterating
* @param findSection The section that will be modified.
* @param addSection The section that'll be added to the target section. If it's undefined then the target section (findSection) is removed from the table of contents.
*/
private buildTOC(version: BookVersion, section: JupyterBookSection, findSection: JupyterBookSection, addSection: JupyterBookSection): JupyterBookSection {
// condition to find the section to be modified
if (section.title === findSection.title && (section.file && section.file === findSection.file || section.url && section.url === findSection.file)) {
if (addSection) {
//if addSection is not undefined, then we added to the table of contents.
section.sections !== undefined && section.sections.length > 0 ? section.sections.push(addSection) : section.sections = [addSection];
return section;
}
// if addSection is undefined then we remove the whole section from the table of contents.
return addSection;
} else {
let newSection = convertTo(version, section);
if (section.sections && section.sections.length > 0) {
newSection.sections = [] as JupyterBookSection[];
for (let s of section.sections) {
const child = this.buildTOC(version, s, findSection, addSection);
if (child) {
newSection.sections.push(convertTo(version, child));
}
}
}
if (newSection.sections?.length === 0) {
// if sections is an empty array then assign it to undefined, so it's converted into a markdown file.
newSection.sections = undefined;
}
return newSection;
}
}
/**
@@ -107,75 +225,198 @@ export class BookTocManager implements IBookTocManager {
await vscode.commands.executeCommand('notebook.command.openNotebookFolder', bookContentPath, undefined, true);
}
async addSection(section: BookTreeItem, book: BookTreeItem, isSection: boolean): Promise<void> {
this.newSection.title = section.title;
//the book contentPath contains the first file of the section, we get the dirname to identify the section's root path
const rootPath = isSection ? path.dirname(book.book.contentPath) : book.rootContentPath;
// TODO: the uri contains the first notebook or markdown file in the TOC format. If we are in a section,
// we want to include the intermediary directories between the book's root and the section
const uri = isSection ? path.join(path.basename(rootPath), section.uri) : section.uri;
if (section.book.version === BookVersion.v1) {
this.newSection.url = uri;
let movedSections: IJupyterBookSectionV1[] = [];
const files = section.sections as IJupyterBookSectionV1[];
/**
* When moving a section, we need to modify every file path that it's within the section. Since sections is a tree like structure, we need to modify each of the file paths
* and move the files individually. The overwrite option is set to false to prevent any issues with duplicated file names.
* @param files Files in the section.
*/
async traverseSections(files: JupyterBookSection[]): Promise<JupyterBookSection[]> {
let movedSections: JupyterBookSection[] = [];
for (const elem of files) {
await fs.promises.mkdir(path.join(rootPath, path.dirname(elem.url)), { recursive: true });
await fs.move(path.join(path.dirname(section.book.contentPath), path.basename(elem.url)), path.join(rootPath, elem.url));
movedSections.push({ url: isSection ? path.join(path.basename(rootPath), elem.url) : elem.url, title: elem.title });
if (elem.file) {
let fileName = undefined;
try {
await fs.move(path.join(this.sourceBookContentPath, elem.file).concat('.ipynb'), path.join(this.targetBookContentPath, elem.file).concat('.ipynb'), { overwrite: false });
this._movedFiles.set(path.join(this.sourceBookContentPath, elem.file).concat('.ipynb'), path.join(this.targetBookContentPath, elem.file).concat('.ipynb'));
} catch (error) {
if (error.code === 'EEXIST') {
fileName = await this.renameFile(path.join(this.sourceBookContentPath, elem.file).concat('.ipynb'), path.join(this.targetBookContentPath, elem.file).concat('.ipynb'));
}
this.newSection.sections = movedSections;
} else if (section.book.version === BookVersion.v2) {
(this.newSection as IJupyterBookSectionV2).file = uri;
let movedSections: IJupyterBookSectionV2[] = [];
const files = section.sections as IJupyterBookSectionV2[];
for (const elem of files) {
await fs.promises.mkdir(path.join(rootPath, path.dirname(elem.file)), { recursive: true });
await fs.move(path.join(path.dirname(section.book.contentPath), path.basename(elem.file)), path.join(rootPath, elem.file));
movedSections.push({ file: isSection ? path.join(path.basename(rootPath), elem.file) : elem.file, title: elem.title });
else if (error.code !== 'ENOENT') {
throw (error);
}
this.newSection.sections = movedSections;
}
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;
}
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
const rootPath = isSection ? path.dirname(book.book.contentPath) : book.rootContentPath;
let notebookName = path.basename(notebook.book.contentPath);
await fs.move(notebook.book.contentPath, path.join(rootPath, notebookName));
if (book.book.version === BookVersion.v1) {
this.newSection = { url: notebookName, title: notebookName };
} else if (book.book.version === BookVersion.v2) {
this.newSection = { file: notebookName, title: notebookName };
movedSections.push(elem);
}
return movedSections;
}
/**
* Moves a section to a book top level or another book's section. If there's a target section we add the the targetSection directory if it has one and append it to the
* notebook's path. The overwrite option is set to false to prevent any issues with duplicated file names.
* @param section The section that's been moved.
* @param book The target book.
*/
async addSection(section: BookTreeItem, book: BookTreeItem): Promise<void> {
const uri = path.sep.concat(path.relative(section.rootContentPath, section.book.contentPath));
let moveFile = path.join(path.parse(uri).dir, path.parse(uri).name);
let fileName = undefined;
try {
await fs.move(section.book.contentPath, path.join(this.targetBookContentPath, moveFile).concat(path.parse(uri).ext), { overwrite: false });
this._movedFiles.set(section.book.contentPath, path.join(this.targetBookContentPath, moveFile).concat(path.parse(uri).ext));
} catch (error) {
if (error.code === 'EEXIST') {
fileName = await this.renameFile(section.book.contentPath, path.join(this.targetBookContentPath, moveFile).concat(path.parse(uri).ext));
}
else if (error.code !== 'ENOENT') {
throw (error);
}
}
fileName = fileName === undefined ? path.parse(uri).name : path.parse(fileName).name;
if (this._sourceBook) {
const sectionTOC = this._sourceBook.bookItems[0].findChildSection(section.uri);
if (sectionTOC) {
this.newSection = sectionTOC;
}
}
this.newSection.title = section.title;
this.newSection.file = path.join(path.parse(uri).dir, fileName)?.replace(/\\/g, '/');
if (section.sections) {
const files = section.sections as JupyterBookSection[];
const movedSections = await this.traverseSections(files);
this.newSection.sections = movedSections;
this._modifiedDirectory.add(path.dirname(section.book.contentPath));
this.cleanUp(path.dirname(section.book.contentPath));
}
if (book.version === BookVersion.v1) {
// here we only convert if is v1 because we are already using the v2 notation for every book that we read.
this.newSection = convertTo(book.version, this.newSection);
}
}
/**
* Moves the element to the book's folder and adds it to the table of contents.
* Moves a notebook to a book top level or a book's section. If there's a target section we add the the targetSection directory if it has one and append it to the
* notebook's path. The overwrite option is set to false to prevent any issues with duplicated file names.
* @param element Notebook, Markdown File, or section that will be added to the book.
* @param book Book or a BookSection that will be modified.
* @param targetBook Book that will be modified.
*/
public async updateBook(element: BookTreeItem, book: BookTreeItem): Promise<void> {
if (element.contextValue === 'section' && book.book.version === element.book.version) {
if (book.contextValue === 'section') {
await this.addSection(element, book, true);
this.tableofContents = this.updateToc(book.tableOfContents.sections, book, this.newSection);
await fs.writeFile(book.tableOfContentsPath, yaml.safeDump(this.tableofContents, { lineWidth: Infinity }));
} else if (book.contextValue === 'savedBook') {
await this.addSection(element, book, false);
book.tableOfContents.sections.push(this.newSection);
await fs.writeFile(book.tableOfContentsPath, yaml.safeDump(book.tableOfContents, { lineWidth: Infinity }));
async addNotebook(notebook: BookTreeItem, book: BookTreeItem): Promise<void> {
const rootPath = book.rootContentPath;
const notebookPath = path.parse(notebook.book.contentPath);
let fileName = undefined;
try {
await fs.move(notebook.book.contentPath, path.join(rootPath, notebookPath.base), { overwrite: false });
} catch (error) {
if (error.code === 'EEXIST') {
fileName = await this.renameFile(notebook.book.contentPath, path.join(rootPath, notebookPath.base));
}
else {
throw (error);
}
}
if (this._sourceBook) {
const sectionTOC = this._sourceBook.bookItems[0].findChildSection(notebook.uri);
this.newSection = sectionTOC;
}
fileName = fileName === undefined ? notebookPath.name : path.parse(fileName).name;
this.newSection.file = path.sep.concat(fileName);
this.newSection.title = notebook.book.title;
if (book.version === BookVersion.v1) {
// here we only convert if is v1 because we are already using the v2 notation for every book that we read.
this.newSection = convertTo(book.version, this.newSection);
}
}
/**
* Moves the element to the target book's folder and adds it to the table of contents.
* @param element Notebook, Markdown File, or section that will be added to the book.
* @param targetBook Book that will be modified.
* @param targetSection Book section that'll be modified.
*/
public async updateBook(element: BookTreeItem, targetBook: BookTreeItem, targetSection?: JupyterBookSection): Promise<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') {
if (book.contextValue === 'savedBook') {
await this.addNotebook(element, book, false);
book.tableOfContents.sections.push(this.newSection);
await fs.writeFile(book.tableOfContentsPath, yaml.safeDump(book.tableOfContents, { lineWidth: Infinity }));
} else if (book.contextValue === 'section') {
await this.addNotebook(element, book, true);
this.tableofContents = this.updateToc(book.tableOfContents.sections, book, this.newSection);
await fs.writeFile(book.tableOfContentsPath, yaml.safeDump(this.tableofContents, { lineWidth: Infinity }));
await this.addNotebook(element, targetBook);
if (element.tableOfContentsPath) {
const elementVersion = element.book.version === BookVersion.v1 ? BookVersion.v1 : BookVersion.v2;
// the notebook is part of a book so we need to modify its toc as well
const findSection = { file: element.book.page.file?.replace(/\\/g, '/'), title: element.book.page.title };
await this.updateTOC(elementVersion, element.tableOfContentsPath, findSection, undefined);
} else {
// close the standalone notebook, so it doesn't throw an error when we move the notebook to new location.
await vscode.commands.executeCommand('notebook.command.closeNotebook', element);
}
if (!targetSection) {
if (this.targetBookContentPath !== this.sourceBookContentPath) {
this.tableofContents = targetBook.sections.map(section => convertTo(targetBook.version, section));
}
this.tableofContents.push(this.newSection);
await fs.writeFile(targetBook.tableOfContentsPath, yaml.safeDump(this.tableofContents, { lineWidth: Infinity, noRefs: true, skipInvalid: true }));
} else {
await this.updateTOC(targetBookVersion, targetBook.tableOfContentsPath, targetSection, this.newSection);
}
}
}
public get movedFiles(): Map<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 path from 'path';
import * as fs from 'fs';
import { JupyterBookSection, IJupyterBookToc, IJupyterBookSectionV2, IJupyterBookSectionV1 } from '../contracts/content';
import { JupyterBookSection, IJupyterBookToc } from '../contracts/content';
import * as loc from '../common/localizedConstants';
import { isBookItemPinned } from '../common/utils';
import { BookVersion } from './bookModel';
const content = 'content';
import { getContentPath, getTocPath } from './bookVersionHandler';
export enum BookTreeItemType {
Book = 'Book',
@@ -45,7 +42,6 @@ export class BookTreeItem extends vscode.TreeItem {
constructor(public book: BookTreeItemFormat, icons: any) {
super(book.title, book.treeItemCollapsibleState);
if (book.type === BookTreeItemType.Book) {
this.collapsibleState = book.treeItemCollapsibleState;
this._sections = book.page;
@@ -64,6 +60,9 @@ export class BookTreeItem extends vscode.TreeItem {
} else {
this.contextValue = isBookItemPinned(book.contentPath) ? 'pinnedNotebook' : 'savedNotebook';
}
} else if (book.type === BookTreeItemType.ExternalLink) {
this.contextValue = BookTreeItemType.ExternalLink;
} else {
this.contextValue = book.type === BookTreeItemType.Notebook ? (isBookItemPinned(book.contentPath) ? 'pinnedNotebook' : 'savedNotebook') : 'section';
}
@@ -71,26 +70,30 @@ export class BookTreeItem extends vscode.TreeItem {
this.setCommand();
}
this.iconPath = icons;
this._tableOfContentsPath = undefined;
if (this.book.type === BookTreeItemType.ExternalLink) {
this.tooltip = `${this._uri}`;
}
else {
this._tableOfContentsPath = (this.book.type === BookTreeItemType.Book || this.contextValue === 'section') ? (this.book.version === BookVersion.v1 ? path.join(this.book.root, '_data', 'toc.yml') : path.join(this.book.root, '_toc.yml')) : undefined;
this._rootContentPath = this.book.version === BookVersion.v1 ? path.join(this.book.root, content) : this.book.root;
// if it's a section, book or a notebook's book then we set the table of contents path.
if (this.book.type === BookTreeItemType.Book || this.contextValue === 'section' || (book.tableOfContents.sections && book.type === BookTreeItemType.Notebook)) {
this._tableOfContentsPath = getTocPath(this.book.version, this.book.root);
}
this._rootContentPath = getContentPath(this.book.version, this.book.root, '');
this.tooltip = this.book.type === BookTreeItemType.Book ? this._rootContentPath : this.book.contentPath;
this.resourceUri = vscode.Uri.file(this.book.root);
}
}
private setPageVariables() {
private setPageVariables(): void {
this.collapsibleState = (this.book.page.sections || this.book.page.subsections) && this.book.page.expand_sections ?
vscode.TreeItemCollapsibleState.Expanded :
this.book.page.sections || this.book.page.subsections ?
vscode.TreeItemCollapsibleState.Collapsed :
vscode.TreeItemCollapsibleState.None;
this._sections = this.book.page.sections || this.book.page.subsections;
this._uri = this.book.version === BookVersion.v1 ? this.book.page.url : this.book.page.file;
this._uri = this.book.page.file ? this.book.page.file : this.book.page.url;
if (this.book.tableOfContents.sections) {
let index = (this.book.tableOfContents.sections.indexOf(this.book.page));
@@ -99,7 +102,7 @@ export class BookTreeItem extends vscode.TreeItem {
}
}
private setCommand() {
private setCommand(): void {
if (this.book.type === BookTreeItemType.Notebook) {
// The Notebook editor expects a posix path for the resource (it will still resolve to the correct fsPath based on OS)
this.command = { command: this.book.isUntitled ? 'bookTreeView.openUntitledNotebook' : 'bookTreeView.openNotebook', title: loc.openNotebookCommand, arguments: [this.book.contentPath], };
@@ -114,13 +117,11 @@ export class BookTreeItem extends vscode.TreeItem {
let i = --index;
while (i > -1) {
let pathToNotebook: string;
if (this.book.version === BookVersion.v2 && (this.book.tableOfContents.sections[i] as IJupyterBookSectionV2).file) {
if (this.book.tableOfContents.sections[i].file) {
// The Notebook editor expects a posix path for the resource (it will still resolve to the correct fsPath based on OS)
pathToNotebook = path.posix.join(this.book.root, (this.book.tableOfContents.sections[i] as IJupyterBookSectionV2).file.concat('.ipynb'));
} else if ((this.book.tableOfContents.sections[i] as IJupyterBookSectionV1).url) {
pathToNotebook = path.posix.join(this.book.root, content, (this.book.tableOfContents.sections[i] as IJupyterBookSectionV1).url.concat('.ipynb'));
pathToNotebook = getContentPath(this.book.version, this.book.root, this.book.tableOfContents.sections[i].file);
pathToNotebook = pathToNotebook.concat('.ipynb');
}
// eslint-disable-next-line no-sync
if (fs.existsSync(pathToNotebook)) {
this._previousUri = pathToNotebook;
@@ -134,13 +135,11 @@ export class BookTreeItem extends vscode.TreeItem {
let i = ++index;
while (i < this.book.tableOfContents.sections.length) {
let pathToNotebook: string;
if (this.book.version === BookVersion.v2 && (this.book.tableOfContents.sections[i] as IJupyterBookSectionV2).file) {
if (this.book.tableOfContents.sections[i].file) {
// The Notebook editor expects a posix path for the resource (it will still resolve to the correct fsPath based on OS)
pathToNotebook = path.posix.join(this.book.root, (this.book.tableOfContents.sections[i] as IJupyterBookSectionV2).file.concat('.ipynb'));
} else if ((this.book.tableOfContents.sections[i] as IJupyterBookSectionV1).url) {
pathToNotebook = path.posix.join(this.book.root, content, (this.book.tableOfContents.sections[i] as IJupyterBookSectionV1).url.concat('.ipynb'));
pathToNotebook = getContentPath(this.book.version, this.book.root, this.book.tableOfContents.sections[i].file);
pathToNotebook = pathToNotebook.concat('.ipynb');
}
// eslint-disable-next-line no-sync
if (fs.existsSync(pathToNotebook)) {
this._nextUri = pathToNotebook;
@@ -174,7 +173,7 @@ export class BookTreeItem extends vscode.TreeItem {
return this.book.tableOfContents;
}
public get sections(): any[] {
public get sections(): JupyterBookSection[] {
return this._sections;
}
@@ -192,6 +191,14 @@ export class BookTreeItem extends vscode.TreeItem {
this._uri = uri;
}
public set sections(sections: JupyterBookSection[]) {
this._sections = sections;
}
public set tableOfContentsPath(tocPath: string) {
this._tableOfContentsPath = tocPath;
}
/**
* Helper method to find a child section with a specified URL
* @param url The url of the section we're searching for
@@ -204,7 +211,7 @@ export class BookTreeItem extends vscode.TreeItem {
}
private findChildSectionRecur(section: JupyterBookSection, url: string): JupyterBookSection | undefined {
if ((section as IJupyterBookSectionV1).url && (section as IJupyterBookSectionV1).url === url || (section as IJupyterBookSectionV2).file && (section as IJupyterBookSectionV2).file === url) {
if (section.file && section.file === url) {
return section;
} else if (section.sections) {
for (const childSection of section.sections) {

View File

@@ -11,19 +11,17 @@ import * as constants from '../common/constants';
import { IPrompter, IQuestion, QuestionTypes } from '../prompts/question';
import CodeAdapter from '../prompts/adapter';
import { BookTreeItem, BookTreeItemType } from './bookTreeItem';
import { BookModel, BookVersion } from './bookModel';
import { BookModel } from './bookModel';
import { Deferred } from '../common/promise';
import { IBookTrustManager, BookTrustManager } from './bookTrustManager';
import * as loc from '../common/localizedConstants';
import * as glob from 'fast-glob';
import { IJupyterBookSectionV2, IJupyterBookSectionV1 } from '../contracts/content';
import { debounce, getPinnedNotebooks } from '../common/utils';
import { IBookPinManager, BookPinManager } from './bookPinManager';
import { BookTocManager, IBookTocManager } from './bookTocManager';
import { BookTocManager, IBookTocManager, quickPickResults } from './bookTocManager';
import { getContentPath } from './bookVersionHandler';
import { TelemetryReporter, BookTelemetryView, NbTelemetryActions } from '../telemetry';
const content = 'content';
interface BookSearchResults {
notebookPaths: string[];
bookPaths: string[];
@@ -50,7 +48,6 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider<BookTreeIte
this._extensionContext = extensionContext;
this.books = [];
this.bookPinManager = new BookPinManager();
this.bookTocManager = new BookTocManager();
this.viewId = view;
this.initialize(workspaceFolders).catch(e => console.error(e));
this.prompter = new CodeAdapter();
@@ -93,6 +90,14 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider<BookTreeIte
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 {
let bookPathToTrust: string = bookTreeItem ? bookTreeItem.root : this.currentBook?.bookPath;
if (bookPathToTrust) {
@@ -144,8 +149,92 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider<BookTreeIte
TelemetryReporter.createActionEvent(BookTelemetryView, NbTelemetryActions.CreateBook).send();
}
async editBook(book: BookTreeItem, section: BookTreeItem): Promise<void> {
await this.bookTocManager.updateBook(section, book);
async getSelectionQuickPick(movingElement: BookTreeItem): Promise<quickPickResults> {
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> {
@@ -180,6 +269,11 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider<BookTreeIte
});
}
} 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));
}
}
@@ -267,9 +361,9 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider<BookTreeIte
if (urlToOpen) {
const bookRoot = this.currentBook.bookItems[0];
const sectionToOpen = bookRoot.findChildSection(urlToOpen);
urlPath = sectionToOpen?.url;
urlPath = sectionToOpen?.file;
} 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) {
@@ -434,20 +528,7 @@ export class BookTreeViewProvider implements vscode.TreeDataProvider<BookTreeIte
public async searchJupyterBooks(treeItem?: BookTreeItem): Promise<void> {
let folderToSearch: string;
if (treeItem && treeItem.sections !== undefined) {
if (treeItem.book.version === BookVersion.v1) {
if (treeItem.uri) {
folderToSearch = path.join(treeItem.book.root, content, path.dirname(treeItem.uri));
} else {
folderToSearch = path.join(treeItem.root, content);
}
} else if (treeItem.book.version === BookVersion.v2) {
if (treeItem.uri) {
folderToSearch = path.join(treeItem.book.root, path.dirname(treeItem.uri));
} else {
folderToSearch = path.join(treeItem.root);
}
}
folderToSearch = treeItem.uri ? getContentPath(treeItem.version, treeItem.book.root, path.dirname(treeItem.uri)) : getContentPath(treeItem.version, treeItem.book.root, '');
} else if (this.currentBook && !this.currentBook.isNotebook) {
folderToSearch = path.join(this.currentBook.contentFolderPath);
} else {
@@ -573,20 +654,15 @@ 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> {
if (element?.uri) {
let parentPath: string;
let contentFolder = element.book.version === BookVersion.v1 ? path.join(element.book.root, content) : element.book.root;
parentPath = path.join(contentFolder, element.uri.substring(0, element.uri.lastIndexOf(path.posix.sep)));
if (parentPath === element.root) {
// Remove it for perf issues.
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 {
let untitledFileName = vscode.Uri.parse(`untitled:${resource}`);

View File

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

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 const missingTocError = localize('bookInitializeFailed', "Failed to find a Table of Contents file in the specified book.");
export const noBooksSelectedError = localize('noBooksSelected', "No books are currently selected in the viewlet.");
export const labelBookSection = localize('labelBookSection', "Select Book Section");
export const labelAddToLevel = localize('labelAddToLevel', "Add to this level");
export function missingFileError(title: string): string { return localize('missingFileError', "Missing file : {0}", title); }
export function missingFileError(title: string, path: string): string { return localize('missingFileError', "Missing file : {0} from {1}", title, path); }
export function invalidTocFileError(): string { return localize('InvalidError.tocFile', "Invalid toc file"); }
export function invalidTocError(title: string): string { return localize('Invalid toc.yml', "Error: {0} has an incorrect toc.yml file", title); }
export function configFileError(): string { return localize('configFileError', "Configuration file missing"); }
export function openFileError(path: string, error: string): string { return localize('openBookError', "Open book {0} failed: {1}", path, error); }
export function readBookError(path: string, error: string): string { return localize('readBookError', "Failed to read book {0}: {1}", path, error); }
@@ -43,6 +46,9 @@ export function openMarkdownError(resource: string, error: string): string { ret
export function openUntitledNotebookError(resource: string, error: string): string { return localize('openUntitledNotebookError', "Open untitled notebook {0} as untitled failed: {1}", resource, error); }
export function openExternalLinkError(resource: string, error: string): string { return localize('openExternalLinkError', "Open link {0} failed: {1}", resource, error); }
export function closeBookError(resource: string, error: string): string { return localize('closeBookError', "Close book {0} failed: {1}", resource, error); }
export function duplicateFileError(title: string, path: string, newPath: string): string { return localize('duplicateFileError', "File {0} already exists in the destination folder {1} \n The file has been renamed to {2} to prevent data loss.", title, path, newPath); }
export function editBookError(path: string, error: string): string { return localize('editBookError', "Error while editing book {0}: {1}", path, error); }
export function selectBookError(error: string): string { return localize('selectBookError', "Error while selecting a book or a section to edit: {0}", error); }
// Remote Book dialog constants
export const url = localize('url', "URL");

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
*/
sections?: IJupyterBookSectionV1[];
sections?: JupyterBookSection[];
/**
* If the section shouldn't have a number in the sidebar
*/
not_numbered?: string;
not_numbered?: boolean;
/**
* If you'd like the sections of this chapter to always be expanded in the sidebar.
*/
@@ -113,7 +113,7 @@ export interface IJupyterBookSectionV1 {
/**
* Will insert a header with no link in the sidebar
*/
header?: boolean;
header?: string;
}
/**
@@ -134,7 +134,7 @@ export interface IJupyterBookSectionV2 {
/**
* Contains a list of more entries that make up the chapter's/section's sub-sections
*/
sections?: IJupyterBookSectionV2[];
sections?: JupyterBookSection[];
/**
* If the section shouldn't have a number in the sidebar
*/
@@ -147,8 +147,23 @@ export interface IJupyterBookSectionV2 {
* External link
*/
url?: string;
// Below are some special values that trigger specific behavior:
/**
* Will insert a header with no link in the sidebar
*/
header?: string;
/**
* If a book is divided into groups then part is the title of the group
*/
part?: string;
/**
* the equivalent of sections in a group.
*/
chapters?: string[];
}
// type that supports new and old version
export type JupyterBookSection = IJupyterBookSectionV1 | IJupyterBookSectionV2;
export interface JupyterBookSection extends IJupyterBookSectionV1, IJupyterBookSectionV2 { }

View File

@@ -19,6 +19,7 @@ import { RemoteBookDialog } from './dialog/remoteBookDialog';
import { RemoteBookDialogModel } from './dialog/remoteBookDialogModel';
import { IconPathHelper } from './common/iconHelper';
import { ExtensionContextHelper } from './common/extensionContextHelper';
import { BookTreeItem } from './book/bookTreeItem';
const localize = nls.loadMessageBundle();
@@ -69,6 +70,10 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi
});
}));
extensionContext.subscriptions.push(vscode.commands.registerCommand('notebook.command.moveTo', async (book: BookTreeItem) => {
await bookTreeViewProvider.editBook(book);
}));
let model = new RemoteBookDialogModel();
let remoteBookController = new RemoteBookController(model, appContext.outputChannel);

View File

@@ -19,12 +19,13 @@ import { exists } from '../../common/utils';
import { BookModel } from '../../book/bookModel';
import { BookTrustManager } from '../../book/bookTrustManager';
import { NavigationProviders } from '../../common/constants';
import { readBookError } from '../../common/localizedConstants';
import { openFileError } from '../../common/localizedConstants';
import * as sinon from 'sinon';
import { AppContext } from '../../common/appContext';
export interface IExpectedBookItem {
title: string;
file?: string;
url?: string;
sections?: any[];
external?: boolean;
@@ -34,7 +35,11 @@ export interface IExpectedBookItem {
export function equalBookItems(book: BookTreeItem, expectedBook: IExpectedBookItem, errorMsg?: string): void {
should(book.title).equal(expectedBook.title, `Book titles do not match, expected ${expectedBook?.title} and got ${book?.title}`);
if (expectedBook.file) {
should(path.posix.parse(book.uri)).deepEqual(path.posix.parse(expectedBook.file));
} else {
should(path.posix.parse(book.uri)).deepEqual(path.posix.parse(expectedBook.url));
}
if (expectedBook.previousUri || expectedBook.nextUri) {
let prevUri = book.previousUri ? book.previousUri.toLocaleLowerCase() : undefined;
let expectedPrevUri = expectedBook.previousUri ? expectedBook.previousUri.replace(/\\/g, '/') : undefined;
@@ -75,25 +80,25 @@ describe('BooksTreeViewTests', function () {
expectedNotebook1 = {
// tslint:disable-next-line: quotemark
title: 'Notebook1',
url: '/notebook1',
file: '/notebook1',
previousUri: undefined,
nextUri: notebook2File.toLocaleLowerCase()
};
expectedNotebook2 = {
title: 'Notebook2',
url: '/notebook2',
file: '/notebook2',
previousUri: notebook1File.toLocaleLowerCase(),
nextUri: notebook3File.toLocaleLowerCase()
};
expectedNotebook3 = {
title: 'Notebook3',
url: '/notebook3',
file: '/notebook3',
previousUri: notebook2File.toLocaleLowerCase(),
nextUri: undefined
};
expectedMarkdown = {
title: 'Markdown',
url: '/markdown'
file: '/markdown'
};
expectedExternalLink = {
title: 'GitHub',
@@ -227,7 +232,7 @@ describe('BooksTreeViewTests', function () {
});
it('getParent should return when element is a valid child notebook', async () => {
it.skip('getParent should return when element is a valid child notebook', async () => {
let parent = await bookTreeViewProvider.getParent();
should(parent).be.undefined();
@@ -417,53 +422,54 @@ describe('BooksTreeViewTests', function () {
});
describe('BookTreeViewProvider.getTableOfContentFiles', function() {
describe('BookTreeViewProvider.getTableOfContentFiles', function () {
let rootFolderPath = path.join(os.tmpdir(), `BookTestData_${uuid.v4()}`);
let bookTreeViewProvider: BookTreeViewProvider;
let runs = [
{
it: 'v1',
folderPaths : {
'dataFolderPath' : path.join(rootFolderPath, '_data'),
'contentFolderPath' : path.join(rootFolderPath, 'content'),
'configFile' : path.join(rootFolderPath, '_config.yml'),
'tableOfContentsFile' : path.join(rootFolderPath,'_data', 'toc.yml'),
'notebook2File' : path.join(rootFolderPath, 'content', 'notebook2.ipynb'),
'tableOfContentsFileIgnore' : path.join(rootFolderPath, 'toc.yml')
folderPaths: {
'dataFolderPath': path.join(rootFolderPath, '_data'),
'contentFolderPath': path.join(rootFolderPath, 'content'),
'configFile': path.join(rootFolderPath, '_config.yml'),
'tableOfContentsFile': path.join(rootFolderPath, '_data', 'toc.yml'),
'notebook2File': path.join(rootFolderPath, 'content', 'notebook2.ipynb'),
'tableOfContentsFileIgnore': path.join(rootFolderPath, 'toc.yml')
},
contents : {
'config' : 'title: Test Book',
'toc' : '- title: Notebook1\n url: /notebook1\n sections:\n - title: Notebook2\n url: /notebook2\n - title: Notebook3\n url: /notebook3\n- title: Markdown\n url: /markdown\n- title: GitHub\n url: https://github.com/\n external: true'
contents: {
'config': 'title: Test Book',
'toc': '- title: Notebook1\n url: /notebook1\n sections:\n - title: Notebook2\n url: /notebook2\n - title: Notebook3\n url: /notebook3\n- title: Markdown\n url: /markdown\n- title: GitHub\n url: https://github.com/\n external: true'
}
},
{
it: 'v2',
folderPaths : {
'dataFolderPath' : path.join(rootFolderPath, '_data'),
'configFile' : path.join(rootFolderPath, '_config.yml'),
'tableOfContentsFile' : path.join(rootFolderPath, '_toc.yml'),
'notebook2File' : path.join(rootFolderPath,'notebook2.ipynb'),
'tableOfContentsFileIgnore' : path.join(rootFolderPath, '_data', 'toc.yml')
folderPaths: {
'dataFolderPath': path.join(rootFolderPath, '_data'),
'configFile': path.join(rootFolderPath, '_config.yml'),
'tableOfContentsFile': path.join(rootFolderPath, '_toc.yml'),
'notebook2File': path.join(rootFolderPath, 'notebook2.ipynb'),
'tableOfContentsFileIgnore': path.join(rootFolderPath, '_data', 'toc.yml')
},
contents : {
'config' : 'title: Test Book',
'toc' : '- title: Notebook1\n file: /notebook1\n sections:\n - title: Notebook2\n file: /notebook2\n - title: Notebook3\n file: /notebook3\n- title: Markdown\n file: /markdown\n- title: GitHub\n url: https://github.com/\n'
contents: {
'config': 'title: Test Book',
'toc': '- title: Notebook1\n file: /notebook1\n sections:\n - title: Notebook2\n file: /notebook2\n - title: Notebook3\n file: /notebook3\n- title: Markdown\n file: /markdown\n- title: GitHub\n url: https://github.com/\n'
}
}
];
runs.forEach(function (run){
runs.forEach(function (run) {
describe('BookTreeViewProvider.getTableOfContentFiles on ' + run.it, function (): void {
let folder: vscode.WorkspaceFolder;
before(async () => {
await fs.mkdir(rootFolderPath);
await fs.mkdir(run.folderPaths.dataFolderPath);
if(run.it === 'v1') {
if (run.it === 'v1') {
await fs.mkdir(run.folderPaths.contentFolderPath);
}
await fs.writeFile(run.folderPaths.tableOfContentsFile, run.contents.toc);
await fs.writeFile(run.folderPaths.tableOfContentsFileIgnore, '');
await fs.writeFile(run.folderPaths.notebook2File, '');
await fs.writeFile(run.folderPaths.configFile, run.contents.config);
const mockExtensionContext = new MockExtensionContext();
folder = {
@@ -474,16 +480,17 @@ describe('BooksTreeViewTests', function () {
bookTreeViewProvider = new BookTreeViewProvider([folder], mockExtensionContext, false, 'bookTreeView', NavigationProviders.NotebooksNavigator);
let errorCase = new Promise((resolve, reject) => setTimeout(() => resolve(), 5000));
await Promise.race([bookTreeViewProvider.initialized, errorCase.then(() => { throw new Error('BookTreeViewProvider did not initialize in time'); })]);
await bookTreeViewProvider.openBook(rootFolderPath, undefined, false, false);
});
if(run.it === 'v1') {
if (run.it === 'v1') {
it('should ignore toc.yml files not in _data folder', async () => {
await bookTreeViewProvider.currentBook.readBookStructure(rootFolderPath);
await bookTreeViewProvider.currentBook.loadTableOfContentFiles();
let path = bookTreeViewProvider.currentBook.tableOfContentsPath;
should(vscode.Uri.file(path).fsPath).equal(vscode.Uri.file(run.folderPaths.tableOfContentsFile).fsPath);
});
} else if (run.it === 'v2'){
} else if (run.it === 'v2') {
it('should ignore toc.yml files not under the root book folder', async () => {
await bookTreeViewProvider.currentBook.readBookStructure(rootFolderPath);
await bookTreeViewProvider.currentBook.loadTableOfContentFiles();
@@ -501,46 +508,47 @@ describe('BooksTreeViewTests', function () {
});
});
describe('BookTreeViewProvider.getBooks', function() {
describe('BookTreeViewProvider.getBooks', function () {
let rootFolderPath = path.join(os.tmpdir(), `BookTestData_${uuid.v4()}`);
let bookTreeViewProvider: BookTreeViewProvider;
let runs = [
{
it: 'v1',
folderPaths : {
'dataFolderPath' : path.join(rootFolderPath, '_data'),
'contentFolderPath' : path.join(rootFolderPath, 'content'),
'configFile' : path.join(rootFolderPath, '_config.yml'),
'tableofContentsFile' : path.join(rootFolderPath,'_data', 'toc.yml'),
'notebook2File' : path.join(rootFolderPath, 'content', 'notebook2.ipynb'),
folderPaths: {
'dataFolderPath': path.join(rootFolderPath, '_data'),
'contentFolderPath': path.join(rootFolderPath, 'content'),
'configFile': path.join(rootFolderPath, '_config.yml'),
'tableofContentsFile': path.join(rootFolderPath, '_data', 'toc.yml'),
'notebook2File': path.join(rootFolderPath, 'content', 'notebook2.ipynb'),
},
contents : {
'config' : 'title: Test Book',
'toc' : '- title: Notebook1\n url: /notebook1\n- title: Notebook2\n url: /notebook2'
contents: {
'config': 'title: Test Book',
'toc': '- title: Notebook1\n url: /notebook1\n- title: Notebook2\n url: /notebook2'
}
}, {
it: 'v2',
folderPaths : {
'configFile' : path.join(rootFolderPath, '_config.yml'),
'tableofContentsFile' : path.join(rootFolderPath, '_toc.yml'),
'notebook2File' : path.join(rootFolderPath, 'notebook2.ipynb'),
folderPaths: {
'configFile': path.join(rootFolderPath, '_config.yml'),
'tableofContentsFile': path.join(rootFolderPath, '_toc.yml'),
'notebook2File': path.join(rootFolderPath, 'notebook2.ipynb'),
},
contents : {
'config' : 'title: Test Book',
'toc' : '- title: Notebook1\n file: /notebook1\n- title: Notebook2\n file: /notebook2'
contents: {
'config': 'title: Test Book',
'toc': '- title: Notebook1\n file: /notebook1\n- title: Notebook2\n file: /notebook2'
}
}
];
runs.forEach(function (run){
runs.forEach(function (run) {
describe('BookTreeViewProvider.getBooks on ' + run.it, function (): void {
let folder: vscode.WorkspaceFolder;
before(async () => {
await fs.mkdir(rootFolderPath);
if(run.it === 'v1'){
if (run.it === 'v1') {
await fs.mkdir(run.folderPaths.dataFolderPath);
await fs.mkdir(run.folderPaths.contentFolderPath);
}
await fs.writeFile(run.folderPaths.tableofContentsFile, run.contents.config);
await fs.writeFile(run.folderPaths.configFile, run.contents.config);
const mockExtensionContext = new MockExtensionContext();
folder = {
uri: vscode.Uri.file(rootFolderPath),
@@ -553,14 +561,22 @@ describe('BooksTreeViewTests', function () {
});
it('should show error message if config.yml file not found', async () => {
await bookTreeViewProvider.currentBook.readBooks();
should(bookTreeViewProvider.currentBook.errorMessage).equal(readBookError(bookTreeViewProvider.currentBook.bookPath, `ENOENT: no such file or directory, open '${run.folderPaths.configFile}'`));
try {
await bookTreeViewProvider.openBook(rootFolderPath, undefined, false, false);
} catch (error) {
should(error).equal(openFileError(bookTreeViewProvider.currentBook.bookPath, `ENOENT: no such file or directory, open '${run.folderPaths.configFile}'`));
}
});
it('should show error if toc.yml file format is invalid', async function (): Promise<void> {
try {
await fs.writeFile(run.folderPaths.configFile, run.contents.config);
await bookTreeViewProvider.currentBook.readBooks();
should(bookTreeViewProvider.currentBook.errorMessage).equal(readBookError(bookTreeViewProvider.currentBook.bookPath, `Invalid toc file`));
await bookTreeViewProvider.openBook(rootFolderPath, undefined, false, false);
} catch (error) {
should(error).equal(openFileError(bookTreeViewProvider.currentBook.bookPath, `Invalid toc file`));
}
});
after(async function (): Promise<void> {
@@ -569,39 +585,40 @@ describe('BooksTreeViewTests', function () {
}
});
});
})});
})
});
describe('BookTreeViewProvider.getSections', function() {
describe('BookTreeViewProvider.getSections', function () {
let rootFolderPath = path.join(os.tmpdir(), `BookTestData_${uuid.v4()}`);
let bookTreeViewProvider: BookTreeViewProvider;
let runs = [
{
it: 'v1',
folderPaths : {
'dataFolderPath' : path.join(rootFolderPath, '_data'),
'contentFolderPath' : path.join(rootFolderPath, 'content'),
'configFile' : path.join(rootFolderPath, '_config.yml'),
'tableofContentsFile' : path.join(rootFolderPath,'_data', 'toc.yml'),
'notebook2File' : path.join(rootFolderPath, 'content', 'notebook2.ipynb'),
folderPaths: {
'dataFolderPath': path.join(rootFolderPath, '_data'),
'contentFolderPath': path.join(rootFolderPath, 'content'),
'configFile': path.join(rootFolderPath, '_config.yml'),
'tableofContentsFile': path.join(rootFolderPath, '_data', 'toc.yml'),
'notebook2File': path.join(rootFolderPath, 'content', 'notebook2.ipynb'),
},
contents : {
'config' : 'title: Test Book',
'toc' : '- title: Notebook1\n url: /notebook1\n- title: Notebook2\n url: /notebook2'
contents: {
'config': 'title: Test Book',
'toc': '- title: Notebook1\n url: /notebook1\n- title: Notebook2\n url: /notebook2'
}
},{
}, {
it: 'v2',
folderPaths : {
'configFile' : path.join(rootFolderPath, '_config.yml'),
'tableofContentsFile' : path.join(rootFolderPath,'_toc.yml'),
'notebook2File' : path.join(rootFolderPath,'notebook2.ipynb'),
folderPaths: {
'configFile': path.join(rootFolderPath, '_config.yml'),
'tableofContentsFile': path.join(rootFolderPath, '_toc.yml'),
'notebook2File': path.join(rootFolderPath, 'notebook2.ipynb'),
},
contents : {
'config' : 'title: Test Book',
'toc' : '- title: Notebook1\n file: /notebook1\n- title: Notebook2\n file: /notebook2'
contents: {
'config': 'title: Test Book',
'toc': '- title: Notebook1\n file: /notebook1\n- title: Notebook2\n file: /notebook2'
}
}
];
runs.forEach(function (run){
runs.forEach(function (run) {
describe('BookTreeViewProvider.getSections on ' + run.it, function (): void {
let folder: vscode.WorkspaceFolder[];
let expectedNotebook2: IExpectedBookItem;
@@ -609,12 +626,12 @@ describe('BooksTreeViewTests', function () {
before(async () => {
expectedNotebook2 = {
title: 'Notebook2',
url: '/notebook2',
file: '/notebook2',
previousUri: undefined,
nextUri: undefined
};
await fs.mkdir(rootFolderPath);
if(run.it === 'v1'){
if (run.it === 'v1') {
await fs.mkdir(run.folderPaths.dataFolderPath);
await fs.mkdir(run.folderPaths.contentFolderPath);
}
@@ -636,7 +653,7 @@ describe('BooksTreeViewTests', function () {
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');
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);
});
@@ -645,48 +662,49 @@ describe('BooksTreeViewTests', function () {
if (await exists(rootFolderPath)) await promisify(rimraf)(rootFolderPath);
});
});
})});
})
});
describe('BookTreeViewProvider.Commands', function() {
describe('BookTreeViewProvider.Commands', function () {
let rootFolderPath = path.join(os.tmpdir(), `BookTestData_${uuid.v4()}`);
let bookTreeViewProvider: BookTreeViewProvider;
let runs = [
{
it: 'v1',
folderPaths : {
'dataFolderPath' : path.join(rootFolderPath, '_data'),
'contentFolderPath' : path.join(rootFolderPath, 'content'),
'configFile' : path.join(rootFolderPath, '_config.yml'),
'tableofContentsFile' : path.join(rootFolderPath,'_data', 'toc.yml'),
'notebook1File' : path.join(rootFolderPath, 'content', 'notebook1.ipynb'),
'notebook2File' : path.join(rootFolderPath, 'content', 'notebook2.ipynb'),
'markdownFile' : path.join(rootFolderPath, 'content', 'readme.md')
folderPaths: {
'dataFolderPath': path.join(rootFolderPath, '_data'),
'contentFolderPath': path.join(rootFolderPath, 'content'),
'configFile': path.join(rootFolderPath, '_config.yml'),
'tableofContentsFile': path.join(rootFolderPath, '_data', 'toc.yml'),
'notebook1File': path.join(rootFolderPath, 'content', 'notebook1.ipynb'),
'notebook2File': path.join(rootFolderPath, 'content', 'notebook2.ipynb'),
'markdownFile': path.join(rootFolderPath, 'content', 'readme.md')
},
contents : {
'config' : 'title: Test Book',
'toc' : '- title: Home\n url: /readme\n- title: Notebook1\n url: /notebook1\n- title: Notebook2\n url: /notebook2'
contents: {
'config': 'title: Test Book',
'toc': '- title: Home\n url: /readme\n- title: Notebook1\n url: /notebook1\n- title: Notebook2\n url: /notebook2'
}
},
{
it: 'v2',
folderPaths : {
'configFile' : path.join(rootFolderPath, '_config.yml'),
'tableofContentsFile' : path.join(rootFolderPath, '_toc.yml'),
'notebook1File' : path.join(rootFolderPath, 'notebook1.ipynb'),
'notebook2File' : path.join(rootFolderPath, 'notebook2.ipynb'),
'markdownFile' : path.join(rootFolderPath, 'readme.md')
folderPaths: {
'configFile': path.join(rootFolderPath, '_config.yml'),
'tableofContentsFile': path.join(rootFolderPath, '_toc.yml'),
'notebook1File': path.join(rootFolderPath, 'notebook1.ipynb'),
'notebook2File': path.join(rootFolderPath, 'notebook2.ipynb'),
'markdownFile': path.join(rootFolderPath, 'readme.md')
},
contents : {
'config' : 'title: Test Book',
'toc' : '- title: Home\n file: /readme\n- title: Notebook1\n file: /notebook1\n- title: Notebook2\n file: /notebook2'
contents: {
'config': 'title: Test Book',
'toc': '- title: Home\n file: /readme\n- title: Notebook1\n file: /notebook1\n- title: Notebook2\n file: /notebook2'
}
}
];
runs.forEach(function (run){
runs.forEach(function (run) {
describe('BookTreeViewProvider.Commands on ' + run.it, function (): void {
before(async () => {
await fs.mkdir(rootFolderPath);
if(run.it === 'v1'){
if (run.it === 'v1') {
await fs.mkdir(run.folderPaths.dataFolderPath);
await fs.mkdir(run.folderPaths.contentFolderPath);
}
@@ -836,53 +854,53 @@ describe('BooksTreeViewTests', function () {
});
});
describe('BookTreeViewProvider.openNotebookFolder', function() {
describe('BookTreeViewProvider.openNotebookFolder', function () {
let rootFolderPath = path.join(os.tmpdir(), `BookFolderTest_${uuid.v4()}`);
let bookTreeViewProvider: BookTreeViewProvider;
let runs = [
{
it: 'v1',
folderPaths : {
'bookFolderPath' : path.join(rootFolderPath, 'BookTestData'),
'dataFolderPath' : path.join(rootFolderPath, 'BookTestData', '_data'),
'contentFolderPath' : path.join(rootFolderPath, 'BookTestData', 'content'),
'configFile' : path.join(rootFolderPath, 'BookTestData', '_config.yml'),
'tableOfContentsFile' : path.join(rootFolderPath,'BookTestData','_data', 'toc.yml'),
'bookNotebookFile' : path.join(rootFolderPath, 'BookTestData', 'content', 'notebook1.ipynb'),
'notebookFolderPath' : path.join(rootFolderPath, 'NotebookTestData'),
'standaloneNotebookFile' : path.join(rootFolderPath, 'NotebookTestData','notebook2.ipynb')
folderPaths: {
'bookFolderPath': path.join(rootFolderPath, 'BookTestData'),
'dataFolderPath': path.join(rootFolderPath, 'BookTestData', '_data'),
'contentFolderPath': path.join(rootFolderPath, 'BookTestData', 'content'),
'configFile': path.join(rootFolderPath, 'BookTestData', '_config.yml'),
'tableOfContentsFile': path.join(rootFolderPath, 'BookTestData', '_data', 'toc.yml'),
'bookNotebookFile': path.join(rootFolderPath, 'BookTestData', 'content', 'notebook1.ipynb'),
'notebookFolderPath': path.join(rootFolderPath, 'NotebookTestData'),
'standaloneNotebookFile': path.join(rootFolderPath, 'NotebookTestData', 'notebook2.ipynb')
},
contents : {
'config' : 'title: Test Book',
'toc' : '- title: Notebook1\n url: /notebook1',
'bookTitle' : 'Test Book',
'standaloneNotebookTitle' : 'notebook2'
contents: {
'config': 'title: Test Book',
'toc': '- title: Notebook1\n url: /notebook1',
'bookTitle': 'Test Book',
'standaloneNotebookTitle': 'notebook2'
}
},
{
it: 'v2',
folderPaths : {
'bookFolderPath' : path.join(rootFolderPath, 'BookTestData'),
'configFile' : path.join(rootFolderPath, 'BookTestData', '_config.yml'),
'tableOfContentsFile' : path.join(rootFolderPath,'BookTestData', '_toc.yml'),
'bookNotebookFile' : path.join(rootFolderPath, 'BookTestData','notebook1.ipynb'),
'notebookFolderPath' : path.join(rootFolderPath, 'NotebookTestData'),
'standaloneNotebookFile' : path.join(rootFolderPath, 'NotebookTestData','notebook2.ipynb')
folderPaths: {
'bookFolderPath': path.join(rootFolderPath, 'BookTestData'),
'configFile': path.join(rootFolderPath, 'BookTestData', '_config.yml'),
'tableOfContentsFile': path.join(rootFolderPath, 'BookTestData', '_toc.yml'),
'bookNotebookFile': path.join(rootFolderPath, 'BookTestData', 'notebook1.ipynb'),
'notebookFolderPath': path.join(rootFolderPath, 'NotebookTestData'),
'standaloneNotebookFile': path.join(rootFolderPath, 'NotebookTestData', 'notebook2.ipynb')
},
contents : {
'config' : 'title: Test Book',
'toc' : '- title: Notebook1\n file: /notebook1',
'bookTitle' : 'Test Book',
'standaloneNotebookTitle' : 'notebook2'
contents: {
'config': 'title: Test Book',
'toc': '- title: Notebook1\n file: /notebook1',
'bookTitle': 'Test Book',
'standaloneNotebookTitle': 'notebook2'
}
}];
runs.forEach(function (run){
runs.forEach(function (run) {
describe('BookTreeViewProvider.openNotebookFolder on ' + run.it, function (): void {
before(async () => {
await fs.mkdir(rootFolderPath);
await fs.mkdir(run.folderPaths.bookFolderPath);
if(run.it === 'v1') {
if (run.it === 'v1') {
await fs.mkdir(run.folderPaths.dataFolderPath);
await fs.mkdir(run.folderPaths.contentFolderPath);
}

View File

@@ -2,17 +2,25 @@
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as should from 'should';
import * as path from 'path';
import { BookTocManager, hasSections } from '../../book/bookTocManager';
import { BookTocManager, hasSections, quickPickResults } from '../../book/bookTocManager';
import { BookTreeItem, BookTreeItemFormat, BookTreeItemType } from '../../book/bookTreeItem';
import * as yaml from 'js-yaml';
import * as sinon from 'sinon';
import { IJupyterBookSectionV1, IJupyterBookSectionV2, JupyterBookSection } from '../../contracts/content';
import * as fs from 'fs-extra';
import * as os from 'os';
import * as uuid from 'uuid';
import { exists } from '../../common/utils';
import * as rimraf from 'rimraf';
import { promisify } from 'util';
import { BookModel } from '../../book/bookModel';
import { MockExtensionContext } from '../common/stubs';
import { BookVersion } from '../../book/bookVersionHandler';
import { BookTreeViewProvider } from '../../book/bookTreeView';
import { NavigationProviders } from '../../common/constants';
import * as loc from '../../common/localizedConstants';
export function equalTOC(actualToc: IJupyterBookSectionV2[], expectedToc: IJupyterBookSectionV2[]): boolean {
for (let [i, section] of actualToc.entries()) {
@@ -89,7 +97,7 @@ describe('BookTocManagerTests', function () {
}];
await bookTocManager.createBook(bookFolderPath, root2FolderPath);
should(equalTOC(bookTocManager.tableofContents[2].sections, expectedSection)).be.true;
should(bookTocManager.tableofContents[2].file).be.equal(path.join(subfolder, 'readme'));
should((bookTocManager.tableofContents[2] as IJupyterBookSectionV2).file).be.equal(path.join(subfolder, 'readme'));
});
it('should ignore invalid file extensions', async () => {
@@ -103,267 +111,466 @@ describe('BookTocManagerTests', function () {
});
describe('EditingBooks', () => {
let book: BookTreeItem;
let bookSection: BookTreeItem;
let bookSection2: BookTreeItem;
let notebook: BookTreeItem;
let rootBookFolderPath: string = path.join(os.tmpdir(), uuid.v4(), 'Book');
let rootSectionFolderPath: string = path.join(os.tmpdir(), uuid.v4(), 'BookSection');
let rootSection2FolderPath: string = path.join(os.tmpdir(), uuid.v4(), 'BookSection2');
let notebookFolder: string = path.join(os.tmpdir(), uuid.v4(), 'Notebook');
let bookTocManager: BookTocManager;
let sourceBookModel: BookModel;
let targetBookModel: BookModel;
let targetBook: BookTreeItem;
let sectionC: BookTreeItem;
let sectionA: BookTreeItem;
let sectionB: BookTreeItem;
let notebook: BookTreeItem;
let duplicatedNotebook: BookTreeItem;
let sourceBookFolderPath: string = path.join(os.tmpdir(), uuid.v4(), 'sourceBook');
let targetBookFolderPath: string = path.join(os.tmpdir(), uuid.v4(), 'targetBook');
let duplicatedNotebookPath: string = path.join(os.tmpdir(), uuid.v4(), 'duplicatedNotebook');
let runs = [
{
it: 'using the jupyter-book legacy version < 0.7.0',
version: 'v1',
url: 'file',
book: {
'rootBookFolderPath': rootBookFolderPath,
'bookContentFolderPath': path.join(rootBookFolderPath, 'content', 'sample'),
'bookDataFolderPath': path.join(rootBookFolderPath, '_data'),
'notebook1': path.join(rootBookFolderPath, 'content', 'notebook'),
'notebook2': path.join(rootBookFolderPath, 'content', 'notebook2'),
'tocPath': path.join(rootBookFolderPath, '_data', 'toc.yml')
},
bookSection1: {
'contentPath': path.join(rootSectionFolderPath, 'content', 'sample', 'readme.md'),
'sectionRoot': rootSectionFolderPath,
'sectionName': 'Sample',
'bookContentFolderPath': path.join(rootSectionFolderPath, 'content', 'sample'),
'bookDataFolderPath': path.join(rootSectionFolderPath, '_data'),
'notebook3': path.join(rootSectionFolderPath, 'content', 'sample', 'notebook3'),
'notebook4': path.join(rootSectionFolderPath, 'content', 'sample', 'notebook4'),
'tocPath': path.join(rootSectionFolderPath, '_data', 'toc.yml')
},
bookSection2: {
'contentPath': path.join(rootSection2FolderPath, 'content', 'test', 'readme.md'),
'sectionRoot': rootSection2FolderPath,
'sectionName': 'Test',
'bookContentFolderPath': path.join(rootSection2FolderPath, 'content', 'test'),
'bookDataFolderPath': path.join(rootSection2FolderPath, '_data'),
'notebook5': path.join(rootSection2FolderPath, 'content', 'test', 'notebook5'),
'notebook6': path.join(rootSection2FolderPath, 'content', 'test', 'notebook6'),
'tocPath': path.join(rootSection2FolderPath, '_data', 'toc.yml')
},
notebook: {
'contentPath': path.join(notebookFolder, 'test', 'readme.md')
},
section: [
sourceBook: {
'rootBookFolderPath': sourceBookFolderPath,
'bookContentFolderPath': path.join(sourceBookFolderPath, 'content'),
'tocPath': path.join(sourceBookFolderPath, '_data', 'toc.yml'),
'readme': path.join(sourceBookFolderPath, 'content', 'readme.md'),
'toc': [
{
'title': 'Notebook',
'url': path.join(path.sep, 'notebook')
'title': 'Notebook 1',
'file': path.join(path.sep, 'sectionA', 'notebook1')
},
{
'title': 'Notebook 2',
'url': path.join(path.sep, 'notebook2')
'file': path.join(path.sep, 'sectionA', 'notebook2')
}
],
section1: [
]
},
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',
'url': path.join('sample', 'notebook3')
'file': path.join(path.sep, 'sectionB', 'notebook3')
},
{
'title': 'Notebook 4',
'url': path.join('sample', 'notebook4')
}
],
section2: [
{
'title': 'Notebook 5',
'url': path.join(path.sep, 'test', 'notebook5')
},
{
'title': 'Notebook 6',
'url': path.join(path.sep, 'test', 'notebook6')
'file': path.join(path.sep, 'sectionB', 'notebook4')
}
]
},
notebook5: {
'contentPath': path.join(sourceBookFolderPath, 'content', 'notebook5.ipynb')
},
targetBook: {
'rootBookFolderPath': targetBookFolderPath,
'bookContentFolderPath': path.join(targetBookFolderPath, 'content'),
'tocPath': path.join(targetBookFolderPath, '_data', 'toc.yml'),
'readme': path.join(targetBookFolderPath, 'content', 'readme.md'),
'toc': [
{
'title': 'Welcome page',
'file': path.join(path.sep, 'readme'),
},
{
'title': 'Section C',
'file': path.join(path.sep, 'sectionC', 'readme'),
'sections': [
{
'title': 'Notebook 6',
'file': path.join(path.sep, 'sectionC', 'notebook6')
}
]
}
]
},
sectionC: {
'contentPath': path.join(targetBookFolderPath, 'content', 'sectionC', 'readme.md'),
'sectionRoot': path.join(targetBookFolderPath, 'content', 'sectionC'),
'sectionName': 'Section C',
'notebook6': path.join(targetBookFolderPath, 'content', 'sectionC', 'notebook6.ipynb'),
'sectionFormat': [
{
'title': 'Notebook 6',
'file': path.join(path.sep, 'sectionC', 'notebook6')
}
]
}
}, {
it: 'using jupyter-book versions >= 0.7.0',
it: 'using the jupyter-book legacy version >= 0.7.0',
version: 'v2',
url: 'file',
book: {
'bookContentFolderPath': path.join(rootBookFolderPath, 'sample'),
'rootBookFolderPath': rootBookFolderPath,
'notebook1': path.join(rootBookFolderPath, 'notebook'),
'notebook2': path.join(rootBookFolderPath, 'notebook2'),
'tocPath': path.join(rootBookFolderPath, '_toc.yml')
sourceBook: {
'rootBookFolderPath': sourceBookFolderPath,
'bookContentFolderPath': sourceBookFolderPath,
'tocPath': path.join(sourceBookFolderPath, '_toc.yml'),
'readme': path.join(sourceBookFolderPath, 'readme.md')
},
bookSection1: {
'bookContentFolderPath': path.join(rootSectionFolderPath, 'sample'),
'contentPath': path.join(rootSectionFolderPath, 'sample', 'readme.md'),
'sectionRoot': rootSectionFolderPath,
'sectionName': 'Sample',
'notebook3': path.join(rootSectionFolderPath, 'sample', 'notebook3'),
'notebook4': path.join(rootSectionFolderPath, 'sample', 'notebook4'),
'tocPath': path.join(rootSectionFolderPath, '_toc.yml')
},
bookSection2: {
'bookContentFolderPath': path.join(rootSection2FolderPath, 'test'),
'contentPath': path.join(rootSection2FolderPath, 'test', 'readme.md'),
'sectionRoot': rootSection2FolderPath,
'sectionName': 'Test',
'notebook5': path.join(rootSection2FolderPath, 'test', 'notebook5'),
'notebook6': path.join(rootSection2FolderPath, 'test', 'notebook6'),
'tocPath': path.join(rootSection2FolderPath, '_toc.yml')
},
notebook: {
'contentPath': path.join(notebookFolder, 'test', 'readme.md')
},
section: [
sectionA: {
'contentPath': path.join(sourceBookFolderPath, 'sectionA', 'readme.md'),
'sectionRoot': path.join(sourceBookFolderPath, 'sectionA'),
'sectionName': 'Section A',
'notebook1': path.join(sourceBookFolderPath, 'sectionA', 'notebook1.ipynb'),
'notebook2': path.join(sourceBookFolderPath, 'sectionA', 'notebook2.ipynb'),
'sectionFormat': [
{
'title': 'Notebook',
'file': path.join(path.sep, 'notebook')
'title': 'Notebook 1',
'file': path.join(path.sep, 'sectionA', 'notebook1')
},
{
'title': 'Notebook 2',
'file': path.join(path.sep, 'notebook2')
'file': path.join(path.sep, 'sectionA', 'notebook2')
}
],
section1: [
]
},
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('sample', 'notebook3')
'file': path.join(path.sep, 'sectionB', 'notebook3')
},
{
'title': 'Notebook 4',
'file': path.join('sample', 'notebook4')
}
],
section2: [
{
'title': 'Notebook 5',
'file': path.join(path.sep, 'test', 'notebook5')
},
{
'title': 'Notebook 6',
'file': path.join(path.sep, 'test', 'notebook6')
'file': path.join(path.sep, 'sectionB', 'notebook4')
}
]
},
notebook5: {
'contentPath': path.join(sourceBookFolderPath, 'notebook5.ipynb')
},
targetBook: {
'rootBookFolderPath': targetBookFolderPath,
'bookContentFolderPath': targetBookFolderPath,
'tocPath': path.join(targetBookFolderPath, '_toc.yml'),
'readme': path.join(targetBookFolderPath, 'readme.md'),
'toc': [
{
'title': 'Welcome',
'file': path.join(path.sep, 'readme'),
},
{
'title': 'Section C',
'file': path.join(path.sep, 'sectionC', 'readme'),
'sections': [
{
'title': 'Notebook 6',
'file': path.join(path.sep, 'sectionC', 'notebook6')
}
]
}
]
},
sectionC: {
'contentPath': path.join(targetBookFolderPath, 'sectionC', 'readme.md'),
'sectionRoot': path.join(targetBookFolderPath, 'sectionC'),
'sectionName': 'Section C',
'notebook6': path.join(targetBookFolderPath, 'sectionC', 'notebook6.ipynb'),
'sectionFormat': [
{
'title': 'Notebook 6',
'file': path.join(path.sep, 'sectionC', 'notebook6')
}
]
}
}
];
runs.forEach(function (run) {
describe('Editing Books ' + run.it, function (): void {
beforeEach(async () => {
let bookTreeItemFormat1: BookTreeItemFormat = {
contentPath: run.version === 'v1' ? path.join(run.book.rootBookFolderPath, 'content', 'index.md') : path.join(run.book.rootBookFolderPath, 'index.md'),
root: run.book.rootBookFolderPath,
let targetBookTreeItemFormat: BookTreeItemFormat = {
contentPath: run.targetBook.readme,
root: run.targetBook.rootBookFolderPath,
tableOfContents: {
sections: run.section
sections: run.targetBook.toc
},
isUntitled: undefined,
title: undefined,
title: 'Target Book',
treeItemCollapsibleState: undefined,
type: BookTreeItemType.Book,
version: run.version,
page: run.section
page: run.targetBook.toc
};
let bookTreeItemFormat2: BookTreeItemFormat = {
title: run.bookSection1.sectionName,
contentPath: run.bookSection1.contentPath,
root: run.bookSection1.sectionRoot,
let sectionCTreeItemFormat: BookTreeItemFormat = {
title: run.sectionC.sectionName,
contentPath: run.sectionC.contentPath,
root: run.targetBook.rootBookFolderPath,
tableOfContents: {
sections: run.section1
sections: run.sectionC.sectionFormat
},
isUntitled: undefined,
treeItemCollapsibleState: undefined,
type: BookTreeItemType.Book,
type: BookTreeItemType.Markdown,
version: run.version,
page: run.section1
page: run.sectionC.sectionFormat
};
let bookTreeItemFormat3: BookTreeItemFormat = {
title: run.bookSection2.sectionName,
contentPath: run.bookSection2.contentPath,
root: run.bookSection2.sectionRoot,
// section A is from source book
let sectionATreeItemFormat: BookTreeItemFormat = {
title: run.sectionA.sectionName,
contentPath: run.sectionA.contentPath,
root: run.sourceBook.rootBookFolderPath,
tableOfContents: {
sections: run.section2
sections: run.sectionA.sectionFormat
},
isUntitled: undefined,
treeItemCollapsibleState: undefined,
type: BookTreeItemType.Book,
type: BookTreeItemType.Markdown,
version: run.version,
page: run.section2
page: run.sectionA.sectionFormat
};
let bookTreeItemFormat4: BookTreeItemFormat = {
title: run.bookSection2.sectionName,
contentPath: run.notebook.contentPath,
root: run.bookSection2.sectionRoot,
// section B is from source book
let sectionBTreeItemFormat: BookTreeItemFormat = {
title: run.sectionB.sectionName,
contentPath: run.sectionB.contentPath,
root: run.sourceBook.rootBookFolderPath,
tableOfContents: {
sections: undefined
sections: run.sectionB.sectionFormat
},
isUntitled: undefined,
treeItemCollapsibleState: undefined,
type: BookTreeItemType.Markdown,
version: run.version,
page: run.sectionB.sectionFormat
};
// notebook5 is from source book
let notebookTreeItemFormat: BookTreeItemFormat = {
title: '',
contentPath: run.notebook5.contentPath,
root: run.sourceBook.rootBookFolderPath,
tableOfContents: {
sections: [
{
'title': 'Notebook 5',
'file': path.join(path.sep, 'notebook5')
}
]
},
isUntitled: undefined,
treeItemCollapsibleState: undefined,
type: BookTreeItemType.Notebook,
version: run.version,
page: {
sections: undefined
sections: [
{
'title': 'Notebook 5',
'file': path.join(path.sep, 'notebook5')
}
]
}
};
book = new BookTreeItem(bookTreeItemFormat1, undefined);
bookSection = new BookTreeItem(bookTreeItemFormat2, undefined);
bookSection2 = new BookTreeItem(bookTreeItemFormat3, undefined);
notebook = new BookTreeItem(bookTreeItemFormat4, undefined);
bookTocManager = new BookTocManager();
bookSection.uri = path.join('sample', 'readme');
bookSection2.uri = path.join('test', 'readme');
book.contextValue = 'savedBook';
bookSection.contextValue = 'section';
bookSection2.contextValue = 'section';
notebook.contextValue = 'savedNotebook';
await fs.promises.mkdir(run.book.bookContentFolderPath, { recursive: true });
await fs.promises.mkdir(run.bookSection1.bookContentFolderPath, { recursive: true });
await fs.promises.mkdir(run.bookSection2.bookContentFolderPath, { recursive: true });
await fs.promises.mkdir(path.dirname(run.notebook.contentPath), { recursive: true });
if (run.book.bookDataFolderPath && run.bookSection1.bookDataFolderPath && run.bookSection2.bookDataFolderPath) {
await fs.promises.mkdir(run.book.bookDataFolderPath, { recursive: true });
await fs.promises.mkdir(run.bookSection1.bookDataFolderPath, { recursive: true });
await fs.promises.mkdir(run.bookSection2.bookDataFolderPath, { recursive: true });
let duplicatedNbTreeItemFormat: BookTreeItemFormat = {
title: 'Duplicated Notebook',
contentPath: path.join(duplicatedNotebookPath, 'notebook5.ipynb'),
root: duplicatedNotebookPath,
tableOfContents: {
sections: [
{
'title': 'Notebook 5',
'file': path.join(path.sep, 'notebook5')
}
await fs.writeFile(run.book.notebook1, '');
await fs.writeFile(run.book.notebook2, '');
await fs.writeFile(run.bookSection1.notebook3, '');
await fs.writeFile(run.bookSection1.notebook4, '');
await fs.writeFile(run.bookSection2.notebook5, '');
await fs.writeFile(run.bookSection2.notebook6, '');
await fs.writeFile(run.notebook.contentPath, '');
]
},
isUntitled: undefined,
treeItemCollapsibleState: undefined,
type: BookTreeItemType.Notebook,
version: run.version,
page: {
sections: [
{
'title': 'Notebook 5',
'file': path.join(path.sep, 'notebook5')
}
]
}
};
targetBook = new BookTreeItem(targetBookTreeItemFormat, undefined);
sectionC = new BookTreeItem(sectionCTreeItemFormat, undefined);
sectionA = new BookTreeItem(sectionATreeItemFormat, undefined);
sectionB = new BookTreeItem(sectionBTreeItemFormat, undefined);
notebook = new BookTreeItem(notebookTreeItemFormat, undefined);
duplicatedNotebook = new BookTreeItem(duplicatedNbTreeItemFormat, undefined);
sectionC.uri = path.join('sectionC', 'readme');
sectionA.uri = path.join('sectionA', 'readme');
sectionB.uri = path.join('sectionB', 'readme');
targetBook.contextValue = 'savedBook';
sectionA.contextValue = 'section';
sectionB.contextValue = 'section';
sectionC.contextValue = 'section';
notebook.contextValue = 'savedNotebook';
duplicatedNotebook.contextValue = 'savedNotebook';
sectionC.tableOfContentsPath = run.targetBook.tocPath;
sectionA.tableOfContentsPath = run.sourceBook.tocPath;
sectionB.tableOfContentsPath = run.sourceBook.tocPath;
notebook.tableOfContentsPath = run.sourceBook.tocPath;
duplicatedNotebook.tableOfContentsPath = run.sourceBook.tocPath;
sectionA.sections = run.sectionA.sectionFormat;
sectionB.sections = run.sectionB.sectionFormat;
sectionC.sections = run.sectionC.sectionFormat;
notebook.sections = [
{
'title': 'Notebook 5',
'file': path.join(path.sep, 'notebook5')
}
];
duplicatedNotebook.sections = notebook.sections;
await fs.promises.mkdir(run.targetBook.bookContentFolderPath, { recursive: true });
await fs.promises.mkdir(run.sectionA.contentPath, { recursive: true });
await fs.promises.mkdir(run.sectionB.contentPath, { recursive: true });
await fs.promises.mkdir(run.sectionC.contentPath, { recursive: true });
await fs.promises.mkdir(duplicatedNotebookPath, { recursive: true });
await fs.writeFile(run.sectionA.notebook1, '');
await fs.writeFile(run.sectionA.notebook2, '');
await fs.writeFile(run.sectionB.notebook3, '');
await fs.writeFile(run.sectionB.notebook4, '');
await fs.writeFile(run.sectionC.notebook6, '');
await fs.writeFile(run.notebook5.contentPath, '');
await fs.writeFile(duplicatedNotebook.book.contentPath, '');
await fs.writeFile(path.join(run.targetBook.rootBookFolderPath, '_config.yml'), 'title: Target Book');
await fs.writeFile(path.join(run.sourceBook.rootBookFolderPath, '_config.yml'), 'title: Source Book');
if (run.version === 'v1') {
await fs.promises.mkdir(path.dirname(run.targetBook.tocPath), { recursive: true });
await fs.promises.mkdir(path.dirname(run.sourceBook.tocPath), { recursive: true });
}
// target book
await fs.writeFile(run.targetBook.tocPath, '- title: Welcome\n file: /readme\n- title: Section C\n file: /sectionC/readme\n sections:\n - title: Notebook6\n file: /sectionC/notebook6');
// source book
await fs.writeFile(run.sourceBook.tocPath, '- title: Notebook 5\n file: /notebook5\n- title: Section A\n file: /sectionA/readme\n sections:\n - title: Notebook1\n file: /sectionA/notebook1\n - title: Notebook2\n file: /sectionA/notebook2');
const mockExtensionContext = new MockExtensionContext();
sourceBookModel = new BookModel(run.sourceBook.rootBookFolderPath, false, false, mockExtensionContext);
targetBookModel = new BookModel(run.targetBook.rootBookFolderPath, false, false, mockExtensionContext);
// create book model mock objects
sinon.stub(sourceBookModel, 'bookItems').value([sectionA]);
sinon.stub(targetBookModel, 'bookItems').value([targetBook]);
});
it('Add section to book', async () => {
await bookTocManager.updateBook(bookSection, book);
const listFiles = await fs.promises.readdir(run.book.bookContentFolderPath);
const tocFile = await fs.promises.readFile(run.book.tocPath, 'utf8');
let toc = yaml.safeLoad(tocFile);
should(JSON.stringify(listFiles)).be.equal(JSON.stringify(['notebook3', 'notebook4']), 'The files of the section should be moved to the books folder');
should(equalSections(toc.sections[2], bookTocManager.newSection)).be.true;
bookTocManager = new BookTocManager(targetBookModel, sourceBookModel);
await bookTocManager.updateBook(sectionA, targetBook, undefined);
const listFiles = await fs.promises.readdir(path.join(run.targetBook.bookContentFolderPath, 'sectionA'));
const listSourceFiles = await fs.promises.readdir(path.join(run.sourceBook.bookContentFolderPath));
should(JSON.stringify(listSourceFiles).includes('sectionA')).be.false('The source book files should not contain the section A files');
should(JSON.stringify(listFiles)).be.equal(JSON.stringify(['notebook1.ipynb', 'notebook2.ipynb', 'readme.md']), 'The files of the section should be moved to the target book folder');
});
it('Add section to section', async () => {
await bookTocManager.updateBook(bookSection, bookSection2);
let listFiles = await fs.promises.readdir(path.join(run.bookSection2.bookContentFolderPath, 'sample'));
const tocFile = await fs.promises.readFile(path.join(run.bookSection2.tocPath), 'utf8');
let toc = yaml.safeLoad(tocFile);
should(JSON.stringify(listFiles)).be.equal(JSON.stringify(['notebook3', 'notebook4']), 'The files of the section should be moved to the books folder');
should(equalSections(toc[1].sections, bookTocManager.newSection)).be.true;
bookTocManager = new BookTocManager(targetBookModel, sourceBookModel);
await bookTocManager.updateBook(sectionB, sectionC, {
'title': 'Notebook 6',
'file': path.join(path.sep, 'sectionC', 'notebook6')
});
const sectionCFiles = await fs.promises.readdir(path.join(run.targetBook.bookContentFolderPath, 'sectionC'));
const sectionBFiles = await fs.promises.readdir(path.join(run.targetBook.bookContentFolderPath, 'sectionB'));
should(JSON.stringify(sectionCFiles)).be.equal(JSON.stringify(['notebook6.ipynb', 'readme.md']), 'sectionB has been moved under target book content directory');
should(JSON.stringify(sectionBFiles)).be.equal(JSON.stringify(['notebook3.ipynb', 'notebook4.ipynb', 'readme.md']), ' Verify that the files on sectionB had been moved to the targetBook');
});
it('Add notebook to book', async () => {
await bookTocManager.updateBook(notebook, book);
const folder = run.version === 'v1' ? path.join(run.book.rootBookFolderPath, 'content') : path.join(run.book.rootBookFolderPath);
let listFiles = await fs.promises.readdir(folder);
const tocFile = await fs.promises.readFile(run.book.tocPath, 'utf8');
let toc = yaml.safeLoad(tocFile);
should(listFiles.findIndex(f => f === 'readme.md')).not.equal(-1);
should(equalSections(toc.sections[2], bookTocManager.newSection)).be.true;
bookTocManager = new BookTocManager(targetBookModel);
await bookTocManager.updateBook(notebook, targetBook);
const listFiles = await fs.promises.readdir(run.targetBook.bookContentFolderPath);
should(JSON.stringify(listFiles).includes('notebook5.ipynb')).be.true('Notebook 5 should be under the target book content folder');
});
it('Add duplicated notebook to book', async () => {
bookTocManager = new BookTocManager(targetBookModel);
await bookTocManager.updateBook(notebook, targetBook);
await bookTocManager.updateBook(duplicatedNotebook, targetBook);
const listFiles = await fs.promises.readdir(run.targetBook.bookContentFolderPath);
if (run.version === BookVersion.v1) {
should(JSON.stringify(listFiles)).be.equal(JSON.stringify(['notebook5 - 2.ipynb', 'notebook5.ipynb', 'sectionC']), 'Should modify the name of the file');
} else {
should(JSON.stringify(listFiles)).be.equal(JSON.stringify(['_config.yml', '_toc.yml', 'notebook5 - 2.ipynb', 'notebook5.ipynb', 'sectionC']), 'Should modify the name of the file');
}
});
it('Recovery method is called after error', async () => {
const mockExtensionContext = new MockExtensionContext();
const recoverySpy = sinon.spy(BookTocManager.prototype, 'recovery');
sinon.stub(BookTocManager.prototype, 'updateBook').throws(new Error('Unexpected error.'));
const bookTreeViewProvider = new BookTreeViewProvider([], mockExtensionContext, false, 'bookTreeView', NavigationProviders.NotebooksNavigator);
const results: quickPickResults = {
book: targetBook,
quickPickSection: {
label: loc.labelAddToLevel,
description: undefined
}
}
bookTocManager = new BookTocManager(targetBookModel);
sinon.stub(bookTreeViewProvider, 'getSelectionQuickPick').returns(Promise.resolve(results));
try {
await bookTreeViewProvider.editBook(notebook);
} catch (error) {
should(recoverySpy.calledOnce).be.true('If unexpected error then recovery method is called.');
}
});
it('Clean up folder with files didnt move', async () => {
bookTocManager = new BookTocManager(targetBookModel);
bookTocManager.movedFiles.set(notebook.book.contentPath, 'movedtest');
await fs.writeFile(path.join(run.sourceBook.bookContentFolderPath, 'test.ipynb'), '');
await bookTocManager.cleanUp(path.dirname(notebook.book.contentPath));
const listFiles = await fs.promises.readdir(path.dirname(notebook.book.contentPath));
should(JSON.stringify(listFiles).includes('test.ipynb')).be.true('Notebook test.ipynb should not be removed');
});
it('Clean up folder when there is an empty folder within the modified directory', async () => {
await fs.promises.mkdir(path.join(run.sourceBook.bookContentFolderPath, 'test'));
bookTocManager.modifiedDir.add(path.join(run.sourceBook.bookContentFolderPath, 'test'));
bookTocManager.movedFiles.set(notebook.book.contentPath, 'movedtest');
await bookTocManager.cleanUp(path.dirname(notebook.book.contentPath));
const listFiles = await fs.promises.readdir(run.sourceBook.bookContentFolderPath);
should(JSON.stringify(listFiles).includes('test')).be.true('Empty directories within the moving element directory are not deleted');
});
afterEach(async function (): Promise<void> {
sinon.restore();
if (await exists(sourceBookFolderPath)) {
await promisify(rimraf)(sourceBookFolderPath);
}
if (await exists(targetBookFolderPath)) {
await promisify(rimraf)(targetBookFolderPath);
}
});
});
});