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
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)

---

## [1.0.1-RC]
## [1.1.0]

### Added

- **Preserve source labels on Reimport** ([#10](https://github.com/NrgXnat/batch-transfer-plugin/issues/10)) — the Reimport panel now offers two checkboxes (both on by default) to preserve the source **subject label** and/or **session label**. When enabled, those XNAT labels are passed to the DICOM importer as `SUBJECT_ID` / `EXPT_LABEL` overrides so the destination uses them instead of deriving labels from DICOM tags (e.g. `PatientName` / `PatientID`).

### Changed

- Updated target XNAT version to 1.9.3.5

---

## [1.0.1]

A performance and hardening release on top of the 1.0.0 rebrand: Reimport and Clone now run in parallel through JMS queues, mixed-operation batches route correctly, the listing query is parameterized, and workflow history is consistent across all three operations. The `/xapi/transfer` request format and the Java packages are unchanged from 1.0.0.

Expand Down
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ plugins {
}

group "org.nrg.xnatx.plugins"
version "1.0.1"
version "1.1.0-SNAPSHOT"
description "XNAT Batch Transfer Plugin"

sourceCompatibility = 1.8
Expand Down
17 changes: 10 additions & 7 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
# Batch Transfer Plugin — Roadmap

**Plugin Version**: 1.0.1-RC (rebranded from Batch Share Plugin 2.0.0-SNAPSHOT; version line reset at 1.0.0)
**Target XNAT**: 1.9.3.3
**Last Updated**: 2026-06-22
**Plugin Version**: 1.1.0 (rebranded from Batch Share Plugin 2.0.0-SNAPSHOT; version line reset at 1.0.0)
**Target XNAT**: 1.9.3.5
**Last Updated**: 2026-06-29

The Batch Transfer Plugin enables bulk data operations across XNAT projects. Users can Share, Clone, or Reimport subjects, sessions, and assessors in batch from a single interface. **Share** adds data into a destination project without copying (XNAT's standard sharing relationship); **Clone** duplicates data into the destination, producing an independent editable copy; **Reimport** re-ingests image sessions through the destination project's anonymization pipeline.

---

## Completed

## [1.1.0]

### Added

- **Preserve source labels on Reimport** ([#10](https://github.com/NrgXnat/batch-transfer-plugin/issues/10)) — the Reimport panel now offers two checkboxes (both on by default) to preserve the source **subject label** and/or **session label**. When enabled, those XNAT labels are passed to the DICOM importer as `subject_ID` / `EXPT_LABEL` overrides so the destination uses them instead of deriving labels from DICOM tags (e.g. `PatientName` / `PatientID`).


## 1.0.1 — Performance & hardening
- **Parallel processing via JMS queues** — Reimport (per session) and Clone (per subject) dispatched onto in-process (`vm://`) JMS queues instead of a single sequential loop; in-memory `BatchTransferMonitor` fan-in emits one terminal event per batch
- **Admin-tunable queue concurrency** — site-admin settings + `GET`/`POST /xapi/batch_transfer/jms_queues`; defaults Reimport 4–8, Clone 1–2 (`min = max = 1` for serial)
Expand Down Expand Up @@ -63,7 +70,3 @@ The Batch Transfer Plugin enables bulk data operations across XNAT projects. Use
- Extend PROJECT_SHARING_FEATURE and PROJECT_COPYING_FEATURE controls to include a separate PROJECT_IMPORTING_FEATURE (feature-flag naming intentionally deferred from the rebrand)
- Per-experiment Reimport timeout to bound importer hangs. `BatchTransferServiceImpl.runImporter` invokes `DicomZipImporter.call()` synchronously, with no upper bound — a stuck prearchive write (wedged NFS mount, deadlocked rebuild queue, slow remote disk) parks the batch-loop thread indefinitely and blocks all subsequent requests in the batch. The 5-minute heartbeat logger makes a hang observable, but recovery still requires a Tomcat restart. Possible fix: wrap `importExperiment` in `Future.get(timeoutMs)` on a separate executor; on timeout, cancel the future, emit a Failed event, and let the batch loop continue with the next request. Caveats: `cancel(true)` cannot reliably stop uninterruptible I/O so the underlying thread leaks; the prearchive has no transactional rollback so a cancelled import may leave partial state; choosing a default timeout is environment-specific (large legitimate sessions can run 30+ min). Defer until a customer incident motivates it; if added, expose the timeout as a plugin config and document explicitly that it means "we gave up waiting", not "we cleaned up".

### Testing & Release
- Test admin/owner/member permissions
- Testing for Share/Clone/Reimport operations with assessors
- Scale testing on large project
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ public void onRequest(final TransferItemRequest request) {
try {
eventService.triggerEvent(BatchTransferEvent.progress(request.getRequestingUserId(),
monitor.currentPercent(trackingId), trackingId, "Reimporting " + itemId + " to " + request.getDestinationProject()));
service.processItem(new TransferRequest(request.getDestinationProject(), itemId, TransferMode.REIMPORT),
service.processItem(new TransferRequest(request.getDestinationProject(), itemId, TransferMode.REIMPORT,
request.getPreserveSubjectLabel(), request.getPreserveSessionLabel()),
user, new EventInfo(trackingId, monitor.currentPercent(trackingId)));
} catch (Exception e) {
log.error("Reimport {} failed", itemId, e);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,6 @@ public class TransferItemRequest implements Serializable {
private final String destinationProject;
private final String username;
private final Integer requestingUserId;
private final Boolean preserveSubjectLabel;
private final Boolean preserveSessionLabel;
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,13 @@ public class TransferRequest {
private String destinationProject;
private String id;
private TransferMode mode;
// Reimport only: preserve the source subject's XNAT label by passing it to the importer
private Boolean preserveSubjectLabel;
// Reimport only: preserve the source session's XNAT label by passing it to the importer
private Boolean preserveSessionLabel;

// Retain no-preserve-flag default constructor
public TransferRequest(String destinationProject, String id, TransferMode mode) {
this(destinationProject, id, mode, null, null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,8 @@ private void enqueueReimport(final String trackingId, final List<TransferRequest
for (final TransferRequest request : requests) {
try {
XDAT.sendJmsRequest(new TransferItemRequest(trackingId, request.getId(),
request.getDestinationProject(), user.getUsername(), user.getID()));
request.getDestinationProject(), user.getUsername(), user.getID(),
request.getPreserveSubjectLabel(), request.getPreserveSessionLabel()));
} catch (Exception e) {
// Couldn't enqueue this item — report it as a failed completion so the batch still finishes.
log.error("Failed to queue reimport for {}", request.getId(), e);
Expand Down Expand Up @@ -311,7 +312,9 @@ public void processItem(final TransferRequest request, final UserI user, final E
} else if (request.getMode().equals(TransferMode.CLONE)) {
getOrCopyExperimentOrAssessor(sourceExperiment, existingExperiment, destinationProjectData, user, eventInfo);
} else if (request.getMode().equals(TransferMode.REIMPORT)) {
importExperiment(sourceExperiment, destinationProjectData, user, eventInfo);
importExperiment(sourceExperiment, destinationProjectData, user, eventInfo,
request.getPreserveSubjectLabel(),
request.getPreserveSessionLabel());
} else {
throw new Exception(String.format("Unsupported mode %s", request.getMode()));
}
Expand All @@ -336,7 +339,8 @@ private void shareSubject(XnatSubjectdata sourceSubject, XnatSubjectdata existin
}
}

private void importExperiment(XnatExperimentdata sourceExperiment, XnatProjectdata destinationProjectData, UserI user, EventInfo eventInfo) throws Exception {
private void importExperiment(XnatExperimentdata sourceExperiment, XnatProjectdata destinationProjectData, UserI user, EventInfo eventInfo,
Boolean preserveSubjectLabel, Boolean preserveSessionLabel) throws Exception {
if (sourceExperiment instanceof XnatImageassessordata) {
throw new Exception("Reimport operation is not supported for assessors.");
}
Expand All @@ -346,6 +350,27 @@ private void importExperiment(XnatExperimentdata sourceExperiment, XnatProjectda
params.put("rename", "true");
params.put("project", destinationProjectData.getId());

// Preserve the source XNAT labels instead of letting the destination derive them from DICOM
// tags. If the user asked to preserve a label but it is blank or missing, there is nothing
// usable to override with, so fail the item rather than silently skipping the substitution.
if (Boolean.TRUE.equals(preserveSubjectLabel)) {
final XnatSubjectdata sourceSubject = XnatUtils.getSubject((String) sourceExperiment.getProperty("subject_ID"), user);
final String subjectLabel = sourceSubject.getLabel();
if (StringUtils.isBlank(subjectLabel)) {
throw new Exception("Cannot preserve the source subject label for " + sourceExperiment.getId()
+ ": the source subject label is blank or missing.");
}
params.put("subject", subjectLabel);
}
if (Boolean.TRUE.equals(preserveSessionLabel)) {
final String sessionLabel = sourceExperiment.getLabel();
if (StringUtils.isBlank(sessionLabel)) {
throw new Exception("Cannot preserve the source session label for " + sourceExperiment.getId()
+ ": the source session label is blank or missing.");
}
params.put("session", sessionLabel);
}

final StreamingZipFileWriter wrapper = new StreamingZipFileWriter(sourcePath, sourceExperiment.getId() + ".zip");
final AtomicReference<List<String>> uriRef = new AtomicReference<>();
XnatUtils.doActionWithWorkflow(user, sourceExperiment,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@ var XNAT = getObject(XNAT || {});

function updateOperationDetail() {
$('#bt-op-detail-desc').text(operationDetails[selectedOp] || '');
// The preserve-source-label checkboxes only apply to Reimport.
$('#bt-reimport-label-option').toggle(selectedOp === 'Reimport');
}

// ── Operation Selection ──
Expand Down Expand Up @@ -672,15 +674,25 @@ var XNAT = getObject(XNAT || {});
return false;
}

// Reimport only: whether to preserve the source XNAT subject/session labels (server resolves
// the actual label values). Ignored for Share/Clone.
var preserveSubjectLabel = (selectedOp === 'Reimport') && $('#bt-preserve-subject-label').is(':checked');
var preserveSessionLabel = (selectedOp === 'Reimport') && $('#bt-preserve-session-label').is(':checked');

var items = [];
$selected.each(function() {
var id = $(this).data('id');
if (id) {
items.push({
var item = {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: prefer const to var (though I see var is used elsewhere so perhaps not the time to change)

id: id,
mode: selectedOp,
destination_project: selectedProject
});
};
if (selectedOp === 'Reimport') {
item.preserve_subject_label = preserveSubjectLabel;
item.preserve_session_label = preserveSessionLabel;
}
items.push(item);
}
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1008,3 +1008,42 @@ tr.bt-import-disabled[data-type="subject"]:hover {
.bt-proj-locked-name {
font-weight: 600;
}

/* Reimport: preserve-source-label checkboxes, laid out side by side (Subject | Session). */
.bt-label-options {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e8eaed;
}

.bt-label-options-title {
font-size: 14px;
font-weight: 500;
color: #202124;
margin-bottom: 3px;
}

.bt-label-options-help {
font-size: 12px;
color: #5f6368;
line-height: 1.45;
margin: 0 0 10px;
}

.bt-label-checks {
display: flex;
gap: 20px;
}

.bt-label-checks label {
display: inline-flex;
align-items: center;
font-size: 13px;
color: #3c4043;
cursor: pointer;
}

.bt-label-checks input[type="checkbox"] {
margin: 0 6px 0 0;
vertical-align: middle;
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,16 @@
<div class="bt-op-desc">Re-anonymize image sessions</div>
</div>
</div>
<!-- Reimport only: preserve source XNAT labels instead of deriving them from DICOM tags. -->
<div id="bt-reimport-label-option" class="bt-label-options" style="display:none;">
<div class="bt-label-options-title">Preserve source labels</div>
<p class="bt-label-options-help">Use the source's XNAT labels instead of deriving them from DICOM tags.</p>
<div class="bt-label-checks">
<label><input type="checkbox" id="bt-preserve-subject-label" checked /> Subject</label>
<label><input type="checkbox" id="bt-preserve-session-label" checked /> Session</label>
</div>
</div>

</div>

<!-- About this transfer (operation description + anon status) -->
Expand Down
Loading
Loading