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
Original file line number Diff line number Diff line change
@@ -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<string, AnnotationFeatureSnapshot> = {}
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<Choice>('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 (
<Dialog
open
title="Duplicate Feature Detected"
handleClose={handleClose}
maxWidth="sm"
fullWidth
>
<DialogContent>
<DialogContentText>
A feature with ID &ldquo;{featureSnapshot._id}&rdquo; already exists.
How would you like to resolve this conflict?
</DialogContentText>
<FormControl component="fieldset" sx={{ mt: 2 }}>
<FormLabel component="legend">Resolution</FormLabel>
<RadioGroup
value={choice}
onChange={(e) => {
setChoice(e.target.value as Choice)
}}
>
<FormControlLabel
value="keepExisting"
control={<Radio />}
label="Keep the existing feature"
/>
<FormControlLabel
value="useNew"
control={<Radio />}
label="Replace with the new feature"
/>
<FormControlLabel
value="keepBoth"
control={<Radio />}
label="Keep both (assign new IDs to the incoming feature)"
/>
</RadioGroup>
</FormControl>
{errorMessage ? (
<DialogContentText color="error" sx={{ mt: 1 }}>
{errorMessage}
</DialogContentText>
) : null}
</DialogContent>
<DialogActions>
<Button variant="contained" onClick={handleSubmit} disabled={submitted}>
Confirm
</Button>
<Button onClick={handleClose} disabled={submitted}>
Cancel
</Button>
</DialogActions>
</Dialog>
)
}
Original file line number Diff line number Diff line change
@@ -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<unknown> {
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<void> {
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<void>((resolve) => {
session.queueDialog((doneCallback) => [
DuplicateFeatureDialog,
{
featureSnapshot,
existingFeature,
assemblyName,
changeManager: apolloDataStore.changeManager,
handleClose: () => {
doneCallback()
resolve()
},
},
])
})
}
}
}
}
31 changes: 28 additions & 3 deletions packages/jbrowse-plugin-apollo/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -45,6 +46,7 @@ import {
type ClientDataStoreModel,
clientDataStoreFactory,
} from './ClientDataStore'
import { handleApolloFeaturesUrlParam } from './handleApolloFeaturesUrlParam'

export interface ApolloSession extends AbstractSessionModel {
apolloDataStore: ClientDataStoreModel
Expand Down Expand Up @@ -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)
Expand Down
Loading