diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3409a0f..0b2b95b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -44,7 +44,7 @@ jobs: pip uninstall -y "jupyterlab_open_url_parameter" jupyterlab - name: Upload extension packages - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v6 with: name: extension-artifacts path: dist/jupyterlab_open_url_parameter* @@ -60,7 +60,7 @@ jobs: with: python-version: '3.9' architecture: 'x64' - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v6 with: name: extension-artifacts - name: Install and Test diff --git a/.github/workflows/check-release.yml b/.github/workflows/check-release.yml index ff7476d..e300632 100644 --- a/.github/workflows/check-release.yml +++ b/.github/workflows/check-release.yml @@ -20,7 +20,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} - name: Upload Distributions - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v6 with: name: jupyterlab_open_url_parameter-releaser-dist-${{ github.run_number }} path: .jupyter_releaser_checkout/dist diff --git a/.readthedocs.yml b/.readthedocs.yml index bac84e7..da9f94e 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,9 +1,9 @@ version: 2 build: - os: "ubuntu-20.04" + os: "ubuntu-lts-latest" tools: - python: "mambaforge-4.10" + python: "mambaforge-latest" conda: environment: docs/environment.yml diff --git a/README.md b/README.md index ecf6213..ec90230 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,15 @@ Which will result in the following URL when JupyterLab is running locally: http://localhost:8888/lab?fromURL=https://raw.githubusercontent.com/jupyterlab/jupyterlab-demo/master/data/iris.csv&fromURL=https://raw.githubusercontent.com/jupyterlab/jupyterlab-demo/master/notebooks/Lorenz.ipynb +To place downloaded files in a specific destination, use `fromURLToFolder` in the URL. +Missing directories are created automatically. + +For example: + +http://localhost:8888/lab?fromURL=https://raw.githubusercontent.com/jupyterlab/jupyterlab_apod/cffae6a4049af97436f0c19a0dae65a574f74390/src/index.ts&fromURLToFolder=step-05 + +With multiple `fromURL` values, the same `fromURLToFolder` destination is used for all files. + https://user-images.githubusercontent.com/591645/230422671-c12761e9-9b9f-4d23-ab66-344568c6b0a5.mp4 ℹ️ This extension uses the command `filebrowser:open-url` available in JupyterLab by default. diff --git a/docs/environment.yml b/docs/environment.yml index 2ece84e..91e3e14 100644 --- a/docs/environment.yml +++ b/docs/environment.yml @@ -2,14 +2,13 @@ name: jupyterlab-open-url-parameter channels: - conda-forge dependencies: -- build +- pip - python=3.10 -- mamba - pydata-sphinx-theme - myst-parser -- jupyterlab>=3.5.0,<3.6 -- nodejs=18 -- jupyterlite>=0.1.0,<0.2 +- jupyterlab>=4.0.0,<5 +- nodejs=22 +- jupyterlite-core>=0.7.0,<0.8 +- jupyterlite-sphinx - pip: - - jupyterlite-sphinx - .. diff --git a/src/index.ts b/src/index.ts index 2647fc2..4047bec 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,21 +47,101 @@ const plugin: JupyterFrontEndPlugin = { const urlParams = new URLSearchParams(search); const paramName = 'fromURL'; + const folderParamName = 'fromURLToFolder'; const paths = urlParams.getAll(paramName); - if (!paths || paths.length === 0) { + if (paths.length === 0) { return; } - const urls = paths.map(path => decodeURIComponent(path)); + const urls = paths; + const folder = (urlParams.get(folderParamName) ?? '').trim(); + const normalizedFolder = folder + ? PathExt.removeSlash(PathExt.normalize(folder)) + : ''; + const uploadDirectory = + normalizedFolder === '.' ? '' : normalizedFolder; + const folderSegments = uploadDirectory.split('/').filter(Boolean); + const hasParentDirectorySegment = folderSegments.some( + part => part === '..' + ); // handle the route and remove the fromURL parameter const handleRoute = () => { const url = new URL(URLExt.join(PageConfig.getBaseUrl(), request)); - // only remove the fromURL parameter + // only remove parameters handled by the extension url.searchParams.delete(paramName); + url.searchParams.delete(folderParamName); const { pathname, search } = url; router.navigate(`${pathname}${search}`, { skipRouting: true }); }; + const ensureDirectory = async ( + directory: string, + basePath = '' + ): Promise => { + if (!directory) { + return; + } + + const isNotFoundError = (reason: any): boolean => { + const message = String(reason?.message ?? reason); + return ( + reason?.response?.status === 404 || + message.includes('Could not find content with path') + ); + }; + + const isConflictError = (reason: any): boolean => { + const message = String(reason?.message ?? reason).toLowerCase(); + return ( + reason?.response?.status === 409 || + message.includes('already exists') + ); + }; + + const contents = + browser?.model.manager.services.contents ?? + app.serviceManager.contents; + const cleanupCreated = async (path: string): Promise => { + await contents.delete(path).catch(() => undefined); + }; + let currentPath = basePath; + for (const part of directory.split('/').filter(Boolean)) { + const parentPath = currentPath; + currentPath = contents.resolvePath(currentPath, part); + try { + const model = await contents.get(currentPath, { content: false }); + if (model.type !== 'directory') { + throw new Error( + trans.__('Path is not a directory: %1', currentPath) + ); + } + } catch (reason) { + if (!isNotFoundError(reason)) { + throw reason; + } + const created = await contents.newUntitled({ + path: parentPath, + type: 'directory' + }); + if (created.path === currentPath) { + continue; + } + + try { + await contents.rename(created.path, currentPath); + } catch (renameReason) { + if (isConflictError(renameReason)) { + await cleanupCreated(created.path); + continue; + } + + await cleanupCreated(created.path); + throw renameReason; + } + } + } + }; + // fetch the file from the URL and open it with the docmanager const fetchAndOpen = async (url: string): Promise => { let type = ''; @@ -84,11 +164,14 @@ const plugin: JupyterFrontEndPlugin = { try { // FIXME: handle Content-Disposition: https://github.com/jupyterlab/jupyterlab/issues/11531 const name = PathExt.basename(url); - const file = new File([blob], name, { type }); - const model = await browser?.model.upload(file); + const model = await browser?.model.upload( + new File([blob], name, { type }) + ); + if (!model) { return; } + return commands.execute('docmanager:open', { path: model.path, options: { @@ -103,18 +186,77 @@ const plugin: JupyterFrontEndPlugin = { } }; + const openUrls = async (targets: string[]): Promise => { + const currentDirectory = browser?.model.path ?? ''; + let changedDirectory = false; + + try { + if (uploadDirectory && browser) { + const contents = browser.model.manager.services.contents; + await ensureDirectory(uploadDirectory, currentDirectory); + await browser.model.refresh(); + const targetDirectory = contents.resolvePath( + currentDirectory, + uploadDirectory + ); + await browser.model.cd(targetDirectory); + changedDirectory = true; + } + + for (const url of targets) { + await fetchAndOpen(url); + } + } catch (error) { + return showErrorMessage( + trans._p('showErrorMessage', 'Upload Error'), + error as Error + ); + } finally { + if (changedDirectory && browser) { + try { + await browser.model.cd(currentDirectory); + } catch (reason) { + void showErrorMessage( + trans._p('showErrorMessage', 'Upload Error'), + reason as Error + ); + } finally { + void browser.model.refresh(); + } + } + } + }; + + if (normalizedFolder && hasParentDirectorySegment) { + await showErrorMessage( + trans.__('Invalid folder path'), + trans.__( + 'The "%1" parameter cannot contain ".." segments.', + folderParamName + ) + ); + handleRoute(); + return; + } + const [match] = matches; // handle opening the URL with the Notebook 7 separately if (match?.includes('/notebooks') || match?.includes('/edit')) { const [first] = urls; - await fetchAndOpen(first); - handleRoute(); + try { + await openUrls([first]); + } finally { + handleRoute(); + } return; } app.restored.then(async () => { - await Promise.all(urls.map(url => fetchAndOpen(url))); - handleRoute(); + try { + await openUrls(urls); + } finally { + handleRoute(); + } }); } });