Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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*
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/check-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions .readthedocs.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 5 additions & 6 deletions docs/environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
- ..
160 changes: 151 additions & 9 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,21 +47,101 @@ const plugin: JupyterFrontEndPlugin<void> = {

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))
: '';
Comment thread
MUFFANUJ marked this conversation as resolved.
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<void> => {
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<void> => {
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<void> => {
let type = '';
Expand All @@ -84,11 +164,14 @@ const plugin: JupyterFrontEndPlugin<void> = {
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: {
Expand All @@ -103,18 +186,77 @@ const plugin: JupyterFrontEndPlugin<void> = {
}
};

const openUrls = async (targets: string[]): Promise<void> => {
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();
}
});
}
});
Expand Down
Loading