From d1931a95220dbfdfc1c41308c9e9535fab1eeb77 Mon Sep 17 00:00:00 2001 From: Garrett Stevens Date: Wed, 24 Jun 2026 22:54:28 +0000 Subject: [PATCH 1/2] Add ability to share features in URL param --- .../src/components/DuplicateFeatureDialog.tsx | 154 ++++++++++++++++++ .../session/handleApolloFeaturesUrlParam.ts | 113 +++++++++++++ .../src/session/session.ts | 31 +++- 3 files changed, 295 insertions(+), 3 deletions(-) create mode 100644 packages/jbrowse-plugin-apollo/src/components/DuplicateFeatureDialog.tsx create mode 100644 packages/jbrowse-plugin-apollo/src/session/handleApolloFeaturesUrlParam.ts diff --git a/packages/jbrowse-plugin-apollo/src/components/DuplicateFeatureDialog.tsx b/packages/jbrowse-plugin-apollo/src/components/DuplicateFeatureDialog.tsx new file mode 100644 index 000000000..aca244f19 --- /dev/null +++ b/packages/jbrowse-plugin-apollo/src/components/DuplicateFeatureDialog.tsx @@ -0,0 +1,154 @@ +/* eslint-disable @typescript-eslint/no-misused-promises */ +/* eslint-disable @typescript-eslint/unbound-method */ +import type { AnnotationFeatureSnapshot } from '@apollo-annotation/mst' +import { + AddFeatureChange, + DeleteFeatureChange, +} from '@apollo-annotation/shared' +import { + Button, + DialogActions, + DialogContent, + DialogContentText, + FormControl, + FormControlLabel, + FormLabel, + Radio, + RadioGroup, +} from '@mui/material' +import ObjectID from 'bson-objectid' +import React, { useState } from 'react' + +import type { ChangeManager } from '../ChangeManager' + +import { Dialog } from './Dialog' + +type Choice = 'keepExisting' | 'useNew' | 'keepBoth' + +interface DuplicateFeatureDialogProps { + featureSnapshot: AnnotationFeatureSnapshot + existingFeature: AnnotationFeatureSnapshot + assemblyName: string + changeManager: ChangeManager + handleClose(): void +} + +function reassignIds( + feature: AnnotationFeatureSnapshot, +): AnnotationFeatureSnapshot { + const newChildren: Record = {} + if (feature.children) { + for (const child of Object.values(feature.children)) { + const newChild = reassignIds(child) + newChildren[newChild._id] = newChild + } + } + return { + ...feature, + _id: new ObjectID().toHexString(), + children: feature.children ? newChildren : undefined, + } +} + +export function DuplicateFeatureDialog({ + assemblyName, + changeManager, + existingFeature, + featureSnapshot, + handleClose, +}: DuplicateFeatureDialogProps) { + const [choice, setChoice] = useState('keepExisting') + const [errorMessage, setErrorMessage] = useState('') + const [submitted, setSubmitted] = useState(false) + + const handleSubmit = async () => { + setErrorMessage('') + setSubmitted(true) + try { + if (choice === 'useNew') { + const deleteChange = new DeleteFeatureChange({ + typeName: 'DeleteFeatureChange', + assembly: assemblyName, + changedIds: [existingFeature._id], + deletedFeature: existingFeature, + }) + await changeManager.submit(deleteChange) + const addChange = new AddFeatureChange({ + typeName: 'AddFeatureChange', + assembly: assemblyName, + changedIds: [featureSnapshot._id], + addedFeature: featureSnapshot, + }) + await changeManager.submit(addChange) + } else if (choice === 'keepBoth') { + const newSnapshot = reassignIds(featureSnapshot) + const addChange = new AddFeatureChange({ + typeName: 'AddFeatureChange', + assembly: assemblyName, + changedIds: [newSnapshot._id], + addedFeature: newSnapshot, + }) + await changeManager.submit(addChange) + } + handleClose() + } catch (error) { + setErrorMessage(String(error)) + setSubmitted(false) + } + } + + return ( + + + + A feature with ID “{featureSnapshot._id}” already exists. + How would you like to resolve this conflict? + + + Resolution + { + setChoice(e.target.value as Choice) + }} + > + } + label="Keep the existing feature" + /> + } + label="Replace with the new feature" + /> + } + label="Keep both (assign new IDs to the incoming feature)" + /> + + + {errorMessage ? ( + + {errorMessage} + + ) : null} + + + + + + + ) +} diff --git a/packages/jbrowse-plugin-apollo/src/session/handleApolloFeaturesUrlParam.ts b/packages/jbrowse-plugin-apollo/src/session/handleApolloFeaturesUrlParam.ts new file mode 100644 index 000000000..40a0142e7 --- /dev/null +++ b/packages/jbrowse-plugin-apollo/src/session/handleApolloFeaturesUrlParam.ts @@ -0,0 +1,113 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import type { AnnotationFeatureSnapshot } from '@apollo-annotation/mst' +import { AddFeatureChange } from '@apollo-annotation/shared' +import type { AbstractSessionModel } from '@jbrowse/core/util' +import type { Assembly } from '@jbrowse/core/assemblyManager/assembly' + +import { LocalDriver } from '../BackendDrivers' +import { openDb } from '../BackendDrivers/LocalDriver/db' +import { DuplicateFeatureDialog } from '../components/DuplicateFeatureDialog' + +import type { ClientDataStoreModel } from './ClientDataStore' + +export function toUrlSafeBase64(base64: string) { + return base64 + .replaceAll('+', '-') // Replace + with - + .replaceAll('/', '_') // Replace / with _ + .replace(/=+$/, '') // Remove padding characters +} + +export function fromUrlSafeBase64(urlSafeBase64: string) { + let base64 = urlSafeBase64.replaceAll('-', '+').replaceAll('_', '/') + const pad = base64.length % 4 + if (pad) { + base64 += '='.repeat(4 - pad) + } + return base64 +} + +export async function compress(data: unknown) { + const json = JSON.stringify(data) + const bytes = new TextEncoder().encode(json) + const stream = new Blob([bytes]).stream() + const compressionStream = new CompressionStream('gzip') + const compressedStream = stream.pipeThrough(compressionStream) + const response = new Response(compressedStream) + const compressed = await response.arrayBuffer() + // eslint-disable-next-line unicorn/prefer-code-point + return btoa(String.fromCharCode(...new Uint8Array(compressed))) +} + +// Base64 decode + decompress +export async function decompress(encoded: string): Promise { + const binaryString = atob(encoded) + // eslint-disable-next-line unicorn/prefer-code-point + const bytes = Uint8Array.from(binaryString, (char) => char.charCodeAt(0)) + const stream = new Blob([bytes]).stream() + const decompressionStream = new DecompressionStream('gzip') + const decompressedStream = stream.pipeThrough(decompressionStream) + const response = new Response(decompressedStream) + const decompressed = await response.arrayBuffer() + return JSON.parse(new TextDecoder().decode(decompressed)) +} + +export async function handleApolloFeaturesUrlParam( + encodedFeatures: string, + apolloDataStore: ClientDataStoreModel, + assemblyManager: AbstractSessionModel['assemblyManager'], + session: AbstractSessionModel, +): Promise { + const base64 = fromUrlSafeBase64(encodedFeatures) + const decompressed = await decompress(base64) + const featuresData = decompressed as Record< + string, + AnnotationFeatureSnapshot[] + > + for (const [assemblyName, featureSnapshots] of Object.entries(featuresData)) { + const backendDriver = apolloDataStore.getBackendDriver(assemblyName) + if (!(backendDriver instanceof LocalDriver)) { + continue + } + const assembly = (await assemblyManager.waitForAssembly(assemblyName)) as + | Assembly + | undefined + if (!assembly) { + throw new Error(`Assembly not found: "${assemblyName}"`) + } + const { regions } = assembly + if (!regions) { + throw new Error(`Assembly not found: "${assemblyName}"`) + } + const refNames = regions.map((r) => r.refName) + const db = await openDb(assemblyName, refNames) + for (const featureSnapshot of featureSnapshots) { + const storeName = `features-${featureSnapshot.refSeq}` + const existing = await db.get(storeName, featureSnapshot._id) + if (existing === undefined) { + const change = new AddFeatureChange({ + typeName: 'AddFeatureChange', + assembly: assemblyName, + changedIds: [featureSnapshot._id], + addedFeature: featureSnapshot, + }) + await apolloDataStore.changeManager.submit(change) + } else { + await new Promise((resolve) => { + session.queueDialog((doneCallback) => [ + DuplicateFeatureDialog, + { + featureSnapshot, + existingFeature: existing as AnnotationFeatureSnapshot, + assemblyName, + changeManager: apolloDataStore.changeManager, + handleClose: () => { + doneCallback() + resolve() + }, + }, + ]) + }) + } + } + } +} diff --git a/packages/jbrowse-plugin-apollo/src/session/session.ts b/packages/jbrowse-plugin-apollo/src/session/session.ts index af41be00c..5c25032b4 100644 --- a/packages/jbrowse-plugin-apollo/src/session/session.ts +++ b/packages/jbrowse-plugin-apollo/src/session/session.ts @@ -18,9 +18,10 @@ import { readConfObject, } from '@jbrowse/core/configuration' import type { BaseTrackConfig } from '@jbrowse/core/pluggableElementTypes' -import type { - AbstractSessionModel, - SessionWithAddTracks, +import { + isElectron, + type AbstractSessionModel, + type SessionWithAddTracks, } from '@jbrowse/core/util' import { type Instance, @@ -45,6 +46,7 @@ import { type ClientDataStoreModel, clientDataStoreFactory, } from './ClientDataStore' +import { handleApolloFeaturesUrlParam } from './handleApolloFeaturesUrlParam' export interface ApolloSession extends AbstractSessionModel { apolloDataStore: ClientDataStoreModel @@ -406,6 +408,29 @@ export function extendSession( ) }, })) + .actions((self) => ({ + async afterCreate() { + if (isElectron) { + return + } + const url = new URL(globalThis.location.href) + const apolloFeatures = url.searchParams.get('apolloFeatures') + if (!apolloFeatures) { + return + } + const { assemblyManager } = self as unknown as AbstractSessionModel + await handleApolloFeaturesUrlParam( + apolloFeatures, + self.apolloDataStore, + assemblyManager, + self as unknown as AbstractSessionModel, + ) + await new Promise((resolve) => setTimeout(resolve, 2000)) + const updatedURL = new URL(globalThis.location.href) + updatedURL.searchParams.delete('apolloFeatures') + globalThis.history.replaceState(null, '', updatedURL.toString()) + }, + })) .views((self) => { const superTrackActions = (self as unknown as AbstractSessionModel) From 299813b3f27451ab79694be4e8c420fee5e21f88 Mon Sep 17 00:00:00 2001 From: Garrett Stevens Date: Fri, 26 Jun 2026 23:08:46 +0000 Subject: [PATCH 2/2] Dont prompt keep new/old if new and old are same --- .../src/session/handleApolloFeaturesUrlParam.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/jbrowse-plugin-apollo/src/session/handleApolloFeaturesUrlParam.ts b/packages/jbrowse-plugin-apollo/src/session/handleApolloFeaturesUrlParam.ts index 40a0142e7..df8940838 100644 --- a/packages/jbrowse-plugin-apollo/src/session/handleApolloFeaturesUrlParam.ts +++ b/packages/jbrowse-plugin-apollo/src/session/handleApolloFeaturesUrlParam.ts @@ -1,8 +1,8 @@ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ import type { AnnotationFeatureSnapshot } from '@apollo-annotation/mst' import { AddFeatureChange } from '@apollo-annotation/shared' import type { AbstractSessionModel } from '@jbrowse/core/util' import type { Assembly } from '@jbrowse/core/assemblyManager/assembly' +import equal from 'fast-deep-equal/es6' import { LocalDriver } from '../BackendDrivers' import { openDb } from '../BackendDrivers/LocalDriver/db' @@ -82,7 +82,14 @@ export async function handleApolloFeaturesUrlParam( const db = await openDb(assemblyName, refNames) for (const featureSnapshot of featureSnapshots) { const storeName = `features-${featureSnapshot.refSeq}` - const existing = await db.get(storeName, featureSnapshot._id) + const existing = (await db.get(storeName, featureSnapshot._id)) as + | AnnotationFeatureSnapshot + | undefined + // get rid of undefined values in JSON + // eslint-disable-next-line unicorn/prefer-structured-clone + const existingFeature = JSON.parse( + JSON.stringify(existing), + ) as AnnotationFeatureSnapshot if (existing === undefined) { const change = new AddFeatureChange({ typeName: 'AddFeatureChange', @@ -91,13 +98,13 @@ export async function handleApolloFeaturesUrlParam( addedFeature: featureSnapshot, }) await apolloDataStore.changeManager.submit(change) - } else { + } else if (!equal(featureSnapshot, existingFeature)) { await new Promise((resolve) => { session.queueDialog((doneCallback) => [ DuplicateFeatureDialog, { featureSnapshot, - existingFeature: existing as AnnotationFeatureSnapshot, + existingFeature, assemblyName, changeManager: apolloDataStore.changeManager, handleClose: () => {