diff --git a/extensions/ipynb/extension-browser.webpack.config.js b/extensions/ipynb/extension-browser.webpack.config.js index e9bc478c03e4a..a9dbdb8b04c88 100644 --- a/extensions/ipynb/extension-browser.webpack.config.js +++ b/extensions/ipynb/extension-browser.webpack.config.js @@ -12,10 +12,10 @@ const withBrowserDefaults = require('../shared.webpack.config').browser; const config = withBrowserDefaults({ context: __dirname, entry: { - extension: './src/ipynbMain.ts' + extension: './src/ipynbMain.browser.ts' }, output: { - filename: 'ipynbMain.js' + filename: 'ipynbMain.browser.js' } }); diff --git a/extensions/ipynb/extension.webpack.config.js b/extensions/ipynb/extension.webpack.config.js index 784411bb06666..aad5f55845a96 100644 --- a/extensions/ipynb/extension.webpack.config.js +++ b/extensions/ipynb/extension.webpack.config.js @@ -8,13 +8,19 @@ 'use strict'; const withDefaults = require('../shared.webpack.config'); +const path = require('path'); module.exports = withDefaults({ context: __dirname, entry: { - extension: './src/ipynbMain.ts', + ['ipynbMain.node']: './src/ipynbMain.node.ts', + notebookSerializerWorker: './src/notebookSerializerWorker.ts', }, output: { - filename: 'ipynbMain.js' - } + path: path.resolve(__dirname, 'dist'), + filename: '[name].js' + }, + plugins: [ + ...withDefaults.nodePlugins(__dirname), // add plugins, don't replace inherited + ] }); diff --git a/extensions/ipynb/package.json b/extensions/ipynb/package.json index d881eb8ca2216..c2fe8c2a012e3 100644 --- a/extensions/ipynb/package.json +++ b/extensions/ipynb/package.json @@ -22,8 +22,8 @@ "workspace", "ui" ], - "main": "./out/ipynbMain.js", - "browser": "./dist/browser/ipynbMain.js", + "main": "./out/ipynbMain.node.js", + "browser": "./dist/browser/ipynbMain.browser.js", "capabilities": { "virtualWorkspaces": true, "untrustedWorkspaces": { @@ -39,6 +39,12 @@ "scope": "resource", "markdownDescription": "%ipynb.pasteImagesAsAttachments.enabled%", "default": true + }, + "ipynb.experimental.serialization": { + "type": "boolean", + "scope": "resource", + "markdownDescription": "%ipynb.experimental.serialization%", + "default": false } } } diff --git a/extensions/ipynb/package.nls.json b/extensions/ipynb/package.nls.json index 7a3d95181cfcd..9fc163137f886 100644 --- a/extensions/ipynb/package.nls.json +++ b/extensions/ipynb/package.nls.json @@ -2,6 +2,7 @@ "displayName": ".ipynb Support", "description": "Provides basic support for opening and reading Jupyter's .ipynb notebook files", "ipynb.pasteImagesAsAttachments.enabled": "Enable/disable pasting of images into Markdown cells in ipynb notebook files. Pasted images are inserted as attachments to the cell.", + "ipynb.experimental.serialization": "Experimental feature to serialize the Jupyter notebook in a worker thread. Not supported when the Extension host is running as a web worker.", "newUntitledIpynb.title": "New Jupyter Notebook", "newUntitledIpynb.shortTitle": "Jupyter Notebook", "openIpynbInNotebookEditor.title": "Open IPYNB File In Notebook Editor", diff --git a/extensions/ipynb/src/common.ts b/extensions/ipynb/src/common.ts index f8514216fef37..3fda0bc74f41a 100644 --- a/extensions/ipynb/src/common.ts +++ b/extensions/ipynb/src/common.ts @@ -64,3 +64,4 @@ export interface CellMetadata { */ execution_count?: number; } + diff --git a/extensions/ipynb/src/constants.ts b/extensions/ipynb/src/constants.ts index 43e13b3b510bb..9a82ccfae3900 100644 --- a/extensions/ipynb/src/constants.ts +++ b/extensions/ipynb/src/constants.ts @@ -3,9 +3,23 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as vscode from 'vscode'; +import type { DocumentSelector } from 'vscode'; export const defaultNotebookFormat = { major: 4, minor: 2 }; export const ATTACHMENT_CLEANUP_COMMANDID = 'ipynb.cleanInvalidImageAttachment'; -export const JUPYTER_NOTEBOOK_MARKDOWN_SELECTOR: vscode.DocumentSelector = { notebookType: 'jupyter-notebook', language: 'markdown' }; +export const JUPYTER_NOTEBOOK_MARKDOWN_SELECTOR: DocumentSelector = { notebookType: 'jupyter-notebook', language: 'markdown' }; + +// Copied from NotebookCellKind.Markup as we cannot import it from vscode directly in worker threads. +export const NotebookCellKindMarkup = 1; +// Copied from NotebookCellKind.Code as we cannot import it from vscode directly in worker threads. +export const NotebookCellKindCode = 2; + +export enum CellOutputMimeTypes { + error = 'application/vnd.code.notebook.error', + stderr = 'application/vnd.code.notebook.stderr', + stdout = 'application/vnd.code.notebook.stdout' +} + +export const textMimeTypes = ['text/plain', 'text/markdown', 'text/latex', CellOutputMimeTypes.stderr, CellOutputMimeTypes.stdout]; + diff --git a/extensions/ipynb/src/deserializers.ts b/extensions/ipynb/src/deserializers.ts index 7de93f34a13c8..de467f6607760 100644 --- a/extensions/ipynb/src/deserializers.ts +++ b/extensions/ipynb/src/deserializers.ts @@ -6,6 +6,7 @@ import type * as nbformat from '@jupyterlab/nbformat'; import { extensions, NotebookCellData, NotebookCellExecutionSummary, NotebookCellKind, NotebookCellOutput, NotebookCellOutputItem, NotebookData } from 'vscode'; import { CellMetadata, CellOutputMetadata } from './common'; +import { textMimeTypes } from './constants'; const jupyterLanguageToMonacoLanguageMapping = new Map([ ['c#', 'csharp'], @@ -89,15 +90,6 @@ function sortOutputItemsBasedOnDisplayOrder(outputItems: NotebookCellOutputItem[ .sort((outputItemA, outputItemB) => outputItemA.index - outputItemB.index).map(item => item.item); } - -enum CellOutputMimeTypes { - error = 'application/vnd.code.notebook.error', - stderr = 'application/vnd.code.notebook.stderr', - stdout = 'application/vnd.code.notebook.stdout' -} - -export const textMimeTypes = ['text/plain', 'text/markdown', 'text/latex', CellOutputMimeTypes.stderr, CellOutputMimeTypes.stdout]; - function concatMultilineString(str: string | string[], trim?: boolean): string { const nonLineFeedWhiteSpaceTrim = /(^[\t\f\v\r ]+|[\t\f\v\r ]+$)/g; if (Array.isArray(str)) { diff --git a/extensions/ipynb/src/helper.ts b/extensions/ipynb/src/helper.ts index fd81250885d30..beab091f5c69f 100644 --- a/extensions/ipynb/src/helper.ts +++ b/extensions/ipynb/src/helper.ts @@ -3,6 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { CancellationError } from 'vscode'; + export function deepClone(obj: T): T { if (!obj || typeof obj !== 'object') { return obj; @@ -140,3 +142,119 @@ export class Delayer { export interface ITask { (): T; } + + +/** + * Copied from src/vs/base/common/uuid.ts + */ +export function generateUuid() { + // use `randomValues` if possible + function getRandomValues(bucket: Uint8Array): Uint8Array { + for (let i = 0; i < bucket.length; i++) { + bucket[i] = Math.floor(Math.random() * 256); + } + return bucket; + } + + // prep-work + const _data = new Uint8Array(16); + const _hex: string[] = []; + for (let i = 0; i < 256; i++) { + _hex.push(i.toString(16).padStart(2, '0')); + } + + // get data + getRandomValues(_data); + + // set version bits + _data[6] = (_data[6] & 0x0f) | 0x40; + _data[8] = (_data[8] & 0x3f) | 0x80; + + // print as string + let i = 0; + let result = ''; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += '-'; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += '-'; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += '-'; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += '-'; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + return result; +} + +export type ValueCallback = (value: T | Promise) => void; + +const enum DeferredOutcome { + Resolved, + Rejected +} + + +/** + * Creates a promise whose resolution or rejection can be controlled imperatively. + */ +export class DeferredPromise { + + private completeCallback!: ValueCallback; + private errorCallback!: (err: unknown) => void; + private outcome?: { outcome: DeferredOutcome.Rejected; value: any } | { outcome: DeferredOutcome.Resolved; value: T }; + + public get isRejected() { + return this.outcome?.outcome === DeferredOutcome.Rejected; + } + + public get isResolved() { + return this.outcome?.outcome === DeferredOutcome.Resolved; + } + + public get isSettled() { + return !!this.outcome; + } + + public get value() { + return this.outcome?.outcome === DeferredOutcome.Resolved ? this.outcome?.value : undefined; + } + + public readonly p: Promise; + + constructor() { + this.p = new Promise((c, e) => { + this.completeCallback = c; + this.errorCallback = e; + }); + } + + public complete(value: T) { + return new Promise(resolve => { + this.completeCallback(value); + this.outcome = { outcome: DeferredOutcome.Resolved, value }; + resolve(); + }); + } + + public error(err: unknown) { + return new Promise(resolve => { + this.errorCallback(err); + this.outcome = { outcome: DeferredOutcome.Rejected, value: err }; + resolve(); + }); + } + + public cancel() { + return this.error(new CancellationError()); + } +} diff --git a/extensions/ipynb/src/ipynbMain.browser.ts b/extensions/ipynb/src/ipynbMain.browser.ts new file mode 100644 index 0000000000000..e3123f1b54117 --- /dev/null +++ b/extensions/ipynb/src/ipynbMain.browser.ts @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as main from './ipynbMain'; + +export function activate(context: vscode.ExtensionContext) { + return main.activate(context, true); +} + +export function deactivate() { + return main.deactivate(); +} diff --git a/extensions/ipynb/src/ipynbMain.node.ts b/extensions/ipynb/src/ipynbMain.node.ts new file mode 100644 index 0000000000000..d0425a490e1cf --- /dev/null +++ b/extensions/ipynb/src/ipynbMain.node.ts @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as main from './ipynbMain'; + +export function activate(context: vscode.ExtensionContext) { + return main.activate(context, false); +} + +export function deactivate() { + return main.deactivate(); +} diff --git a/extensions/ipynb/src/ipynbMain.ts b/extensions/ipynb/src/ipynbMain.ts index 58c7e100b3b0f..4c30fe8c7969c 100644 --- a/extensions/ipynb/src/ipynbMain.ts +++ b/extensions/ipynb/src/ipynbMain.ts @@ -8,6 +8,7 @@ import { NotebookSerializer } from './notebookSerializer'; import { activate as keepNotebookModelStoreInSync } from './notebookModelStoreSync'; import { notebookImagePasteSetup } from './notebookImagePaste'; import { AttachmentCleaner } from './notebookAttachmentCleaner'; +import { serializeNotebookToString } from './serializers'; // From {nbformat.INotebookMetadata} in @jupyterlab/coreutils type NotebookMetadata = { @@ -28,8 +29,8 @@ type NotebookMetadata = { [propName: string]: unknown; }; -export function activate(context: vscode.ExtensionContext) { - const serializer = new NotebookSerializer(context); +export function activate(context: vscode.ExtensionContext, isBrowser: boolean) { + const serializer = new NotebookSerializer(context, isBrowser); keepNotebookModelStoreInSync(context); context.subscriptions.push(vscode.workspace.registerNotebookSerializer('jupyter-notebook', serializer, { transientOutputs: false, @@ -105,8 +106,8 @@ export function activate(context: vscode.ExtensionContext) { get dropCustomMetadata() { return true; }, - exportNotebook: (notebook: vscode.NotebookData): string => { - return exportNotebook(notebook, serializer); + exportNotebook: (notebook: vscode.NotebookData): Promise => { + return Promise.resolve(serializeNotebookToString(notebook)); }, setNotebookMetadata: async (resource: vscode.Uri, metadata: Partial): Promise => { const document = vscode.workspace.notebookDocuments.find(doc => doc.uri.toString() === resource.toString()); @@ -127,8 +128,4 @@ export function activate(context: vscode.ExtensionContext) { }; } -function exportNotebook(notebook: vscode.NotebookData, serializer: NotebookSerializer): string { - return serializer.serializeNotebookToString(notebook); -} - export function deactivate() { } diff --git a/extensions/ipynb/src/notebookModelStoreSync.ts b/extensions/ipynb/src/notebookModelStoreSync.ts index f51c5aad4f554..451085718c6f5 100644 --- a/extensions/ipynb/src/notebookModelStoreSync.ts +++ b/extensions/ipynb/src/notebookModelStoreSync.ts @@ -4,10 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, ExtensionContext, NotebookCellKind, NotebookDocument, NotebookDocumentChangeEvent, NotebookEdit, workspace, WorkspaceEdit, type NotebookCell, type NotebookDocumentWillSaveEvent } from 'vscode'; -import { getCellMetadata, getVSCodeCellLanguageId, removeVSCodeCellLanguageId, setVSCodeCellLanguageId, sortObjectPropertiesRecursively } from './serializers'; +import { getCellMetadata, getVSCodeCellLanguageId, removeVSCodeCellLanguageId, setVSCodeCellLanguageId, sortObjectPropertiesRecursively, getNotebookMetadata } from './serializers'; import { CellMetadata } from './common'; -import { getNotebookMetadata } from './notebookSerializer'; import type * as nbformat from '@jupyterlab/nbformat'; +import { generateUuid } from './helper'; const noop = () => { // @@ -242,55 +242,3 @@ function generateCellId(notebook: NotebookDocument) { } } - -/** - * Copied from src/vs/base/common/uuid.ts - */ -function generateUuid() { - // use `randomValues` if possible - function getRandomValues(bucket: Uint8Array): Uint8Array { - for (let i = 0; i < bucket.length; i++) { - bucket[i] = Math.floor(Math.random() * 256); - } - return bucket; - } - - // prep-work - const _data = new Uint8Array(16); - const _hex: string[] = []; - for (let i = 0; i < 256; i++) { - _hex.push(i.toString(16).padStart(2, '0')); - } - - // get data - getRandomValues(_data); - - // set version bits - _data[6] = (_data[6] & 0x0f) | 0x40; - _data[8] = (_data[8] & 0x3f) | 0x80; - - // print as string - let i = 0; - let result = ''; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += '-'; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += '-'; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += '-'; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += '-'; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - result += _hex[_data[i++]]; - return result; -} diff --git a/extensions/ipynb/src/notebookSerializer.ts b/extensions/ipynb/src/notebookSerializer.ts index 3304853d894ff..217e7948d7813 100644 --- a/extensions/ipynb/src/notebookSerializer.ts +++ b/extensions/ipynb/src/notebookSerializer.ts @@ -6,13 +6,34 @@ import type * as nbformat from '@jupyterlab/nbformat'; import * as detectIndent from 'detect-indent'; import * as vscode from 'vscode'; -import { defaultNotebookFormat } from './constants'; import { getPreferredLanguage, jupyterNotebookModelToNotebookData } from './deserializers'; -import { createJupyterCellFromNotebookCell, pruneCell, sortObjectPropertiesRecursively } from './serializers'; import * as fnv from '@enonic/fnv-plus'; +import { DeferredPromise, generateUuid } from './helper'; +import { serializeNotebookToString } from './serializers'; + +export class NotebookSerializer extends vscode.Disposable implements vscode.NotebookSerializer { + private experimentalSave = vscode.workspace.getConfiguration('ipynb').get('experimental.serialization', false); + private disposed: boolean = false; + private worker?: import('node:worker_threads').Worker; + private tasks = new Map>(); + + constructor(readonly context: vscode.ExtensionContext, private readonly isBrowser: boolean) { + super(() => { }); + context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('ipynb.experimental.serialization')) { + this.experimentalSave = vscode.workspace.getConfiguration('ipynb').get('experimental.serialization', false); + } + })); + } -export class NotebookSerializer implements vscode.NotebookSerializer { - constructor(readonly context: vscode.ExtensionContext) { + override dispose() { + this.disposed = true; + try { + void this.worker?.terminate(); + } catch { + // + } + super.dispose(); } public async deserializeNotebook(content: Uint8Array, _token: vscode.CancellationToken): Promise { @@ -70,33 +91,62 @@ export class NotebookSerializer implements vscode.NotebookSerializer { return data; } - public serializeNotebook(data: vscode.NotebookData, _token: vscode.CancellationToken): Uint8Array { - return new TextEncoder().encode(this.serializeNotebookToString(data)); + public async serializeNotebook(data: vscode.NotebookData, _token: vscode.CancellationToken): Promise { + if (this.disposed) { + return new Uint8Array(0); + } + + if (this.experimentalSave && !this.isBrowser) { + return this.serializeViaWorker(data); + } + const serialized = serializeNotebookToString(data); + return new TextEncoder().encode(serialized); } - public serializeNotebookToString(data: vscode.NotebookData): string { - const notebookContent = getNotebookMetadata(data); - // use the preferred language from document metadata or the first cell language as the notebook preferred cell language - const preferredCellLanguage = notebookContent.metadata?.language_info?.name ?? data.cells.find(cell => cell.kind === vscode.NotebookCellKind.Code)?.languageId; + private async startWorker() { + if (this.disposed) { + throw new Error('Serializer disposed'); + } + if (this.worker) { + return this.worker; + } + const { Worker } = await import('node:worker_threads'); + const outputDir = getOutputDir(this.context); + this.worker = new Worker(vscode.Uri.joinPath(this.context.extensionUri, outputDir, 'notebookSerializerWorker.js').fsPath, {}); + this.worker.on('exit', (exitCode) => { + if (!this.disposed) { + console.error(`IPynb Notebook Serializer Worker exited unexpectedly`, exitCode); + } + this.worker = undefined; + }); + this.worker.on('message', (result: { data: Uint8Array; id: string }) => { + const task = this.tasks.get(result.id); + if (task) { + task.complete(result.data); + this.tasks.delete(result.id); + } + }); + this.worker.on('error', (err) => { + if (!this.disposed) { + console.error(`IPynb Notebook Serializer Worker errored unexpectedly`, err); + } + }); + return this.worker; + } + private async serializeViaWorker(data: vscode.NotebookData): Promise { + const worker = await this.startWorker(); + const id = generateUuid(); - notebookContent.cells = data.cells - .map(cell => createJupyterCellFromNotebookCell(cell, preferredCellLanguage)) - .map(pruneCell); + const deferred = new DeferredPromise(); + this.tasks.set(id, deferred); + worker.postMessage({ data, id }); - const indentAmount = data.metadata && 'indentAmount' in data.metadata && typeof data.metadata.indentAmount === 'string' ? - data.metadata.indentAmount : - ' '; - // ipynb always ends with a trailing new line (we add this so that SCMs do not show unnecessary changes, resulting from a missing trailing new line). - return JSON.stringify(sortObjectPropertiesRecursively(notebookContent), undefined, indentAmount) + '\n'; + return deferred.p; } } -export function getNotebookMetadata(document: vscode.NotebookDocument | vscode.NotebookData) { - const existingContent: Partial = document.metadata || {}; - const notebookContent: Partial = {}; - notebookContent.cells = existingContent.cells || []; - notebookContent.nbformat = existingContent.nbformat || defaultNotebookFormat.major; - notebookContent.nbformat_minor = existingContent.nbformat_minor ?? defaultNotebookFormat.minor; - notebookContent.metadata = existingContent.metadata || {}; - return notebookContent; + +function getOutputDir(context: vscode.ExtensionContext): string { + const main = context.extension.packageJSON.main as string; + return main.indexOf('/dist/') !== -1 ? 'dist' : 'out'; } diff --git a/extensions/ipynb/src/notebookSerializerWorker.ts b/extensions/ipynb/src/notebookSerializerWorker.ts new file mode 100644 index 0000000000000..594af6da7916d --- /dev/null +++ b/extensions/ipynb/src/notebookSerializerWorker.ts @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { parentPort } from 'worker_threads'; +import { serializeNotebookToString } from './serializers'; +import type { NotebookData } from 'vscode'; + + +if (parentPort) { + parentPort.on('message', ({ id, data }: { id: string; data: NotebookData }) => { + if (parentPort) { + const json = serializeNotebookToString(data); + const bytes = new TextEncoder().encode(json); + parentPort.postMessage({ id, data: bytes }); + } + }); +} diff --git a/extensions/ipynb/src/serializers.ts b/extensions/ipynb/src/serializers.ts index b257053f70058..e1896ee3be105 100644 --- a/extensions/ipynb/src/serializers.ts +++ b/extensions/ipynb/src/serializers.ts @@ -4,24 +4,18 @@ *--------------------------------------------------------------------------------------------*/ import type * as nbformat from '@jupyterlab/nbformat'; -import { NotebookCell, NotebookCellData, NotebookCellKind, NotebookCellOutput } from 'vscode'; +import type { NotebookCell, NotebookCellData, NotebookCellOutput, NotebookData, NotebookDocument } from 'vscode'; import { CellOutputMetadata, type CellMetadata } from './common'; -import { textMimeTypes } from './deserializers'; +import { textMimeTypes, NotebookCellKindMarkup, CellOutputMimeTypes, defaultNotebookFormat } from './constants'; const textDecoder = new TextDecoder(); -enum CellOutputMimeTypes { - error = 'application/vnd.code.notebook.error', - stderr = 'application/vnd.code.notebook.stderr', - stdout = 'application/vnd.code.notebook.stdout' -} - export function createJupyterCellFromNotebookCell( vscCell: NotebookCellData, - preferredLanguage: string | undefined + preferredLanguage: string | undefined, ): nbformat.IRawCell | nbformat.IMarkdownCell | nbformat.ICodeCell { let cell: nbformat.IRawCell | nbformat.IMarkdownCell | nbformat.ICodeCell; - if (vscCell.kind === NotebookCellKind.Markup) { + if (vscCell.kind === NotebookCellKindMarkup) { cell = createMarkdownCellFromNotebookCell(vscCell); } else if (vscCell.languageId === 'raw') { cell = createRawCellFromNotebookCell(vscCell); @@ -97,7 +91,7 @@ function createCodeCellFromNotebookCell(cell: NotebookCellData, preferredLanguag removeVSCodeCellLanguageId(cellMetadata); } - const codeCell: any = { + const codeCell: nbformat.ICodeCell = { cell_type: 'code', // Metadata should always contain the execution_count. // When ever execution summary data changes we will update the metadata to contain the execution count. @@ -451,3 +445,36 @@ function fixupOutput(output: nbformat.IOutput): nbformat.IOutput { } return result; } + + +export function serializeNotebookToString(data: NotebookData): string { + const notebookContent = getNotebookMetadata(data); + // use the preferred language from document metadata or the first cell language as the notebook preferred cell language + const preferredCellLanguage = notebookContent.metadata?.language_info?.name ?? data.cells.find(cell => cell.kind === 2)?.languageId; + + notebookContent.cells = data.cells + .map(cell => createJupyterCellFromNotebookCell(cell, preferredCellLanguage)) + .map(pruneCell); + + const indentAmount = data.metadata && 'indentAmount' in data.metadata && typeof data.metadata.indentAmount === 'string' ? + data.metadata.indentAmount : + ' '; + + return serializeNotebookToJSON(notebookContent, indentAmount); +} +function serializeNotebookToJSON(notebookContent: Partial, indentAmount: string): string { + // ipynb always ends with a trailing new line (we add this so that SCMs do not show unnecessary changes, resulting from a missing trailing new line). + const sorted = sortObjectPropertiesRecursively(notebookContent); + + return JSON.stringify(sorted, undefined, indentAmount) + '\n'; +} + +export function getNotebookMetadata(document: NotebookDocument | NotebookData) { + const existingContent: Partial = document.metadata || {}; + const notebookContent: Partial = {}; + notebookContent.cells = existingContent.cells || []; + notebookContent.nbformat = existingContent.nbformat || defaultNotebookFormat.major; + notebookContent.nbformat_minor = existingContent.nbformat_minor ?? defaultNotebookFormat.minor; + notebookContent.metadata = existingContent.metadata || {}; + return notebookContent; +}