diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1b9a471..876bf74 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -39,6 +39,7 @@ The Batch Transfer Plugin enables bulk data operations across XNAT projects. Use ## Planned ### Operations & Workflow +- Implement UX cohort building filters for large (>10k+) datasets where the select/deselect item workflow would be tedious - Launch Advanced Search from destination project Action Menu - Include all XNAT experiment data types - Computed size estimate in the operation-detail panel — replace the static duplication warning with a real byte count for the current selection (e.g. "≈ 4.2 GB will be duplicated to *DestProject*"). Requires either a new XAPI endpoint that walks the archive, or per-row `data-size` attributes emitted by `XDATScreen_batch_transfer.java`, plus rollup logic in `batchTransfer.js`. diff --git a/src/main/java/org/nrg/xnat/turbine/modules/screens/XDATScreen_batch_transfer.java b/src/main/java/org/nrg/xnat/turbine/modules/screens/XDATScreen_batch_transfer.java index d52c472..5a5b780 100644 --- a/src/main/java/org/nrg/xnat/turbine/modules/screens/XDATScreen_batch_transfer.java +++ b/src/main/java/org/nrg/xnat/turbine/modules/screens/XDATScreen_batch_transfer.java @@ -3,7 +3,6 @@ package org.nrg.xnat.turbine.modules.screens; -import org.nrg.xnatx.plugins.transfer.model.TransferMode; import org.nrg.xnatx.plugins.transfer.model.Fields; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; @@ -30,8 +29,6 @@ @SuppressWarnings("unused") @Slf4j public class XDATScreen_batch_transfer extends SecureScreen { - private final List nonSharingProjects = new ArrayList<>(); - private final List nonCopyingProjects = new ArrayList<>(); private static final String FIELD_ID = "BATCH_TRANSFER_ID"; private static final String READABLE_SUBJECTS = "SELECT field_value,read_element,field,element_name,grp.tag, xdat_user_id\n" + @@ -70,7 +67,8 @@ protected void doBuildTemplate(RunData data, Context context) throws Exception { // (2) default — DisplaySearch in the session from a bulk action on a search results table. String sourceProject = data.getParameters().getString("sourceProject"); String column; - List itemIds; + List itemIds = null; // search-results entry point only + String sourceProjectId = null; // sourceProject entry point only DisplaySearch search = null; if (StringUtils.isNotBlank(sourceProject)) { @@ -80,15 +78,10 @@ protected void doBuildTemplate(RunData data, Context context) throws Exception { context.put("msg", "Project not found or access denied."); return; } - String validatedProjectId = proj.getId(); + sourceProjectId = proj.getId(); context.put("searchType", "subject"); - context.put("projectContext", validatedProjectId); + context.put("projectContext", sourceProjectId); column = "subj.id"; - itemIds = getSubjectIdsInProject(validatedProjectId, user); - if (itemIds.isEmpty()) { - context.put("sharingMsg", "None of the requested data is available for sharing or cloning."); - return; - } } else { // retrieve passed search object search = TurbineUtils.getSearch(data); @@ -118,6 +111,12 @@ protected void doBuildTemplate(RunData data, Context context) throws Exception { itemIds = table.convertColumnToArrayList(FIELD_ID); } + boolean byProject = (sourceProjectId != null); + String safeProj = byProject ? sourceProjectId.replaceAll("[^A-Za-z0-9_\\-]", "") : ""; + String ownedScope = byProject ? "WHERE subj.project='" + safeProj + "'\n" : ""; + String sharedScope = byProject ? "WHERE pp.project='" + safeProj + "'\n" : ""; + String finalFilter = byProject ? "" : ("WHERE " + column + " IN ('" + buildWhereClause(itemIds) + "')"); + // Build the query to retrieve information about the items we want listed. String query = "SELECT DISTINCT ON (SUBJ.ID, SADS.ID, IAD.ID) secondary_id AS project_label,subj.id AS subject_id, subj.label AS subject_label, subj.project AS subject_project, SADS.id AS session_id, SADS.label AS session_label, SADS.project AS session_project, SADS.date AS session_expt_date, SADS.element_name as session_element, IAD.id AS assessor_id, IAD.label AS assessor_label, IAD.project AS assessor_project, IAD.date AS assess_expt_date, IAD.element_name AS assessor_element\n" + "FROM (\n" + @@ -126,6 +125,7 @@ protected void doBuildTemplate(RunData data, Context context) throws Exception { "JOIN (\n" + READABLE_SUBJECTS + "WHERE xdat_user_id={USER_ID} AND read_element=1 AND element_name='xnat:subjectData' AND field='xnat:subjectData/project') OWNED ON subj.project=OWNED.tag\n" + + ownedScope + "UNION\n" + "SELECT subj.id, pp.label, subj.project\n" + "FROM xnat_subjectData subj\n" + @@ -133,6 +133,7 @@ protected void doBuildTemplate(RunData data, Context context) throws Exception { "JOIN (\n" + READABLE_SUBJECTS + "WHERE xdat_user_id={USER_ID} AND read_element=1 AND element_name='xnat:subjectData' AND field='xnat:subjectData/sharing/share/project' ) SHARED ON pp.project=SHARED.tag\n" + + sharedScope + ") SUBJ\n" + "LEFT JOIN (\n" + " SELECT expt.id, expt.label, expt.project, sad.subject_id, expt.date, xme.element_name\n" + @@ -165,40 +166,49 @@ protected void doBuildTemplate(RunData data, Context context) throws Exception { "JOIN xnat_imageAssessorData sad ON shared.id=sad.id\n" + ") IAD ON SADS.id=IAD.imagesession_id\n" + "LEFT JOIN xnat_projectData ON subj.project=xnat_projectData.id " + - "WHERE " + - column + - " IN ('" + - buildWhereClause(itemIds) + - "');"; + finalFilter + + ";"; query = query.replace("{USER_ID}", ((XDATUser) user).getXdatUserId().toString()); // Execute the query to get a list of experiments XFTTable experiments = XFTTable.Execute(query, user.getDBName(), user.getUsername()); - // Insert the items into the context. - Map sharingItems = getItems(experiments, search, TransferMode.SHARE); - - Map copyingItems = getItems(experiments, search, TransferMode.CLONE); - - if (sharingItems.isEmpty() && copyingItems.isEmpty()) { - context.put("sharingMsg", "None of the requested data is available for sharing or cloning."); + // Build ONE item set (all readable items), then compute per-project eligibility once. + // Share/Clone are feature-gated; Reimport is always allowed, so the set is unfiltered. + Map items = buildItems(experiments, search); + if (items.isEmpty()) { + context.put("sharingMsg", "None of the requested data is available."); return; } - if (sharingItems.isEmpty()) { - context.put("sharingMsg", "There is no data available to share. Please try another operation to proceed."); - } else if (!nonSharingProjects.isEmpty()) { - context.put("sharingMsg", "Some of the data requested is not available for sharing and has been excluded from the table below."); + UserI details = XDAT.getUserDetails(); + Set shareableProjects = new HashSet<>(); + Set cloneableProjects = new HashSet<>(); + for (String proj : items.keySet()) { + if (Features.checkRestrictedFeature(details, proj, Features.PROJECT_SHARING_FEATURE)) { + shareableProjects.add(proj); + } + if (Features.checkRestrictedFeature(details, proj, Fields.PROJECT_COPYING_FEATURE)) { + cloneableProjects.add(proj); + } } - if (copyingItems.isEmpty()) { - context.put("copyingMsg", "There is no data available to clone. Please try another operation to proceed."); - } else if (!nonCopyingProjects.isEmpty()) { - context.put("copyingMsg", "Some of the data requested is not available for cloning and has been excluded from the table below."); + // Per-operation availability warnings, derived from eligibility (Reimport always has data). + if (shareableProjects.isEmpty()) { + context.put("sharingMsg", "No data is available to share. Try another operation to proceed."); + } else if (shareableProjects.size() < items.size()) { + context.put("sharingMsg", "Some data is not available for sharing and is hidden while Share is selected."); + } + if (cloneableProjects.isEmpty()) { + context.put("copyingMsg", "No data is available to clone. Try another operation to proceed."); + } else if (cloneableProjects.size() < items.size()) { + context.put("copyingMsg", "Some data is not available for cloning and is hidden while Clone is selected."); } - context.put("sharingItems", sharingItems); - context.put("copyingItems", copyingItems); + + context.put("items", items); + context.put("shareableProjects", shareableProjects); + context.put("cloneableProjects", cloneableProjects); context.put("turbineUtils", TurbineUtils.GetInstance()); } @@ -221,10 +231,12 @@ private void addShareIdDisplayField(SchemaElement rootElement, DisplaySearch sea * @param search - DisplaySearch * @return Hashtable */ - private Map getItems(XFTTable t, DisplaySearch search, TransferMode operation) { + private Map buildItems(XFTTable t, DisplaySearch search) { Map items = new HashMap<>(); - // For each experiment in the hashtable. + // For each experiment row, build the project→subject→session→assessor tree. The set is + // unfiltered — per-project Share/Clone eligibility is computed once in doBuildTemplate, + // and Reimport is always allowed. for(Hashtable exp : t.rowHashs()){ String project = (String) exp.get("subject_project"); @@ -233,18 +245,6 @@ private Map getItems(XFTTable t, DisplaySearch search, Tr continue; } - if (operation == TransferMode.SHARE) { - if(!Features.checkRestrictedFeature(XDAT.getUserDetails(), project, Features.PROJECT_SHARING_FEATURE)){ - nonSharingProjects.add(project); - continue; - } - } else { - if(!Features.checkRestrictedFeature(XDAT.getUserDetails(), project, Fields.PROJECT_COPYING_FEATURE)){ - nonCopyingProjects.add(project); - continue; - } - } - ItemContainer projectContainer = items.get(project); if (projectContainer == null) { projectContainer = new ItemContainer(project, (String) exp.get("project_label"), @@ -297,22 +297,6 @@ private String buildWhereClause(List ids){ return StringUtils.join(ids, "','"); } - /** - * Returns all subject IDs belonging to the given project, including subjects shared into it. - * Permission filtering is applied downstream by the main hierarchy query's READABLE_* joins. - * @param projectId - validated project id (already checked via XnatProjectdata.getProjectByIDorAlias) - * @param user - current user - * @return list of subject ids - */ - private List getSubjectIdsInProject(String projectId, UserI user) throws Exception { - String safeProjectId = projectId.replaceAll("[^A-Za-z0-9_\\-]", ""); - String query = "SELECT DISTINCT subj.id AS id FROM xnat_subjectData subj " + - "LEFT JOIN xnat_projectParticipant pp ON subj.id=pp.subject_id " + - "WHERE subj.project = '" + safeProjectId + "' OR pp.project = '" + safeProjectId + "';"; - XFTTable table = XFTTable.Execute(query, user.getDBName(), user.getUsername()); - return table.convertColumnToArrayList("id"); - } - private static Map sortItemContainersByLabel(Map items) { items.forEach((key, value) -> value.sortChildren()); return items.entrySet().stream() @@ -362,5 +346,13 @@ public String getProject() { public String getXsiType() { return this.xsiType; } + + /** + * Reimport is session-only: only image sessions (xsiType ending in "SessionData") are + * reimportable — subjects and (image) assessors are not. Drives data-reimportable in the UI. + */ + public boolean isReimportable() { + return this.xsiType != null && this.xsiType.toLowerCase().endsWith("sessiondata"); + } } } diff --git a/src/main/java/org/nrg/xnatx/plugins/transfer/service/impl/BatchTransferServiceImpl.java b/src/main/java/org/nrg/xnatx/plugins/transfer/service/impl/BatchTransferServiceImpl.java index 8919dbd..020c492 100644 --- a/src/main/java/org/nrg/xnatx/plugins/transfer/service/impl/BatchTransferServiceImpl.java +++ b/src/main/java/org/nrg/xnatx/plugins/transfer/service/impl/BatchTransferServiceImpl.java @@ -249,17 +249,35 @@ private void importExperiment(XnatExperimentdata sourceExperiment, XnatProjectda */ protected List runImporter(final UserI user, final FileWriterWrapperI wrapper, final Map params) throws Exception { final Thread heartbeat = startImporterHeartbeat(wrapper.getName()); + List result = null; + Exception importerError = null; try (DicomZipImporter importer = new DicomZipImporter(null, user, wrapper, params)) { importer.setIdentifier(this.identifier); - return importer.call(); + result = importer.call(); + } catch (Exception e) { + importerError = e; } finally { heartbeat.interrupt(); if (wrapper instanceof StreamingZipFileWriter) { final StreamingZipFileWriter s = (StreamingZipFileWriter) wrapper; s.shutdown(); - s.awaitProducer(30_000L); + try { + s.awaitProducer(30_000L); + } catch (IOException producerError) { + // The importer's own failure is the user-facing cause; a producer-side error + // (genuine source truncation) must never mask it — attach it as suppressed. + if (importerError == null) { + importerError = producerError; + } else { + importerError.addSuppressed(producerError); + } + } } } + if (importerError != null) { + throw importerError; + } + return result; } private static Thread startImporterHeartbeat(final String name) { @@ -341,35 +359,86 @@ public InputStream getInputStream() throws IOException { } private void produce() { - try (ZipOutputStream zos = new ZipOutputStream(new BufferedOutputStream(pipedOut, 65536)); - Stream paths = Files.walk(sourceDir)) { - zos.setLevel(Deflater.BEST_SPEED); - final byte[] buffer = new byte[65536]; + final ZipOutputStream zos = new ZipOutputStream(new BufferedOutputStream(pipedOut, 65536)); + zos.setLevel(Deflater.BEST_SPEED); + final byte[] buffer = new byte[65536]; + try (Stream paths = Files.walk(sourceDir)) { + // writeEntry returns false once the consumer closes the pipe; allMatch then stops + // streaming. Real source-read failures throw and are caught (and recorded) below. paths.filter(Files::isRegularFile) .filter(p -> StreamSupport.stream(p.spliterator(), false) .anyMatch(part -> part.toString().equals("DICOM"))) .filter(p -> !p.getFileName().toString().toLowerCase().endsWith("catalog.xml")) - .forEach(p -> writeEntry(zos, p, buffer)); - } catch (Throwable t) { - // Unwrap UncheckedIOException for cleaner error messages. - final Throwable recorded = (t instanceof UncheckedIOException) ? t.getCause() : t; - error.set(recorded); - log.warn("Streaming zip producer for {} failed", displayName, recorded); + .allMatch(p -> writeEntry(zos, p, buffer)); + } catch (UncheckedIOException e) { + recordError(e.getCause()); // a source DICOM file could not be read + } catch (IOException e) { + recordError(e); // the source tree could not be walked + } finally { + finishZip(zos); } } - private void writeEntry(final ZipOutputStream zos, final Path filePath, final byte[] buffer) { - try { - zos.putNextEntry(new ZipEntry(sourceDir.relativize(filePath).toString())); - try (InputStream in = Files.newInputStream(filePath)) { - int len; - while ((len = in.read(buffer)) > 0) { - zos.write(buffer, 0, len); + private void recordError(final Throwable cause) { + error.set(cause); + log.warn("Streaming zip producer for {} failed", displayName, cause); + } + + /** + * Copy one source file into the zip, streaming through the shared fixed-size buffer. + * Returns {@code false} — not an error — when a write to the pipe fails: that means the + * consumer (importer) closed the read end after taking the DICOM it needed, so there is + * nothing left to stream. A failure to read the source file is a genuine producer + * error, rethrown as {@link UncheckedIOException} so it surfaces via {@link #awaitProducer(long)}. + * + *

Streaming in fixed-size chunks (which block and drain through the 64 KB pipe) + * keeps producer memory constant regardless of slice size — a large multi-frame instance + * is never buffered whole. + */ + private boolean writeEntry(final ZipOutputStream zos, final Path filePath, final byte[] buffer) { + try (InputStream in = Files.newInputStream(filePath)) { + try { + zos.putNextEntry(new ZipEntry(sourceDir.relativize(filePath).toString())); + } catch (IOException consumerClosedPipe) { + return false; + } + while (true) { + final int len; + try { + len = in.read(buffer); // read from source + } catch (IOException sourceReadError) { + throw new UncheckedIOException(sourceReadError); + } + if (len <= 0) { + break; + } + try { + zos.write(buffer, 0, len); // write to the pipe (sink) + } catch (IOException consumerClosedPipe) { + return false; } } - zos.closeEntry(); - } catch (IOException e) { - throw new UncheckedIOException(e); + try { + zos.closeEntry(); + } catch (IOException consumerClosedPipe) { + return false; + } + return true; + } catch (IOException sourceOpenError) { + throw new UncheckedIOException(sourceOpenError); + } + } + + /** + * Flush and close the zip, writing its central directory. The consumer never reads past + * the last entry, so for a large session this final write can fail once the consumer has + * closed the pipe — that is the normal end of streaming, not an error. + */ + private void finishZip(final ZipOutputStream zos) { + try { + zos.close(); + } catch (IOException consumerClosedPipe) { + log.debug("Streaming zip producer for {}: consumer closed before the zip finished (benign)", displayName); } } diff --git a/src/main/resources/META-INF/resources/scripts/xnat/plugin/batchTransfer/batchTransfer.js b/src/main/resources/META-INF/resources/scripts/xnat/plugin/batchTransfer/batchTransfer.js index f33f102..f010539 100644 --- a/src/main/resources/META-INF/resources/scripts/xnat/plugin/batchTransfer/batchTransfer.js +++ b/src/main/resources/META-INF/resources/scripts/xnat/plugin/batchTransfer/batchTransfer.js @@ -13,16 +13,24 @@ var XNAT = getObject(XNAT || {}); var projectAnonCache = {}; var selectedOp = 'Share'; + var lastOp = 'Share'; // previous operation, to detect leaving Reimport var selectedProject = null; var selectedAnon = null; var config = {}; + var submitInFlight = false; + + // Row index, built once after load (rows are static — only classes/visibility change). + // Replaces per-row `$('#bt-data-body tr[data-id=…]')` scans that made tree walks O(n²). + var rowById = {}; // data-id -> DOM + var childrenByParent = {}; // data-parent -> [DOM , ...] var operationDetails = { Share: 'Shares the selected data into the destination project. ' + 'The data is not copied - it remains the same data element, owned by the source project, ' + 'and changes made there are reflected in the destination.', Clone: 'Creates an independent copy of the selected data in the destination project. ' + - 'The cloned data is editable separately from the source and is not anonymized.', + 'The cloned data is editable separately from the source and is not anonymized. ' + + 'Cloned session files are hard-linked to the source, requiring no additional disk space at the time of cloning.', Reimport: 'Reimports the selected image sessions through the destination project\'s ' + 'anonymization pipeline. Only DICOM files are transferred; target session metadata ' + 'is derived from DICOM tags and/or anonymization parameters.' @@ -33,6 +41,7 @@ var XNAT = getObject(XNAT || {}); XNAT.plugin.batchtransfer.init = function(cfg) { config = cfg || {}; selectedOp = 'Share'; + lastOp = 'Share'; if (config.presetDestination) { selectedProject = config.presetDestination; @@ -46,13 +55,48 @@ var XNAT = getObject(XNAT || {}); loadProjects(); } + buildRowIndex(); bindOperationCards(); bindProjectSelect(); bindTableInteractions(); + // One item set is rendered (all readable items). Apply the default operation's + // eligibility so rows whose project can't be shared are hidden/excluded on load. + applyOperationEligibility('Share'); updateOperationDetail(); updateSummary(); }; + // Build the id/parent indexes once. The server renders a fixed row set; rows are never + // added or removed (only .selected / .bt-import-* classes and visibility toggle), so the + // index stays valid for the page lifetime. + function buildRowIndex() { + rowById = {}; + childrenByParent = {}; + var rows = document.querySelectorAll('#bt-data-body tr'); + for (var i = 0; i < rows.length; i++) { + var el = rows[i]; + var id = el.getAttribute('data-id'); + var parent = el.getAttribute('data-parent'); + if (id) rowById[id] = el; + if (parent) { + (childrenByParent[parent] || (childrenByParent[parent] = [])).push(el); + } + // Cache lowercased row text once for the filter (label/type/id are static; the only + // volatile part is the checkmark glyph, which never matters for a text query). + el._btFilterText = (el.textContent || '').toLowerCase(); + } + } + + function rowFor(id) { + var el = rowById[id]; + return el ? $(el) : $(); + } + + function childrenOf(parentId) { + var arr = childrenByParent[parentId]; + return (arr && arr.length) ? $(arr) : $(); + } + // ── Project Loading ── function loadProjects() { @@ -71,7 +115,6 @@ var XNAT = getObject(XNAT || {}); // Cache each project's anonymization state up-front so the operation-detail // panel banner can render synchronously when the user picks a destination. - // No visual decoration is applied to the dropdown options themselves. function fetchAnonForProjects(projects) { projects.forEach(function(p) { XNAT.plugin.batchtransfer.checkProjectAnon(p.id, function(anonEnabled) { @@ -109,9 +152,8 @@ var XNAT = getObject(XNAT || {}); // Renders 0–2 alert banners into the "About this transfer" panel: // 1) Anon-status banner — only "warn" or "good" severities; never shown when there's // nothing actionable to say (e.g. Share into a non-anonymized project). - // 2) Storage caution banner — appears for any operation that duplicates data (Clone, - // Reimport), regardless of destination state, since the operational cost is a - // property of the chosen operation. + // 2) Storage caution banner — shown for Reimport (its storage/time cost is a property + // of the operation). Clone's storage note lives in its operation description instead. function updateAnonBanner() { var area = document.getElementById('bt-op-detail-warning-area'); @@ -144,12 +186,8 @@ var XNAT = getObject(XNAT || {}); // Share + no anon, Clone + no anon: no banner — nothing actionable to flag. } - // Storage caution — fires for any data-duplicating op, regardless of destination. - if (selectedOp === 'Clone') { - html += '

' + - '' + - '
Cloned data can take up significant storage in the destination project, and the cloning operation may take some time.
'; - } else if (selectedOp === 'Reimport') { + // Storage caution — shown for Reimport (Clone's storage note is in its description). + if (selectedOp === 'Reimport') { html += '
' + '' + '
Reimported data can take up significant storage in the destination project, and the reimporting operation may take some time.
'; @@ -179,73 +217,95 @@ var XNAT = getObject(XNAT || {}); var opLabels = { Share: 'share', Clone: 'clone', Reimport: 'reimport' }; $('#bt-main-title').text('Select data to ' + (opLabels[op] || 'transfer')); - // Update filter placeholder $('#bt-data-search').attr('placeholder', op === 'Reimport' ? 'Filter sessions by name or datatype...' : 'Filter subjects and sessions by name or datatype...'); - // Show/hide data pools (Clone uses the 'copy' pool internally; data-pool values are unchanged in Phase 1) - var showPool = (op === 'Clone') ? 'copy' : 'share'; - var hidePool = (op === 'Clone') ? 'share' : 'copy'; - $('#bt-data-body tr[data-pool="' + hidePool + '"]').hide(); - showPoolRows(showPool); + // Single row set persists across operations, so selection carries naturally. + // Reset any prior op's hide/disable, then apply the new op's eligibility. + enableAllRows(); + applyOperationEligibility(op); - // For Reimport, disable subject-level checkboxes and non-session assessors - if (op === 'Reimport') { - disableImportSubjects(); - } else { - enableAllRows(); + // Leaving Reimport: subjects follow their session selections (a subject with no + // selected sessions becomes deselected, rather than staying stuck at its old value). + if (lastOp === 'Reimport' && op !== 'Reimport') { + reconcileSubjectsFromSessions(); } + lastOp = op; - // Reapply the current filter to the newly visible pool - var currentFilter = $('#bt-data-search').val() || ''; - filterData(currentFilter); + // Restore visibility (expand state) / reapply the current filter. filterData() also + // refreshes the counts + summary, so no separate count call is needed here. + filterData($('#bt-data-search').val() || ''); updateAnonBanner(); updateOperationDetail(); - updateSelectionCount(); - updateSummary(); - } - - function isSessionData(xsiType) { - return xsiType && /SessionData$/i.test(xsiType); } - function disableImportSubjects() { - $('#bt-data-body tr[data-pool="share"]').each(function() { - var xsi = $(this).attr('data-xsi') || ''; - var type = $(this).attr('data-type') || ''; - - // Hide subjects entirely in Reimport mode (keep bt-import-disabled so - // they're also excluded from submission; keep selection chain intact) - if (type === 'subject') { + // Hide+disable rows not eligible for the chosen operation (excluded from counts/submission + // and from view). Eligibility is a per-row data attribute, uniform across operations: + // Share → data-shareable (per project's sharing feature) + // Clone → data-cloneable (per project's copying feature) + // Reimport → data-reimportable (per item type — only image sessions; never feature-gated) + // A non-reimportable subject is hidden, but its (reimportable) sessions still render: + // showRows() treats a bt-import-hidden parent as logically visible. + function applyOperationEligibility(op) { + var attr = (op === 'Clone') ? 'data-cloneable' + : (op === 'Reimport') ? 'data-reimportable' + : 'data-shareable'; + $('#bt-data-body tr').each(function() { + if (this.getAttribute(attr) !== 'true') { $(this).addClass('bt-import-disabled bt-import-hidden'); - $(this).find('.bt-cb').removeClass('checked').html(''); - $(this).removeClass('selected'); - return; - } - - // Disable non-SessionData assessors; select SessionData assessors - if (type === 'assessor') { - if (isSessionData(xsi)) { - $(this).removeClass('bt-import-disabled'); - $(this).addClass('selected'); - $(this).find('.bt-cb').addClass('checked').html('✓'); - } else { - $(this).addClass('bt-import-disabled'); - $(this).find('.bt-cb').removeClass('checked').html(''); - $(this).removeClass('selected'); - } } }); } + // Re-enable everything an operation disabled/hid, and re-sync each checkmark to its + // (preserved) .selected state. Selection itself is untouched. function enableAllRows() { $('#bt-data-body tr.bt-import-disabled').each(function() { $(this).removeClass('bt-import-disabled bt-import-hidden'); - // Re-select rows that were disabled by Reimport mode - $(this).addClass('selected'); - $(this).find('.bt-cb').addClass('checked').html('✓'); + syncCheckbox($(this)); }); + $('#bt-data-body tr.bt-import-hidden').removeClass('bt-import-hidden'); + } + + // Does any selected, still-eligible descendant exist under parentId? Raw-DOM walk via the + // index, short-circuiting on the first hit — no jQuery set building. + function subtreeHasSelected(parentId) { + var kids = childrenByParent[parentId]; + if (!kids) return false; + for (var i = 0; i < kids.length; i++) { + var el = kids[i]; + if (!el.classList.contains('bt-import-disabled') && el.classList.contains('selected')) return true; + var cid = el.getAttribute('data-id'); + if (cid && subtreeHasSelected(cid)) return true; + } + return false; + } + + // A subject is selected iff it has at least one selected, eligible descendant. Used when + // leaving Reimport so the (hidden) subjects reflect the session choices made there. + // Raw DOM + short-circuit + write only on an actual flip, so the common (unchanged) case + // costs nothing — this is what removes the Reimport→Share/Clone lag at ~1,000 subjects. + function reconcileSubjectsFromSessions() { + var subs = document.querySelectorAll('#bt-data-body tr[data-type="subject"]'); + for (var i = 0; i < subs.length; i++) { + var el = subs[i]; + var id = el.getAttribute('data-id'); + if (!id) continue; + var want = subtreeHasSelected(id); + if (want === el.classList.contains('selected')) continue; // unchanged → no DOM write + el.classList.toggle('selected', want); + syncCheckbox($(el)); + } + } + + function syncCheckbox($row) { + var $cb = $row.find('.bt-cb'); + if (!$cb.length) return; + var want = $row.hasClass('selected'); + if (want === $cb.hasClass('checked')) return; // checkmark already correct — skip the DOM write + if (want) $cb.addClass('checked').html('✓'); + else $cb.removeClass('checked').html(''); } // ── Project Selection ── @@ -301,9 +361,12 @@ var XNAT = getObject(XNAT || {}); toggleSelectAll(); }); - // Filter + // Filter — debounced so fast typing doesn't run the filter on every keystroke. + var filterTimer = null; $(document).on('input', '#bt-data-search', function() { - filterData($(this).val()); + var val = this.value; + clearTimeout(filterTimer); + filterTimer = setTimeout(function() { filterData(val); }, 150); }); } @@ -311,92 +374,72 @@ var XNAT = getObject(XNAT || {}); var $cb = $(cb); var $row = $cb.closest('tr'); var isChecked = $cb.hasClass('checked'); - var rowId = $row.data('id'); - var pool = getVisiblePool(); + var rowId = $row.attr('data-id'); if (isChecked) { $cb.removeClass('checked').html(''); $row.removeClass('selected'); - // Deselect all children - if (rowId) { - deselectChildren(rowId, pool); - } + if (rowId) deselectChildren(rowId); } else { $cb.addClass('checked').html('✓'); $row.addClass('selected'); - // Select all eligible children - if (rowId) { - selectChildren(rowId, pool); - } - // Select parent chain + if (rowId) selectChildren(rowId); selectParentChain($row); } - updateSelectionCount(); updateSummary(); } // Toggle children of a subject in Reimport mode (subject itself stays disabled) function toggleSubjectChildren($row) { - var rowId = $row.data('id'); - var pool = getVisiblePool(); + var rowId = $row.attr('data-id'); if (!rowId) return; - // Check if children are currently selected - var $eligibleChildren = getAllDescendants(rowId, pool).not('.bt-import-disabled'); + var $eligibleChildren = getAllDescendants(rowId).not('.bt-import-disabled'); var allSelected = $eligibleChildren.length > 0 && $eligibleChildren.filter('.selected').length === $eligibleChildren.length; - if (allSelected) { - deselectChildren(rowId, pool); - } else { - selectChildren(rowId, pool); - } - updateSelectionCount(); + if (allSelected) deselectChildren(rowId); + else selectChildren(rowId); + updateSummary(); } - function getAllDescendants(parentId, pool) { + function getAllDescendants(parentId) { var $result = $(); - var $children = $('#bt-data-body tr[data-parent="' + parentId + '"][data-pool="' + pool + '"]'); - $children.each(function() { - $result = $result.add($(this)); - var childId = $(this).data('id'); + childrenOf(parentId).each(function() { + $result = $result.add(this); + var childId = this.getAttribute('data-id'); if (childId) { - $result = $result.add(getAllDescendants(childId, pool)); + $result = $result.add(getAllDescendants(childId)); } }); return $result; } - function deselectChildren(parentId, pool) { - $('#bt-data-body tr[data-parent="' + parentId + '"][data-pool="' + pool + '"]').each(function() { - if ($(this).hasClass('bt-import-disabled')) return; + function deselectChildren(parentId) { + childrenOf(parentId).each(function() { + if (this.classList.contains('bt-import-disabled')) return; $(this).removeClass('selected'); $(this).find('.bt-cb').removeClass('checked').html(''); - var childId = $(this).data('id'); - if (childId) { - deselectChildren(childId, pool); - } + var childId = this.getAttribute('data-id'); + if (childId) deselectChildren(childId); }); } - function selectChildren(parentId, pool) { - $('#bt-data-body tr[data-parent="' + parentId + '"][data-pool="' + pool + '"]').each(function() { - if ($(this).hasClass('bt-import-disabled')) return; + function selectChildren(parentId) { + childrenOf(parentId).each(function() { + if (this.classList.contains('bt-import-disabled')) return; $(this).addClass('selected'); $(this).find('.bt-cb').addClass('checked').html('✓'); - var childId = $(this).data('id'); - if (childId) { - selectChildren(childId, pool); - } + var childId = this.getAttribute('data-id'); + if (childId) selectChildren(childId); }); } function selectParentChain($row) { - var parentId = $row.data('parent'); - var pool = $row.data('pool'); + var parentId = $row.attr('data-parent'); if (!parentId) return; - var $parent = $('#bt-data-body tr[data-id="' + parentId + '"][data-pool="' + pool + '"]'); + var $parent = rowFor(parentId); if ($parent.length && !$parent.hasClass('bt-import-disabled')) { $parent.addClass('selected'); $parent.find('.bt-cb').first().addClass('checked').html('✓'); @@ -404,91 +447,91 @@ var XNAT = getObject(XNAT || {}); } } - // Show rows for a pool respecting expand/collapse state - function showPoolRows(pool) { - // Show top-level rows (no parent), then recursively show children of expanded parents - $('#bt-data-body tr[data-pool="' + pool + '"]').each(function() { - var parentId = $(this).data('parent'); + // Show rows respecting expand/collapse state — WITHOUT reading layout. The previous version + // called jQuery :visible on each parent, which forces a reflow; interleaved with the + // show/hide writes that is layout thrashing (O(n) reflows), and it's what made clearing the + // filter lag badly (clearing transitions ~all rows from display:none → shown). Here we + // compute each row's visibility top-down from the index + expand classes into a map and + // write `display` in a single pass, so the browser reflows once. + // Rows render in parent-before-child document order, so a parent's visibility is already + // known when we reach its children. (.bt-import-hidden still hides reimport-hidden / + // ineligible rows via CSS regardless of the inline display we set.) + function showRows() { + var shown = {}; // data-id -> did we set this row to display (independent of CSS !important hides) + var rows = document.querySelectorAll('#bt-data-body tr'); + for (var i = 0; i < rows.length; i++) { + var el = rows[i]; + var parentId = el.getAttribute('data-parent'); + var show; if (!parentId) { - // Top-level row: always show (CSS class .bt-import-hidden keeps subjects hidden in Reimport mode) - $(this).show(); + show = true; } else { - // Child row: only show if parent is visible and expanded. - // Treat parents hidden by Reimport mode as logically visible so their session children still render. - var $parent = $('#bt-data-body tr[data-id="' + parentId + '"][data-pool="' + pool + '"]'); - var parentIsVisible = $parent.length && ($parent.is(':visible') || $parent.hasClass('bt-import-hidden')); - if (parentIsVisible) { - var expandBtn = $parent.find('.bt-expand'); - if (expandBtn.length && expandBtn.hasClass('expanded')) { - $(this).show(); - } else { - $(this).hide(); - } - } else { - $(this).hide(); - } + var parentEl = rowById[parentId]; + // Treat a bt-import-hidden parent (e.g. a Reimport-hidden subject) as logically + // visible so its sessions still render. + var parentVisible = !!parentEl && (shown[parentId] || parentEl.classList.contains('bt-import-hidden')); + var expandBtn = parentEl ? parentEl.querySelector('.bt-expand') : null; + var parentExpanded = !!expandBtn && expandBtn.classList.contains('expanded'); + show = parentVisible && parentExpanded; } - }); + el.style.display = show ? '' : 'none'; + var id = el.getAttribute('data-id'); + if (id) shown[id] = show; + } } function toggleChildren(btn) { var $btn = $(btn); var $row = $btn.closest('tr'); - var parentId = $row.data('id'); - var pool = $row.data('pool'); + var parentId = $row.attr('data-id'); var isExpanded = $btn.hasClass('expanded'); $btn.toggleClass('expanded'); - var $children = $('#bt-data-body tr[data-parent="' + parentId + '"][data-pool="' + pool + '"]'); if (isExpanded) { - // Collapse: hide children and all descendants - hideDescendants(parentId, pool); + hideDescendants(parentId); } else { - // Expand: show direct children, respect their own expand state - $children.each(function() { + childrenOf(parentId).each(function() { $(this).show(); - var childId = $(this).data('id'); + var childId = this.getAttribute('data-id'); var childBtn = $(this).find('.bt-expand'); if (childBtn.length && childBtn.hasClass('expanded') && childId) { - showDescendants(childId, pool); + showDescendants(childId); } }); } } - function hideDescendants(parentId, pool) { - $('#bt-data-body tr[data-parent="' + parentId + '"][data-pool="' + pool + '"]').each(function() { + function hideDescendants(parentId) { + childrenOf(parentId).each(function() { $(this).hide(); - var childId = $(this).data('id'); - if (childId) hideDescendants(childId, pool); + var childId = this.getAttribute('data-id'); + if (childId) hideDescendants(childId); }); } - function showDescendants(parentId, pool) { - $('#bt-data-body tr[data-parent="' + parentId + '"][data-pool="' + pool + '"]').each(function() { + function showDescendants(parentId) { + childrenOf(parentId).each(function() { $(this).show(); - var childId = $(this).data('id'); + var childId = this.getAttribute('data-id'); var childBtn = $(this).find('.bt-expand'); if (childBtn.length && childBtn.hasClass('expanded') && childId) { - showDescendants(childId, pool); + showDescendants(childId); } }); } function toggleSelectAll() { - var pool = getVisiblePool(); - var $rows = getVisibleRows(pool); + var $rows = getVisibleRows(); var allChecked = $rows.length > 0 && $rows.filter('.selected').length === $rows.not('.bt-import-disabled').length; $rows.each(function() { - var $cb = $(this).find('.bt-cb'); - if ($(this).hasClass('bt-import-disabled')) return; + if (this.classList.contains('bt-import-disabled')) return; if (allChecked) { - $cb.removeClass('checked').html(''); $(this).removeClass('selected'); + $(this).find('.bt-cb').removeClass('checked').html(''); } else { - $cb.addClass('checked').html('✓'); $(this).addClass('selected'); + $(this).find('.bt-cb').addClass('checked').html('✓'); } }); @@ -498,109 +541,109 @@ var XNAT = getObject(XNAT || {}); } else { $headerCb.addClass('checked').html('✓'); } - updateSelectionCount(); updateSummary(); } + function getVisibleRows() { + return $('#bt-data-body tr').filter(':visible'); + } + function filterData(query) { - var q = query.toLowerCase(); - var pool = getVisiblePool(); - var $rows = $('#bt-data-body tr[data-pool="' + pool + '"]'); + var q = (query || '').toLowerCase(); + // The filter is a VIEW filter only: it shows/hides rows but never changes which + // rows are selected, so the user's manual selections survive filtering and clearing. if (q.length === 0) { - // No filter: restore tree visibility via showPoolRows, select all (except import-disabled) - showPoolRows(pool); - $rows.each(function() { - if (!$(this).hasClass('bt-import-disabled')) { - $(this).addClass('selected'); - $(this).find('.bt-cb').addClass('checked').html('✓'); - } - }); - } else { - // First pass: determine which rows match the query text - var matchingIds = {}; - $rows.each(function() { - var text = $(this).text().toLowerCase(); - if (text.includes(q)) { - matchingIds[$(this).data('pool') + ':' + $(this).data('id')] = true; - } - }); - - // Second pass: include ancestors of matching rows so the tree context is visible - $rows.each(function() { - var key = $(this).data('pool') + ':' + $(this).data('id'); - if (matchingIds[key]) { - var parentId = $(this).data('parent'); - while (parentId) { - var parentKey = pool + ':' + parentId; - matchingIds[parentKey] = true; - var $parent = $('#bt-data-body tr[data-id="' + parentId + '"][data-pool="' + pool + '"]'); - parentId = $parent.data('parent'); - } - } - }); + showRows(); + updateSummary(); + return; + } - // Third pass: show/hide and select/deselect using inline styles - $rows.each(function() { - var key = $(this).data('pool') + ':' + $(this).data('id'); - var matches = !!matchingIds[key]; + // Raw DOM, matching against text cached at index time, one batched display pass — so + // each keystroke stays cheap even at tens of thousands of rows. + var rows = document.querySelectorAll('#bt-data-body tr'); + var i, el, parentId, parentEl; + + // Pass 1: rows whose cached text matches the query. + var matching = {}; + for (i = 0; i < rows.length; i++) { + el = rows[i]; + if ((el._btFilterText || '').indexOf(q) !== -1) { + matching[el.getAttribute('data-id')] = true; + } + } - if (matches) { - $(this).show(); - } else { - $(this).hide(); + // Pass 2: include ancestors of matches so the tree context shows (index walk; stop as + // soon as an already-marked ancestor is reached). + for (i = 0; i < rows.length; i++) { + el = rows[i]; + if (matching[el.getAttribute('data-id')]) { + parentId = el.getAttribute('data-parent'); + while (parentId && !matching[parentId]) { + matching[parentId] = true; + parentEl = rowById[parentId]; + parentId = parentEl ? parentEl.getAttribute('data-parent') : null; } + } + } - if ($(this).hasClass('bt-import-disabled')) return; - - if (matches) { - $(this).addClass('selected'); - $(this).find('.bt-cb').addClass('checked').html('✓'); - } else { - $(this).removeClass('selected'); - $(this).find('.bt-cb').removeClass('checked').html(''); - } - }); + // Pass 3: show matches (and ancestors), hide the rest — raw display writes (one reflow). + // Selection state is left untouched. + for (i = 0; i < rows.length; i++) { + el = rows[i]; + el.style.display = matching[el.getAttribute('data-id')] ? '' : 'none'; } - updateSelectionCount(); updateSummary(); } - function getVisiblePool() { - return selectedOp === 'Clone' ? 'copy' : 'share'; - } - - function getVisibleRows(pool) { - return $('#bt-data-body tr[data-pool="' + pool + '"]').filter(':visible'); - } + // ── Summary + selection counts ── + // Single O(n) pass drives both the selection bar and the sidebar summary (previously two + // or three separate full scans ran on every click). - function updateSelectionCount() { - var pool = getVisiblePool(); - var count = $('#bt-data-body tr[data-pool="' + pool + '"].selected').not('.bt-import-disabled').length; - $('#bt-sel-count').text(count); + function updateSummary() { + $('#bt-sum-op').text(selectedOp); + $('#bt-sum-dest').text(selectedProject || '—'); + + var subjects = 0, sessions = 0, assessors = 0, selectedCount = 0, eligibleCount = 0; + var rows = document.querySelectorAll('#bt-data-body tr'); + for (var i = 0; i < rows.length; i++) { + var el = rows[i]; + if (el.classList.contains('bt-import-disabled')) continue; + eligibleCount++; + if (!el.classList.contains('selected')) continue; + selectedCount++; + var type = el.getAttribute('data-type') || ''; + if (type === 'subject') { + subjects++; + } else if (type === 'assessor') { + if (el.getAttribute('data-reimportable') === 'true') sessions++; + else assessors++; + } + } - var $link = $('.bt-select-all-link'); - var total = $('#bt-data-body tr[data-pool="' + pool + '"]').not('.bt-import-disabled').length; - $link.text(count === total ? 'Deselect all' : 'Select all'); + $('#bt-sel-count').text(selectedCount); + $('.bt-select-all-link').text(eligibleCount > 0 && selectedCount === eligibleCount ? 'Deselect all' : 'Select all'); + $('#bt-sum-subjects').text(subjects); + $('#bt-sum-sessions').text(sessions); + $('#bt-sum-assessors').text(assessors); + updateSubmitButton(); } - // ── Summary ── - + // Used only by the submit confirmation (infrequent) for its "N subjects, M sessions" text. function getSelectionCounts() { - var pool = getVisiblePool(); - var $rows = $('#bt-data-body tr[data-pool="' + pool + '"].selected').not('.bt-import-disabled'); var subjects = 0, sessions = 0, assessors = 0; - $rows.each(function() { - var type = $(this).attr('data-type') || ''; - var xsi = $(this).attr('data-xsi') || ''; - if (type === 'subject') { - subjects++; - } else if (type === 'assessor') { - if (isSessionData(xsi)) sessions++; + var rows = document.querySelectorAll('#bt-data-body tr'); + for (var i = 0; i < rows.length; i++) { + var el = rows[i]; + if (el.classList.contains('bt-import-disabled') || !el.classList.contains('selected')) continue; + var type = el.getAttribute('data-type') || ''; + if (type === 'subject') subjects++; + else if (type === 'assessor') { + if (el.getAttribute('data-reimportable') === 'true') sessions++; else assessors++; } - }); + } return { subjects: subjects, sessions: sessions, assessors: assessors }; } @@ -620,16 +663,6 @@ var XNAT = getObject(XNAT || {}); $('#bt-submit-btn').text(label); } - function updateSummary() { - $('#bt-sum-op').text(selectedOp); - $('#bt-sum-dest').text(selectedProject || '\u2014'); - var counts = getSelectionCounts(); - $('#bt-sum-subjects').text(counts.subjects); - $('#bt-sum-sessions').text(counts.sessions); - $('#bt-sum-assessors').text(counts.assessors); - updateSubmitButton(); - } - // ── Submit ── XNAT.plugin.batchtransfer.submitTransfer = function() { @@ -638,8 +671,7 @@ var XNAT = getObject(XNAT || {}); return false; } - var pool = getVisiblePool(); - var $selected = $('#bt-data-body tr[data-pool="' + pool + '"].selected').not('.bt-import-disabled'); + var $selected = $('#bt-data-body tr.selected').not('.bt-import-disabled'); if ($selected.length === 0) { xmodal.message('Batch Transfer', 'Please select at least one item'); return false; @@ -667,6 +699,11 @@ var XNAT = getObject(XNAT || {}); xModalConfirm({ content: confirmationMsg, okAction: function() { + // Guard against a second submission while one is already in flight. + if (submitInFlight) return; + submitInFlight = true; + $('#bt-submit-btn').prop('disabled', true); + var batchTransfer = { requests: items, tracking_id: 'batch_transfer_' + Date.now() @@ -686,6 +723,10 @@ var XNAT = getObject(XNAT || {}); }, error: function() { xmodal.message('Error', 'Failed to submit transfer request.'); + }, + complete: function() { + submitInFlight = false; + $('#bt-submit-btn').prop('disabled', false); } }); } diff --git a/src/main/resources/META-INF/resources/templates/screens/XDATScreen_batch_transfer.vm b/src/main/resources/META-INF/resources/templates/screens/XDATScreen_batch_transfer.vm index abb1c1e..6f87d6b 100644 --- a/src/main/resources/META-INF/resources/templates/screens/XDATScreen_batch_transfer.vm +++ b/src/main/resources/META-INF/resources/templates/screens/XDATScreen_batch_transfer.vm @@ -8,7 +8,7 @@
$msg
#end -#if((!$sharingItems || $sharingItems.size() == 0) && (!$copyingItems || $copyingItems.size() == 0)) +#if(!$items || $items.size() == 0)
$sharingMsg

@@ -19,8 +19,8 @@ ## Determine source project context #set($projectContext = false) -#if($sharingItems && $sharingItems.size() == 1) - #foreach($proj in $sharingItems) +#if($items && $items.size() == 1) + #foreach($proj in $items) #set($projectContext = $proj.getId()) #end #end @@ -130,13 +130,13 @@

Select data to transfer

- #if($sharingMsg && !$sharingItems.isEmpty()) + #if($sharingMsg)
$sharingMsg
#end - #if($copyingMsg && !$copyingItems.isEmpty()) + #if($copyingMsg)