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..df8940838 --- /dev/null +++ b/packages/jbrowse-plugin-apollo/src/session/handleApolloFeaturesUrlParam.ts @@ -0,0 +1,120 @@ +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' +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)) 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', + assembly: assemblyName, + changedIds: [featureSnapshot._id], + addedFeature: featureSnapshot, + }) + await apolloDataStore.changeManager.submit(change) + } else if (!equal(featureSnapshot, existingFeature)) { + await new Promise((resolve) => { + session.queueDialog((doneCallback) => [ + DuplicateFeatureDialog, + { + featureSnapshot, + existingFeature, + 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)