diff --git a/package-lock.json b/package-lock.json index c5b1bd3a..dbcd919c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,8 @@ "redux": "4.0.x", "redux-logic": "3.x", "reselect": "4.0.x", - "string-natural-compare": "3.0.x" + "string-natural-compare": "3.0.x", + "uuid": "^13.0.0" }, "devDependencies": { "@babel/cli": "7.x", @@ -110,6 +111,15 @@ "uuid": "~8.3" } }, + "node_modules/@aics/frontend-insights-plugin-amplitude-node/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@aics/redux-utils": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@aics/redux-utils/-/redux-utils-0.6.0.tgz", @@ -18446,6 +18456,16 @@ "websocket-driver": "^0.7.4" } }, + "node_modules/sockjs/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/socks": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", @@ -19843,11 +19863,16 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/uzip-module": { @@ -21152,7 +21177,7 @@ }, "packages/desktop": { "name": "fms-file-explorer-desktop", - "version": "8.6.0", + "version": "8.6.1", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@aics/frontend-insights": "0.2.x", @@ -21268,6 +21293,13 @@ "@aics/frontend-insights": "0.2.3", "@amplitude/node": "~1.3", "uuid": "~8.3" + }, + "dependencies": { + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + } } }, "@aics/redux-utils": { @@ -34904,6 +34936,14 @@ "faye-websocket": "^0.11.3", "uuid": "^8.3.2", "websocket-driver": "^0.7.4" + }, + "dependencies": { + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true + } } }, "socks": { @@ -35953,9 +35993,9 @@ "dev": true }, "uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==" }, "uzip-module": { "version": "1.0.3", diff --git a/package.json b/package.json index 574112ff..d3b84c3b 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "redux": "4.0.x", "redux-logic": "3.x", "reselect": "4.0.x", - "string-natural-compare": "3.0.x" + "string-natural-compare": "3.0.x", + "uuid": "^13.0.0" } } diff --git a/packages/core/hooks/useOpenWithMenuItems/index.tsx b/packages/core/hooks/useOpenWithMenuItems/index.tsx index 8d370a30..eef06e42 100644 --- a/packages/core/hooks/useOpenWithMenuItems/index.tsx +++ b/packages/core/hooks/useOpenWithMenuItems/index.tsx @@ -2,6 +2,7 @@ import { ContextualMenuItemType, DefaultButton, Icon, IContextualMenuItem } from import { isEmpty } from "lodash"; import * as React from "react"; import { useDispatch, useSelector } from "react-redux"; +import { v4 as uuidv4 } from "uuid"; import AnnotationName from "../../entity/Annotation/AnnotationName"; import FileDetail from "../../entity/FileDetail"; @@ -38,6 +39,7 @@ interface Apps { type AppOptions = { openInCfe: () => void; + openInVole: () => void; }; const SUPPORTED_APPS_HEADER = { @@ -160,7 +162,7 @@ const APPS = ( key: AppKeys.VOLE, text: "Vol-E", title: `Open files with Vol-E`, - href: `https://volumeviewer.allencell.org/viewer?url=${fileDetails?.path}/`, + onClick: options?.openInVole, disabled: !fileDetails?.path, target: "_blank", onRenderContent(props, defaultRenders) { @@ -205,6 +207,12 @@ const APPS = ( } as IContextualMenuItem, }); +type VoleMessage = { + scenes?: string[]; + meta: Record>; + sceneIndex?: number; +}; + function getSupportedApps( apps: Apps, isSmallFile: boolean, @@ -267,6 +275,44 @@ function getFileExtension(fileDetails: FileDetail): string { return fileDetails.path.slice(fileDetails.path.lastIndexOf(".") + 1).toLowerCase(); } +/** + * Opens a window at `openUrl`, then attempts to send the data in `entry` to it. + * + * This requires a bit of protocol to accomplish: + * 1. We add some query params to `openUrl` before opening: `msgorigin` is this site's origin for + * validation, and `storageid` uniquely identifies the message we want to send. + * 2. *The opened window must check if these params are present* and post the value of `storageid` + * back to us (via `window.opener`, validated using `msgorigin`) once it's loaded and ready. + * 3. Once we receive that message, we send over `entry`. + * + * This is currently only used by `openInVole`, but is broken out into a separate function to + * emphasize that this protocol is both message- and receiver-agnostic, and could be used to send + * large bundles of data to other apps as well. + */ +function openWindowWithMessage(openUrl: URL, message: any): void { + if (message === undefined || message === null) { + window.open(openUrl); + return; + } + + const storageid = uuidv4(); + openUrl.searchParams.append("msgorigin", window.location.origin); + openUrl.searchParams.append("storageid", storageid); + + const handle = window.open(openUrl); + const loadHandler = (event: MessageEvent): void => { + if (event.origin !== openUrl.origin || event.data !== storageid) { + return; + } + handle?.postMessage(message, openUrl.origin); + window.removeEventListener("message", loadHandler); + }; + + window.addEventListener("message", loadHandler); + // ensure handlers can't build up with repeated failed requests + window.setTimeout(() => window.removeEventListener("message", loadHandler), 60000); +} + export default (fileDetails?: FileDetail, filters?: FileFilter[]): IContextualMenuItem[] => { const path = fileDetails?.path; const size = fileDetails?.size; @@ -299,6 +345,67 @@ export default (fileDetails?: FileDetail, filters?: FileFilter[]): IContextualMe fileService ); + // custom hook this, like `useOpenInCfe`? + const openInVole = React.useCallback(async (): Promise => { + const VOLE_BASE_URL = "https://vole.allencell.org/viewer"; + + const allDetails = await fileSelection.fetchAllDetails(); + const details = allDetails.filter((detail) => { + const fileExt = getFileExtension(detail); + return fileExt === "zarr" || fileExt === ""; + }); + + const scenes: string[] = []; + const message: VoleMessage = { meta: {} }; + + for (const detail of details) { + const sceneMeta: Record = {}; + for (const annotation of detail.annotations) { + const isSingleValue = annotation.values.length === 1; + const value = isSingleValue ? annotation.values[0] : annotation.values; + sceneMeta[annotation.name] = value; + } + scenes.push(detail.path); + if (Object.keys(sceneMeta).length > 0) { + message.meta[detail.path] = sceneMeta; + } + } + + const openUrl = new URL(VOLE_BASE_URL); + + // Start on the focused scene + const sceneIndex = details.findIndex((detail) => detail.path === fileDetails?.path); + + // Prefer putting the image URLs directly in the query string for easy sharing, if the + // length of the URL would be reasonable + const includeUrls = + details.length < 5 || + details.reduce((acc, detail) => acc + detail.path.length + 1, 0) <= 250; + + if (includeUrls) { + // We can fit all the URLs we want! + openUrl.searchParams.append("url", details.map(({ path }) => path).join("+")); + if (sceneIndex > 0) { + openUrl.searchParams.append("scene", sceneIndex.toString()); + } + } else { + // There are more scene URLs than we want to put in the full URL. We need to send them over as a message. + // Include only the URL of the focused scene, so the link is usable even if the message fails. + const initialImageUrl = details[Math.max(sceneIndex, 0)].path; + openUrl.searchParams.append("url", initialImageUrl); + message.scenes = scenes; + if (sceneIndex > 0) { + message.sceneIndex = sceneIndex; + } + } + + if (includeUrls && Object.keys(message.meta).length === 0) { + window.open(openUrl); + } else { + openWindowWithMessage(openUrl, message); + } + }, [fileDetails, fileSelection]); + const plateLink = fileDetails?.getLinkToPlateUI(loadBalancerBaseUrl); const annotationNameToLinkMap = React.useMemo( () => @@ -365,7 +472,7 @@ export default (fileDetails?: FileDetail, filters?: FileFilter[]): IContextualMe }) .sort((a, b) => (a.text || "").localeCompare(b.text || "")); - const apps = APPS(fileDetails, { openInCfe }); + const apps = APPS(fileDetails, { openInCfe, openInVole }); // Determine is the file is small or not asynchronously React.useEffect(() => {