Skip to content

Commit

Permalink
Added export to PDF, HTML and python to interactive window (microsoft…
Browse files Browse the repository at this point in the history
…#12732)

* mostly working

* fixed naming

* added news file

* added export util to clean up code

* added export util to clean up code

* added export util to service container

* removed busy indicator

* addressed comments

* refactored and cleaned up

* added sleep to temp file creation
  • Loading branch information
techwithtim committed Jul 6, 2020
1 parent 84a99ee commit 1b3bebd
Show file tree
Hide file tree
Showing 14 changed files with 146 additions and 74 deletions.
1 change: 1 addition & 0 deletions news/1 Enhancements/12732.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added exporting to python, HTML and PDF from the interative window.
2 changes: 1 addition & 1 deletion package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@
"DataScience.exportDialogFailed": "Failed to export notebook. {0}",
"DataScience.exportOpenQuestion": "Open in browser",
"DataScience.exportOpenQuestion1": "Open in editor",
"DataScience.notebookExportAs": "Convert and save to a python script",
"DataScience.notebookExportAs": "Export As",
"DataScience.exportAsQuickPickPlaceholder": "Export As...",
"DataScience.exportPythonQuickPickLabel": "Python Script",
"DataScience.collapseInputTooltip": "Collapse input block",
Expand Down
2 changes: 1 addition & 1 deletion src/client/common/utils/localize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -815,7 +815,7 @@ export namespace DataScience {
'DataScience.remoteDebuggerNotSupported',
'Debugging while attached to a remote server is not currently supported.'
);
export const notebookExportAs = localize('DataScience.notebookExportAs', 'Convert and save to a python script');
export const notebookExportAs = localize('DataScience.notebookExportAs', 'Export As');
export const exportAsPythonFileTitle = localize('DataScience.exportAsPythonFileTitle', 'Save As Python File');
export const exportAsQuickPickPlaceholder = localize('DataScience.exportAsQuickPickPlaceholder', 'Export As...');
export const openExportedFileMessage = localize(
Expand Down
39 changes: 16 additions & 23 deletions src/client/datascience/export/exportManager.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { inject, injectable, named } from 'inversify';
import * as path from 'path';
import { Uri } from 'vscode';
import { IFileSystem, TemporaryFile } from '../../common/platform/types';
import { IFileSystem } from '../../common/platform/types';
import { ProgressReporter } from '../progress/progressReporter';
import { IDataScienceErrorHandler, INotebookModel } from '../types';
import { INotebookModel } from '../types';
import { ExportUtil } from './exportUtil';
import { ExportFormat, IExport, IExportManager, IExportManagerFilePicker } from './types';

@injectable()
Expand All @@ -12,9 +14,9 @@ export class ExportManager implements IExportManager {
@inject(IExport) @named(ExportFormat.html) private readonly exportToHTML: IExport,
@inject(IExport) @named(ExportFormat.python) private readonly exportToPython: IExport,
@inject(IFileSystem) private readonly fileSystem: IFileSystem,
@inject(IDataScienceErrorHandler) private readonly errorHandler: IDataScienceErrorHandler,
@inject(IExportManagerFilePicker) private readonly filePicker: IExportManagerFilePicker,
@inject(ProgressReporter) private readonly progressReporter: ProgressReporter
@inject(ProgressReporter) private readonly progressReporter: ProgressReporter,
@inject(ExportUtil) private readonly exportUtil: ExportUtil
) {}

public async export(format: ExportFormat, model: INotebookModel): Promise<Uri | undefined> {
Expand All @@ -28,13 +30,17 @@ export class ExportManager implements IExportManager {
target = Uri.file((await this.fileSystem.createTemporaryFile('.py')).filePath);
}

const tempFile = await this.makeTemporaryFile(model);
if (!tempFile) {
return; // error making temp file
}
// Need to make a temp directory here, instead of just a temp file. This is because
// we need to store the contents of the notebook in a file that is named the same
// as what we want the title of the exported file to be. To ensure this file path will be unique
// we store it in a temp directory. The name of the file matters because when
// exporting to certain formats the filename is used within the exported document as the title.
const fileName = path.basename(target.fsPath, path.extname(target.fsPath));
const tempDir = await this.exportUtil.generateTempDir();
const sourceFilePath = await this.exportUtil.makeFileInDirectory(model, fileName, tempDir.path);
const source = Uri.file(sourceFilePath);

const reporter = this.progressReporter.createProgressIndicator(`Exporting to ${format}`);
const source = Uri.file(tempFile.filePath);
try {
switch (format) {
case ExportFormat.python:
Expand All @@ -53,23 +59,10 @@ export class ExportManager implements IExportManager {
break;
}
} finally {
tempFile.dispose();
reporter.dispose();
tempDir.dispose();
}

return target;
}

private async makeTemporaryFile(model: INotebookModel): Promise<TemporaryFile | undefined> {
let tempFile: TemporaryFile | undefined;
try {
tempFile = await this.fileSystem.createTemporaryFile('.ipynb');
const content = model ? model.getContent() : '';
await this.fileSystem.writeFile(tempFile.filePath, content, 'utf-8');
} catch (e) {
await this.errorHandler.handleError(e);
}

return tempFile;
}
}
44 changes: 2 additions & 42 deletions src/client/datascience/export/exportToPDF.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,55 +19,15 @@ export class ExportToPDF extends ExportBase {
}

public async export(source: Uri, target: Uri): Promise<void> {
const tempFile = await this.fileSystem.createTemporaryFile('.ipynb');
const directoryPath = path.join(
path.dirname(tempFile.filePath),
path.basename(tempFile.filePath, path.extname(tempFile.filePath))
);
tempFile.dispose();
const newFileName = path.basename(target.fsPath, path.extname(target.fsPath));
const newSource = Uri.file(await this.createNewFile(directoryPath, newFileName, source));

const args = [
newSource.fsPath,
source.fsPath,
'--to',
'pdf',
'--output',
path.basename(target.fsPath),
'--output-dir',
path.dirname(target.fsPath)
];
try {
await this.executeCommand(newSource, target, args);
} finally {
await this.deleteNewDirectory(directoryPath);
}
}

private async createNewFile(dirPath: string, newName: string, source: Uri): Promise<string> {
// When exporting to PDF we need to change the source files name to match
// what the title of the pdf should be.
// To ensure the new file path is unique we will create a directory and
// save the new file there
try {
await this.fileSystem.createDirectory(dirPath);
const newFilePath = path.join(dirPath, newName);
await this.fileSystem.copyFile(source.fsPath, newFilePath);
return newFilePath;
} catch (e) {
await this.deleteNewDirectory(dirPath);
throw e;
}
}

private async deleteNewDirectory(dirPath: string) {
if (!(await this.fileSystem.directoryExists(dirPath))) {
return;
}
const files = await this.fileSystem.getFiles(dirPath);
for (const file of files) {
await this.fileSystem.deleteFile(file);
}
await this.fileSystem.deleteDirectory(dirPath);
await this.executeCommand(source, target, args);
}
}
73 changes: 73 additions & 0 deletions src/client/datascience/export/exportUtil.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { inject, injectable } from 'inversify';
import * as os from 'os';
import * as path from 'path';
import * as uuid from 'uuid/v4';
import { Uri } from 'vscode';
import { IFileSystem, TemporaryDirectory } from '../../common/platform/types';
import { sleep } from '../../common/utils/async';
import { ICell, IDataScienceErrorHandler, INotebookExporter, INotebookModel, INotebookStorage } from '../types';

@injectable()
export class ExportUtil {
constructor(
@inject(IFileSystem) private fileSystem: IFileSystem,
@inject(IDataScienceErrorHandler) private readonly errorHandler: IDataScienceErrorHandler,
@inject(INotebookStorage) private notebookStorage: INotebookStorage,
@inject(INotebookExporter) private jupyterExporter: INotebookExporter
) {}

public async generateTempDir(): Promise<TemporaryDirectory> {
const resultDir = path.join(os.tmpdir(), uuid());
await this.fileSystem.createDirectory(resultDir);

return {
path: resultDir,
dispose: async () => {
// Try ten times. Process may still be up and running.
// We don't want to do async as async dispose means it may never finish and then we don't
// delete
let count = 0;
while (count < 10) {
try {
await this.fileSystem.deleteDirectory(resultDir);
count = 10;
} catch {
await sleep(3000);
count += 1;
}
}
}
};
}

public async makeFileInDirectory(model: INotebookModel, fileName: string, dirPath: string): Promise<string> {
const newFilePath = path.join(dirPath, fileName);

try {
const content = model ? model.getContent() : '';
await this.fileSystem.writeFile(newFilePath, content, 'utf-8');
} catch (e) {
await this.errorHandler.handleError(e);
}

return newFilePath;
}

public async getModelFromCells(cells: ICell[]): Promise<INotebookModel> {
const tempDir = await this.generateTempDir();
const tempFile = await this.fileSystem.createTemporaryFile('.ipynb');
let model: INotebookModel;

try {
await this.jupyterExporter.exportToFile(cells, tempFile.filePath, false);
const newPath = path.join(tempDir.path, '.ipynb');
await this.fileSystem.copyFile(tempFile.filePath, newPath);
model = await this.notebookStorage.load(Uri.file(newPath));
} finally {
tempFile.dispose();
tempDir.dispose();
}

return model;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -575,7 +575,7 @@ export class IInteractiveWindowMapping {
public [InteractiveWindowMessages.SelectJupyterServer]: never | undefined;
public [InteractiveWindowMessages.OpenSettings]: string | undefined;
public [InteractiveWindowMessages.Export]: ICell[];
public [InteractiveWindowMessages.ExportNotebookAs]: never | undefined;
public [InteractiveWindowMessages.ExportNotebookAs]: ICell[];
public [InteractiveWindowMessages.GetAllCells]: never | undefined;
public [InteractiveWindowMessages.ReturnAllCells]: ICell[];
public [InteractiveWindowMessages.DeleteAllCells]: IAddCellAction;
Expand Down
25 changes: 23 additions & 2 deletions src/client/datascience/interactive-window/interactiveWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@ import * as localize from '../../common/utils/localize';
import { EXTENSION_ROOT_DIR } from '../../constants';
import { PythonInterpreter } from '../../pythonEnvironments/info';
import { captureTelemetry, sendTelemetryEvent } from '../../telemetry';
import { EditorContexts, Identifiers, Telemetry } from '../constants';
import { Commands, EditorContexts, Identifiers, Telemetry } from '../constants';
import { IDataViewerFactory } from '../data-viewing/types';
import { ExportUtil } from '../export/exportUtil';
import { InteractiveBase } from '../interactive-common/interactiveBase';
import {
INotebookIdentity,
Expand All @@ -57,6 +58,7 @@ import {
IJupyterVariableDataProviderFactory,
IJupyterVariables,
INotebookExporter,
INotebookModel,
INotebookProvider,
IStatusProvider,
IThemeFinder,
Expand Down Expand Up @@ -115,7 +117,8 @@ export class InteractiveWindow extends InteractiveBase implements IInteractiveWi
@inject(KernelSwitcher) switcher: KernelSwitcher,
@inject(INotebookProvider) notebookProvider: INotebookProvider,
@inject(UseCustomEditorApi) useCustomEditorApi: boolean,
@inject(IExperimentService) expService: IExperimentService
@inject(IExperimentService) expService: IExperimentService,
@inject(ExportUtil) private exportUtil: ExportUtil
) {
super(
listeners,
Expand Down Expand Up @@ -220,6 +223,10 @@ export class InteractiveWindow extends InteractiveBase implements IInteractiveWi
this.handleMessage(message, payload, this.handleModelChange);
break;

case InteractiveWindowMessages.ExportNotebookAs:
this.handleMessage(message, payload, this.exportAs);
break;

default:
break;
}
Expand Down Expand Up @@ -420,6 +427,20 @@ export class InteractiveWindow extends InteractiveBase implements IInteractiveWi
}
}

private async exportAs(cells: ICell[]) {
let model: INotebookModel;

this.startProgress();
try {
model = await this.exportUtil.getModelFromCells(cells);
} finally {
this.stopProgress();
}
if (model) {
this.commandManager.executeCommand(Commands.Export, model);
}
}

private handleModelChange(update: NotebookModelChange) {
// Send telemetry for delete and delete all. We don't send telemetry for the other updates yet
if (update.source === 'user') {
Expand Down
5 changes: 4 additions & 1 deletion src/client/datascience/jupyter/jupyterExporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export class JupyterExporter implements INotebookExporter {
noop();
}

public async exportToFile(cells: ICell[], file: string): Promise<void> {
public async exportToFile(cells: ICell[], file: string, showOpenPrompt: boolean = true): Promise<void> {
let directoryChange;
const settings = this.configService.getSettings();
if (settings.datascience.changeDirOnImportExport) {
Expand All @@ -60,6 +60,9 @@ export class JupyterExporter implements INotebookExporter {
const contents = JSON.stringify(notebook);
await this.trustService.trustNotebook(Uri.file(file.toLowerCase()), contents);
await this.fileSystem.writeFile(file, contents, { encoding: 'utf8', flag: 'w' });
if (!showOpenPrompt) {
return;
}
const openQuestion1 = localize.DataScience.exportOpenQuestion1();
const openQuestion2 = (await this.jupyterExecution.isSpawnSupported())
? localize.DataScience.exportOpenQuestion()
Expand Down
2 changes: 2 additions & 0 deletions src/client/datascience/serviceRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { ExportManagerFilePicker } from './export/exportManagerFilePicker';
import { ExportToHTML } from './export/exportToHTML';
import { ExportToPDF } from './export/exportToPDF';
import { ExportToPython } from './export/exportToPython';
import { ExportUtil } from './export/exportUtil';
import { ExportFormat, IExport, IExportManager, IExportManagerFilePicker } from './export/types';
import { GatherListener } from './gather/gatherListener';
import { GatherLogger } from './gather/gatherLogger';
Expand Down Expand Up @@ -298,6 +299,7 @@ export function registerTypes(serviceManager: IServiceManager) {
serviceManager.addSingleton<IExport>(IExport, ExportToHTML, ExportFormat.html);
serviceManager.addSingleton<IExport>(IExport, ExportToPython, ExportFormat.python);
serviceManager.addSingleton<IExport>(IExport, ExportBase, 'Export Base');
serviceManager.addSingleton<ExportUtil>(ExportUtil, ExportUtil);
serviceManager.addSingleton<ExportCommands>(ExportCommands, ExportCommands);
serviceManager.addSingleton<IExportManagerFilePicker>(IExportManagerFilePicker, ExportManagerFilePicker);
serviceManager.addSingleton<IJupyterUriProviderRegistration>(IJupyterUriProviderRegistration, JupyterUriProviderRegistration);
Expand Down
2 changes: 1 addition & 1 deletion src/client/datascience/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,7 @@ export interface INotebookImporter extends Disposable {
export const INotebookExporter = Symbol('INotebookExporter');
export interface INotebookExporter extends Disposable {
translateToNotebook(cells: ICell[], directoryChange?: string): Promise<nbformat.INotebookContent | undefined>;
exportToFile(cells: ICell[], file: string): Promise<void>;
exportToFile(cells: ICell[], file: string, showOpenPrompt?: boolean): Promise<void>;
}

export const IInteractiveWindowProvider = Symbol('IInteractiveWindowProvider');
Expand Down
13 changes: 13 additions & 0 deletions src/datascience-ui/history-react/interactivePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,19 @@ ${buildSettingsCss(this.props.settings)}`}</style>
image={ImageName.SaveAs}
/>
</ImageButton>
<ImageButton
baseTheme={this.props.baseTheme}
onClick={this.props.exportAs}
disabled={this.props.busy || !this.props.isNotebookTrusted}
className="native-button"
tooltip={getLocString('DataScience.notebookExportAs', 'Export as')}
>
<Image
baseTheme={this.props.baseTheme}
class="image-button-image"
image={ImageName.ExportToPython}
/>
</ImageButton>
<ImageButton
baseTheme={this.props.baseTheme}
onClick={this.props.expandAll}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,12 @@ export namespace Transfer {
}

export function showExportAsMenu(arg: CommonReducerArg): IMainState {
postActionToExtension(arg, InteractiveWindowMessages.ExportNotebookAs, arg.payload.data); // want to send filename
return arg.prevState;
const cellContents = arg.prevState.cellVMs.map((v) => v.cell);
postActionToExtension(arg, InteractiveWindowMessages.ExportNotebookAs, cellContents);

return {
...arg.prevState
};
}

export function save(arg: CommonReducerArg): IMainState {
Expand Down
2 changes: 2 additions & 0 deletions src/test/datascience/dataScienceIocContainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ import { ExportManagerFilePicker } from '../../client/datascience/export/exportM
import { ExportToHTML } from '../../client/datascience/export/exportToHTML';
import { ExportToPDF } from '../../client/datascience/export/exportToPDF';
import { ExportToPython } from '../../client/datascience/export/exportToPython';
import { ExportUtil } from '../../client/datascience/export/exportUtil';
import { ExportFormat, IExport, IExportManager, IExportManagerFilePicker } from '../../client/datascience/export/types';
import { GatherProvider } from '../../client/datascience/gather/gather';
import { GatherListener } from '../../client/datascience/gather/gatherListener';
Expand Down Expand Up @@ -617,6 +618,7 @@ export class DataScienceIocContainer extends UnitTestIocContainer {
ExportManagerDependencyChecker,
ExportManagerDependencyChecker
);
this.serviceManager.addSingleton<ExportUtil>(ExportUtil, ExportUtil);
this.serviceManager.addSingleton<INotebookModelFactory>(INotebookModelFactory, NotebookModelFactory);
this.serviceManager.addSingleton<IExportManager>(IExportManager, ExportManagerFileOpener);
this.serviceManager.addSingleton<IExport>(IExport, ExportToPDF, ExportFormat.pdf);
Expand Down

0 comments on commit 1b3bebd

Please sign in to comment.