diff --git a/README.md b/README.md index d2cf2c0..08e6fcf 100644 --- a/README.md +++ b/README.md @@ -28,16 +28,23 @@ For more info, keep an eye on the JupyterLite documentation: - How-to Guides: https://jupyterlite.readthedocs.io/en/latest/howto/index.html - Reference: https://jupyterlite.readthedocs.io/en/latest/reference/index.html -## Additional Notes +## Development Notes -From Team Mat3ra: - -- `data_bridge` extensions is built using the `setup.sh` -- pass `INSTALL=1 BUILD=1` to also build and install the jupyter lite with extension +To build and run the JupyterLite server with extension, we use the following steps: +- check that `pyenv` and `npm` are installed +- run `npm install` to install the required packages and setup the `data_bridge` extension +- run `npm install INSTALL=1 BUILD=1` to also build and install the jupyter lite with extension - `requirements.txt` is updated as part of the above to include the extension -- requires `pyenv` and `npm` installed +- run `npm run start -p=8000` to start the server (specify the port if needed) +- content is populated with a submodule of `exabyte-io/api-examples` + +To develop the extension: +- run `npm install` or `sh setup.sh` to create the extension +- change code in `extensions/dist/data_bridge/src/index.ts` +- run `npm run restart` or `sh update.sh` to build the extension, install it, and restart the server with it -- content is populated with a submodule of `exabyte-io/api-examples`: +To publish: +- commit changes to the `extensions/src/data_bridge/index.ts` file ```shell cd content diff --git a/content/api-examples b/content/api-examples index c5931e6..858efe9 160000 --- a/content/api-examples +++ b/content/api-examples @@ -1 +1 @@ -Subproject commit c5931e6babe84c8448837c95dee3abb146ff7fb4 +Subproject commit 858efe957a18f20d674db7eeb9186e4b75e8683c diff --git a/extensions/src/data_bridge/index.ts b/extensions/src/data_bridge/index.ts index f4db562..78ed27e 100644 --- a/extensions/src/data_bridge/index.ts +++ b/extensions/src/data_bridge/index.ts @@ -1,9 +1,13 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +// @ts-nocheck import { JupyterFrontEnd, - JupyterFrontEndPlugin, + JupyterFrontEndPlugin } from "@jupyterlab/application"; +import { IKernelConnection } from "@jupyterlab/services/lib/kernel/kernel"; import { NotebookPanel, INotebookTracker } from "@jupyterlab/notebook"; +import { IframeMessageSchema } from "@mat3ra/esse/lib/js/types"; /** * Initialization data for the data-bridge extension. @@ -15,77 +19,99 @@ const plugin: JupyterFrontEndPlugin = { "Extension to pass JSON data between host page and Jupyter Lite instance", autoStart: true, requires: [INotebookTracker], - activate: async ( - app: JupyterFrontEnd, - notebookTracker: INotebookTracker - ) => { + activate: async (app: JupyterFrontEnd, notebookTracker: INotebookTracker) => { console.log("JupyterLab extension data-bridge is activated!"); - // Send path of the currently opened notebook to the host page when the notebook is opened - notebookTracker.currentChanged.connect((sender, notebookPanel) => { - if (notebookPanel) { - const currentPath = notebookPanel.context.path; + // Variable to hold the data from the host page + let dataFromHost = ""; + // When data is loaded into the kernel, save it into this object to later check it to avoid reloading the same data + const kernelsDataFromHost: { [id: string]: string } = {}; - window.parent.postMessage( - { - type: "from-iframe-to-host", - path: currentPath, - }, - "*" - ); - } - }); - - // @ts-ignore - window.sendDataToHost = (data: any) => { - window.parent.postMessage( - { - type: "from-iframe-to-host", - data: data, - }, - "*" - ); + const MESSAGE_GET_DATA_CONTENT = { + type: "from-iframe-to-host", + action: "get-data", + payload: {} }; + // On JupyterLite startup send get-data message to the host to request data + window.parent.postMessage(MESSAGE_GET_DATA_CONTENT, "*"); + + /** + * Listen for the current notebook being changed, and on kernel status change load the data into the kernel + */ + notebookTracker.currentChanged.connect( + // @ts-ignore + async (sender, notebookPanel: NotebookPanel) => { + if (notebookPanel) { + console.debug("Notebook opened", notebookPanel.context.path); + await notebookPanel.sessionContext.ready; + const sessionContext = notebookPanel.sessionContext; + + sessionContext.session?.kernel?.statusChanged.connect( + (kernel, status) => { + if ( + status === "idle" && + kernelsDataFromHost[kernel.id] !== dataFromHost + ) { + loadData(kernel, dataFromHost); + // Save data for the current kernel to avoid reloading the same data + kernelsDataFromHost[kernel.id] = dataFromHost; + } + // Reset the data when the kernel is restarting, since the loaded data is lost + if (status === "restarting") { + kernelsDataFromHost[kernel.id] = ""; + } + } + ); + } + } + ); + + /** + * Send data to the host page + * @param data + */ // @ts-ignore - window.requestDataFromHost = (variableName = "data") => { - window.parent.postMessage( - { - type: "from-iframe-to-host", - requestData: true, - variableName, - }, - "*" - ); + window.sendDataToHost = (data: object) => { + const MESSAGE_SET_DATA_CONTENT = { + type: "from-iframe-to-host", + action: "set-data", + payload: data + }; + window.parent.postMessage(MESSAGE_SET_DATA_CONTENT, "*"); }; - window.addEventListener("message", async (event) => { - if (event.data.type === "from-host-to-iframe") { - let data = event.data.data; - let variableName = event.data.variableName || "data"; - const dataJson = JSON.stringify(data); - const code = ` - import json - ${variableName} = json.loads('${dataJson}') - `; - // Similar to https://jupyterlab.readthedocs.io/en/stable/api/classes/application.LabShell.html#currentWidget - // https://jupyterlite.readthedocs.io/en/latest/reference/api/ts/interfaces/jupyterlite_application.ISingleWidgetShell.html#currentwidget - const currentWidget = app.shell.currentWidget; - - if (currentWidget instanceof NotebookPanel) { - const notebookPanel = currentWidget; - const kernel = notebookPanel.sessionContext.session?.kernel; + /** + * Listen for messages from the host page, and update the data in the kernel + * @param event MessageEvent + */ + window.addEventListener( + "message", + async (event: MessageEvent) => { + if (event.data.type === "from-host-to-iframe") { + dataFromHost = JSON.stringify(event.data.payload); + const notebookPanel = notebookTracker.currentWidget; + await notebookPanel?.sessionContext.ready; + const sessionContext = notebookPanel?.sessionContext; + const kernel = sessionContext?.session?.kernel; if (kernel) { - kernel.requestExecute({ code: code }); - } else { - console.error("No active kernel found"); + loadData(kernel, dataFromHost); } - } else { - console.error("Current active widget is not a notebook"); } } - }); - }, + ); + + /** + * Load the data into the kernel by executing code + * @param kernel + * @param data string representation of JSON + */ + const loadData = (kernel: IKernelConnection, data: string) => { + const code = `import json\ndata_from_host = json.loads('${data}')`; + const result = kernel.requestExecute({ code: code }); + console.debug("Execution result:", result); + }; + } }; export default plugin; diff --git a/package.json b/package.json index abd3ffe..ad567e5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,8 @@ { "scripts": { + "install": "sh setup.sh", "start": "python -m http.server -b localhost -d ./dist", - "build": "python -m pip install -r requirements.txt; cp -rL content content-resolved; jupyter lite build --contents content-resolved --output-dir dist" + "build": "python -m pip install -r requirements.txt; cp -rL content content-resolved; jupyter lite build --contents content-resolved --output-dir dist", + "restart": "sh update.sh" } } diff --git a/setup.sh b/setup.sh index 6a43870..260d511 100644 --- a/setup.sh +++ b/setup.sh @@ -1,6 +1,6 @@ #!/bin/bash # This script creates a JupyterLab extension using the cookiecutter template -# and updates the requirements.txt file to make it installable in the current +# and updates the requirements.txt file to make it installable in the current # JupyterLab environment. # It assumes that pyenv and nvm are installed and configured correctly. @@ -64,7 +64,7 @@ if [ ! -d "$COOKIECUTTER_TEMPLATE_PATH" ]; then cookiecutter "${COOKIECUTTER_OPTIONS[@]}" echo "Created extension using cookiecutter template." else - # COOKIECUTTER_OPTIONS[0]="$COOKIECUTTER_TEMPLATE_PATH" + # COOKIECUTTER_OPTIONS[0]="$COOKIECUTTER_TEMPLATE_PATH" cookiecutter "${COOKIECUTTER_OPTIONS[@]}" echo "Created extension using cached cookiecutter template." fi @@ -78,7 +78,7 @@ else echo "Source file or destination directory not found. Skipping copy." fi -# The extension is a separate package so it requires to have a yarn.lock file +# The extension is treated here as a separate package so it requires to have a yarn.lock file cd $EXTENSION_NAME touch yarn.lock pip install -ve . @@ -88,6 +88,9 @@ jupyter labextension develop --overwrite . jlpm add @jupyterlab/application jlpm add @jupyterlab/notebook +# Install mat3ra specific dependencies +jlpm add @mat3ra/esse + # Build the extension jlpm run build diff --git a/update.sh b/update.sh new file mode 100644 index 0000000..fb05e07 --- /dev/null +++ b/update.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# This script rebuilds the JupyterLab extension and starts the JupyterLite server +# Meant to automate the process during development + +rm -rf dist/extensions/data_bridge +cd extensions/dist/data_bridge +jlpm run build + +cd ../../.. + +npm run build && npm run start -p=8000