diff --git a/backend/sbs_server/app/route.py b/backend/sbs_server/app/route.py index 5b0717b..7eff490 100644 --- a/backend/sbs_server/app/route.py +++ b/backend/sbs_server/app/route.py @@ -86,7 +86,7 @@ def xdc_run(files): return jsonify({"error": "No selected Params file"}), 400 params_from_request = json.loads(params_file.read()) - required_params = ['sbh_url', 'sbh_token', 'fj_url', 'fj_token', 'collection_url', 'fj_study_id', + required_params = ['sbh_url', 'sbh_prefix', 'sbh_token', 'fj_url', 'fj_token', 'collection_url', 'fj_study_id', 'sbh_overwrite', 'fj_overwrite', 'importType'] for param in required_params: @@ -180,7 +180,7 @@ def xdc_run(files): if isinstance(tl, sbol2.Experiment): experimentId = tl.displayId break - upload_sbh_attachments(sbh_url, sbh_token, sbh_user, sbol_graph_uri, sbh_collection_url, attachments, experimentId) + upload_sbh_attachments(sbh_url, sbh_prefix, sbh_token, sbh_user, sbol_graph_uri, sbh_collection_url, attachments, experimentId) except Exception as e: print('Error uploading attachments to SynBioHub') return jsonify({"error": f"Error uploading attachments to SynBioHub: {e}"}), 400 diff --git a/backend/sbs_server/app/synbiohubUpload.py b/backend/sbs_server/app/synbiohubUpload.py index 1e5f068..2377c21 100644 --- a/backend/sbs_server/app/synbiohubUpload.py +++ b/backend/sbs_server/app/synbiohubUpload.py @@ -4,7 +4,7 @@ import requests import os -def upload_sbh_attachments(sbh_url, sbh_token, sbh_user, sbh_user_graph, sbh_collection_url, attachments, experimentId=None): +def upload_sbh_attachments(sbh_url, sbh_prefix, sbh_token, sbh_user, sbh_user_graph, sbh_collection_url, attachments, experimentId=None): headers = {'Accept': 'text/plain', 'X-authorization': sbh_token} for attachment_name, file in attachments.items(): @@ -15,6 +15,8 @@ def upload_sbh_attachments(sbh_url, sbh_token, sbh_user, sbh_user_graph, sbh_col search_result = sbh_get_attachment_uri(sbh_url, sbh_token, sbh_user_graph, sbh_collection_url, resolved_name) for binding in search_result["results"]["bindings"]: uri = binding["s"]["value"] + if sbh_prefix: + uri = uri.replace(sbh_prefix,sbh_url) response = requests.get(f'{uri}/remove', headers=headers) if not response.ok: raise Exception(f"Deleting existing attachment failed ({response.status_code}): {response.text}") diff --git a/frontend/src/API.js b/frontend/src/API.js index b393535..a735880 100644 --- a/frontend/src/API.js +++ b/frontend/src/API.js @@ -301,7 +301,6 @@ export async function uploadExperiment( ), ...(extraFiles.sheetName ? { sheet_name: extraFiles.sheetName } : {}) } - console.log("uploadExperiment paramsObj:", paramsObj); const paramsJson = JSON.stringify(paramsObj); const paramBlob = new Blob([paramsJson], { type: 'application/json' }); diff --git a/frontend/src/commands.js b/frontend/src/commands.js index 07530d9..0dd9142 100644 --- a/frontend/src/commands.js +++ b/frontend/src/commands.js @@ -9,6 +9,7 @@ import { loadOverlay, closeOverlay } from "./redux/slices/loadingOverlay" import { MODAL_TYPES } from "./modules/unified_modal/unifiedModal" import { upload_resource, upload_sbol, CheckLogin } from "./API" import { readStudy } from "./modules/util"; +import { workingDirectorySlice } from './redux/store' const EXCEL_VIEWER_PANEL_TYPE = 'synbio.panel-type.excel-viewer' @@ -29,32 +30,68 @@ export default { const file = findFileByNameOrId(fileNameOrId) if (!file) return "File doesn't exist." - - const dirHandle = store.getState().workingDirectory.directoryHandle - const directory = file.id.split("/")[0] - try { - const tempDirectory = await dirHandle.getDirectoryHandle(directory); + const dirHandle = + store.getState().workingDirectory.directoryHandle + + // Resolve the directory containing the file + const parts = file.id.split('/') + const fileName = parts.pop() + + let currentDir = dirHandle + + for (const part of parts) { + currentDir = await currentDir.getDirectoryHandle(part) + } + + // If this is a JSON workflow file, preserve your existing + // behavior of finding an uploaded file referenced by it. + let uploadedFilePath = null - let uploadedFilePath = null; + if (fileName.toLowerCase().endsWith('.json')) { try { - const jsonFH = await tempDirectory.getFileHandle(file.name); - const jsonText = await (await jsonFH.getFile()).text(); - const jsonData = JSON.parse(jsonText); - uploadedFilePath = jsonData.file || null; - } catch (e) {} + const jsonFH = await currentDir.getFileHandle(fileName) + const jsonText = await (await jsonFH.getFile()).text() + const jsonData = JSON.parse(jsonText) - await tempDirectory.removeEntry(file.name); + uploadedFilePath = jsonData.file || null + } catch (e) { + // Not a readable workflow JSON file + } + } + + // Delete the selected file + await currentDir.removeEntry(fileName) + + // If deleting an XML source, also delete its upload sidecar. + if (fileName.toLowerCase().endsWith('.xml')) { + const sidecarName = fileName.replace(/\.xml$/i, '.json') + const sidecarId = [...parts, sidecarName].join('/') try { - if (uploadedFilePath) { - const uploadsDir = await tempDirectory.getDirectoryHandle('uploads'); - const uploadFileName = uploadedFilePath.split('/').pop(); - await uploadsDir.removeEntry(uploadFileName); + await currentDir.removeEntry(sidecarName) + } catch (e) { + if (e.name !== 'NotFoundError') { + console.warn(`Could not delete sidecar ${sidecarId}:`, e) } - } catch (e) {} - } catch { - await dirHandle?.removeEntry(file.name); + } + + store.dispatch(workDirActions.removeFile(sidecarId)) + } + + // Preserve your existing uploads/ cleanup. + if (uploadedFilePath) { + try { + const uploadsDir = + await currentDir.getDirectoryHandle('uploads') + + const uploadFileName = + uploadedFilePath.split('/').pop() + + await uploadsDir.removeEntry(uploadFileName) + } catch (e) { + // Uploaded file may already be gone. + } } store.dispatch(panelsActions.closePanel(file.id)) @@ -350,6 +387,7 @@ export default { return "Panel isn't open." await writeToFileHandle(file, serializePanel(file.id)) + store.dispatch(workingDirectorySlice.actions.uploadChanged()) if (file.objectType === ObjectTypes.SBOL.id) { const panel = panelsSelectors.selectById(store.getState(), file.id) @@ -363,7 +401,6 @@ export default { const dirHandle = store.getState().workingDirectory.directoryHandle sbmlFile = await createFileInDirectory(dirHandle, sbmlFileName, ObjectTypes.SBML.id, store.dispatch) } - await writeToFileHandle(sbmlFile, sbmlContent) } } @@ -487,6 +524,7 @@ export default { const selectedRepo = jsonData.registryURL; const expectedEmail = jsonData.userEmail || null; const collectionUrl = jsonData.collectionUri; + const collectionId = jsonData.collectionId; const collectionName = jsonData.name; const registryAPI = jsonData.registryAPI; const importType = directory.endsWith(".xml")?"designs":directory; @@ -508,6 +546,43 @@ export default { store.dispatch(closeOverlay()); } + const collectionEntry = { + name: collectionName, + displayId: collectionId, + uri: collectionUrl, + selectedRepo, + userEmail: expectedEmail + } + + const uploadEntry = { + collectionName, + collectionUri: collectionUrl, + uri: collectionUrl, + file: file.id, + date: new Date().toLocaleString(undefined, { timeZoneName: 'short' }), + selectedRepo, + userEmail: expectedEmail, + type: 'upload', + }; + + const updatedJson = { + file: file.id, + collection: collectionEntry, + uploads: [uploadEntry] + }; + + const jsonPath = file.id.replace(/\.xml$/i, '.json'); + const parts = jsonPath.split('/'); + const fileName = parts.pop(); + let currentDir = dirHandle; + for (const part of parts) { + currentDir = await currentDir.getDirectoryHandle(part); + } + const jsonFH = await currentDir.getFileHandle(fileName, { create: true }); + await writeToFileHandle(jsonFH, JSON.stringify(updatedJson)); + + store.dispatch(workingDirectorySlice.actions.uploadChanged()) + showNotification({ title: "File uploaded", message: `${file.name} uploaded successfully to ${collectionName} study.`, diff --git a/frontend/src/components/DragObject.jsx b/frontend/src/components/DragObject.jsx index 0ec556c..9c980d7 100644 --- a/frontend/src/components/DragObject.jsx +++ b/frontend/src/components/DragObject.jsx @@ -1,7 +1,7 @@ import { Group, Text } from '@mantine/core' import React from 'react' -export default function DragObject({ icon, type, title, fileId, ...props }) { +export default function DragObject({ icon, type, title, fileId, uploadInfo, ...props }) { const handleDragStart = event => { event.dataTransfer.setData("name", title) @@ -17,13 +17,23 @@ export default function DragObject({ icon, type, title, fileId, ...props }) { return ( ({...groupStyle(theme), + color: uploadInfo!=null?theme.colors.green[6]:undefined, + })} + draggable + onDragStart={handleDragStart} > {icon} - {title} + ({ + ...textStyle(theme), + color: uploadInfo!=null?theme.colors.green[6]:undefined, + })} + > + {title} + ) } diff --git a/frontend/src/components/activities/explorer/ExplorerList.jsx b/frontend/src/components/activities/explorer/ExplorerList.jsx index 195b952..e55ea61 100644 --- a/frontend/src/components/activities/explorer/ExplorerList.jsx +++ b/frontend/src/components/activities/explorer/ExplorerList.jsx @@ -19,6 +19,10 @@ export default function ExplorerList({workDir, objectTypesToList}) { // grab file handles const files = useFiles() + const filteredFiles = files.filter(file => + objectTypesToList.includes(file.objectType) + ) + const { workflows } = useUnifiedModal() const [importedFile, setImportedFile] = useState(null) @@ -189,7 +193,7 @@ export default function ExplorerList({workDir, objectTypesToList}) { Object.values(ObjectTypes).map((objectType, i) => { // grab files of current type if(objectTypesToList.includes(objectType.id)){ - const filesOfType = files.filter(file => file.objectType == objectType.id) + const filesOfType = filteredFiles.filter(file => file.objectType == objectType.id) .sort((a, b) => a.name?.localeCompare(b.name)) return ( diff --git a/frontend/src/components/activities/explorer/ExplorerListItem.jsx b/frontend/src/components/activities/explorer/ExplorerListItem.jsx index ff459d6..6bf34c3 100644 --- a/frontend/src/components/activities/explorer/ExplorerListItem.jsx +++ b/frontend/src/components/activities/explorer/ExplorerListItem.jsx @@ -1,18 +1,106 @@ import commands from "../../../commands" import { Menu } from '@mantine/core' -import { useState } from 'react' +import { useEffect, useState } from 'react' +import { useSelector } from 'react-redux' import { useOpenPanel } from '../../../redux/hooks/panelsHooks' import { titleFromFileName, useFile } from '../../../redux/hooks/workingDirectoryHooks' import DragObject from '../../DragObject' import { getPanelTypeForObject } from '../../../panels' - +import store from '../../../redux/store' export default function ExplorerListItem({ fileId, icon }) { const file = useFile(fileId) + const [uploadInfo, setUploadInfo] = useState(null) + + const uploadRevision = useSelector(state => state.workingDirectory.uploadRevision ?? 0) + + useEffect(() => { + const getUploadInfo = async () => { + try { + let jsonFile; + if (file?.objectType === 'synbio.object-type.study-data' || + file?.objectType === 'synbio.object-type.plate-reader' || + file?.objectType === 'synbio.object-type.experimental-results') { + const state = store.getState().workingDirectory + const xdcFiles = Object.values(state.entities) + .filter(f => f?.name?.toLowerCase().endsWith('.xdc')) + for (const xdcHandle of xdcFiles) { + try { + const xdcFile = await xdcHandle.getFile() + const xdcText = await xdcFile.text() + const xdc = JSON.parse(xdcText) + const metadataMatches = + file?.objectType === 'synbio.object-type.study-data' && + (xdc.metadata === file.id || + xdc.metadata?.split('/').pop() === file.name) + const plateMatches = + file?.objectType === 'synbio.object-type.plate-reader' && + (xdc.plateOutput === file.id || + xdc.plateOutput?.split('/').pop() === file.name) + const results = Array.isArray(xdc.results) + ? xdc.results + : xdc.results + ? [xdc.results] + : [] + + const resultsMatches = results.some(result => + file?.objectType === 'synbio.object-type.experimental-results' && + (result === file.id || + result.split('/').pop() === file.name) + ) + if (!metadataMatches && !plateMatches && !resultsMatches) { + continue + } + if (xdc.uploads.length > 0) { + setUploadInfo(xdc.uploads[xdc.uploads.length - 1]) + return + } + } catch (error) { + console.warn(`Could not inspect ${xdcHandle.id}:`,error) + } + } + setUploadInfo(null) + return + } else if (file?.name?.toLowerCase().endsWith('.json')|| + file?.name?.toLowerCase().endsWith('.xdc')) { + // The selected file is already the JSON metadata file + jsonFile = await file.getFile(); + } else if (file?.name?.toLowerCase().endsWith('.xml')) { + const jsonPath = file.id.replace(/\.xml$/i, '.json'); + const parts = jsonPath.split('/'); + const fileName = parts.pop(); + const rootHandle = + store.getState().workingDirectory.directoryHandle; + let currentDir = rootHandle; + for (const part of parts) { + currentDir = await currentDir.getDirectoryHandle(part); + } + const jsonHandle = + await currentDir.getFileHandle(fileName); + jsonFile = await jsonHandle.getFile(); + } else { + setUploadInfo(null); + return; + } + const jsonText = await jsonFile.text(); + const json = JSON.parse(jsonText); + if (Array.isArray(json.uploads) && json.uploads.length > 0) { + setUploadInfo(json.uploads[json.uploads.length - 1]); + } else { + setUploadInfo(null); + } + } catch (error) { + setUploadInfo(null); + } + }; + getUploadInfo(); + }, [file?.id, file?.name, uploadRevision]); + // handle opening of file const openPanel = useOpenPanel() + const handleOpenFile = async () => { const hasWorkflowPanel = !!getPanelTypeForObject(file) if (hasWorkflowPanel) { @@ -95,6 +183,7 @@ export default function ExplorerListItem({ fileId, icon }) { fileId={fileId} type={file.objectType} icon={icon} + uploadInfo={uploadInfo} onDoubleClick={handleOpenFile} onContextMenu={handleRightClick} /> diff --git a/frontend/src/components/panels/xdc/CollectionWizard.jsx b/frontend/src/components/panels/xdc/CollectionWizard.jsx index fa72c00..6ade9c3 100644 --- a/frontend/src/components/panels/xdc/CollectionWizard.jsx +++ b/frontend/src/components/panels/xdc/CollectionWizard.jsx @@ -254,7 +254,6 @@ export default function CollectionWizard() { selectedRepo, status: response?.status || 'success', } - setUploads((currentUploads) => [...(currentUploads || []), uploadEntry]) } catch (error) { showErrorNotification('Upload failed', error?.response?.data?.error || error.message || 'Unable to upload the collection metadata.') @@ -266,7 +265,7 @@ export default function CollectionWizard() { return ( - + { state.directoryHandle = action.payload }, + uploadChanged: state => { + state.uploadRevision += 1 + }, } })