From f457a4aaa1497b46e3447f5f2c68a1ad272ff8a5 Mon Sep 17 00:00:00 2001
From: luis-godinho <125367612+luis-godinho@users.noreply.github.com>
Date: Sat, 11 Apr 2026 15:01:06 +0100
Subject: [PATCH 1/6] feat: add Study Root C-FIND with pluggable query module
and mixed text/keyword search
---
backend/README.md | 7 +-
backend/dicoogle-next-protocol-dimse/pom.xml | 5 +
.../dicoogle/protocol/dimse/CFindService.java | 131 +++++++++++
.../protocol/dimse/DimseCFindProperties.java | 30 +++
.../protocol/dimse/DimseCFindSCP.java | 60 +++++
.../protocol/dimse/DimseCStoreConfig.java | 13 +-
.../protocol/dimse/DimseCStoreServer.java | 36 ++-
.../protocol/dimse/CFindServiceTest.java | 64 ++++++
backend/dicoogle-next-sdk/pom.xml | 8 +
.../sdk/query/DimseFindServicePlugin.java | 30 +++
backend/dicoogle-next/pom.xml | 5 +
.../src/main/resources/application-dev.yml | 2 +
.../src/main/resources/application-test.yml | 2 +
.../src/main/resources/application.yml | 6 +
.../plugins/dicoogle-next-query-file/pom.xml | 47 ++++
.../dicoogle/query/file/FileQueryConfig.java | 17 ++
.../file/FileReadWriteDimseFindPlugin.java | 196 ++++++++++++++++
.../FileReadWriteDimseFindPluginTest.java | 77 +++++++
.../dicoogle-next-storage-file-ro/pom.xml | 0
.../filero/FileReadOnlyStoragePlugin.java | 129 +++++++++++
.../dicoogle-next-storage-file-rw/pom.xml | 0
.../filerw/FileReadWriteStorageConfig.java | 18 ++
.../filerw/FileReadWriteStoragePlugin.java | 211 ++++++++++++++++++
.../FileReadWriteStorageProperties.java | 35 +++
.../FileReadWriteStoragePluginTest.java | 70 ++++++
backend/pom.xml | 1 +
26 files changed, 1195 insertions(+), 5 deletions(-)
create mode 100644 backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/CFindService.java
create mode 100644 backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindProperties.java
create mode 100644 backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindSCP.java
create mode 100644 backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/CFindServiceTest.java
create mode 100644 backend/dicoogle-next-sdk/src/main/java/org/dicoogle/sdk/query/DimseFindServicePlugin.java
create mode 100644 backend/plugins/dicoogle-next-query-file/pom.xml
create mode 100644 backend/plugins/dicoogle-next-query-file/src/main/java/org/dicoogle/query/file/FileQueryConfig.java
create mode 100644 backend/plugins/dicoogle-next-query-file/src/main/java/org/dicoogle/query/file/FileReadWriteDimseFindPlugin.java
create mode 100644 backend/plugins/dicoogle-next-query-file/src/test/java/org/dicoogle/query/file/FileReadWriteDimseFindPluginTest.java
rename backend/{ => plugins}/dicoogle-next-storage-file-ro/pom.xml (100%)
create mode 100644 backend/plugins/dicoogle-next-storage-file-ro/src/main/java/org/dicoogle/storage/filero/FileReadOnlyStoragePlugin.java
rename backend/{ => plugins}/dicoogle-next-storage-file-rw/pom.xml (100%)
create mode 100644 backend/plugins/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStorageConfig.java
create mode 100644 backend/plugins/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStoragePlugin.java
create mode 100644 backend/plugins/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStorageProperties.java
create mode 100644 backend/plugins/dicoogle-next-storage-file-rw/src/test/java/org/dicoogle/storage/filerw/FileReadWriteStoragePluginTest.java
diff --git a/backend/README.md b/backend/README.md
index 29305b3..5333d7b 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -19,8 +19,8 @@ Default profile is `dev`, which enables:
DIMSE status (current):
-- Implemented: C-ECHO, C-STORE
-- Not implemented yet: C-FIND, C-MOVE
+- Implemented: C-ECHO, C-STORE, C-FIND (Study Root)
+- Not implemented yet: C-MOVE
DIMSE C-STORE currently uses a strict curated list of accepted transfer capabilities (SOP class +
transfer syntax combinations) under `app.dimse.cstore.accepted-transfer-capabilities`.
@@ -34,6 +34,9 @@ You can change transfer capabilities at runtime via API:
- `DELETE /api/system/config/dimse/transfer-capabilities/{SOPClassUID}`
- `POST /api/system/config/dimse/transfer-capabilities/replace`
+C-FIND supports Study Root with query levels `STUDY`, `SERIES`, and `IMAGE`. Query plugins receive
+both structured query keys and a free-text field extracted from query attributes.
+
Config source options:
- `app.dimse.cstore.config.source=yaml` (single-node runtime updates)
diff --git a/backend/dicoogle-next-protocol-dimse/pom.xml b/backend/dicoogle-next-protocol-dimse/pom.xml
index 356fbdf..30ed743 100644
--- a/backend/dicoogle-next-protocol-dimse/pom.xml
+++ b/backend/dicoogle-next-protocol-dimse/pom.xml
@@ -20,6 +20,11 @@
dicoogle-next-core
${project.version}
+
+ org.dicoogle
+ dicoogle-next-sdk
+ ${project.version}
+
org.dcm4che
dcm4che-core
diff --git a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/CFindService.java b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/CFindService.java
new file mode 100644
index 0000000..0d46f2a
--- /dev/null
+++ b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/CFindService.java
@@ -0,0 +1,131 @@
+package org.dicoogle.protocol.dimse;
+
+import java.io.IOException;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+import org.dcm4che3.data.Attributes;
+import org.dcm4che3.data.Tag;
+import org.dcm4che3.net.Status;
+import org.dcm4che3.net.service.DicomServiceException;
+import org.dicoogle.sdk.query.DimseFindServicePlugin;
+
+public class CFindService {
+
+ private static final String STUDY_ROOT_MODEL_UID = "1.2.840.10008.5.1.4.1.2.2.1";
+ private static final Pattern KEYWORD_PATTERN = Pattern.compile("[A-Za-z0-9_.-]+:[^\\s]+");
+
+ private final List plugins;
+ private final Set supportedLevels;
+
+ public CFindService(List plugins, DimseCFindProperties properties) {
+ this.plugins = List.copyOf(plugins);
+ this.supportedLevels =
+ properties.getSupportedQueryLevels().stream()
+ .map(level -> level.toUpperCase(Locale.ROOT))
+ .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
+ }
+
+ public List find(
+ String affectedSopClassUid,
+ Attributes keys,
+ String callingAet,
+ String calledAet,
+ int associationSerialNo)
+ throws DicomServiceException {
+ if (!STUDY_ROOT_MODEL_UID.equals(affectedSopClassUid)) {
+ throw new DicomServiceException(
+ Status.SOPclassNotSupported, "Only Study Root C-FIND is supported");
+ }
+
+ String queryLevel = keys.getString(Tag.QueryRetrieveLevel);
+ if (queryLevel == null || queryLevel.isBlank()) {
+ throw new DicomServiceException(
+ Status.IdentifierDoesNotMatchSOPClass, "Missing QueryRetrieveLevel");
+ }
+
+ String normalizedLevel = queryLevel.trim().toUpperCase(Locale.ROOT);
+ if (!supportedLevels.contains(normalizedLevel)) {
+ throw new DicomServiceException(
+ Status.UnableToProcess, "Unsupported QueryRetrieveLevel: " + normalizedLevel);
+ }
+
+ DimseFindServicePlugin.QueryRetrieveLevel level;
+ try {
+ level = DimseFindServicePlugin.QueryRetrieveLevel.valueOf(normalizedLevel);
+ } catch (IllegalArgumentException ex) {
+ throw new DicomServiceException(Status.UnableToProcess, "Invalid QueryRetrieveLevel");
+ }
+
+ if (plugins.isEmpty()) {
+ throw new DicomServiceException(
+ Status.UnableToProcess, "No DIMSE query plugin is configured");
+ }
+
+ String freeText = extractFreeText(keys);
+ Map keywordFilters = extractKeywordFilters(keys);
+ DimseFindServicePlugin.FindRequest request =
+ new DimseFindServicePlugin.FindRequest(
+ DimseFindServicePlugin.InformationModel.STUDY_ROOT,
+ level,
+ callingAet,
+ calledAet,
+ associationSerialNo,
+ keys,
+ freeText,
+ keywordFilters);
+
+ for (DimseFindServicePlugin plugin : plugins) {
+ try {
+ List matches = plugin.find(request);
+ if (matches != null) {
+ return matches;
+ }
+ } catch (IOException ex) {
+ throw new DicomServiceException(Status.UnableToProcess, ex.getMessage());
+ }
+ }
+
+ return List.of();
+ }
+
+ private String extractFreeText(Attributes keys) {
+ String direct = keys.getString(Tag.PatientName);
+ if (direct == null || direct.isBlank()) {
+ return null;
+ }
+
+ StringBuilder out = new StringBuilder();
+ for (String token : direct.trim().split("\\s+")) {
+ if (!KEYWORD_PATTERN.matcher(token).matches()) {
+ if (!out.isEmpty()) {
+ out.append(' ');
+ }
+ out.append(token);
+ }
+ }
+ return out.isEmpty() ? null : out.toString();
+ }
+
+ private Map extractKeywordFilters(Attributes keys) {
+ String raw = keys.getString(Tag.PatientName);
+ if (raw == null || raw.isBlank()) {
+ return Map.of();
+ }
+
+ Map out = new LinkedHashMap<>();
+ for (String token : raw.trim().split("\\s+")) {
+ if (KEYWORD_PATTERN.matcher(token).matches()) {
+ int index = token.indexOf(':');
+ if (index > 0 && index < token.length() - 1) {
+ out.put(token.substring(0, index), token.substring(index + 1));
+ }
+ }
+ }
+ return out;
+ }
+}
diff --git a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindProperties.java b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindProperties.java
new file mode 100644
index 0000000..ac650b0
--- /dev/null
+++ b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindProperties.java
@@ -0,0 +1,30 @@
+package org.dicoogle.protocol.dimse;
+
+import java.util.List;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@ConfigurationProperties(prefix = "app.dimse.cfind")
+public class DimseCFindProperties {
+
+ private boolean enabled = true;
+ private List supportedQueryLevels = List.of("STUDY", "SERIES", "IMAGE");
+
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ public List getSupportedQueryLevels() {
+ return supportedQueryLevels;
+ }
+
+ public void setSupportedQueryLevels(List supportedQueryLevels) {
+ this.supportedQueryLevels =
+ supportedQueryLevels == null || supportedQueryLevels.isEmpty()
+ ? List.of("STUDY", "SERIES", "IMAGE")
+ : supportedQueryLevels;
+ }
+}
diff --git a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindSCP.java b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindSCP.java
new file mode 100644
index 0000000..a5cab4e
--- /dev/null
+++ b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindSCP.java
@@ -0,0 +1,60 @@
+package org.dicoogle.protocol.dimse;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import org.dcm4che3.data.Attributes;
+import org.dcm4che3.net.Association;
+import org.dcm4che3.net.pdu.PresentationContext;
+import org.dcm4che3.net.service.BasicCFindSCP;
+import org.dcm4che3.net.service.BasicQueryTask;
+import org.dcm4che3.net.service.DicomServiceException;
+import org.dcm4che3.net.service.QueryTask;
+
+class DimseCFindSCP extends BasicCFindSCP {
+
+ private final CFindService cFindService;
+
+ DimseCFindSCP(CFindService cFindService) {
+ super("1.2.840.10008.5.1.4.1.2.2.1");
+ this.cFindService = cFindService;
+ }
+
+ @Override
+ protected QueryTask calculateMatches(
+ Association as, PresentationContext pc, Attributes rq, Attributes keys)
+ throws DicomServiceException {
+ List matches =
+ cFindService.find(
+ rq.getString(org.dcm4che3.data.Tag.AffectedSOPClassUID),
+ keys,
+ as.getCallingAET(),
+ as.getCalledAET(),
+ as.getSerialNo());
+ return new ListQueryTask(as, pc, rq, keys, matches);
+ }
+
+ private static final class ListQueryTask extends BasicQueryTask {
+ private final Iterator iterator;
+
+ private ListQueryTask(
+ Association as,
+ PresentationContext pc,
+ Attributes rq,
+ Attributes keys,
+ List matches) {
+ super(as, pc, rq, keys);
+ this.iterator = new ArrayList<>(matches).iterator();
+ }
+
+ @Override
+ protected boolean hasMoreMatches() {
+ return iterator.hasNext();
+ }
+
+ @Override
+ protected Attributes nextMatch() {
+ return iterator.next();
+ }
+ }
+}
diff --git a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCStoreConfig.java b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCStoreConfig.java
index 8103251..2f52df5 100644
--- a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCStoreConfig.java
+++ b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCStoreConfig.java
@@ -12,7 +12,7 @@
import org.springframework.context.annotation.Configuration;
@Configuration
-@EnableConfigurationProperties(DimseCStoreProperties.class)
+@EnableConfigurationProperties({DimseCStoreProperties.class, DimseCFindProperties.class})
public class DimseCStoreConfig {
@Bean
@@ -23,18 +23,29 @@ CStoreService cStoreService(
return new CStoreService(storageRouter, storageIngestEventListeners, meterRegistry);
}
+ @Bean
+ CFindService cFindService(
+ List queryPlugins,
+ DimseCFindProperties properties) {
+ return new CFindService(queryPlugins, properties);
+ }
+
@Bean
@ConditionalOnProperty(prefix = "app.dimse.cstore", name = "enabled", havingValue = "true")
DimseCStoreServer dimseCStoreServer(
CStoreService cStoreService,
+ CFindService cFindService,
DimseCStoreProperties properties,
+ DimseCFindProperties cfindProperties,
List associationEventListeners,
List associationAccessPolicies,
@org.springframework.beans.factory.annotation.Qualifier("primaryStorageScheme")
String primaryStorageScheme) {
return new DimseCStoreServer(
cStoreService,
+ cFindService,
properties,
+ cfindProperties,
primaryStorageScheme,
associationEventListeners,
associationAccessPolicies);
diff --git a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCStoreServer.java b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCStoreServer.java
index 9c9feff..9ef9d65 100644
--- a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCStoreServer.java
+++ b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCStoreServer.java
@@ -35,8 +35,12 @@
public class DimseCStoreServer implements SmartLifecycle {
+ private static final String STUDY_ROOT_FIND_UID = "1.2.840.10008.5.1.4.1.2.2.1";
+
private final CStoreService cStoreService;
+ private final CFindService cFindService;
private final DimseCStoreProperties properties;
+ private final DimseCFindProperties cfindProperties;
private final String primaryStorageScheme;
private final List associationEventListeners;
private final List associationAccessPolicies;
@@ -47,18 +51,33 @@ public class DimseCStoreServer implements SmartLifecycle {
private ScheduledExecutorService scheduler;
public DimseCStoreServer(
- CStoreService cStoreService, DimseCStoreProperties properties, String primaryStorageScheme) {
- this(cStoreService, properties, primaryStorageScheme, List.of(), List.of());
+ CStoreService cStoreService,
+ CFindService cFindService,
+ DimseCStoreProperties properties,
+ DimseCFindProperties cfindProperties,
+ String primaryStorageScheme) {
+ this(
+ cStoreService,
+ cFindService,
+ properties,
+ cfindProperties,
+ primaryStorageScheme,
+ List.of(),
+ List.of());
}
public DimseCStoreServer(
CStoreService cStoreService,
+ CFindService cFindService,
DimseCStoreProperties properties,
+ DimseCFindProperties cfindProperties,
String primaryStorageScheme,
List associationEventListeners,
List associationAccessPolicies) {
this.cStoreService = Objects.requireNonNull(cStoreService);
+ this.cFindService = Objects.requireNonNull(cFindService);
this.properties = Objects.requireNonNull(properties);
+ this.cfindProperties = Objects.requireNonNull(cfindProperties);
this.primaryStorageScheme =
primaryStorageScheme == null || primaryStorageScheme.isBlank()
? "file"
@@ -96,6 +115,9 @@ public synchronized void start() {
DicomServiceRegistry services = new DicomServiceRegistry();
services.addDicomService(new BasicCEchoSCP());
+ if (cfindProperties.isEnabled()) {
+ services.addDicomService(new DimseCFindSCP(cFindService));
+ }
services.addDicomService(
new BasicCStoreSCP("*") {
@Override
@@ -221,6 +243,16 @@ private void configureTransferCapabilities(
TransferCapability.Role.SCP,
UID.ImplicitVRLittleEndian));
}
+
+ if (cfindProperties.isEnabled()) {
+ ae.addTransferCapability(
+ new TransferCapability(
+ "cfind-study-root-scp",
+ STUDY_ROOT_FIND_UID,
+ TransferCapability.Role.SCP,
+ UID.ImplicitVRLittleEndian,
+ UID.ExplicitVRLittleEndian));
+ }
}
public synchronized void applyAcceptedTransferCapabilities(
diff --git a/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/CFindServiceTest.java b/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/CFindServiceTest.java
new file mode 100644
index 0000000..af287b9
--- /dev/null
+++ b/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/CFindServiceTest.java
@@ -0,0 +1,64 @@
+package org.dicoogle.protocol.dimse;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import java.util.List;
+import org.dcm4che3.data.Attributes;
+import org.dcm4che3.data.Tag;
+import org.dcm4che3.data.VR;
+import org.dcm4che3.net.Status;
+import org.dcm4che3.net.service.DicomServiceException;
+import org.dicoogle.sdk.PluginMetadata;
+import org.dicoogle.sdk.query.DimseFindServicePlugin;
+import org.junit.jupiter.api.Test;
+
+class CFindServiceTest {
+
+ @Test
+ void failsWhenQueryLevelMissing() {
+ CFindService service =
+ new CFindService(List.of(new DummyFindPlugin()), new DimseCFindProperties());
+ Attributes keys = new Attributes();
+
+ DicomServiceException ex =
+ assertThrows(
+ DicomServiceException.class,
+ () -> service.find("1.2.840.10008.5.1.4.1.2.2.1", keys, "A", "B", 10));
+ assertEquals(Status.IdentifierDoesNotMatchSOPClass, ex.getStatus());
+ }
+
+ @Test
+ void supportsFreeTextAndLevel() throws Exception {
+ CFindService service =
+ new CFindService(List.of(new DummyFindPlugin()), new DimseCFindProperties());
+ Attributes keys = new Attributes();
+ keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY");
+ keys.setString(Tag.StudyInstanceUID, VR.UI, "1.2.3");
+ keys.setString(Tag.AccessionNumber, VR.SH, "brain");
+
+ List matches =
+ service.find("1.2.840.10008.5.1.4.1.2.2.1", keys, "CALLING", "CALLED", 77);
+
+ assertEquals(1, matches.size());
+ assertEquals("1.2.3", matches.get(0).getString(Tag.StudyInstanceUID));
+ }
+
+ private static final class DummyFindPlugin implements DimseFindServicePlugin {
+
+ @Override
+ public PluginMetadata metadata() {
+ return new PluginMetadata("dummy-find", "Dummy Find", "1.0.0", "query-index");
+ }
+
+ @Override
+ public List find(FindRequest request) throws IOException {
+ Attributes a = new Attributes();
+ a.setString(
+ Tag.StudyInstanceUID, VR.UI, request.keys().getString(Tag.StudyInstanceUID, "1.2.3"));
+ a.setString(Tag.QueryRetrieveLevel, VR.CS, request.level().name());
+ return List.of(a);
+ }
+ }
+}
diff --git a/backend/dicoogle-next-sdk/pom.xml b/backend/dicoogle-next-sdk/pom.xml
index de4a0b1..350219d 100644
--- a/backend/dicoogle-next-sdk/pom.xml
+++ b/backend/dicoogle-next-sdk/pom.xml
@@ -13,4 +13,12 @@
dicoogle-next-sdk
dicoogle-next-sdk
Plugin contracts for Dicoogle Next
+
+
+
+ org.dcm4che
+ dcm4che-core
+ ${dcm4che.version}
+
+
diff --git a/backend/dicoogle-next-sdk/src/main/java/org/dicoogle/sdk/query/DimseFindServicePlugin.java b/backend/dicoogle-next-sdk/src/main/java/org/dicoogle/sdk/query/DimseFindServicePlugin.java
new file mode 100644
index 0000000..f4890e7
--- /dev/null
+++ b/backend/dicoogle-next-sdk/src/main/java/org/dicoogle/sdk/query/DimseFindServicePlugin.java
@@ -0,0 +1,30 @@
+package org.dicoogle.sdk.query;
+
+import java.io.IOException;
+import java.util.List;
+import org.dcm4che3.data.Attributes;
+
+public interface DimseFindServicePlugin extends QueryIndexPlugin {
+
+ enum InformationModel {
+ STUDY_ROOT
+ }
+
+ enum QueryRetrieveLevel {
+ STUDY,
+ SERIES,
+ IMAGE
+ }
+
+ record FindRequest(
+ InformationModel informationModel,
+ QueryRetrieveLevel level,
+ String callingAet,
+ String calledAet,
+ int associationSerialNo,
+ Attributes keys,
+ String freeText,
+ java.util.Map keywordFilters) {}
+
+ List find(FindRequest request) throws IOException;
+}
diff --git a/backend/dicoogle-next/pom.xml b/backend/dicoogle-next/pom.xml
index 8808e0d..58d63b3 100644
--- a/backend/dicoogle-next/pom.xml
+++ b/backend/dicoogle-next/pom.xml
@@ -61,6 +61,11 @@
dicoogle-next-storage-file-rw
${project.version}
+
+ org.dicoogle
+ dicoogle-next-query-file
+ ${project.version}
+
org.dicoogle
dicoogle-next-protocol-dimse
diff --git a/backend/dicoogle-next/src/main/resources/application-dev.yml b/backend/dicoogle-next/src/main/resources/application-dev.yml
index 6b0f5d8..1537807 100644
--- a/backend/dicoogle-next/src/main/resources/application-dev.yml
+++ b/backend/dicoogle-next/src/main/resources/application-dev.yml
@@ -20,6 +20,8 @@ springdoc:
app:
dimse:
+ cfind:
+ enabled: true
cstore:
enabled: true
storage:
diff --git a/backend/dicoogle-next/src/main/resources/application-test.yml b/backend/dicoogle-next/src/main/resources/application-test.yml
index eac74ba..500307d 100644
--- a/backend/dicoogle-next/src/main/resources/application-test.yml
+++ b/backend/dicoogle-next/src/main/resources/application-test.yml
@@ -16,6 +16,8 @@ springdoc:
app:
dimse:
+ cfind:
+ enabled: true
cstore:
enabled: false
security:
diff --git a/backend/dicoogle-next/src/main/resources/application.yml b/backend/dicoogle-next/src/main/resources/application.yml
index cac6cea..c9e7ffd 100644
--- a/backend/dicoogle-next/src/main/resources/application.yml
+++ b/backend/dicoogle-next/src/main/resources/application.yml
@@ -37,6 +37,12 @@ app:
docs-enabled: false
allowed-origins: []
dimse:
+ cfind:
+ enabled: true
+ supported-query-levels:
+ - STUDY
+ - SERIES
+ - IMAGE
cstore:
enabled: false
ae-title: DICOOGLE
diff --git a/backend/plugins/dicoogle-next-query-file/pom.xml b/backend/plugins/dicoogle-next-query-file/pom.xml
new file mode 100644
index 0000000..1cfba85
--- /dev/null
+++ b/backend/plugins/dicoogle-next-query-file/pom.xml
@@ -0,0 +1,47 @@
+
+
+ 4.0.0
+
+
+ org.dicoogle
+ dicoogle-next-backend-parent
+ 0.0.1-SNAPSHOT
+ ../pom.xml
+
+
+ dicoogle-next-query-file
+ dicoogle-next-query-file
+ Filesystem query/index plugin for Dicoogle Next
+
+
+
+ org.dicoogle
+ dicoogle-next-sdk
+ ${project.version}
+
+
+ org.dicoogle
+ dicoogle-next-storage-file-rw
+ ${project.version}
+
+
+ org.dcm4che
+ dcm4che-core
+ ${dcm4che.version}
+
+
+ org.springframework
+ spring-context
+
+
+ org.springframework.boot
+ spring-boot-autoconfigure
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+
diff --git a/backend/plugins/dicoogle-next-query-file/src/main/java/org/dicoogle/query/file/FileQueryConfig.java b/backend/plugins/dicoogle-next-query-file/src/main/java/org/dicoogle/query/file/FileQueryConfig.java
new file mode 100644
index 0000000..3ca3708
--- /dev/null
+++ b/backend/plugins/dicoogle-next-query-file/src/main/java/org/dicoogle/query/file/FileQueryConfig.java
@@ -0,0 +1,17 @@
+package org.dicoogle.query.file;
+
+import org.dicoogle.storage.filerw.FileReadWriteStoragePlugin;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class FileQueryConfig {
+
+ @Bean
+ @ConditionalOnBean(FileReadWriteStoragePlugin.class)
+ FileReadWriteDimseFindPlugin fileReadWriteDimseFindPlugin(
+ FileReadWriteStoragePlugin storagePlugin) {
+ return new FileReadWriteDimseFindPlugin(storagePlugin);
+ }
+}
diff --git a/backend/plugins/dicoogle-next-query-file/src/main/java/org/dicoogle/query/file/FileReadWriteDimseFindPlugin.java b/backend/plugins/dicoogle-next-query-file/src/main/java/org/dicoogle/query/file/FileReadWriteDimseFindPlugin.java
new file mode 100644
index 0000000..c185598
--- /dev/null
+++ b/backend/plugins/dicoogle-next-query-file/src/main/java/org/dicoogle/query/file/FileReadWriteDimseFindPlugin.java
@@ -0,0 +1,196 @@
+package org.dicoogle.query.file;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.regex.Pattern;
+import org.dcm4che3.data.Attributes;
+import org.dcm4che3.data.Tag;
+import org.dcm4che3.data.VR;
+import org.dcm4che3.io.DicomInputStream;
+import org.dicoogle.sdk.PluginMetadata;
+import org.dicoogle.sdk.query.DimseFindServicePlugin;
+import org.dicoogle.storage.filerw.FileReadWriteStoragePlugin;
+
+public class FileReadWriteDimseFindPlugin implements DimseFindServicePlugin {
+
+ private static final PluginMetadata METADATA =
+ new PluginMetadata("query-file-rw", "Filesystem DIMSE C-FIND Query", "0.1.0", "query-index");
+ private static final Pattern KEYWORD_PATTERN = Pattern.compile("[A-Za-z0-9_.-]+:[^\\s]+");
+
+ private final FileReadWriteStoragePlugin storagePlugin;
+
+ public FileReadWriteDimseFindPlugin(FileReadWriteStoragePlugin storagePlugin) {
+ this.storagePlugin = Objects.requireNonNull(storagePlugin);
+ }
+
+ @Override
+ public PluginMetadata metadata() {
+ return METADATA;
+ }
+
+ @Override
+ public List find(FindRequest request) throws IOException {
+ List uris =
+ switch (request.level()) {
+ case STUDY ->
+ storagePlugin.listStudyInstances(
+ request.keys().getString(Tag.StudyInstanceUID, "__MISSING_STUDY_UID__"));
+ case SERIES ->
+ storagePlugin.listSeriesInstances(
+ request.keys().getString(Tag.StudyInstanceUID, "__MISSING_STUDY_UID__"),
+ request.keys().getString(Tag.SeriesInstanceUID, "__MISSING_SERIES_UID__"));
+ case IMAGE ->
+ storagePlugin
+ .locateInstance(
+ request.keys().getString(Tag.StudyInstanceUID, "__MISSING_STUDY_UID__"),
+ request.keys().getString(Tag.SeriesInstanceUID, "__MISSING_SERIES_UID__"),
+ request.keys().getString(Tag.SOPInstanceUID, "__MISSING_INSTANCE_UID__"))
+ .map(List::of)
+ .orElse(List.of());
+ };
+
+ Set seen = new LinkedHashSet<>();
+ List out = new ArrayList<>();
+ String freeText =
+ request.freeText() == null ? null : request.freeText().toLowerCase(Locale.ROOT);
+ Map filters = request.keywordFilters();
+
+ for (java.net.URI uri : uris) {
+ if (!seen.add(uri.toString())) {
+ continue;
+ }
+ Attributes attrs = readDataset(uri);
+ if (!matchesFreeText(attrs, freeText)) {
+ continue;
+ }
+ if (!matchesKeywordFilters(attrs, filters)) {
+ continue;
+ }
+ out.add(filterByLevel(attrs, request.level()));
+ }
+
+ return out;
+ }
+
+ private Attributes readDataset(java.net.URI location) throws IOException {
+ try (var stream = storagePlugin.openForRead(location);
+ DicomInputStream dis = new DicomInputStream(stream)) {
+ return dis.readDataset();
+ }
+ }
+
+ private boolean matchesFreeText(Attributes attrs, String freeText) {
+ if (freeText == null || freeText.isBlank()) {
+ return true;
+ }
+ String patientName = attrs.getString(Tag.PatientName, "");
+ String patientId = attrs.getString(Tag.PatientID, "");
+ String accession = attrs.getString(Tag.AccessionNumber, "");
+ String studyDesc = attrs.getString(Tag.StudyDescription, "");
+ String seriesDesc = attrs.getString(Tag.SeriesDescription, "");
+
+ String haystack =
+ (patientName + " " + patientId + " " + accession + " " + studyDesc + " " + seriesDesc)
+ .toLowerCase(Locale.ROOT);
+ return haystack.contains(freeText);
+ }
+
+ private boolean matchesKeywordFilters(Attributes attrs, Map filters) {
+ if (filters == null || filters.isEmpty()) {
+ return true;
+ }
+
+ for (Map.Entry entry : filters.entrySet()) {
+ int tag = mapKeywordToTag(entry.getKey());
+ if (tag == -1) {
+ continue;
+ }
+ String actual = attrs.getString(tag, "");
+ if (!actual.equalsIgnoreCase(entry.getValue())) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private int mapKeywordToTag(String keyword) {
+ return switch (keyword.toLowerCase(Locale.ROOT)) {
+ case "patientid" -> Tag.PatientID;
+ case "patientname" -> Tag.PatientName;
+ case "studyinstanceuid" -> Tag.StudyInstanceUID;
+ case "seriesinstanceuid" -> Tag.SeriesInstanceUID;
+ case "sopinstanceuid" -> Tag.SOPInstanceUID;
+ case "accessionnumber" -> Tag.AccessionNumber;
+ case "modality" -> Tag.Modality;
+ default -> -1;
+ };
+ }
+
+ private Attributes filterByLevel(Attributes src, QueryRetrieveLevel level) {
+ Attributes out = new Attributes();
+ out.setString(Tag.QueryRetrieveLevel, VR.CS, level.name());
+ out.setString(Tag.StudyInstanceUID, VR.UI, src.getString(Tag.StudyInstanceUID, ""));
+ out.setString(Tag.SeriesInstanceUID, VR.UI, src.getString(Tag.SeriesInstanceUID, ""));
+ out.setString(Tag.SOPInstanceUID, VR.UI, src.getString(Tag.SOPInstanceUID, ""));
+ out.setString(Tag.SOPClassUID, VR.UI, src.getString(Tag.SOPClassUID, ""));
+ out.setString(Tag.PatientID, VR.LO, src.getString(Tag.PatientID, ""));
+ out.setString(Tag.PatientName, VR.PN, src.getString(Tag.PatientName, ""));
+ out.setString(Tag.StudyDescription, VR.LO, src.getString(Tag.StudyDescription, ""));
+ out.setString(Tag.SeriesDescription, VR.LO, src.getString(Tag.SeriesDescription, ""));
+ out.setString(Tag.Modality, VR.CS, src.getString(Tag.Modality, ""));
+ out.setString(Tag.AccessionNumber, VR.SH, src.getString(Tag.AccessionNumber, ""));
+
+ if (level == QueryRetrieveLevel.STUDY) {
+ Attributes studyOnly = new Attributes();
+ studyOnly.addAll(out);
+ studyOnly.setNull(Tag.SeriesInstanceUID, VR.UI);
+ studyOnly.setNull(Tag.SOPInstanceUID, VR.UI);
+ return studyOnly;
+ }
+ if (level == QueryRetrieveLevel.SERIES) {
+ Attributes seriesOnly = new Attributes();
+ seriesOnly.addAll(out);
+ seriesOnly.setNull(Tag.SOPInstanceUID, VR.UI);
+ return seriesOnly;
+ }
+ return out;
+ }
+
+ public static String extractFreeText(String raw) {
+ if (raw == null || raw.isBlank()) {
+ return null;
+ }
+ StringBuilder out = new StringBuilder();
+ for (String token : raw.trim().split("\\s+")) {
+ if (!KEYWORD_PATTERN.matcher(token).matches()) {
+ if (!out.isEmpty()) {
+ out.append(' ');
+ }
+ out.append(token);
+ }
+ }
+ return out.isEmpty() ? null : out.toString();
+ }
+
+ public static java.util.Map extractKeywordFilters(String raw) {
+ if (raw == null || raw.isBlank()) {
+ return java.util.Map.of();
+ }
+ java.util.Map out = new java.util.LinkedHashMap<>();
+ for (String token : raw.trim().split("\\s+")) {
+ if (KEYWORD_PATTERN.matcher(token).matches()) {
+ int index = token.indexOf(':');
+ if (index > 0 && index < token.length() - 1) {
+ out.put(token.substring(0, index), token.substring(index + 1));
+ }
+ }
+ }
+ return out;
+ }
+}
diff --git a/backend/plugins/dicoogle-next-query-file/src/test/java/org/dicoogle/query/file/FileReadWriteDimseFindPluginTest.java b/backend/plugins/dicoogle-next-query-file/src/test/java/org/dicoogle/query/file/FileReadWriteDimseFindPluginTest.java
new file mode 100644
index 0000000..8263279
--- /dev/null
+++ b/backend/plugins/dicoogle-next-query-file/src/test/java/org/dicoogle/query/file/FileReadWriteDimseFindPluginTest.java
@@ -0,0 +1,77 @@
+package org.dicoogle.query.file;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.dcm4che3.data.Attributes;
+import org.dcm4che3.data.Tag;
+import org.dcm4che3.data.UID;
+import org.dcm4che3.data.VR;
+import org.dcm4che3.io.DicomOutputStream;
+import org.dicoogle.sdk.query.DimseFindServicePlugin;
+import org.dicoogle.storage.filerw.FileReadWriteStoragePlugin;
+import org.junit.jupiter.api.Test;
+
+class FileReadWriteDimseFindPluginTest {
+
+ @Test
+ void supportsTextAndKeywordFilters() throws Exception {
+ Path root = Files.createTempDirectory("query-file-test");
+ FileReadWriteStoragePlugin storage = new FileReadWriteStoragePlugin(root, "file");
+ storage.store(
+ new java.io.ByteArrayInputStream(createDicom("P1", "FELIX", "MR")), "application/dicom");
+
+ FileReadWriteDimseFindPlugin plugin = new FileReadWriteDimseFindPlugin(storage);
+
+ Attributes keys = new Attributes();
+ keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY");
+ keys.setString(Tag.StudyInstanceUID, VR.UI, "1.2.3");
+ keys.setString(Tag.PatientName, VR.PN, "felix modality:MR");
+
+ var request =
+ new DimseFindServicePlugin.FindRequest(
+ DimseFindServicePlugin.InformationModel.STUDY_ROOT,
+ DimseFindServicePlugin.QueryRetrieveLevel.STUDY,
+ "CALLING",
+ "CALLED",
+ 1,
+ keys,
+ "felix",
+ java.util.Map.of("modality", "MR"));
+
+ assertFalse(plugin.find(request).isEmpty());
+ }
+
+ @Test
+ void parserKeepsTextAndKeywordsSeparated() {
+ String raw = "brain patientid:123 modality:MR";
+ assertTrue(FileReadWriteDimseFindPlugin.extractKeywordFilters(raw).containsKey("patientid"));
+ assertTrue(FileReadWriteDimseFindPlugin.extractFreeText(raw).contains("brain"));
+ }
+
+ private byte[] createDicom(String patientId, String patientName, String modality)
+ throws Exception {
+ Attributes fmi = new Attributes();
+ fmi.setString(Tag.TransferSyntaxUID, VR.UI, UID.ExplicitVRLittleEndian);
+ fmi.setString(Tag.MediaStorageSOPClassUID, VR.UI, UID.SecondaryCaptureImageStorage);
+ fmi.setString(Tag.MediaStorageSOPInstanceUID, VR.UI, "1.2.3.4.5");
+ fmi.setString(Tag.ImplementationClassUID, VR.UI, "1.2.826.0.1.3680043.2.1125.99");
+
+ Attributes attrs = new Attributes();
+ attrs.setString(Tag.SOPClassUID, VR.UI, UID.SecondaryCaptureImageStorage);
+ attrs.setString(Tag.SOPInstanceUID, VR.UI, "1.2.3.4.5");
+ attrs.setString(Tag.StudyInstanceUID, VR.UI, "1.2.3");
+ attrs.setString(Tag.SeriesInstanceUID, VR.UI, "1.2.3.1");
+ attrs.setString(Tag.PatientID, VR.LO, patientId);
+ attrs.setString(Tag.PatientName, VR.PN, patientName);
+ attrs.setString(Tag.Modality, VR.CS, modality);
+
+ java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
+ try (DicomOutputStream dos = new DicomOutputStream(out, UID.ExplicitVRLittleEndian)) {
+ dos.writeDataset(fmi, attrs);
+ }
+ return out.toByteArray();
+ }
+}
diff --git a/backend/dicoogle-next-storage-file-ro/pom.xml b/backend/plugins/dicoogle-next-storage-file-ro/pom.xml
similarity index 100%
rename from backend/dicoogle-next-storage-file-ro/pom.xml
rename to backend/plugins/dicoogle-next-storage-file-ro/pom.xml
diff --git a/backend/plugins/dicoogle-next-storage-file-ro/src/main/java/org/dicoogle/storage/filero/FileReadOnlyStoragePlugin.java b/backend/plugins/dicoogle-next-storage-file-ro/src/main/java/org/dicoogle/storage/filero/FileReadOnlyStoragePlugin.java
new file mode 100644
index 0000000..e204b47
--- /dev/null
+++ b/backend/plugins/dicoogle-next-storage-file-ro/src/main/java/org/dicoogle/storage/filero/FileReadOnlyStoragePlugin.java
@@ -0,0 +1,129 @@
+package org.dicoogle.storage.filero;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import org.dicoogle.sdk.PluginMetadata;
+import org.dicoogle.sdk.query.QueryIndexStorageLocator;
+import org.dicoogle.sdk.storage.DicomInstanceLocator;
+import org.dicoogle.sdk.storage.ReadableStoragePlugin;
+
+public class FileReadOnlyStoragePlugin
+ implements ReadableStoragePlugin, DicomInstanceLocator, QueryIndexStorageLocator {
+
+ private static final PluginMetadata METADATA =
+ new PluginMetadata("storage-file-ro", "Filesystem Read-Only Storage", "0.1.0", "storage");
+ private final Path rootDirectory;
+
+ public FileReadOnlyStoragePlugin() {
+ this(Path.of("./data/storage"));
+ }
+
+ public FileReadOnlyStoragePlugin(Path rootDirectory) {
+ this.rootDirectory = rootDirectory.toAbsolutePath().normalize();
+ }
+
+ @Override
+ public PluginMetadata metadata() {
+ return METADATA;
+ }
+
+ @Override
+ public String scheme() {
+ return "file";
+ }
+
+ @Override
+ public InputStream openForRead(URI location) throws IOException {
+ Path path = Path.of(location);
+ return Files.newInputStream(path);
+ }
+
+ @Override
+ public Optional locateInstance(
+ String studyInstanceUid, String seriesInstanceUid, String sopInstanceUid) throws IOException {
+ String safeStudy = safeSegment(studyInstanceUid);
+ String safeSeries = safeSegment(seriesInstanceUid);
+ String safeSop = safeSegment(sopInstanceUid) + ".dcm";
+
+ if (!Files.exists(rootDirectory)) {
+ return Optional.empty();
+ }
+
+ try (var patientDirs = Files.list(rootDirectory)) {
+ return patientDirs
+ .filter(Files::isDirectory)
+ .map(patientDir -> patientDir.resolve(safeStudy).resolve(safeSeries).resolve(safeSop))
+ .filter(Files::exists)
+ .map(Path::toUri)
+ .findFirst();
+ }
+ }
+
+ @Override
+ public List listStudyInstances(String studyInstanceUid) throws IOException {
+ String safeStudy = safeSegment(studyInstanceUid);
+ List results = new ArrayList<>();
+
+ if (!Files.exists(rootDirectory)) {
+ return results;
+ }
+
+ try (var patientDirs = Files.list(rootDirectory)) {
+ for (Path patientDir : patientDirs.filter(Files::isDirectory).toList()) {
+ Path studyDir = patientDir.resolve(safeStudy);
+ if (!Files.isDirectory(studyDir)) {
+ continue;
+ }
+ collectDicomFiles(studyDir, results);
+ }
+ }
+
+ return results;
+ }
+
+ @Override
+ public List listSeriesInstances(String studyInstanceUid, String seriesInstanceUid)
+ throws IOException {
+ String safeStudy = safeSegment(studyInstanceUid);
+ String safeSeries = safeSegment(seriesInstanceUid);
+ List results = new ArrayList<>();
+
+ if (!Files.exists(rootDirectory)) {
+ return results;
+ }
+
+ try (var patientDirs = Files.list(rootDirectory)) {
+ for (Path patientDir : patientDirs.filter(Files::isDirectory).toList()) {
+ Path seriesDir = patientDir.resolve(safeStudy).resolve(safeSeries);
+ if (!Files.isDirectory(seriesDir)) {
+ continue;
+ }
+ collectDicomFiles(seriesDir, results);
+ }
+ }
+
+ return results;
+ }
+
+ private String safeSegment(String value) {
+ if (value == null || value.isBlank()) {
+ return "UNKNOWN";
+ }
+ return value.replaceAll("[^A-Za-z0-9._-]", "_");
+ }
+
+ private void collectDicomFiles(Path directory, List output) throws IOException {
+ try (var walk = Files.walk(directory)) {
+ walk.filter(Files::isRegularFile)
+ .filter(path -> path.getFileName().toString().toLowerCase().endsWith(".dcm"))
+ .map(Path::toUri)
+ .forEach(output::add);
+ }
+ }
+}
diff --git a/backend/dicoogle-next-storage-file-rw/pom.xml b/backend/plugins/dicoogle-next-storage-file-rw/pom.xml
similarity index 100%
rename from backend/dicoogle-next-storage-file-rw/pom.xml
rename to backend/plugins/dicoogle-next-storage-file-rw/pom.xml
diff --git a/backend/plugins/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStorageConfig.java b/backend/plugins/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStorageConfig.java
new file mode 100644
index 0000000..75b12d3
--- /dev/null
+++ b/backend/plugins/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStorageConfig.java
@@ -0,0 +1,18 @@
+package org.dicoogle.storage.filerw;
+
+import java.nio.file.Path;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+@EnableConfigurationProperties(FileReadWriteStorageProperties.class)
+public class FileReadWriteStorageConfig {
+
+ @Bean
+ @ConditionalOnProperty(prefix = "app.storage.file-rw", name = "enabled", havingValue = "true")
+ FileReadWriteStoragePlugin fileReadWriteStoragePlugin(FileReadWriteStorageProperties properties) {
+ return new FileReadWriteStoragePlugin(Path.of(properties.getRootDir()), properties.getScheme());
+ }
+}
diff --git a/backend/plugins/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStoragePlugin.java b/backend/plugins/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStoragePlugin.java
new file mode 100644
index 0000000..58b50c7
--- /dev/null
+++ b/backend/plugins/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStoragePlugin.java
@@ -0,0 +1,211 @@
+package org.dicoogle.storage.filerw;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.regex.Pattern;
+import org.dcm4che3.data.Attributes;
+import org.dcm4che3.data.Tag;
+import org.dcm4che3.io.DicomInputStream;
+import org.dicoogle.sdk.PluginMetadata;
+import org.dicoogle.sdk.query.QueryIndexStorageLocator;
+import org.dicoogle.sdk.storage.DicomInstanceLocator;
+import org.dicoogle.sdk.storage.ReadableStoragePlugin;
+import org.dicoogle.sdk.storage.StoredObject;
+import org.dicoogle.sdk.storage.WritableStoragePlugin;
+
+public class FileReadWriteStoragePlugin
+ implements ReadableStoragePlugin,
+ WritableStoragePlugin,
+ DicomInstanceLocator,
+ QueryIndexStorageLocator {
+
+ private static final String UNKNOWN = "UNKNOWN";
+ private static final Pattern SAFE_SEGMENT_PATTERN = Pattern.compile("[^A-Za-z0-9._-]");
+ private static final PluginMetadata METADATA =
+ new PluginMetadata("storage-file-rw", "Filesystem Read-Write Storage", "0.1.0", "storage");
+
+ private final Path rootDirectory;
+ private final String scheme;
+
+ public FileReadWriteStoragePlugin(Path rootDirectory, String scheme) {
+ this.rootDirectory = Objects.requireNonNull(rootDirectory).toAbsolutePath().normalize();
+ this.scheme = (scheme == null || scheme.isBlank()) ? "file" : scheme;
+ }
+
+ @Override
+ public PluginMetadata metadata() {
+ return METADATA;
+ }
+
+ @Override
+ public String scheme() {
+ return scheme;
+ }
+
+ @Override
+ public InputStream openForRead(URI location) throws IOException {
+ if (!location.getScheme().equalsIgnoreCase(scheme)) {
+ throw new IOException("Unsupported URI scheme: " + location.getScheme());
+ }
+
+ Path path = toPath(location);
+ return Files.newInputStream(path);
+ }
+
+ @Override
+ public StoredObject store(InputStream data, String contentType) throws IOException {
+ byte[] bytes = data.readAllBytes();
+ if (bytes.length == 0) {
+ throw new IOException("Cannot store empty payload");
+ }
+
+ DicomHierarchy hierarchy = extractHierarchy(bytes);
+
+ Path relativePath =
+ Path.of(
+ safeSegment(hierarchy.patientId()),
+ safeSegment(hierarchy.studyInstanceUid()),
+ safeSegment(hierarchy.seriesInstanceUid()),
+ safeSegment(hierarchy.sopInstanceUid()) + ".dcm");
+
+ Path target = rootDirectory.resolve(relativePath).normalize();
+ if (!target.startsWith(rootDirectory)) {
+ throw new IOException("Resolved path escaped root directory");
+ }
+
+ Files.createDirectories(target.getParent());
+
+ Path tmp = target.resolveSibling(target.getFileName() + ".tmp");
+ Files.write(tmp, bytes);
+ Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
+
+ URI location =
+ scheme.equalsIgnoreCase("file")
+ ? target.toUri()
+ : URI.create(scheme + ":" + target.toAbsolutePath());
+
+ return new StoredObject(location, bytes.length, contentType);
+ }
+
+ @Override
+ public Optional locateInstance(
+ String studyInstanceUid, String seriesInstanceUid, String sopInstanceUid) throws IOException {
+ String safeStudy = safeSegment(studyInstanceUid);
+ String safeSeries = safeSegment(seriesInstanceUid);
+ String safeSop = safeSegment(sopInstanceUid) + ".dcm";
+
+ if (!Files.exists(rootDirectory)) {
+ return Optional.empty();
+ }
+
+ try (var patientDirs = Files.list(rootDirectory)) {
+ return patientDirs
+ .filter(Files::isDirectory)
+ .map(patientDir -> patientDir.resolve(safeStudy).resolve(safeSeries).resolve(safeSop))
+ .filter(Files::exists)
+ .map(Path::toUri)
+ .findFirst();
+ }
+ }
+
+ @Override
+ public List listStudyInstances(String studyInstanceUid) throws IOException {
+ String safeStudy = safeSegment(studyInstanceUid);
+ List results = new ArrayList<>();
+
+ if (!Files.exists(rootDirectory)) {
+ return results;
+ }
+
+ try (var patientDirs = Files.list(rootDirectory)) {
+ for (Path patientDir : patientDirs.filter(Files::isDirectory).toList()) {
+ Path studyDir = patientDir.resolve(safeStudy);
+ if (!Files.isDirectory(studyDir)) {
+ continue;
+ }
+ collectDicomFiles(studyDir, results);
+ }
+ }
+
+ return results;
+ }
+
+ @Override
+ public List listSeriesInstances(String studyInstanceUid, String seriesInstanceUid)
+ throws IOException {
+ String safeStudy = safeSegment(studyInstanceUid);
+ String safeSeries = safeSegment(seriesInstanceUid);
+ List results = new ArrayList<>();
+
+ if (!Files.exists(rootDirectory)) {
+ return results;
+ }
+
+ try (var patientDirs = Files.list(rootDirectory)) {
+ for (Path patientDir : patientDirs.filter(Files::isDirectory).toList()) {
+ Path seriesDir = patientDir.resolve(safeStudy).resolve(safeSeries);
+ if (!Files.isDirectory(seriesDir)) {
+ continue;
+ }
+ collectDicomFiles(seriesDir, results);
+ }
+ }
+
+ return results;
+ }
+
+ private DicomHierarchy extractHierarchy(byte[] bytes) throws IOException {
+ try (DicomInputStream dis = new DicomInputStream(new ByteArrayInputStream(bytes))) {
+ Attributes attrs = dis.readDataset();
+
+ String patientId = attrs.getString(Tag.PatientID, UNKNOWN);
+ String study = attrs.getString(Tag.StudyInstanceUID, UNKNOWN);
+ String series = attrs.getString(Tag.SeriesInstanceUID, UNKNOWN);
+ String sop = attrs.getString(Tag.SOPInstanceUID, UNKNOWN);
+
+ return new DicomHierarchy(patientId, study, series, sop);
+ }
+ }
+
+ private Path toPath(URI location) throws IOException {
+ Path candidate;
+ if (location.getScheme().equalsIgnoreCase("file")) {
+ candidate = Path.of(location);
+ } else {
+ String schemeSpecificPart = location.getSchemeSpecificPart();
+ candidate = Path.of(schemeSpecificPart);
+ }
+
+ candidate = candidate.toAbsolutePath().normalize();
+ if (!candidate.startsWith(rootDirectory)) {
+ throw new IOException("Path outside configured storage root");
+ }
+ return candidate;
+ }
+
+ private String safeSegment(String value) {
+ String normalized = (value == null || value.isBlank()) ? UNKNOWN : value;
+ return SAFE_SEGMENT_PATTERN.matcher(normalized).replaceAll("_");
+ }
+
+ private void collectDicomFiles(Path directory, List output) throws IOException {
+ try (var walk = Files.walk(directory)) {
+ walk.filter(Files::isRegularFile)
+ .filter(path -> path.getFileName().toString().toLowerCase().endsWith(".dcm"))
+ .map(Path::toUri)
+ .forEach(output::add);
+ }
+ }
+
+ private record DicomHierarchy(
+ String patientId, String studyInstanceUid, String seriesInstanceUid, String sopInstanceUid) {}
+}
diff --git a/backend/plugins/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStorageProperties.java b/backend/plugins/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStorageProperties.java
new file mode 100644
index 0000000..d01f226
--- /dev/null
+++ b/backend/plugins/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStorageProperties.java
@@ -0,0 +1,35 @@
+package org.dicoogle.storage.filerw;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@ConfigurationProperties(prefix = "app.storage.file-rw")
+public class FileReadWriteStorageProperties {
+
+ private boolean enabled;
+ private String rootDir = "./data/storage";
+ private String scheme = "file";
+
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ public String getRootDir() {
+ return rootDir;
+ }
+
+ public void setRootDir(String rootDir) {
+ this.rootDir = rootDir;
+ }
+
+ public String getScheme() {
+ return scheme;
+ }
+
+ public void setScheme(String scheme) {
+ this.scheme = scheme;
+ }
+}
diff --git a/backend/plugins/dicoogle-next-storage-file-rw/src/test/java/org/dicoogle/storage/filerw/FileReadWriteStoragePluginTest.java b/backend/plugins/dicoogle-next-storage-file-rw/src/test/java/org/dicoogle/storage/filerw/FileReadWriteStoragePluginTest.java
new file mode 100644
index 0000000..4740374
--- /dev/null
+++ b/backend/plugins/dicoogle-next-storage-file-rw/src/test/java/org/dicoogle/storage/filerw/FileReadWriteStoragePluginTest.java
@@ -0,0 +1,70 @@
+package org.dicoogle.storage.filerw;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.dcm4che3.data.Attributes;
+import org.dcm4che3.data.Tag;
+import org.dcm4che3.data.UID;
+import org.dcm4che3.data.VR;
+import org.dcm4che3.io.DicomOutputStream;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class FileReadWriteStoragePluginTest {
+
+ @TempDir Path tempDir;
+
+ @Test
+ void storesUsingOldStyleHierarchyAndReadsBack() throws Exception {
+ FileReadWriteStoragePlugin plugin = new FileReadWriteStoragePlugin(tempDir, "file");
+ byte[] dicom = createValidDicom();
+
+ var stored = plugin.store(new java.io.ByteArrayInputStream(dicom), "application/dicom");
+
+ Path expected =
+ tempDir
+ .resolve("PATIENT-1")
+ .resolve("1.2.826.0.1.3680043.2.1125.2")
+ .resolve("1.2.826.0.1.3680043.2.1125.3")
+ .resolve("1.2.826.0.1.3680043.2.1125.1.dcm");
+
+ assertTrue(Files.exists(expected));
+
+ byte[] readBack;
+ try (var is = plugin.openForRead(stored.location())) {
+ readBack = is.readAllBytes();
+ }
+
+ assertArrayEquals(dicom, readBack);
+ }
+
+ private byte[] createValidDicom() {
+ try {
+ Attributes fmi = new Attributes();
+ fmi.setString(Tag.TransferSyntaxUID, VR.UI, UID.ExplicitVRLittleEndian);
+ fmi.setString(Tag.MediaStorageSOPClassUID, VR.UI, UID.SecondaryCaptureImageStorage);
+ fmi.setString(Tag.MediaStorageSOPInstanceUID, VR.UI, "1.2.826.0.1.3680043.2.1125.1");
+ fmi.setString(Tag.ImplementationClassUID, VR.UI, "1.2.826.0.1.3680043.2.1125.99");
+
+ Attributes attrs = new Attributes();
+ attrs.setString(Tag.SOPClassUID, VR.UI, UID.SecondaryCaptureImageStorage);
+ attrs.setString(Tag.SOPInstanceUID, VR.UI, "1.2.826.0.1.3680043.2.1125.1");
+ attrs.setString(Tag.StudyInstanceUID, VR.UI, "1.2.826.0.1.3680043.2.1125.2");
+ attrs.setString(Tag.SeriesInstanceUID, VR.UI, "1.2.826.0.1.3680043.2.1125.3");
+ attrs.setString(Tag.PatientID, VR.LO, "PATIENT-1");
+
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ try (DicomOutputStream dos = new DicomOutputStream(output, UID.ExplicitVRLittleEndian)) {
+ dos.writeDataset(fmi, attrs);
+ }
+ return output.toByteArray();
+ } catch (IOException ex) {
+ throw new IllegalStateException(ex);
+ }
+ }
+}
diff --git a/backend/pom.xml b/backend/pom.xml
index 095ed3b..c99298c 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -26,6 +26,7 @@
dicoogle-next-protocol-dicomweb
dicoogle-next-storage-file-ro
dicoogle-next-storage-file-rw
+ dicoogle-next-query-file
From 07494d730227e92d20d206841a27b4ee97348cf9 Mon Sep 17 00:00:00 2001
From: luis-godinho <125367612+luis-godinho@users.noreply.github.com>
Date: Sat, 11 Apr 2026 15:40:00 +0100
Subject: [PATCH 2/6] feat: harden Study Root C-FIND behavior and document
query semantics
---
backend/README.md | 39 +++++-
.../dicoogle/protocol/dimse/CFindService.java | 109 +++++++++++++--
.../protocol/dimse/DimseCFindProperties.java | 18 +--
.../protocol/dimse/DimseCFindSCP.java | 10 ++
.../protocol/dimse/DimseCStoreConfig.java | 9 +-
.../protocol/dimse/DimseCStoreServer.java | 33 ++---
.../protocol/dimse/CFindServiceTest.java | 82 ++++++++++-
.../sdk/query/DimseFindAccessPolicy.java | 18 +++
.../src/main/resources/application-dev.yml | 1 -
.../src/main/resources/application-test.yml | 1 -
.../src/main/resources/application.yml | 2 +-
.../plugins/dicoogle-next-query-file/pom.xml | 2 +-
.../file/FileReadWriteDimseFindPlugin.java | 130 ++++++++++++++----
.../FileReadWriteDimseFindPluginTest.java | 4 +-
.../dicoogle-next-storage-file-ro/pom.xml | 2 +-
.../dicoogle-next-storage-file-rw/pom.xml | 2 +-
.../filerw/FileReadWriteStoragePlugin.java | 10 ++
backend/pom.xml | 10 +-
18 files changed, 389 insertions(+), 93 deletions(-)
create mode 100644 backend/dicoogle-next-sdk/src/main/java/org/dicoogle/sdk/query/DimseFindAccessPolicy.java
diff --git a/backend/README.md b/backend/README.md
index 5333d7b..4a91e58 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -34,8 +34,27 @@ You can change transfer capabilities at runtime via API:
- `DELETE /api/system/config/dimse/transfer-capabilities/{SOPClassUID}`
- `POST /api/system/config/dimse/transfer-capabilities/replace`
-C-FIND supports Study Root with query levels `STUDY`, `SERIES`, and `IMAGE`. Query plugins receive
-both structured query keys and a free-text field extracted from query attributes.
+C-FIND (Study Root) is always enabled when DIMSE server is enabled (`app.dimse.cstore.enabled=true`).
+Supported query levels are `STUDY`, `SERIES`, and `IMAGE`.
+
+Query behavior:
+
+- Standard C-FIND keys are matched (`StudyInstanceUID`, `SeriesInstanceUID`, `SOPInstanceUID`,
+ `PatientID`, `PatientName`, `Modality`, `StudyDescription`, `SeriesDescription`).
+- Old Dicoogle-like free-text and `keyword:value` filters are accepted on the same endpoint.
+- Mixed queries are supported (for example: free text + strict DICOM keys together).
+- Broad queries without UIDs are supported by scanning indexed instances and filtering in-memory.
+- Response size is capped by `app.dimse.cfind.max-results` (default `1000`).
+
+`keyword:value` filters currently supported:
+
+- `patientid`
+- `patientname`
+- `studyinstanceuid`
+- `seriesinstanceuid`
+- `sopinstanceuid`
+- `accessionnumber`
+- `modality`
Config source options:
@@ -127,3 +146,19 @@ curl -i -u developer:developer \
```
Expected: `404` with `application/problem+json`.
+
+### 8) C-FIND examples (findscu)
+
+Standard Study Root by UID:
+
+```bash
+findscu -v -S -k QueryRetrieveLevel=STUDY -k StudyInstanceUID= \
+ -aet TESTSCU -aec DICOOGLE localhost 11112
+```
+
+Mixed free-text + keyword filter on same request (old Dicoogle style):
+
+```bash
+findscu -v -S -k QueryRetrieveLevel=STUDY -k "PatientName=brain modality:MR" \
+ -aet TESTSCU -aec DICOOGLE localhost 11112
+```
diff --git a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/CFindService.java b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/CFindService.java
index 0d46f2a..250d47b 100644
--- a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/CFindService.java
+++ b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/CFindService.java
@@ -1,5 +1,7 @@
package org.dicoogle.protocol.dimse;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.Timer;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
@@ -7,27 +9,42 @@
import java.util.Locale;
import java.util.Map;
import java.util.Set;
+import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
import org.dcm4che3.data.Attributes;
import org.dcm4che3.data.Tag;
import org.dcm4che3.net.Status;
import org.dcm4che3.net.service.DicomServiceException;
+import org.dicoogle.sdk.query.DimseFindAccessPolicy;
import org.dicoogle.sdk.query.DimseFindServicePlugin;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
public class CFindService {
private static final String STUDY_ROOT_MODEL_UID = "1.2.840.10008.5.1.4.1.2.2.1";
private static final Pattern KEYWORD_PATTERN = Pattern.compile("[A-Za-z0-9_.-]+:[^\\s]+");
+ private static final Logger LOGGER = LoggerFactory.getLogger(CFindService.class);
private final List plugins;
+ private final List accessPolicies;
private final Set supportedLevels;
-
- public CFindService(List plugins, DimseCFindProperties properties) {
+ private final int maxResults;
+ private final MeterRegistry meterRegistry;
+
+ public CFindService(
+ List plugins,
+ List accessPolicies,
+ DimseCFindProperties properties,
+ MeterRegistry meterRegistry) {
this.plugins = List.copyOf(plugins);
+ this.accessPolicies = List.copyOf(accessPolicies);
this.supportedLevels =
properties.getSupportedQueryLevels().stream()
.map(level -> level.toUpperCase(Locale.ROOT))
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
+ this.maxResults = properties.getMaxResults();
+ this.meterRegistry = meterRegistry;
}
public List find(
@@ -37,19 +54,25 @@ public List find(
String calledAet,
int associationSerialNo)
throws DicomServiceException {
+ long startNs = System.nanoTime();
+ increment("dicoogle.cfind.requests", null);
+
if (!STUDY_ROOT_MODEL_UID.equals(affectedSopClassUid)) {
+ increment("dicoogle.cfind.failure", "unsupported-sop-class");
throw new DicomServiceException(
Status.SOPclassNotSupported, "Only Study Root C-FIND is supported");
}
String queryLevel = keys.getString(Tag.QueryRetrieveLevel);
if (queryLevel == null || queryLevel.isBlank()) {
+ increment("dicoogle.cfind.failure", "missing-query-level");
throw new DicomServiceException(
Status.IdentifierDoesNotMatchSOPClass, "Missing QueryRetrieveLevel");
}
String normalizedLevel = queryLevel.trim().toUpperCase(Locale.ROOT);
if (!supportedLevels.contains(normalizedLevel)) {
+ increment("dicoogle.cfind.failure", "unsupported-query-level");
throw new DicomServiceException(
Status.UnableToProcess, "Unsupported QueryRetrieveLevel: " + normalizedLevel);
}
@@ -58,16 +81,27 @@ public List find(
try {
level = DimseFindServicePlugin.QueryRetrieveLevel.valueOf(normalizedLevel);
} catch (IllegalArgumentException ex) {
+ increment("dicoogle.cfind.failure", "invalid-query-level");
throw new DicomServiceException(Status.UnableToProcess, "Invalid QueryRetrieveLevel");
}
if (plugins.isEmpty()) {
+ increment("dicoogle.cfind.failure", "no-query-plugin");
throw new DicomServiceException(
Status.UnableToProcess, "No DIMSE query plugin is configured");
}
- String freeText = extractFreeText(keys);
- Map keywordFilters = extractKeywordFilters(keys);
+ String rawPatientName = keys.getString(Tag.PatientName, null);
+ String freeText = extractFreeText(rawPatientName);
+ Map keywordFilters = extractKeywordFilters(rawPatientName);
+
+ Attributes normalizedKeys = new Attributes(keys);
+ if (freeText == null || freeText.isBlank()) {
+ normalizedKeys.setNull(Tag.PatientName, org.dcm4che3.data.VR.PN);
+ } else {
+ normalizedKeys.setString(Tag.PatientName, org.dcm4che3.data.VR.PN, freeText);
+ }
+
DimseFindServicePlugin.FindRequest request =
new DimseFindServicePlugin.FindRequest(
DimseFindServicePlugin.InformationModel.STUDY_ROOT,
@@ -75,26 +109,66 @@ public List find(
callingAet,
calledAet,
associationSerialNo,
- keys,
+ normalizedKeys,
freeText,
keywordFilters);
+ for (DimseFindAccessPolicy policy : accessPolicies) {
+ DimseFindAccessPolicy.Decision decision = policy.evaluate(request);
+ if (!decision.allowed()) {
+ increment("dicoogle.cfind.failure", "policy-denied");
+ throw new DicomServiceException(Status.UnableToProcess, decision.reason());
+ }
+ }
+
+ LOGGER.info(
+ "C-FIND request association={} aet={} level={} keys={} freeText={} keywordFilters={}",
+ associationSerialNo,
+ callingAet,
+ normalizedLevel,
+ requestedKeyTags(normalizedKeys),
+ freeText,
+ keywordFilters.keySet());
+
for (DimseFindServicePlugin plugin : plugins) {
try {
List matches = plugin.find(request);
if (matches != null) {
- return matches;
+ List limited =
+ matches.size() <= maxResults ? matches : matches.subList(0, maxResults);
+ increment("dicoogle.cfind.success", null);
+ meterRegistry
+ .counter("dicoogle.cfind.matches", "level", normalizedLevel)
+ .increment(limited.size());
+ recordLatency("success", normalizedLevel, startNs);
+ return limited;
}
} catch (IOException ex) {
+ increment("dicoogle.cfind.failure", "plugin-io");
+ recordLatency("failure", normalizedLevel, startNs);
throw new DicomServiceException(Status.UnableToProcess, ex.getMessage());
}
}
+ increment("dicoogle.cfind.success", null);
+ recordLatency("success", normalizedLevel, startNs);
return List.of();
}
- private String extractFreeText(Attributes keys) {
- String direct = keys.getString(Tag.PatientName);
+ private String requestedKeyTags(Attributes keys) {
+ StringBuilder out = new StringBuilder();
+ for (int tag : keys.tags()) {
+ String value = keys.getString(tag, null);
+ boolean hasValue = value != null && !value.isBlank();
+ if (!out.isEmpty()) {
+ out.append(',');
+ }
+ out.append(String.format("%08X%s", tag, hasValue ? "=*" : ""));
+ }
+ return out.toString();
+ }
+
+ private String extractFreeText(String direct) {
if (direct == null || direct.isBlank()) {
return null;
}
@@ -111,8 +185,7 @@ private String extractFreeText(Attributes keys) {
return out.isEmpty() ? null : out.toString();
}
- private Map extractKeywordFilters(Attributes keys) {
- String raw = keys.getString(Tag.PatientName);
+ private Map extractKeywordFilters(String raw) {
if (raw == null || raw.isBlank()) {
return Map.of();
}
@@ -128,4 +201,20 @@ private Map extractKeywordFilters(Attributes keys) {
}
return out;
}
+
+ private void increment(String meterName, String reason) {
+ if (reason == null) {
+ meterRegistry.counter(meterName).increment();
+ } else {
+ meterRegistry.counter(meterName, "reason", reason).increment();
+ }
+ }
+
+ private void recordLatency(String outcome, String level, long startNs) {
+ Timer.builder("dicoogle.cfind.latency")
+ .tag("outcome", outcome)
+ .tag("level", level)
+ .register(meterRegistry)
+ .record(System.nanoTime() - startNs, TimeUnit.NANOSECONDS);
+ }
}
diff --git a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindProperties.java b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindProperties.java
index ac650b0..58a47fd 100644
--- a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindProperties.java
+++ b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindProperties.java
@@ -6,16 +6,8 @@
@ConfigurationProperties(prefix = "app.dimse.cfind")
public class DimseCFindProperties {
- private boolean enabled = true;
private List supportedQueryLevels = List.of("STUDY", "SERIES", "IMAGE");
-
- public boolean isEnabled() {
- return enabled;
- }
-
- public void setEnabled(boolean enabled) {
- this.enabled = enabled;
- }
+ private int maxResults = 1000;
public List getSupportedQueryLevels() {
return supportedQueryLevels;
@@ -27,4 +19,12 @@ public void setSupportedQueryLevels(List supportedQueryLevels) {
? List.of("STUDY", "SERIES", "IMAGE")
: supportedQueryLevels;
}
+
+ public int getMaxResults() {
+ return maxResults;
+ }
+
+ public void setMaxResults(int maxResults) {
+ this.maxResults = Math.max(1, maxResults);
+ }
}
diff --git a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindSCP.java b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindSCP.java
index a5cab4e..c8c862a 100644
--- a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindSCP.java
+++ b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindSCP.java
@@ -10,9 +10,13 @@
import org.dcm4che3.net.service.BasicQueryTask;
import org.dcm4che3.net.service.DicomServiceException;
import org.dcm4che3.net.service.QueryTask;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
class DimseCFindSCP extends BasicCFindSCP {
+ private static final Logger LOGGER = LoggerFactory.getLogger(DimseCFindSCP.class);
+
private final CFindService cFindService;
DimseCFindSCP(CFindService cFindService) {
@@ -24,6 +28,12 @@ class DimseCFindSCP extends BasicCFindSCP {
protected QueryTask calculateMatches(
Association as, PresentationContext pc, Attributes rq, Attributes keys)
throws DicomServiceException {
+ LOGGER.info(
+ "C-FIND request association={} callingAET={} calledAET={}",
+ as.getSerialNo(),
+ as.getCallingAET(),
+ as.getCalledAET());
+
List matches =
cFindService.find(
rq.getString(org.dcm4che3.data.Tag.AffectedSOPClassUID),
diff --git a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCStoreConfig.java b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCStoreConfig.java
index 2f52df5..2f1e122 100644
--- a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCStoreConfig.java
+++ b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCStoreConfig.java
@@ -5,6 +5,7 @@
import org.dicoogle.core.storage.StorageRouter;
import org.dicoogle.sdk.query.DimseAssociationAccessPolicy;
import org.dicoogle.sdk.query.DimseAssociationEventListener;
+import org.dicoogle.sdk.query.DimseFindAccessPolicy;
import org.dicoogle.sdk.query.StorageIngestEventListener;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -26,8 +27,10 @@ CStoreService cStoreService(
@Bean
CFindService cFindService(
List queryPlugins,
- DimseCFindProperties properties) {
- return new CFindService(queryPlugins, properties);
+ List cfindAccessPolicies,
+ DimseCFindProperties properties,
+ MeterRegistry meterRegistry) {
+ return new CFindService(queryPlugins, cfindAccessPolicies, properties, meterRegistry);
}
@Bean
@@ -36,7 +39,6 @@ DimseCStoreServer dimseCStoreServer(
CStoreService cStoreService,
CFindService cFindService,
DimseCStoreProperties properties,
- DimseCFindProperties cfindProperties,
List associationEventListeners,
List associationAccessPolicies,
@org.springframework.beans.factory.annotation.Qualifier("primaryStorageScheme")
@@ -45,7 +47,6 @@ DimseCStoreServer dimseCStoreServer(
cStoreService,
cFindService,
properties,
- cfindProperties,
primaryStorageScheme,
associationEventListeners,
associationAccessPolicies);
diff --git a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCStoreServer.java b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCStoreServer.java
index 9ef9d65..d191aca 100644
--- a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCStoreServer.java
+++ b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCStoreServer.java
@@ -40,7 +40,6 @@ public class DimseCStoreServer implements SmartLifecycle {
private final CStoreService cStoreService;
private final CFindService cFindService;
private final DimseCStoreProperties properties;
- private final DimseCFindProperties cfindProperties;
private final String primaryStorageScheme;
private final List associationEventListeners;
private final List associationAccessPolicies;
@@ -54,30 +53,20 @@ public DimseCStoreServer(
CStoreService cStoreService,
CFindService cFindService,
DimseCStoreProperties properties,
- DimseCFindProperties cfindProperties,
String primaryStorageScheme) {
- this(
- cStoreService,
- cFindService,
- properties,
- cfindProperties,
- primaryStorageScheme,
- List.of(),
- List.of());
+ this(cStoreService, cFindService, properties, primaryStorageScheme, List.of(), List.of());
}
public DimseCStoreServer(
CStoreService cStoreService,
CFindService cFindService,
DimseCStoreProperties properties,
- DimseCFindProperties cfindProperties,
String primaryStorageScheme,
List associationEventListeners,
List associationAccessPolicies) {
this.cStoreService = Objects.requireNonNull(cStoreService);
this.cFindService = Objects.requireNonNull(cFindService);
this.properties = Objects.requireNonNull(properties);
- this.cfindProperties = Objects.requireNonNull(cfindProperties);
this.primaryStorageScheme =
primaryStorageScheme == null || primaryStorageScheme.isBlank()
? "file"
@@ -115,9 +104,7 @@ public synchronized void start() {
DicomServiceRegistry services = new DicomServiceRegistry();
services.addDicomService(new BasicCEchoSCP());
- if (cfindProperties.isEnabled()) {
- services.addDicomService(new DimseCFindSCP(cFindService));
- }
+ services.addDicomService(new DimseCFindSCP(cFindService));
services.addDicomService(
new BasicCStoreSCP("*") {
@Override
@@ -244,15 +231,13 @@ private void configureTransferCapabilities(
UID.ImplicitVRLittleEndian));
}
- if (cfindProperties.isEnabled()) {
- ae.addTransferCapability(
- new TransferCapability(
- "cfind-study-root-scp",
- STUDY_ROOT_FIND_UID,
- TransferCapability.Role.SCP,
- UID.ImplicitVRLittleEndian,
- UID.ExplicitVRLittleEndian));
- }
+ ae.addTransferCapability(
+ new TransferCapability(
+ "cfind-study-root-scp",
+ STUDY_ROOT_FIND_UID,
+ TransferCapability.Role.SCP,
+ UID.ImplicitVRLittleEndian,
+ UID.ExplicitVRLittleEndian));
}
public synchronized void applyAcceptedTransferCapabilities(
diff --git a/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/CFindServiceTest.java b/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/CFindServiceTest.java
index af287b9..b035ab2 100644
--- a/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/CFindServiceTest.java
+++ b/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/CFindServiceTest.java
@@ -3,6 +3,7 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import java.io.IOException;
import java.util.List;
import org.dcm4che3.data.Attributes;
@@ -11,6 +12,7 @@
import org.dcm4che3.net.Status;
import org.dcm4che3.net.service.DicomServiceException;
import org.dicoogle.sdk.PluginMetadata;
+import org.dicoogle.sdk.query.DimseFindAccessPolicy;
import org.dicoogle.sdk.query.DimseFindServicePlugin;
import org.junit.jupiter.api.Test;
@@ -19,7 +21,11 @@ class CFindServiceTest {
@Test
void failsWhenQueryLevelMissing() {
CFindService service =
- new CFindService(List.of(new DummyFindPlugin()), new DimseCFindProperties());
+ new CFindService(
+ List.of(new DummyFindPlugin()),
+ List.of(),
+ new DimseCFindProperties(),
+ new SimpleMeterRegistry());
Attributes keys = new Attributes();
DicomServiceException ex =
@@ -32,7 +38,11 @@ void failsWhenQueryLevelMissing() {
@Test
void supportsFreeTextAndLevel() throws Exception {
CFindService service =
- new CFindService(List.of(new DummyFindPlugin()), new DimseCFindProperties());
+ new CFindService(
+ List.of(new DummyFindPlugin()),
+ List.of(),
+ new DimseCFindProperties(),
+ new SimpleMeterRegistry());
Attributes keys = new Attributes();
keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY");
keys.setString(Tag.StudyInstanceUID, VR.UI, "1.2.3");
@@ -45,6 +55,57 @@ void supportsFreeTextAndLevel() throws Exception {
assertEquals("1.2.3", matches.get(0).getString(Tag.StudyInstanceUID));
}
+ @Test
+ void appliesConfiguredMaxResults() throws Exception {
+ DimseCFindProperties properties = new DimseCFindProperties();
+ properties.setMaxResults(1);
+ CFindService service =
+ new CFindService(
+ List.of(new MultiResultFindPlugin()),
+ List.of(),
+ properties,
+ new SimpleMeterRegistry());
+
+ Attributes keys = new Attributes();
+ keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY");
+
+ List matches =
+ service.find("1.2.840.10008.5.1.4.1.2.2.1", keys, "CALLING", "CALLED", 88);
+ assertEquals(1, matches.size());
+ }
+
+ @Test
+ void deniesQueryWhenPolicyRejects() {
+ DimseFindAccessPolicy denyPolicy =
+ new DimseFindAccessPolicy() {
+ @Override
+ public org.dicoogle.sdk.PluginMetadata metadata() {
+ return new org.dicoogle.sdk.PluginMetadata(
+ "deny-cfind", "Deny C-FIND", "1.0.0", "query-index");
+ }
+
+ @Override
+ public Decision evaluate(DimseFindServicePlugin.FindRequest request) {
+ return Decision.deny("denied by policy");
+ }
+ };
+ CFindService service =
+ new CFindService(
+ List.of(new DummyFindPlugin()),
+ List.of(denyPolicy),
+ new DimseCFindProperties(),
+ new SimpleMeterRegistry());
+
+ Attributes keys = new Attributes();
+ keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY");
+
+ DicomServiceException ex =
+ assertThrows(
+ DicomServiceException.class,
+ () -> service.find("1.2.840.10008.5.1.4.1.2.2.1", keys, "CALLING", "CALLED", 99));
+ assertEquals(Status.UnableToProcess, ex.getStatus());
+ }
+
private static final class DummyFindPlugin implements DimseFindServicePlugin {
@Override
@@ -61,4 +122,21 @@ public List find(FindRequest request) throws IOException {
return List.of(a);
}
}
+
+ private static final class MultiResultFindPlugin implements DimseFindServicePlugin {
+
+ @Override
+ public PluginMetadata metadata() {
+ return new PluginMetadata("multi-find", "Multi Find", "1.0.0", "query-index");
+ }
+
+ @Override
+ public List find(FindRequest request) throws IOException {
+ Attributes a1 = new Attributes();
+ a1.setString(Tag.StudyInstanceUID, VR.UI, "1.2.3");
+ Attributes a2 = new Attributes();
+ a2.setString(Tag.StudyInstanceUID, VR.UI, "1.2.4");
+ return List.of(a1, a2);
+ }
+ }
}
diff --git a/backend/dicoogle-next-sdk/src/main/java/org/dicoogle/sdk/query/DimseFindAccessPolicy.java b/backend/dicoogle-next-sdk/src/main/java/org/dicoogle/sdk/query/DimseFindAccessPolicy.java
new file mode 100644
index 0000000..dba36d3
--- /dev/null
+++ b/backend/dicoogle-next-sdk/src/main/java/org/dicoogle/sdk/query/DimseFindAccessPolicy.java
@@ -0,0 +1,18 @@
+package org.dicoogle.sdk.query;
+
+public interface DimseFindAccessPolicy extends QueryIndexPlugin {
+
+ default Decision evaluate(DimseFindServicePlugin.FindRequest request) {
+ return Decision.allow();
+ }
+
+ record Decision(boolean allowed, String reason) {
+ public static Decision allow() {
+ return new Decision(true, "");
+ }
+
+ public static Decision deny(String reason) {
+ return new Decision(false, reason == null ? "c-find request denied" : reason);
+ }
+ }
+}
diff --git a/backend/dicoogle-next/src/main/resources/application-dev.yml b/backend/dicoogle-next/src/main/resources/application-dev.yml
index 1537807..ce6033d 100644
--- a/backend/dicoogle-next/src/main/resources/application-dev.yml
+++ b/backend/dicoogle-next/src/main/resources/application-dev.yml
@@ -21,7 +21,6 @@ springdoc:
app:
dimse:
cfind:
- enabled: true
cstore:
enabled: true
storage:
diff --git a/backend/dicoogle-next/src/main/resources/application-test.yml b/backend/dicoogle-next/src/main/resources/application-test.yml
index 500307d..a3cf627 100644
--- a/backend/dicoogle-next/src/main/resources/application-test.yml
+++ b/backend/dicoogle-next/src/main/resources/application-test.yml
@@ -17,7 +17,6 @@ springdoc:
app:
dimse:
cfind:
- enabled: true
cstore:
enabled: false
security:
diff --git a/backend/dicoogle-next/src/main/resources/application.yml b/backend/dicoogle-next/src/main/resources/application.yml
index c9e7ffd..f6c87c2 100644
--- a/backend/dicoogle-next/src/main/resources/application.yml
+++ b/backend/dicoogle-next/src/main/resources/application.yml
@@ -38,7 +38,7 @@ app:
allowed-origins: []
dimse:
cfind:
- enabled: true
+ max-results: 1000
supported-query-levels:
- STUDY
- SERIES
diff --git a/backend/plugins/dicoogle-next-query-file/pom.xml b/backend/plugins/dicoogle-next-query-file/pom.xml
index 1cfba85..08180c4 100644
--- a/backend/plugins/dicoogle-next-query-file/pom.xml
+++ b/backend/plugins/dicoogle-next-query-file/pom.xml
@@ -7,7 +7,7 @@
org.dicoogle
dicoogle-next-backend-parent
0.0.1-SNAPSHOT
- ../pom.xml
+ ../../pom.xml
dicoogle-next-query-file
diff --git a/backend/plugins/dicoogle-next-query-file/src/main/java/org/dicoogle/query/file/FileReadWriteDimseFindPlugin.java b/backend/plugins/dicoogle-next-query-file/src/main/java/org/dicoogle/query/file/FileReadWriteDimseFindPlugin.java
index c185598..9647943 100644
--- a/backend/plugins/dicoogle-next-query-file/src/main/java/org/dicoogle/query/file/FileReadWriteDimseFindPlugin.java
+++ b/backend/plugins/dicoogle-next-query-file/src/main/java/org/dicoogle/query/file/FileReadWriteDimseFindPlugin.java
@@ -36,24 +36,26 @@ public PluginMetadata metadata() {
@Override
public List find(FindRequest request) throws IOException {
- List uris =
- switch (request.level()) {
- case STUDY ->
- storagePlugin.listStudyInstances(
- request.keys().getString(Tag.StudyInstanceUID, "__MISSING_STUDY_UID__"));
- case SERIES ->
- storagePlugin.listSeriesInstances(
- request.keys().getString(Tag.StudyInstanceUID, "__MISSING_STUDY_UID__"),
- request.keys().getString(Tag.SeriesInstanceUID, "__MISSING_SERIES_UID__"));
- case IMAGE ->
- storagePlugin
- .locateInstance(
- request.keys().getString(Tag.StudyInstanceUID, "__MISSING_STUDY_UID__"),
- request.keys().getString(Tag.SeriesInstanceUID, "__MISSING_SERIES_UID__"),
- request.keys().getString(Tag.SOPInstanceUID, "__MISSING_INSTANCE_UID__"))
- .map(List::of)
- .orElse(List.of());
- };
+ String studyUid = request.keys().getString(Tag.StudyInstanceUID, null);
+ String seriesUid = request.keys().getString(Tag.SeriesInstanceUID, null);
+ String sopUid = request.keys().getString(Tag.SOPInstanceUID, null);
+
+ List uris;
+ if (request.level() == QueryRetrieveLevel.IMAGE
+ && hasText(studyUid)
+ && hasText(seriesUid)
+ && hasText(sopUid)) {
+ uris =
+ storagePlugin.locateInstance(studyUid, seriesUid, sopUid).map(List::of).orElse(List.of());
+ } else if (request.level() == QueryRetrieveLevel.SERIES
+ && hasText(studyUid)
+ && hasText(seriesUid)) {
+ uris = storagePlugin.listSeriesInstances(studyUid, seriesUid);
+ } else if (request.level() == QueryRetrieveLevel.STUDY && hasText(studyUid)) {
+ uris = storagePlugin.listStudyInstances(studyUid);
+ } else {
+ uris = storagePlugin.listAllInstances();
+ }
Set seen = new LinkedHashSet<>();
List out = new ArrayList<>();
@@ -72,6 +74,9 @@ public List find(FindRequest request) throws IOException {
if (!matchesKeywordFilters(attrs, filters)) {
continue;
}
+ if (!matchesDicomKeys(attrs, request.keys())) {
+ continue;
+ }
out.add(filterByLevel(attrs, request.level()));
}
@@ -132,19 +137,75 @@ private int mapKeywordToTag(String keyword) {
};
}
+ private boolean matchesDicomKeys(Attributes attrs, Attributes keys) {
+ return matchesExactUi(attrs, Tag.StudyInstanceUID, keys)
+ && matchesExactUi(attrs, Tag.SeriesInstanceUID, keys)
+ && matchesExactUi(attrs, Tag.SOPInstanceUID, keys)
+ && matchesExactIgnoreCase(attrs, Tag.PatientID, keys)
+ && matchesExactIgnoreCase(attrs, Tag.Modality, keys)
+ && matchesContainsIgnoreCase(attrs, Tag.PatientName, keys)
+ && matchesContainsIgnoreCase(attrs, Tag.StudyDescription, keys)
+ && matchesContainsIgnoreCase(attrs, Tag.SeriesDescription, keys);
+ }
+
+ private boolean matchesExactUi(Attributes attrs, int tag, Attributes keys) {
+ String expected = keys.getString(tag, null);
+ if (!hasText(expected)) {
+ return true;
+ }
+ String actual = attrs.getString(tag, "");
+ return actual.equals(expected);
+ }
+
+ private boolean matchesExactIgnoreCase(Attributes attrs, int tag, Attributes keys) {
+ String expected = keys.getString(tag, null);
+ if (!hasText(expected)) {
+ return true;
+ }
+ String actual = attrs.getString(tag, "");
+ return actual.equalsIgnoreCase(expected);
+ }
+
+ private boolean matchesContainsIgnoreCase(Attributes attrs, int tag, Attributes keys) {
+ String expected = keys.getString(tag, null);
+ if (!hasText(expected)) {
+ return true;
+ }
+ String actual = attrs.getString(tag, "");
+ return actual.toLowerCase(Locale.ROOT).contains(expected.toLowerCase(Locale.ROOT));
+ }
+
+ private boolean hasText(String value) {
+ return value != null && !value.isBlank();
+ }
+
private Attributes filterByLevel(Attributes src, QueryRetrieveLevel level) {
Attributes out = new Attributes();
- out.setString(Tag.QueryRetrieveLevel, VR.CS, level.name());
- out.setString(Tag.StudyInstanceUID, VR.UI, src.getString(Tag.StudyInstanceUID, ""));
- out.setString(Tag.SeriesInstanceUID, VR.UI, src.getString(Tag.SeriesInstanceUID, ""));
- out.setString(Tag.SOPInstanceUID, VR.UI, src.getString(Tag.SOPInstanceUID, ""));
- out.setString(Tag.SOPClassUID, VR.UI, src.getString(Tag.SOPClassUID, ""));
- out.setString(Tag.PatientID, VR.LO, src.getString(Tag.PatientID, ""));
- out.setString(Tag.PatientName, VR.PN, src.getString(Tag.PatientName, ""));
- out.setString(Tag.StudyDescription, VR.LO, src.getString(Tag.StudyDescription, ""));
- out.setString(Tag.SeriesDescription, VR.LO, src.getString(Tag.SeriesDescription, ""));
- out.setString(Tag.Modality, VR.CS, src.getString(Tag.Modality, ""));
- out.setString(Tag.AccessionNumber, VR.SH, src.getString(Tag.AccessionNumber, ""));
+ copyIfPresent(src, out, Tag.QueryRetrieveLevel, VR.CS, level.name());
+ copyIfPresent(src, out, Tag.StudyInstanceUID, VR.UI, null);
+ copyIfPresent(src, out, Tag.SeriesInstanceUID, VR.UI, null);
+ copyIfPresent(src, out, Tag.SOPInstanceUID, VR.UI, null);
+ copyIfPresent(src, out, Tag.SOPClassUID, VR.UI, null);
+ copyIfPresent(src, out, Tag.PatientID, VR.LO, null);
+ copyIfPresent(src, out, Tag.PatientName, VR.PN, null);
+ copyIfPresent(src, out, Tag.PatientSex, VR.CS, null);
+ copyIfPresent(src, out, Tag.PatientBirthDate, VR.DA, null);
+ copyIfPresent(src, out, Tag.StudyDate, VR.DA, null);
+ copyIfPresent(src, out, Tag.StudyTime, VR.TM, null);
+ copyIfPresent(src, out, Tag.AccessionNumber, VR.SH, null);
+ copyIfPresent(src, out, Tag.StudyID, VR.SH, null);
+ copyIfPresent(src, out, Tag.StudyDescription, VR.LO, null);
+ copyIfPresent(src, out, Tag.Modality, VR.CS, null);
+ copyIfPresent(src, out, Tag.ModalitiesInStudy, VR.CS, null);
+ copyIfPresent(src, out, Tag.InstitutionName, VR.LO, null);
+ copyIfPresent(src, out, Tag.SeriesDescription, VR.LO, null);
+ copyIfPresent(src, out, Tag.SeriesDate, VR.DA, null);
+ copyIfPresent(src, out, Tag.SeriesTime, VR.TM, null);
+ copyIfPresent(src, out, Tag.SeriesNumber, VR.IS, null);
+ copyIfPresent(src, out, Tag.OperatorsName, VR.PN, null);
+ copyIfPresent(src, out, Tag.RequestingPhysician, VR.PN, null);
+ copyIfPresent(src, out, Tag.ProtocolName, VR.LO, null);
+ copyIfPresent(src, out, Tag.BodyPartThickness, VR.DS, null);
if (level == QueryRetrieveLevel.STUDY) {
Attributes studyOnly = new Attributes();
@@ -162,6 +223,17 @@ private Attributes filterByLevel(Attributes src, QueryRetrieveLevel level) {
return out;
}
+ private void copyIfPresent(Attributes src, Attributes dst, int tag, VR vr, String defaultValue) {
+ String value = src.getString(tag, null);
+ if (value != null && !value.isBlank()) {
+ dst.setString(tag, vr, value);
+ return;
+ }
+ if (defaultValue != null) {
+ dst.setString(tag, vr, defaultValue);
+ }
+ }
+
public static String extractFreeText(String raw) {
if (raw == null || raw.isBlank()) {
return null;
diff --git a/backend/plugins/dicoogle-next-query-file/src/test/java/org/dicoogle/query/file/FileReadWriteDimseFindPluginTest.java b/backend/plugins/dicoogle-next-query-file/src/test/java/org/dicoogle/query/file/FileReadWriteDimseFindPluginTest.java
index 8263279..19849b3 100644
--- a/backend/plugins/dicoogle-next-query-file/src/test/java/org/dicoogle/query/file/FileReadWriteDimseFindPluginTest.java
+++ b/backend/plugins/dicoogle-next-query-file/src/test/java/org/dicoogle/query/file/FileReadWriteDimseFindPluginTest.java
@@ -27,8 +27,8 @@ void supportsTextAndKeywordFilters() throws Exception {
Attributes keys = new Attributes();
keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY");
- keys.setString(Tag.StudyInstanceUID, VR.UI, "1.2.3");
- keys.setString(Tag.PatientName, VR.PN, "felix modality:MR");
+ keys.setString(Tag.PatientName, VR.PN, "felix");
+ keys.setString(Tag.Modality, VR.CS, "MR");
var request =
new DimseFindServicePlugin.FindRequest(
diff --git a/backend/plugins/dicoogle-next-storage-file-ro/pom.xml b/backend/plugins/dicoogle-next-storage-file-ro/pom.xml
index 27da56c..7c21347 100644
--- a/backend/plugins/dicoogle-next-storage-file-ro/pom.xml
+++ b/backend/plugins/dicoogle-next-storage-file-ro/pom.xml
@@ -7,7 +7,7 @@
org.dicoogle
dicoogle-next-backend-parent
0.0.1-SNAPSHOT
- ../pom.xml
+ ../../pom.xml
dicoogle-next-storage-file-ro
diff --git a/backend/plugins/dicoogle-next-storage-file-rw/pom.xml b/backend/plugins/dicoogle-next-storage-file-rw/pom.xml
index bad12bf..0f94bd7 100644
--- a/backend/plugins/dicoogle-next-storage-file-rw/pom.xml
+++ b/backend/plugins/dicoogle-next-storage-file-rw/pom.xml
@@ -7,7 +7,7 @@
org.dicoogle
dicoogle-next-backend-parent
0.0.1-SNAPSHOT
- ../pom.xml
+ ../../pom.xml
dicoogle-next-storage-file-rw
diff --git a/backend/plugins/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStoragePlugin.java b/backend/plugins/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStoragePlugin.java
index 58b50c7..cd33186 100644
--- a/backend/plugins/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStoragePlugin.java
+++ b/backend/plugins/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStoragePlugin.java
@@ -139,6 +139,16 @@ public List listStudyInstances(String studyInstanceUid) throws IOException
return results;
}
+ public List listAllInstances() throws IOException {
+ List results = new ArrayList<>();
+ if (!Files.exists(rootDirectory)) {
+ return results;
+ }
+
+ collectDicomFiles(rootDirectory, results);
+ return results;
+ }
+
@Override
public List listSeriesInstances(String studyInstanceUid, String seriesInstanceUid)
throws IOException {
diff --git a/backend/pom.xml b/backend/pom.xml
index c99298c..5cf8b13 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -24,9 +24,9 @@
dicoogle-next-core
dicoogle-next-protocol-dimse
dicoogle-next-protocol-dicomweb
- dicoogle-next-storage-file-ro
- dicoogle-next-storage-file-rw
- dicoogle-next-query-file
+ plugins/dicoogle-next-storage-file-ro
+ plugins/dicoogle-next-storage-file-rw
+ plugins/dicoogle-next-query-file
@@ -100,10 +100,10 @@
- spotless-check
+ spotless-apply
validate
- check
+ apply
From b6727e4057a231d995c0c5e1e5ff3140c5fab100 Mon Sep 17 00:00:00 2001
From: luis-godinho <125367612+luis-godinho@users.noreply.github.com>
Date: Sat, 11 Apr 2026 15:44:46 +0100
Subject: [PATCH 3/6] test: cover C-FIND query task cancel signaling
---
.../protocol/dimse/DimseCFindSCP.java | 29 +-------
.../protocol/dimse/DimseListQueryTask.java | 33 +++++++++
.../dimse/DimseListQueryTaskTest.java | 69 +++++++++++++++++++
3 files changed, 103 insertions(+), 28 deletions(-)
create mode 100644 backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseListQueryTask.java
create mode 100644 backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/DimseListQueryTaskTest.java
diff --git a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindSCP.java b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindSCP.java
index c8c862a..29c9343 100644
--- a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindSCP.java
+++ b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindSCP.java
@@ -1,13 +1,10 @@
package org.dicoogle.protocol.dimse;
-import java.util.ArrayList;
-import java.util.Iterator;
import java.util.List;
import org.dcm4che3.data.Attributes;
import org.dcm4che3.net.Association;
import org.dcm4che3.net.pdu.PresentationContext;
import org.dcm4che3.net.service.BasicCFindSCP;
-import org.dcm4che3.net.service.BasicQueryTask;
import org.dcm4che3.net.service.DicomServiceException;
import org.dcm4che3.net.service.QueryTask;
import org.slf4j.Logger;
@@ -41,30 +38,6 @@ protected QueryTask calculateMatches(
as.getCallingAET(),
as.getCalledAET(),
as.getSerialNo());
- return new ListQueryTask(as, pc, rq, keys, matches);
- }
-
- private static final class ListQueryTask extends BasicQueryTask {
- private final Iterator iterator;
-
- private ListQueryTask(
- Association as,
- PresentationContext pc,
- Attributes rq,
- Attributes keys,
- List matches) {
- super(as, pc, rq, keys);
- this.iterator = new ArrayList<>(matches).iterator();
- }
-
- @Override
- protected boolean hasMoreMatches() {
- return iterator.hasNext();
- }
-
- @Override
- protected Attributes nextMatch() {
- return iterator.next();
- }
+ return new DimseListQueryTask(as, pc, rq, keys, matches);
}
}
diff --git a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseListQueryTask.java b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseListQueryTask.java
new file mode 100644
index 0000000..e147842
--- /dev/null
+++ b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseListQueryTask.java
@@ -0,0 +1,33 @@
+package org.dicoogle.protocol.dimse;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import org.dcm4che3.data.Attributes;
+import org.dcm4che3.net.Association;
+import org.dcm4che3.net.pdu.PresentationContext;
+import org.dcm4che3.net.service.BasicQueryTask;
+
+class DimseListQueryTask extends BasicQueryTask {
+ private final Iterator iterator;
+
+ DimseListQueryTask(
+ Association as,
+ PresentationContext pc,
+ Attributes rq,
+ Attributes keys,
+ List matches) {
+ super(as, pc, rq, keys);
+ this.iterator = new ArrayList<>(matches).iterator();
+ }
+
+ @Override
+ protected boolean hasMoreMatches() {
+ return iterator.hasNext();
+ }
+
+ @Override
+ protected Attributes nextMatch() {
+ return iterator.next();
+ }
+}
diff --git a/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/DimseListQueryTaskTest.java b/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/DimseListQueryTaskTest.java
new file mode 100644
index 0000000..a112e53
--- /dev/null
+++ b/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/DimseListQueryTaskTest.java
@@ -0,0 +1,69 @@
+package org.dicoogle.protocol.dimse;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.lang.reflect.Field;
+import java.util.List;
+import org.dcm4che3.data.Attributes;
+import org.dcm4che3.data.Tag;
+import org.dcm4che3.data.VR;
+import org.junit.jupiter.api.Test;
+
+class DimseListQueryTaskTest {
+
+ @Test
+ void onCancelRequestMarksTaskAsCanceled() throws Exception {
+ Attributes rq = new Attributes();
+ rq.setInt(Tag.MessageID, VR.US, 7);
+ Attributes keys = new Attributes();
+ keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY");
+
+ Attributes match = new Attributes();
+ match.setString(Tag.StudyInstanceUID, VR.UI, "1.2.3");
+
+ DimseListQueryTask task = new DimseListQueryTask(null, null, rq, keys, List.of(match));
+
+ task.onCancelRQ(null);
+
+ Field canceledField = task.getClass().getSuperclass().getDeclaredField("canceled");
+ canceledField.setAccessible(true);
+ assertEquals(true, canceledField.getBoolean(task));
+ }
+
+ @Test
+ void returnsProvidedMatchesInOrder() throws Exception {
+ Attributes rq = new Attributes();
+ rq.setInt(Tag.MessageID, VR.US, 9);
+ Attributes keys = new Attributes();
+ keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY");
+
+ Attributes first = new Attributes();
+ first.setString(Tag.StudyInstanceUID, VR.UI, "1.2.3");
+ Attributes second = new Attributes();
+ second.setString(Tag.StudyInstanceUID, VR.UI, "1.2.4");
+
+ DimseListQueryTask task = new DimseListQueryTask(null, null, rq, keys, List.of(first, second));
+
+ assertEquals(true, invokeHasMore(task));
+ assertEquals("1.2.3", invokeNext(task).getString(Tag.StudyInstanceUID));
+ assertEquals(true, invokeHasMore(task));
+ assertEquals("1.2.4", invokeNext(task).getString(Tag.StudyInstanceUID));
+ assertFalse(invokeHasMore(task));
+ }
+
+ private boolean invokeHasMore(DimseListQueryTask task) throws Exception {
+ var method = DimseListQueryTask.class.getDeclaredMethod("hasMoreMatches");
+ method.setAccessible(true);
+ return (boolean) method.invoke(task);
+ }
+
+ private Attributes invokeNext(DimseListQueryTask task) throws Exception {
+ var method = DimseListQueryTask.class.getDeclaredMethod("nextMatch");
+ method.setAccessible(true);
+ Attributes out = (Attributes) method.invoke(task);
+ assertNotNull(out);
+ return out;
+ }
+}
From 8047018e80647633c4b107a34efa861507786ed9 Mon Sep 17 00:00:00 2001
From: luis-godinho <125367612+luis-godinho@users.noreply.github.com>
Date: Sat, 11 Apr 2026 15:51:08 +0100
Subject: [PATCH 4/6] feat: extend C-FIND matching with wildcard, multi-value,
and date ranges
---
.../file/FileReadWriteDimseFindPlugin.java | 103 +++++++++++++++---
.../FileReadWriteDimseFindPluginTest.java | 73 ++++++++++++-
2 files changed, 160 insertions(+), 16 deletions(-)
diff --git a/backend/plugins/dicoogle-next-query-file/src/main/java/org/dicoogle/query/file/FileReadWriteDimseFindPlugin.java b/backend/plugins/dicoogle-next-query-file/src/main/java/org/dicoogle/query/file/FileReadWriteDimseFindPlugin.java
index 9647943..548c310 100644
--- a/backend/plugins/dicoogle-next-query-file/src/main/java/org/dicoogle/query/file/FileReadWriteDimseFindPlugin.java
+++ b/backend/plugins/dicoogle-next-query-file/src/main/java/org/dicoogle/query/file/FileReadWriteDimseFindPlugin.java
@@ -138,41 +138,116 @@ private int mapKeywordToTag(String keyword) {
}
private boolean matchesDicomKeys(Attributes attrs, Attributes keys) {
- return matchesExactUi(attrs, Tag.StudyInstanceUID, keys)
- && matchesExactUi(attrs, Tag.SeriesInstanceUID, keys)
- && matchesExactUi(attrs, Tag.SOPInstanceUID, keys)
- && matchesExactIgnoreCase(attrs, Tag.PatientID, keys)
- && matchesExactIgnoreCase(attrs, Tag.Modality, keys)
- && matchesContainsIgnoreCase(attrs, Tag.PatientName, keys)
- && matchesContainsIgnoreCase(attrs, Tag.StudyDescription, keys)
- && matchesContainsIgnoreCase(attrs, Tag.SeriesDescription, keys);
+ return matchesUid(attrs, Tag.StudyInstanceUID, keys)
+ && matchesUid(attrs, Tag.SeriesInstanceUID, keys)
+ && matchesUid(attrs, Tag.SOPInstanceUID, keys)
+ && matchesString(attrs, Tag.PatientID, keys)
+ && matchesString(attrs, Tag.Modality, keys)
+ && matchesString(attrs, Tag.PatientName, keys)
+ && matchesString(attrs, Tag.StudyDescription, keys)
+ && matchesString(attrs, Tag.SeriesDescription, keys)
+ && matchesString(attrs, Tag.AccessionNumber, keys)
+ && matchesDate(attrs, Tag.PatientBirthDate, keys)
+ && matchesDate(attrs, Tag.StudyDate, keys)
+ && matchesDate(attrs, Tag.SeriesDate, keys);
}
- private boolean matchesExactUi(Attributes attrs, int tag, Attributes keys) {
+ private boolean matchesUid(Attributes attrs, int tag, Attributes keys) {
String expected = keys.getString(tag, null);
if (!hasText(expected)) {
return true;
}
String actual = attrs.getString(tag, "");
- return actual.equals(expected);
+ for (String candidate : splitMultiValue(expected)) {
+ if (actual.equals(candidate)) {
+ return true;
+ }
+ }
+ return false;
}
- private boolean matchesExactIgnoreCase(Attributes attrs, int tag, Attributes keys) {
+ private boolean matchesString(Attributes attrs, int tag, Attributes keys) {
String expected = keys.getString(tag, null);
if (!hasText(expected)) {
return true;
}
String actual = attrs.getString(tag, "");
- return actual.equalsIgnoreCase(expected);
+ for (String candidate : splitMultiValue(expected)) {
+ if (matchesStringValue(actual, candidate)) {
+ return true;
+ }
+ }
+ return false;
}
- private boolean matchesContainsIgnoreCase(Attributes attrs, int tag, Attributes keys) {
+ private boolean matchesDate(Attributes attrs, int tag, Attributes keys) {
String expected = keys.getString(tag, null);
if (!hasText(expected)) {
return true;
}
String actual = attrs.getString(tag, "");
- return actual.toLowerCase(Locale.ROOT).contains(expected.toLowerCase(Locale.ROOT));
+ for (String candidate : splitMultiValue(expected)) {
+ if (matchesDateValue(actual, candidate)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private List splitMultiValue(String expected) {
+ String[] values = expected.split("\\\\");
+ List out = new ArrayList<>(values.length);
+ for (String value : values) {
+ if (hasText(value)) {
+ out.add(value.trim());
+ }
+ }
+ return out;
+ }
+
+ private boolean matchesStringValue(String actual, String expected) {
+ if (expected.indexOf('*') >= 0 || expected.indexOf('?') >= 0) {
+ String regex = wildcardToRegex(expected);
+ return Pattern.compile(regex, Pattern.CASE_INSENSITIVE | Pattern.DOTALL)
+ .matcher(actual)
+ .matches();
+ }
+ return actual.equalsIgnoreCase(expected);
+ }
+
+ private String wildcardToRegex(String wildcard) {
+ StringBuilder regex = new StringBuilder("^");
+ for (char c : wildcard.toCharArray()) {
+ if (c == '*') {
+ regex.append(".*");
+ } else if (c == '?') {
+ regex.append('.');
+ } else if ("\\.^$|()[]{}+".indexOf(c) >= 0) {
+ regex.append('\\').append(c);
+ } else {
+ regex.append(c);
+ }
+ }
+ regex.append('$');
+ return regex.toString();
+ }
+
+ private boolean matchesDateValue(String actual, String expected) {
+ int dash = expected.indexOf('-');
+ if (dash < 0) {
+ return actual.equals(expected);
+ }
+
+ String start = expected.substring(0, dash).trim();
+ String end = expected.substring(dash + 1).trim();
+
+ if (hasText(start) && actual.compareTo(start) < 0) {
+ return false;
+ }
+ if (hasText(end) && actual.compareTo(end) > 0) {
+ return false;
+ }
+ return true;
}
private boolean hasText(String value) {
diff --git a/backend/plugins/dicoogle-next-query-file/src/test/java/org/dicoogle/query/file/FileReadWriteDimseFindPluginTest.java b/backend/plugins/dicoogle-next-query-file/src/test/java/org/dicoogle/query/file/FileReadWriteDimseFindPluginTest.java
index 19849b3..acbda24 100644
--- a/backend/plugins/dicoogle-next-query-file/src/test/java/org/dicoogle/query/file/FileReadWriteDimseFindPluginTest.java
+++ b/backend/plugins/dicoogle-next-query-file/src/test/java/org/dicoogle/query/file/FileReadWriteDimseFindPluginTest.java
@@ -1,5 +1,6 @@
package org.dicoogle.query.file;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -21,7 +22,8 @@ void supportsTextAndKeywordFilters() throws Exception {
Path root = Files.createTempDirectory("query-file-test");
FileReadWriteStoragePlugin storage = new FileReadWriteStoragePlugin(root, "file");
storage.store(
- new java.io.ByteArrayInputStream(createDicom("P1", "FELIX", "MR")), "application/dicom");
+ new java.io.ByteArrayInputStream(createDicom("P1", "FELIX", "MR", "A1", "20240101")),
+ "application/dicom");
FileReadWriteDimseFindPlugin plugin = new FileReadWriteDimseFindPlugin(storage);
@@ -51,7 +53,72 @@ void parserKeepsTextAndKeywordsSeparated() {
assertTrue(FileReadWriteDimseFindPlugin.extractFreeText(raw).contains("brain"));
}
- private byte[] createDicom(String patientId, String patientName, String modality)
+ @Test
+ void supportsWildcardAndMultiValueMatching() throws Exception {
+ Path root = Files.createTempDirectory("query-file-test-wildcards");
+ FileReadWriteStoragePlugin storage = new FileReadWriteStoragePlugin(root, "file");
+ storage.store(
+ new java.io.ByteArrayInputStream(
+ createDicom("P1", "FELIX^ALMEIDA", "MR", "A100", "20240115")),
+ "application/dicom");
+ storage.store(
+ new java.io.ByteArrayInputStream(createDicom("P2", "JOAO", "CT", "A200", "20250310")),
+ "application/dicom");
+
+ FileReadWriteDimseFindPlugin plugin = new FileReadWriteDimseFindPlugin(storage);
+
+ Attributes keys = new Attributes();
+ keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY");
+ keys.setString(Tag.PatientName, VR.PN, "F*");
+ keys.setString(Tag.Modality, VR.CS, "MR\\US");
+
+ var request =
+ new DimseFindServicePlugin.FindRequest(
+ DimseFindServicePlugin.InformationModel.STUDY_ROOT,
+ DimseFindServicePlugin.QueryRetrieveLevel.STUDY,
+ "CALLING",
+ "CALLED",
+ 2,
+ keys,
+ null,
+ java.util.Map.of());
+
+ assertEquals(1, plugin.find(request).size());
+ }
+
+ @Test
+ void supportsDateRangeMatching() throws Exception {
+ Path root = Files.createTempDirectory("query-file-test-dates");
+ FileReadWriteStoragePlugin storage = new FileReadWriteStoragePlugin(root, "file");
+ storage.store(
+ new java.io.ByteArrayInputStream(createDicom("P1", "ANA", "MR", "A1", "20240115")),
+ "application/dicom");
+ storage.store(
+ new java.io.ByteArrayInputStream(createDicom("P2", "BEA", "MR", "A2", "20250310")),
+ "application/dicom");
+
+ FileReadWriteDimseFindPlugin plugin = new FileReadWriteDimseFindPlugin(storage);
+
+ Attributes keys = new Attributes();
+ keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY");
+ keys.setString(Tag.StudyDate, VR.DA, "20240101-20241231");
+
+ var request =
+ new DimseFindServicePlugin.FindRequest(
+ DimseFindServicePlugin.InformationModel.STUDY_ROOT,
+ DimseFindServicePlugin.QueryRetrieveLevel.STUDY,
+ "CALLING",
+ "CALLED",
+ 3,
+ keys,
+ null,
+ java.util.Map.of());
+
+ assertEquals(1, plugin.find(request).size());
+ }
+
+ private byte[] createDicom(
+ String patientId, String patientName, String modality, String accession, String studyDate)
throws Exception {
Attributes fmi = new Attributes();
fmi.setString(Tag.TransferSyntaxUID, VR.UI, UID.ExplicitVRLittleEndian);
@@ -67,6 +134,8 @@ private byte[] createDicom(String patientId, String patientName, String modality
attrs.setString(Tag.PatientID, VR.LO, patientId);
attrs.setString(Tag.PatientName, VR.PN, patientName);
attrs.setString(Tag.Modality, VR.CS, modality);
+ attrs.setString(Tag.AccessionNumber, VR.SH, accession);
+ attrs.setString(Tag.StudyDate, VR.DA, studyDate);
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
try (DicomOutputStream dos = new DicomOutputStream(out, UID.ExplicitVRLittleEndian)) {
From 2458de2994efc68fb8e0a00d3ef091c44a408c6a Mon Sep 17 00:00:00 2001
From: luis-godinho <125367612+luis-godinho@users.noreply.github.com>
Date: Sun, 19 Apr 2026 14:02:19 +0100
Subject: [PATCH 5/6] feat: harden C-FIND with fuzzy PN, TM/DT ranges, nested
sequence matching, and early C-CANCEL stop
---
backend/README.md | 4 +
.../dicoogle/protocol/dimse/CFindService.java | 12 +-
.../protocol/dimse/DimseCFindSCP.java | 27 ++-
.../protocol/dimse/DimseCStoreServer.java | 9 +-
.../protocol/dimse/DimseListQueryTask.java | 31 ++-
.../protocol/dimse/CFindServiceTest.java | 11 +-
.../dimse/DimseListQueryTaskTest.java | 6 +-
.../sdk/query/DimseFindServicePlugin.java | 7 +-
.../file/FileReadWriteDimseFindPlugin.java | 140 ++++++++----
.../FileReadWriteDimseFindPluginTest.java | 205 +++++++++++++++++-
10 files changed, 379 insertions(+), 73 deletions(-)
diff --git a/backend/README.md b/backend/README.md
index 4a91e58..729201c 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -45,6 +45,10 @@ Query behavior:
- Mixed queries are supported (for example: free text + strict DICOM keys together).
- Broad queries without UIDs are supported by scanning indexed instances and filtering in-memory.
- Response size is capped by `app.dimse.cfind.max-results` (default `1000`).
+- Optional query negotiation supported: `FUZZY` (PN fuzzy matching), `DATETIME` (DT matching/ranges),
+ and `RELATIONAL` advertised at association level.
+- Sequence/nested matching keys are supported for sequence items in the query dataset.
+- C-CANCEL stops response emission and now also short-circuits plugin scanning.
`keyword:value` filters currently supported:
diff --git a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/CFindService.java b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/CFindService.java
index 250d47b..6077c8b 100644
--- a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/CFindService.java
+++ b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/CFindService.java
@@ -10,9 +10,11 @@
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
+import java.util.function.BooleanSupplier;
import java.util.regex.Pattern;
import org.dcm4che3.data.Attributes;
import org.dcm4che3.data.Tag;
+import org.dcm4che3.net.QueryOption;
import org.dcm4che3.net.Status;
import org.dcm4che3.net.service.DicomServiceException;
import org.dicoogle.sdk.query.DimseFindAccessPolicy;
@@ -52,7 +54,9 @@ public List find(
Attributes keys,
String callingAet,
String calledAet,
- int associationSerialNo)
+ int associationSerialNo,
+ java.util.Set queryOptions,
+ BooleanSupplier cancelRequested)
throws DicomServiceException {
long startNs = System.nanoTime();
increment("dicoogle.cfind.requests", null);
@@ -111,7 +115,11 @@ public List find(
associationSerialNo,
normalizedKeys,
freeText,
- keywordFilters);
+ keywordFilters,
+ queryOptions.contains(QueryOption.FUZZY),
+ queryOptions.contains(QueryOption.DATETIME),
+ queryOptions.contains(QueryOption.RELATIONAL),
+ cancelRequested == null ? () -> false : cancelRequested);
for (DimseFindAccessPolicy policy : accessPolicies) {
DimseFindAccessPolicy.Decision decision = policy.evaluate(request);
diff --git a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindSCP.java b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindSCP.java
index 29c9343..92b0ed2 100644
--- a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindSCP.java
+++ b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindSCP.java
@@ -1,8 +1,9 @@
package org.dicoogle.protocol.dimse;
-import java.util.List;
+import java.util.Set;
import org.dcm4che3.data.Attributes;
import org.dcm4che3.net.Association;
+import org.dcm4che3.net.QueryOption;
import org.dcm4che3.net.pdu.PresentationContext;
import org.dcm4che3.net.service.BasicCFindSCP;
import org.dcm4che3.net.service.DicomServiceException;
@@ -31,13 +32,21 @@ protected QueryTask calculateMatches(
as.getCallingAET(),
as.getCalledAET());
- List matches =
- cFindService.find(
- rq.getString(org.dcm4che3.data.Tag.AffectedSOPClassUID),
- keys,
- as.getCallingAET(),
- as.getCalledAET(),
- as.getSerialNo());
- return new DimseListQueryTask(as, pc, rq, keys, matches);
+ Set queryOptions =
+ as.getRequestedQueryOptionsFor(rq.getString(org.dcm4che3.data.Tag.AffectedSOPClassUID));
+ return new DimseListQueryTask(
+ as,
+ pc,
+ rq,
+ keys,
+ cancelRequested ->
+ cFindService.find(
+ rq.getString(org.dcm4che3.data.Tag.AffectedSOPClassUID),
+ keys,
+ as.getCallingAET(),
+ as.getCalledAET(),
+ as.getSerialNo(),
+ queryOptions,
+ cancelRequested));
}
}
diff --git a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCStoreServer.java b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCStoreServer.java
index d191aca..c76f1f3 100644
--- a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCStoreServer.java
+++ b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCStoreServer.java
@@ -3,6 +3,7 @@
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.time.Instant;
+import java.util.EnumSet;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ExecutorService;
@@ -18,6 +19,7 @@
import org.dcm4che3.net.Connection;
import org.dcm4che3.net.Device;
import org.dcm4che3.net.PDVInputStream;
+import org.dcm4che3.net.QueryOption;
import org.dcm4che3.net.TransferCapability;
import org.dcm4che3.net.pdu.AAssociateRJ;
import org.dcm4che3.net.pdu.PresentationContext;
@@ -231,13 +233,16 @@ private void configureTransferCapabilities(
UID.ImplicitVRLittleEndian));
}
- ae.addTransferCapability(
+ TransferCapability cfindTc =
new TransferCapability(
"cfind-study-root-scp",
STUDY_ROOT_FIND_UID,
TransferCapability.Role.SCP,
UID.ImplicitVRLittleEndian,
- UID.ExplicitVRLittleEndian));
+ UID.ExplicitVRLittleEndian);
+ cfindTc.setQueryOptions(
+ EnumSet.of(QueryOption.RELATIONAL, QueryOption.DATETIME, QueryOption.FUZZY));
+ ae.addTransferCapability(cfindTc);
}
public synchronized void applyAcceptedTransferCapabilities(
diff --git a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseListQueryTask.java b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseListQueryTask.java
index e147842..8bfc961 100644
--- a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseListQueryTask.java
+++ b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseListQueryTask.java
@@ -3,26 +3,36 @@
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
+import java.util.function.BooleanSupplier;
import org.dcm4che3.data.Attributes;
import org.dcm4che3.net.Association;
import org.dcm4che3.net.pdu.PresentationContext;
import org.dcm4che3.net.service.BasicQueryTask;
+import org.dcm4che3.net.service.DicomServiceException;
class DimseListQueryTask extends BasicQueryTask {
- private final Iterator iterator;
+ @FunctionalInterface
+ interface MatchProvider {
+ List load(BooleanSupplier cancelRequested) throws DicomServiceException;
+ }
+
+ private final MatchProvider matchProvider;
+ private Iterator iterator;
+ private boolean initialized;
DimseListQueryTask(
Association as,
PresentationContext pc,
Attributes rq,
Attributes keys,
- List matches) {
+ MatchProvider matchProvider) {
super(as, pc, rq, keys);
- this.iterator = new ArrayList<>(matches).iterator();
+ this.matchProvider = matchProvider;
}
@Override
- protected boolean hasMoreMatches() {
+ protected boolean hasMoreMatches() throws DicomServiceException {
+ ensureInitialized();
return iterator.hasNext();
}
@@ -30,4 +40,17 @@ protected boolean hasMoreMatches() {
protected Attributes nextMatch() {
return iterator.next();
}
+
+ private void ensureInitialized() throws DicomServiceException {
+ if (initialized) {
+ return;
+ }
+ initialized = true;
+ if (canceled) {
+ iterator = List.of().iterator();
+ return;
+ }
+ List matches = matchProvider.load(() -> canceled);
+ iterator = new ArrayList<>(matches).iterator();
+ }
}
diff --git a/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/CFindServiceTest.java b/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/CFindServiceTest.java
index b035ab2..7821f30 100644
--- a/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/CFindServiceTest.java
+++ b/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/CFindServiceTest.java
@@ -6,6 +6,7 @@
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import java.io.IOException;
import java.util.List;
+import java.util.Set;
import org.dcm4che3.data.Attributes;
import org.dcm4che3.data.Tag;
import org.dcm4che3.data.VR;
@@ -31,7 +32,7 @@ void failsWhenQueryLevelMissing() {
DicomServiceException ex =
assertThrows(
DicomServiceException.class,
- () -> service.find("1.2.840.10008.5.1.4.1.2.2.1", keys, "A", "B", 10));
+ () -> service.find("1.2.840.10008.5.1.4.1.2.2.1", keys, "A", "B", 10, Set.of(), null));
assertEquals(Status.IdentifierDoesNotMatchSOPClass, ex.getStatus());
}
@@ -49,7 +50,7 @@ void supportsFreeTextAndLevel() throws Exception {
keys.setString(Tag.AccessionNumber, VR.SH, "brain");
List matches =
- service.find("1.2.840.10008.5.1.4.1.2.2.1", keys, "CALLING", "CALLED", 77);
+ service.find("1.2.840.10008.5.1.4.1.2.2.1", keys, "CALLING", "CALLED", 77, Set.of(), null);
assertEquals(1, matches.size());
assertEquals("1.2.3", matches.get(0).getString(Tag.StudyInstanceUID));
@@ -70,7 +71,7 @@ void appliesConfiguredMaxResults() throws Exception {
keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY");
List matches =
- service.find("1.2.840.10008.5.1.4.1.2.2.1", keys, "CALLING", "CALLED", 88);
+ service.find("1.2.840.10008.5.1.4.1.2.2.1", keys, "CALLING", "CALLED", 88, Set.of(), null);
assertEquals(1, matches.size());
}
@@ -102,7 +103,9 @@ public Decision evaluate(DimseFindServicePlugin.FindRequest request) {
DicomServiceException ex =
assertThrows(
DicomServiceException.class,
- () -> service.find("1.2.840.10008.5.1.4.1.2.2.1", keys, "CALLING", "CALLED", 99));
+ () ->
+ service.find(
+ "1.2.840.10008.5.1.4.1.2.2.1", keys, "CALLING", "CALLED", 99, Set.of(), null));
assertEquals(Status.UnableToProcess, ex.getStatus());
}
diff --git a/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/DimseListQueryTaskTest.java b/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/DimseListQueryTaskTest.java
index a112e53..7c9cd1c 100644
--- a/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/DimseListQueryTaskTest.java
+++ b/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/DimseListQueryTaskTest.java
@@ -23,7 +23,8 @@ void onCancelRequestMarksTaskAsCanceled() throws Exception {
Attributes match = new Attributes();
match.setString(Tag.StudyInstanceUID, VR.UI, "1.2.3");
- DimseListQueryTask task = new DimseListQueryTask(null, null, rq, keys, List.of(match));
+ DimseListQueryTask task =
+ new DimseListQueryTask(null, null, rq, keys, ignored -> List.of(match));
task.onCancelRQ(null);
@@ -44,7 +45,8 @@ void returnsProvidedMatchesInOrder() throws Exception {
Attributes second = new Attributes();
second.setString(Tag.StudyInstanceUID, VR.UI, "1.2.4");
- DimseListQueryTask task = new DimseListQueryTask(null, null, rq, keys, List.of(first, second));
+ DimseListQueryTask task =
+ new DimseListQueryTask(null, null, rq, keys, ignored -> List.of(first, second));
assertEquals(true, invokeHasMore(task));
assertEquals("1.2.3", invokeNext(task).getString(Tag.StudyInstanceUID));
diff --git a/backend/dicoogle-next-sdk/src/main/java/org/dicoogle/sdk/query/DimseFindServicePlugin.java b/backend/dicoogle-next-sdk/src/main/java/org/dicoogle/sdk/query/DimseFindServicePlugin.java
index f4890e7..8de7907 100644
--- a/backend/dicoogle-next-sdk/src/main/java/org/dicoogle/sdk/query/DimseFindServicePlugin.java
+++ b/backend/dicoogle-next-sdk/src/main/java/org/dicoogle/sdk/query/DimseFindServicePlugin.java
@@ -2,6 +2,7 @@
import java.io.IOException;
import java.util.List;
+import java.util.function.BooleanSupplier;
import org.dcm4che3.data.Attributes;
public interface DimseFindServicePlugin extends QueryIndexPlugin {
@@ -24,7 +25,11 @@ record FindRequest(
int associationSerialNo,
Attributes keys,
String freeText,
- java.util.Map keywordFilters) {}
+ java.util.Map keywordFilters,
+ boolean fuzzyMatchingEnabled,
+ boolean dateTimeMatchingEnabled,
+ boolean relationalMatchingEnabled,
+ BooleanSupplier cancelRequested) {}
List find(FindRequest request) throws IOException;
}
diff --git a/backend/plugins/dicoogle-next-query-file/src/main/java/org/dicoogle/query/file/FileReadWriteDimseFindPlugin.java b/backend/plugins/dicoogle-next-query-file/src/main/java/org/dicoogle/query/file/FileReadWriteDimseFindPlugin.java
index 548c310..6541969 100644
--- a/backend/plugins/dicoogle-next-query-file/src/main/java/org/dicoogle/query/file/FileReadWriteDimseFindPlugin.java
+++ b/backend/plugins/dicoogle-next-query-file/src/main/java/org/dicoogle/query/file/FileReadWriteDimseFindPlugin.java
@@ -10,6 +10,7 @@
import java.util.Set;
import java.util.regex.Pattern;
import org.dcm4che3.data.Attributes;
+import org.dcm4che3.data.Sequence;
import org.dcm4che3.data.Tag;
import org.dcm4che3.data.VR;
import org.dcm4che3.io.DicomInputStream;
@@ -67,6 +68,9 @@ && hasText(seriesUid)) {
if (!seen.add(uri.toString())) {
continue;
}
+ if (request.cancelRequested() != null && request.cancelRequested().getAsBoolean()) {
+ break;
+ }
Attributes attrs = readDataset(uri);
if (!matchesFreeText(attrs, freeText)) {
continue;
@@ -74,7 +78,7 @@ && hasText(seriesUid)) {
if (!matchesKeywordFilters(attrs, filters)) {
continue;
}
- if (!matchesDicomKeys(attrs, request.keys())) {
+ if (!matchesDicomKeys(attrs, request.keys(), request)) {
continue;
}
out.add(filterByLevel(attrs, request.level()));
@@ -137,57 +141,78 @@ private int mapKeywordToTag(String keyword) {
};
}
- private boolean matchesDicomKeys(Attributes attrs, Attributes keys) {
- return matchesUid(attrs, Tag.StudyInstanceUID, keys)
- && matchesUid(attrs, Tag.SeriesInstanceUID, keys)
- && matchesUid(attrs, Tag.SOPInstanceUID, keys)
- && matchesString(attrs, Tag.PatientID, keys)
- && matchesString(attrs, Tag.Modality, keys)
- && matchesString(attrs, Tag.PatientName, keys)
- && matchesString(attrs, Tag.StudyDescription, keys)
- && matchesString(attrs, Tag.SeriesDescription, keys)
- && matchesString(attrs, Tag.AccessionNumber, keys)
- && matchesDate(attrs, Tag.PatientBirthDate, keys)
- && matchesDate(attrs, Tag.StudyDate, keys)
- && matchesDate(attrs, Tag.SeriesDate, keys);
- }
+ private boolean matchesDicomKeys(Attributes attrs, Attributes keys, FindRequest request) {
+ for (int tag : keys.tags()) {
+ if (tag == Tag.QueryRetrieveLevel) {
+ continue;
+ }
+ VR vr = keys.getVR(tag);
+ if (vr == VR.SQ) {
+ if (!matchesSequence(attrs, keys, tag, request)) {
+ return false;
+ }
+ continue;
+ }
- private boolean matchesUid(Attributes attrs, int tag, Attributes keys) {
- String expected = keys.getString(tag, null);
- if (!hasText(expected)) {
- return true;
- }
- String actual = attrs.getString(tag, "");
- for (String candidate : splitMultiValue(expected)) {
- if (actual.equals(candidate)) {
- return true;
+ String expected = keys.getString(tag, null);
+ if (!hasText(expected)) {
+ continue;
+ }
+ if (!matchesTagValue(attrs, expected, tag, vr, request)) {
+ return false;
}
}
- return false;
+ return true;
}
- private boolean matchesString(Attributes attrs, int tag, Attributes keys) {
- String expected = keys.getString(tag, null);
- if (!hasText(expected)) {
- return true;
- }
+ private boolean matchesTagValue(
+ Attributes attrs, String expected, int tag, VR vr, FindRequest request) {
String actual = attrs.getString(tag, "");
+ if (vr == VR.UI) {
+ return matchesUidValue(actual, expected);
+ }
+ if (vr == VR.DA) {
+ return matchesDateValue(actual, expected);
+ }
+ if (vr == VR.TM) {
+ return matchesRangeValue(actual, expected);
+ }
+ if (vr == VR.DT) {
+ return request.dateTimeMatchingEnabled() && matchesRangeValue(actual, expected);
+ }
+ boolean fuzzy = vr == VR.PN && request.fuzzyMatchingEnabled();
+ return matchesStringValue(actual, expected, fuzzy);
+ }
+
+ private boolean matchesUidValue(String actual, String expected) {
for (String candidate : splitMultiValue(expected)) {
- if (matchesStringValue(actual, candidate)) {
+ if (actual.equals(candidate)) {
return true;
}
}
return false;
}
- private boolean matchesDate(Attributes attrs, int tag, Attributes keys) {
- String expected = keys.getString(tag, null);
- if (!hasText(expected)) {
- return true;
- }
- String actual = attrs.getString(tag, "");
+ private boolean matchesStringValue(String actual, String expected, boolean fuzzy) {
for (String candidate : splitMultiValue(expected)) {
- if (matchesDateValue(actual, candidate)) {
+ if (fuzzy) {
+ if (normalizeForFuzzy(actual).contains(normalizeForFuzzy(candidate))) {
+ return true;
+ }
+ continue;
+ }
+
+ if (candidate.indexOf('*') >= 0 || candidate.indexOf('?') >= 0) {
+ String regex = wildcardToRegex(candidate);
+ if (Pattern.compile(regex, Pattern.CASE_INSENSITIVE | Pattern.DOTALL)
+ .matcher(actual)
+ .matches()) {
+ return true;
+ }
+ continue;
+ }
+
+ if (actual.equalsIgnoreCase(candidate)) {
return true;
}
}
@@ -205,14 +230,8 @@ private List splitMultiValue(String expected) {
return out;
}
- private boolean matchesStringValue(String actual, String expected) {
- if (expected.indexOf('*') >= 0 || expected.indexOf('?') >= 0) {
- String regex = wildcardToRegex(expected);
- return Pattern.compile(regex, Pattern.CASE_INSENSITIVE | Pattern.DOTALL)
- .matcher(actual)
- .matches();
- }
- return actual.equalsIgnoreCase(expected);
+ private String normalizeForFuzzy(String value) {
+ return value == null ? "" : value.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", "");
}
private String wildcardToRegex(String wildcard) {
@@ -233,6 +252,10 @@ private String wildcardToRegex(String wildcard) {
}
private boolean matchesDateValue(String actual, String expected) {
+ return matchesRangeValue(actual, expected);
+ }
+
+ private boolean matchesRangeValue(String actual, String expected) {
int dash = expected.indexOf('-');
if (dash < 0) {
return actual.equals(expected);
@@ -250,6 +273,31 @@ private boolean matchesDateValue(String actual, String expected) {
return true;
}
+ private boolean matchesSequence(Attributes attrs, Attributes keys, int tag, FindRequest request) {
+ Sequence expected = keys.getSequence(tag);
+ if (expected == null || expected.isEmpty()) {
+ return true;
+ }
+ Sequence actual = attrs.getSequence(tag);
+ if (actual == null || actual.isEmpty()) {
+ return false;
+ }
+
+ for (Attributes expectedItem : expected) {
+ boolean matchedOne = false;
+ for (Attributes actualItem : actual) {
+ if (matchesDicomKeys(actualItem, expectedItem, request)) {
+ matchedOne = true;
+ break;
+ }
+ }
+ if (!matchedOne) {
+ return false;
+ }
+ }
+ return true;
+ }
+
private boolean hasText(String value) {
return value != null && !value.isBlank();
}
diff --git a/backend/plugins/dicoogle-next-query-file/src/test/java/org/dicoogle/query/file/FileReadWriteDimseFindPluginTest.java b/backend/plugins/dicoogle-next-query-file/src/test/java/org/dicoogle/query/file/FileReadWriteDimseFindPluginTest.java
index acbda24..292ea68 100644
--- a/backend/plugins/dicoogle-next-query-file/src/test/java/org/dicoogle/query/file/FileReadWriteDimseFindPluginTest.java
+++ b/backend/plugins/dicoogle-next-query-file/src/test/java/org/dicoogle/query/file/FileReadWriteDimseFindPluginTest.java
@@ -7,6 +7,7 @@
import java.nio.file.Files;
import java.nio.file.Path;
import org.dcm4che3.data.Attributes;
+import org.dcm4che3.data.Sequence;
import org.dcm4che3.data.Tag;
import org.dcm4che3.data.UID;
import org.dcm4che3.data.VR;
@@ -41,7 +42,11 @@ void supportsTextAndKeywordFilters() throws Exception {
1,
keys,
"felix",
- java.util.Map.of("modality", "MR"));
+ java.util.Map.of("modality", "MR"),
+ false,
+ false,
+ false,
+ () -> false);
assertFalse(plugin.find(request).isEmpty());
}
@@ -81,7 +86,11 @@ void supportsWildcardAndMultiValueMatching() throws Exception {
2,
keys,
null,
- java.util.Map.of());
+ java.util.Map.of(),
+ false,
+ false,
+ false,
+ () -> false);
assertEquals(1, plugin.find(request).size());
}
@@ -112,14 +121,198 @@ void supportsDateRangeMatching() throws Exception {
3,
keys,
null,
- java.util.Map.of());
+ java.util.Map.of(),
+ false,
+ false,
+ false,
+ () -> false);
assertEquals(1, plugin.find(request).size());
}
+ @Test
+ void supportsTimeAndDateTimeRangeMatchingWithNegotiation() throws Exception {
+ Path root = Files.createTempDirectory("query-file-test-datetime");
+ FileReadWriteStoragePlugin storage = new FileReadWriteStoragePlugin(root, "file");
+ storage.store(
+ new java.io.ByteArrayInputStream(
+ createDicom("P1", "ANA", "MR", "A1", "20240115", "103000", "20240115103000")),
+ "application/dicom");
+ storage.store(
+ new java.io.ByteArrayInputStream(
+ createDicom("P2", "BEA", "MR", "A2", "20240115", "153000", "20240115153000")),
+ "application/dicom");
+
+ FileReadWriteDimseFindPlugin plugin = new FileReadWriteDimseFindPlugin(storage);
+
+ Attributes keys = new Attributes();
+ keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY");
+ keys.setString(Tag.StudyTime, VR.TM, "100000-120000");
+ keys.setString(Tag.AcquisitionDateTime, VR.DT, "20240115100000-20240115120000");
+
+ var requestNoDateTimeNegotiation =
+ new DimseFindServicePlugin.FindRequest(
+ DimseFindServicePlugin.InformationModel.STUDY_ROOT,
+ DimseFindServicePlugin.QueryRetrieveLevel.STUDY,
+ "CALLING",
+ "CALLED",
+ 4,
+ keys,
+ null,
+ java.util.Map.of(),
+ false,
+ false,
+ false,
+ () -> false);
+ assertEquals(0, plugin.find(requestNoDateTimeNegotiation).size());
+
+ var requestWithDateTimeNegotiation =
+ new DimseFindServicePlugin.FindRequest(
+ DimseFindServicePlugin.InformationModel.STUDY_ROOT,
+ DimseFindServicePlugin.QueryRetrieveLevel.STUDY,
+ "CALLING",
+ "CALLED",
+ 5,
+ keys,
+ null,
+ java.util.Map.of(),
+ false,
+ true,
+ false,
+ () -> false);
+ assertEquals(1, plugin.find(requestWithDateTimeNegotiation).size());
+ }
+
+ @Test
+ void supportsFuzzyPersonNameWhenNegotiated() throws Exception {
+ Path root = Files.createTempDirectory("query-file-test-fuzzy");
+ FileReadWriteStoragePlugin storage = new FileReadWriteStoragePlugin(root, "file");
+ storage.store(
+ new java.io.ByteArrayInputStream(
+ createDicom("P1", "FELIX^ALMEIDA", "MR", "A1", "20240101", "101010", "20240101101010")),
+ "application/dicom");
+
+ FileReadWriteDimseFindPlugin plugin = new FileReadWriteDimseFindPlugin(storage);
+
+ Attributes keys = new Attributes();
+ keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY");
+ keys.setString(Tag.PatientName, VR.PN, "felixal");
+
+ var requestWithoutFuzzy =
+ new DimseFindServicePlugin.FindRequest(
+ DimseFindServicePlugin.InformationModel.STUDY_ROOT,
+ DimseFindServicePlugin.QueryRetrieveLevel.STUDY,
+ "CALLING",
+ "CALLED",
+ 6,
+ keys,
+ null,
+ java.util.Map.of(),
+ false,
+ false,
+ false,
+ () -> false);
+ assertEquals(0, plugin.find(requestWithoutFuzzy).size());
+
+ var requestWithFuzzy =
+ new DimseFindServicePlugin.FindRequest(
+ DimseFindServicePlugin.InformationModel.STUDY_ROOT,
+ DimseFindServicePlugin.QueryRetrieveLevel.STUDY,
+ "CALLING",
+ "CALLED",
+ 7,
+ keys,
+ null,
+ java.util.Map.of(),
+ true,
+ false,
+ false,
+ () -> false);
+ assertEquals(1, plugin.find(requestWithFuzzy).size());
+ }
+
+ @Test
+ void supportsNestedSequenceMatching() throws Exception {
+ Path root = Files.createTempDirectory("query-file-test-nested");
+ FileReadWriteStoragePlugin storage = new FileReadWriteStoragePlugin(root, "file");
+ storage.store(
+ new java.io.ByteArrayInputStream(
+ createDicom("P1", "NESTED", "MR", "A1", "20240101", "101010", "20240101101010")),
+ "application/dicom");
+
+ FileReadWriteDimseFindPlugin plugin = new FileReadWriteDimseFindPlugin(storage);
+
+ Attributes keys = new Attributes();
+ keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY");
+ Sequence seq = keys.newSequence(Tag.RequestAttributesSequence, 1);
+ Attributes item = new Attributes();
+ item.setString(Tag.AccessionNumber, VR.SH, "A1");
+ seq.add(item);
+
+ var request =
+ new DimseFindServicePlugin.FindRequest(
+ DimseFindServicePlugin.InformationModel.STUDY_ROOT,
+ DimseFindServicePlugin.QueryRetrieveLevel.STUDY,
+ "CALLING",
+ "CALLED",
+ 8,
+ keys,
+ null,
+ java.util.Map.of(),
+ false,
+ false,
+ false,
+ () -> false);
+ assertEquals(1, plugin.find(request).size());
+ }
+
+ @Test
+ void stopsEarlyWhenCancelRequested() throws Exception {
+ Path root = Files.createTempDirectory("query-file-test-cancel");
+ FileReadWriteStoragePlugin storage = new FileReadWriteStoragePlugin(root, "file");
+ storage.store(
+ new java.io.ByteArrayInputStream(
+ createDicom("P1", "CANCEL", "MR", "A1", "20240101", "101010", "20240101101010")),
+ "application/dicom");
+
+ FileReadWriteDimseFindPlugin plugin = new FileReadWriteDimseFindPlugin(storage);
+ Attributes keys = new Attributes();
+ keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY");
+
+ var request =
+ new DimseFindServicePlugin.FindRequest(
+ DimseFindServicePlugin.InformationModel.STUDY_ROOT,
+ DimseFindServicePlugin.QueryRetrieveLevel.STUDY,
+ "CALLING",
+ "CALLED",
+ 9,
+ keys,
+ null,
+ java.util.Map.of(),
+ false,
+ false,
+ false,
+ () -> true);
+
+ assertEquals(0, plugin.find(request).size());
+ }
+
private byte[] createDicom(
String patientId, String patientName, String modality, String accession, String studyDate)
throws Exception {
+ return createDicom(
+ patientId, patientName, modality, accession, studyDate, "101010", "20240101101010");
+ }
+
+ private byte[] createDicom(
+ String patientId,
+ String patientName,
+ String modality,
+ String accession,
+ String studyDate,
+ String studyTime,
+ String acquisitionDateTime)
+ throws Exception {
Attributes fmi = new Attributes();
fmi.setString(Tag.TransferSyntaxUID, VR.UI, UID.ExplicitVRLittleEndian);
fmi.setString(Tag.MediaStorageSOPClassUID, VR.UI, UID.SecondaryCaptureImageStorage);
@@ -136,6 +329,12 @@ private byte[] createDicom(
attrs.setString(Tag.Modality, VR.CS, modality);
attrs.setString(Tag.AccessionNumber, VR.SH, accession);
attrs.setString(Tag.StudyDate, VR.DA, studyDate);
+ attrs.setString(Tag.StudyTime, VR.TM, studyTime);
+ attrs.setString(Tag.AcquisitionDateTime, VR.DT, acquisitionDateTime);
+ Sequence requestSequence = attrs.newSequence(Tag.RequestAttributesSequence, 1);
+ Attributes requestItem = new Attributes();
+ requestItem.setString(Tag.AccessionNumber, VR.SH, accession);
+ requestSequence.add(requestItem);
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
try (DicomOutputStream dos = new DicomOutputStream(out, UID.ExplicitVRLittleEndian)) {
From 4ba451940ba605b3f11332437d82bbc06cf089e0 Mon Sep 17 00:00:00 2001
From: luis-godinho <125367612+luis-godinho@users.noreply.github.com>
Date: Sun, 19 Apr 2026 14:18:57 +0100
Subject: [PATCH 6/6] feat: add negotiated C-FIND integration tests and tighten
identifier/status validation
---
backend/README.md | 17 +-
.../dicoogle/protocol/dimse/CFindService.java | 56 ++-
.../protocol/dimse/DimseCFindSCP.java | 2 +-
.../protocol/dimse/DimseCStoreServer.java | 3 +-
.../protocol/dimse/CFindServiceTest.java | 67 ++++
.../dimse/DimseCFindSCPIntegrationTest.java | 370 ++++++++++++++++++
.../sdk/query/DimseFindServicePlugin.java | 1 -
.../filero/FileReadOnlyStoragePlugin.java | 129 ------
.../filerw/FileReadWriteStorageConfig.java | 18 -
.../filerw/FileReadWriteStoragePlugin.java | 211 ----------
.../FileReadWriteStorageProperties.java | 35 --
.../FileReadWriteStoragePluginTest.java | 70 ----
.../file/FileReadWriteDimseFindPlugin.java | 49 ++-
.../FileReadWriteDimseFindPluginTest.java | 39 +-
14 files changed, 561 insertions(+), 506 deletions(-)
create mode 100644 backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/DimseCFindSCPIntegrationTest.java
delete mode 100644 backend/dicoogle-next-storage-file-ro/src/main/java/org/dicoogle/storage/filero/FileReadOnlyStoragePlugin.java
delete mode 100644 backend/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStorageConfig.java
delete mode 100644 backend/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStoragePlugin.java
delete mode 100644 backend/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStorageProperties.java
delete mode 100644 backend/dicoogle-next-storage-file-rw/src/test/java/org/dicoogle/storage/filerw/FileReadWriteStoragePluginTest.java
diff --git a/backend/README.md b/backend/README.md
index 729201c..a5b190e 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -45,20 +45,17 @@ Query behavior:
- Mixed queries are supported (for example: free text + strict DICOM keys together).
- Broad queries without UIDs are supported by scanning indexed instances and filtering in-memory.
- Response size is capped by `app.dimse.cfind.max-results` (default `1000`).
-- Optional query negotiation supported: `FUZZY` (PN fuzzy matching), `DATETIME` (DT matching/ranges),
- and `RELATIONAL` advertised at association level.
+- Optional query negotiation supported: `FUZZY` (PN fuzzy matching) and `DATETIME`
+ (DT matching/ranges).
- Sequence/nested matching keys are supported for sequence items in the query dataset.
- C-CANCEL stops response emission and now also short-circuits plugin scanning.
+- Invalid C-FIND identifiers (missing/unsupported level, invalid range syntax, DT ranges without
+ DATETIME negotiation) are rejected with `IdentifierDoesNotMatchSOPClass`.
-`keyword:value` filters currently supported:
+`keyword:value` filters:
-- `patientid`
-- `patientname`
-- `studyinstanceuid`
-- `seriesinstanceuid`
-- `sopinstanceuid`
-- `accessionnumber`
-- `modality`
+- Any DICOM keyword present in the object can be used (case-insensitive), for example
+ `StudyDate`, `AccessionNumber`, `PatientID`, `Modality`, `StudyDescription`.
Config source options:
diff --git a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/CFindService.java b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/CFindService.java
index 6077c8b..33fca01 100644
--- a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/CFindService.java
+++ b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/CFindService.java
@@ -14,6 +14,7 @@
import java.util.regex.Pattern;
import org.dcm4che3.data.Attributes;
import org.dcm4che3.data.Tag;
+import org.dcm4che3.data.VR;
import org.dcm4che3.net.QueryOption;
import org.dcm4che3.net.Status;
import org.dcm4che3.net.service.DicomServiceException;
@@ -78,7 +79,8 @@ public List find(
if (!supportedLevels.contains(normalizedLevel)) {
increment("dicoogle.cfind.failure", "unsupported-query-level");
throw new DicomServiceException(
- Status.UnableToProcess, "Unsupported QueryRetrieveLevel: " + normalizedLevel);
+ Status.IdentifierDoesNotMatchSOPClass,
+ "Unsupported QueryRetrieveLevel: " + normalizedLevel);
}
DimseFindServicePlugin.QueryRetrieveLevel level;
@@ -86,9 +88,12 @@ public List find(
level = DimseFindServicePlugin.QueryRetrieveLevel.valueOf(normalizedLevel);
} catch (IllegalArgumentException ex) {
increment("dicoogle.cfind.failure", "invalid-query-level");
- throw new DicomServiceException(Status.UnableToProcess, "Invalid QueryRetrieveLevel");
+ throw new DicomServiceException(
+ Status.IdentifierDoesNotMatchSOPClass, "Invalid QueryRetrieveLevel");
}
+ validateIdentifier(keys, queryOptions);
+
if (plugins.isEmpty()) {
increment("dicoogle.cfind.failure", "no-query-plugin");
throw new DicomServiceException(
@@ -118,7 +123,6 @@ public List find(
keywordFilters,
queryOptions.contains(QueryOption.FUZZY),
queryOptions.contains(QueryOption.DATETIME),
- queryOptions.contains(QueryOption.RELATIONAL),
cancelRequested == null ? () -> false : cancelRequested);
for (DimseFindAccessPolicy policy : accessPolicies) {
@@ -218,6 +222,52 @@ private void increment(String meterName, String reason) {
}
}
+ private void validateIdentifier(Attributes keys, Set queryOptions)
+ throws DicomServiceException {
+ for (int tag : keys.tags()) {
+ if (tag == Tag.QueryRetrieveLevel) {
+ continue;
+ }
+
+ String value = keys.getString(tag, null);
+ if (value == null || value.isBlank()) {
+ continue;
+ }
+
+ VR vr = keys.getVR(tag);
+ if (vr == VR.DT && containsRange(value) && !queryOptions.contains(QueryOption.DATETIME)) {
+ increment("dicoogle.cfind.failure", "datetime-negotiation-required");
+ throw new DicomServiceException(
+ Status.IdentifierDoesNotMatchSOPClass,
+ "DT range matching requires DATETIME query negotiation");
+ }
+
+ if ((vr == VR.DA || vr == VR.TM || vr == VR.DT) && hasInvalidRangeSyntax(value)) {
+ increment("dicoogle.cfind.failure", "invalid-range-syntax");
+ throw new DicomServiceException(
+ Status.IdentifierDoesNotMatchSOPClass,
+ String.format("Invalid range syntax for query key: %08X", tag));
+ }
+ }
+ }
+
+ private boolean containsRange(String value) {
+ return value.indexOf('-') >= 0;
+ }
+
+ private boolean hasInvalidRangeSyntax(String value) {
+ int firstDash = value.indexOf('-');
+ if (firstDash < 0) {
+ return false;
+ }
+ if (value.indexOf('-', firstDash + 1) >= 0) {
+ return true;
+ }
+ String start = value.substring(0, firstDash).trim();
+ String end = value.substring(firstDash + 1).trim();
+ return start.isEmpty() && end.isEmpty();
+ }
+
private void recordLatency(String outcome, String level, long startNs) {
Timer.builder("dicoogle.cfind.latency")
.tag("outcome", outcome)
diff --git a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindSCP.java b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindSCP.java
index 92b0ed2..32a549c 100644
--- a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindSCP.java
+++ b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCFindSCP.java
@@ -33,7 +33,7 @@ protected QueryTask calculateMatches(
as.getCalledAET());
Set queryOptions =
- as.getRequestedQueryOptionsFor(rq.getString(org.dcm4che3.data.Tag.AffectedSOPClassUID));
+ as.getQueryOptionsFor(rq.getString(org.dcm4che3.data.Tag.AffectedSOPClassUID));
return new DimseListQueryTask(
as,
pc,
diff --git a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCStoreServer.java b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCStoreServer.java
index c76f1f3..c7d164d 100644
--- a/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCStoreServer.java
+++ b/backend/dicoogle-next-protocol-dimse/src/main/java/org/dicoogle/protocol/dimse/DimseCStoreServer.java
@@ -240,8 +240,7 @@ private void configureTransferCapabilities(
TransferCapability.Role.SCP,
UID.ImplicitVRLittleEndian,
UID.ExplicitVRLittleEndian);
- cfindTc.setQueryOptions(
- EnumSet.of(QueryOption.RELATIONAL, QueryOption.DATETIME, QueryOption.FUZZY));
+ cfindTc.setQueryOptions(EnumSet.of(QueryOption.DATETIME, QueryOption.FUZZY));
ae.addTransferCapability(cfindTc);
}
diff --git a/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/CFindServiceTest.java b/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/CFindServiceTest.java
index 7821f30..7f5876f 100644
--- a/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/CFindServiceTest.java
+++ b/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/CFindServiceTest.java
@@ -10,6 +10,7 @@
import org.dcm4che3.data.Attributes;
import org.dcm4che3.data.Tag;
import org.dcm4che3.data.VR;
+import org.dcm4che3.net.QueryOption;
import org.dcm4che3.net.Status;
import org.dcm4che3.net.service.DicomServiceException;
import org.dicoogle.sdk.PluginMetadata;
@@ -56,6 +57,72 @@ void supportsFreeTextAndLevel() throws Exception {
assertEquals("1.2.3", matches.get(0).getString(Tag.StudyInstanceUID));
}
+ @Test
+ void rejectsDateTimeRangeWithoutNegotiation() {
+ CFindService service =
+ new CFindService(
+ List.of(new DummyFindPlugin()),
+ List.of(),
+ new DimseCFindProperties(),
+ new SimpleMeterRegistry());
+ Attributes keys = new Attributes();
+ keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY");
+ keys.setString(Tag.AcquisitionDateTime, VR.DT, "20240101000000-20241231235959");
+
+ DicomServiceException ex =
+ assertThrows(
+ DicomServiceException.class,
+ () ->
+ service.find(
+ "1.2.840.10008.5.1.4.1.2.2.1", keys, "CALLING", "CALLED", 90, Set.of(), null));
+ assertEquals(Status.IdentifierDoesNotMatchSOPClass, ex.getStatus());
+ }
+
+ @Test
+ void rejectsInvalidRangeSyntax() {
+ CFindService service =
+ new CFindService(
+ List.of(new DummyFindPlugin()),
+ List.of(),
+ new DimseCFindProperties(),
+ new SimpleMeterRegistry());
+ Attributes keys = new Attributes();
+ keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY");
+ keys.setString(Tag.StudyDate, VR.DA, "-");
+
+ DicomServiceException ex =
+ assertThrows(
+ DicomServiceException.class,
+ () ->
+ service.find(
+ "1.2.840.10008.5.1.4.1.2.2.1", keys, "CALLING", "CALLED", 91, Set.of(), null));
+ assertEquals(Status.IdentifierDoesNotMatchSOPClass, ex.getStatus());
+ }
+
+ @Test
+ void acceptsDateTimeRangeWithNegotiation() throws Exception {
+ CFindService service =
+ new CFindService(
+ List.of(new DummyFindPlugin()),
+ List.of(),
+ new DimseCFindProperties(),
+ new SimpleMeterRegistry());
+ Attributes keys = new Attributes();
+ keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY");
+ keys.setString(Tag.AcquisitionDateTime, VR.DT, "20240101000000-20241231235959");
+
+ List matches =
+ service.find(
+ "1.2.840.10008.5.1.4.1.2.2.1",
+ keys,
+ "CALLING",
+ "CALLED",
+ 92,
+ Set.of(QueryOption.DATETIME),
+ null);
+ assertEquals(1, matches.size());
+ }
+
@Test
void appliesConfiguredMaxResults() throws Exception {
DimseCFindProperties properties = new DimseCFindProperties();
diff --git a/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/DimseCFindSCPIntegrationTest.java b/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/DimseCFindSCPIntegrationTest.java
new file mode 100644
index 0000000..182391a
--- /dev/null
+++ b/backend/dicoogle-next-protocol-dimse/src/test/java/org/dicoogle/protocol/dimse/DimseCFindSCPIntegrationTest.java
@@ -0,0 +1,370 @@
+package org.dicoogle.protocol.dimse;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.net.ServerSocket;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.GeneralSecurityException;
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.function.BooleanSupplier;
+import org.dcm4che3.data.Attributes;
+import org.dcm4che3.data.Tag;
+import org.dcm4che3.data.UID;
+import org.dcm4che3.data.VR;
+import org.dcm4che3.io.DicomInputStream;
+import org.dcm4che3.io.DicomOutputStream;
+import org.dcm4che3.net.ApplicationEntity;
+import org.dcm4che3.net.Association;
+import org.dcm4che3.net.Connection;
+import org.dcm4che3.net.Device;
+import org.dcm4che3.net.DimseRSP;
+import org.dcm4che3.net.IncompatibleConnectionException;
+import org.dcm4che3.net.QueryOption;
+import org.dcm4che3.net.Status;
+import org.dcm4che3.net.TransferCapability;
+import org.dcm4che3.net.pdu.AAssociateRQ;
+import org.dicoogle.core.storage.StorageRouter;
+import org.dicoogle.sdk.PluginMetadata;
+import org.dicoogle.sdk.query.DimseFindServicePlugin;
+import org.dicoogle.sdk.storage.StoredObject;
+import org.dicoogle.sdk.storage.WritableStoragePlugin;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+class DimseCFindSCPIntegrationTest {
+
+ private DimseCStoreServer server;
+ private Device scuDevice;
+ private Association association;
+
+ @AfterEach
+ void tearDown() throws Exception {
+ if (association != null) {
+ association.release();
+ association.waitForSocketClose();
+ association = null;
+ }
+ if (scuDevice != null) {
+ scuDevice.waitForNoOpenConnections();
+ scuDevice.unbindConnections();
+ scuDevice = null;
+ }
+ if (server != null) {
+ server.stop();
+ server = null;
+ }
+ }
+
+ @Test
+ void negotiatedFuzzyMatchingReturnsResult() throws Exception {
+ Path root = Files.createTempDirectory("cfind-integ-fuzzy");
+ int port = randomPort();
+ startServer(root, port);
+
+ storeDicom(
+ root, createDicom("P1", "FELIX^ALMEIDA", "A1", "20240101", "101010", "20240101101010"));
+
+ association = openScu(port, EnumSet.of(QueryOption.FUZZY));
+ Attributes keys = new Attributes();
+ keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY");
+ keys.setString(Tag.PatientName, VR.PN, "felixal");
+
+ DimseRSP rsp =
+ association.cfind("1.2.840.10008.5.1.4.1.2.2.1", 0, keys, UID.ImplicitVRLittleEndian, 0);
+
+ int pending = 0;
+ boolean successSeen = false;
+ while (rsp.next()) {
+ int status = rsp.getCommand().getInt(Tag.Status, -1);
+ if (Status.isPending(status)) {
+ pending++;
+ } else {
+ successSeen = status == Status.Success;
+ }
+ }
+
+ assertTrue(successSeen);
+ assertTrue(pending >= 1);
+ }
+
+ @Test
+ void missingDateTimeNegotiationReturnsIdentifierError() throws Exception {
+ Path root = Files.createTempDirectory("cfind-integ-datetime");
+ int port = randomPort();
+ startServer(root, port);
+
+ storeDicom(root, createDicom("P1", "ANA", "A1", "20240101", "101010", "20240101101010"));
+
+ association = openScu(port, EnumSet.noneOf(QueryOption.class));
+ Attributes keys = new Attributes();
+ keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY");
+ keys.setString(Tag.AcquisitionDateTime, VR.DT, "20240101000000-20241231235959");
+
+ DimseRSP rsp =
+ association.cfind("1.2.840.10008.5.1.4.1.2.2.1", 0, keys, UID.ImplicitVRLittleEndian, 0);
+ assertTrue(rsp.next());
+ int status = rsp.getCommand().getInt(Tag.Status, -1);
+ assertEquals(Status.IdentifierDoesNotMatchSOPClass, status);
+ }
+
+ @Test
+ void negotiatedDateTimeRangeReturnsPendingMatch() throws Exception {
+ Path root = Files.createTempDirectory("cfind-integ-datetime-ok");
+ int port = randomPort();
+ startServer(root, port);
+
+ storeDicom(root, createDicom("P1", "ANA", "A1", "20240101", "101010", "20240101101010"));
+
+ association = openScu(port, EnumSet.of(QueryOption.DATETIME));
+ Attributes keys = new Attributes();
+ keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY");
+ keys.setString(Tag.AcquisitionDateTime, VR.DT, "20240101000000-20241231235959");
+
+ DimseRSP rsp =
+ association.cfind("1.2.840.10008.5.1.4.1.2.2.1", 0, keys, UID.ImplicitVRLittleEndian, 0);
+
+ int pending = 0;
+ int finalStatus = -1;
+ while (rsp.next()) {
+ int status = rsp.getCommand().getInt(Tag.Status, -1);
+ if (Status.isPending(status)) {
+ pending++;
+ } else {
+ finalStatus = status;
+ }
+ }
+
+ assertTrue(pending >= 1);
+ assertEquals(Status.Success, finalStatus);
+ }
+
+ private void startServer(Path root, int port) {
+ WritableStoragePlugin storage = new InMemoryStoragePlugin(root);
+ CStoreService cStoreService = new CStoreService(new StorageRouter(List.of(storage)));
+ CFindService cFindService =
+ new CFindService(
+ List.of(new IntegrationFindPlugin(root)),
+ List.of(),
+ new DimseCFindProperties(),
+ new SimpleMeterRegistry());
+
+ DimseCStoreProperties properties = new DimseCStoreProperties();
+ properties.setEnabled(true);
+ properties.setAeTitle("DICOOGLE");
+ properties.setBindAddress("127.0.0.1");
+ properties.setPort(port);
+ properties.setStorageScheme("mem");
+
+ server = new DimseCStoreServer(cStoreService, cFindService, properties, "mem");
+ server.start();
+ }
+
+ private Association openScu(int port, EnumSet options)
+ throws IOException,
+ InterruptedException,
+ GeneralSecurityException,
+ IncompatibleConnectionException {
+ scuDevice = new Device("cfind-scu-device");
+ scuDevice.setExecutor(java.util.concurrent.Executors.newCachedThreadPool());
+ scuDevice.setScheduledExecutor(
+ java.util.concurrent.Executors.newSingleThreadScheduledExecutor());
+ Connection localConn = new Connection();
+ localConn.setHostname("127.0.0.1");
+ ApplicationEntity scu = new ApplicationEntity("TESTSCU");
+ scu.setAssociationInitiator(true);
+ scu.setAssociationAcceptor(false);
+ TransferCapability tc =
+ new TransferCapability(
+ "cfind-scu",
+ "1.2.840.10008.5.1.4.1.2.2.1",
+ TransferCapability.Role.SCU,
+ UID.ImplicitVRLittleEndian,
+ UID.ExplicitVRLittleEndian);
+ tc.setQueryOptions(EnumSet.of(QueryOption.DATETIME, QueryOption.FUZZY));
+ scu.addTransferCapability(tc);
+
+ scuDevice.addConnection(localConn);
+ scuDevice.addApplicationEntity(scu);
+ scu.addConnection(localConn);
+
+ Connection remote = new Connection();
+ remote.setHostname("127.0.0.1");
+ remote.setPort(port);
+
+ AAssociateRQ rq = new AAssociateRQ();
+ rq.setCallingAET("TESTSCU");
+ rq.setCalledAET("DICOOGLE");
+ rq.addPresentationContext(
+ new org.dcm4che3.net.pdu.PresentationContext(
+ 1,
+ "1.2.840.10008.5.1.4.1.2.2.1",
+ UID.ImplicitVRLittleEndian,
+ UID.ExplicitVRLittleEndian));
+ rq.addExtendedNegotiation(
+ new org.dcm4che3.net.pdu.ExtendedNegotiation(
+ "1.2.840.10008.5.1.4.1.2.2.1", QueryOption.toExtendedNegotiationInformation(options)));
+
+ return scu.connect(localConn, remote, rq);
+ }
+
+ private static int randomPort() throws IOException {
+ try (ServerSocket socket = new ServerSocket(0)) {
+ return socket.getLocalPort();
+ }
+ }
+
+ private static byte[] createDicom(
+ String patientId,
+ String patientName,
+ String accession,
+ String studyDate,
+ String studyTime,
+ String acquisitionDateTime)
+ throws IOException {
+ Attributes fmi = new Attributes();
+ fmi.setString(Tag.TransferSyntaxUID, VR.UI, UID.ExplicitVRLittleEndian);
+ fmi.setString(Tag.MediaStorageSOPClassUID, VR.UI, UID.SecondaryCaptureImageStorage);
+ fmi.setString(Tag.MediaStorageSOPInstanceUID, VR.UI, "1.2.3.4.5.6.7");
+ fmi.setString(Tag.ImplementationClassUID, VR.UI, "1.2.826.0.1.3680043.2.1125.99");
+
+ Attributes attrs = new Attributes();
+ attrs.setString(Tag.SOPClassUID, VR.UI, UID.SecondaryCaptureImageStorage);
+ attrs.setString(Tag.SOPInstanceUID, VR.UI, "1.2.3.4.5.6.7");
+ attrs.setString(Tag.StudyInstanceUID, VR.UI, "1.2.3");
+ attrs.setString(Tag.SeriesInstanceUID, VR.UI, "1.2.3.1");
+ attrs.setString(Tag.PatientID, VR.LO, patientId);
+ attrs.setString(Tag.PatientName, VR.PN, patientName);
+ attrs.setString(Tag.AccessionNumber, VR.SH, accession);
+ attrs.setString(Tag.StudyDate, VR.DA, studyDate);
+ attrs.setString(Tag.StudyTime, VR.TM, studyTime);
+ attrs.setString(Tag.AcquisitionDateTime, VR.DT, acquisitionDateTime);
+
+ java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
+ try (DicomOutputStream dos = new DicomOutputStream(out, UID.ExplicitVRLittleEndian)) {
+ dos.writeDataset(fmi, attrs);
+ }
+ return out.toByteArray();
+ }
+
+ private static void storeDicom(Path root, byte[] payload) throws IOException {
+ WritableStoragePlugin plugin = new InMemoryStoragePlugin(root);
+ StoredObject stored = plugin.store(new ByteArrayInputStream(payload), "application/dicom");
+ assertNotNull(stored);
+ assertFalse(stored.location().toString().isBlank());
+ }
+
+ private record InMemoryStoragePlugin(Path root) implements WritableStoragePlugin {
+
+ @Override
+ public String scheme() {
+ return "mem";
+ }
+
+ @Override
+ public org.dicoogle.sdk.PluginMetadata metadata() {
+ return new org.dicoogle.sdk.PluginMetadata("mem", "Mem Storage", "1.0.0", "storage");
+ }
+
+ @Override
+ public StoredObject store(java.io.InputStream data, String contentType) throws IOException {
+ byte[] bytes = data.readAllBytes();
+ String patient = "UNKNOWN";
+ String study = "UNKNOWN";
+ String series = "UNKNOWN";
+ String sop = "UNKNOWN";
+ try (org.dcm4che3.io.DicomInputStream dis =
+ new org.dcm4che3.io.DicomInputStream(new ByteArrayInputStream(bytes))) {
+ Attributes attrs = dis.readDataset();
+ patient = attrs.getString(Tag.PatientID, patient);
+ study = attrs.getString(Tag.StudyInstanceUID, study);
+ series = attrs.getString(Tag.SeriesInstanceUID, series);
+ sop = attrs.getString(Tag.SOPInstanceUID, sop);
+ }
+ Path path = root.resolve(patient).resolve(study).resolve(series);
+ Files.createDirectories(path);
+ Path file = path.resolve(sop + ".dcm");
+ Files.write(file, bytes);
+ return new StoredObject(file.toUri(), bytes.length, contentType);
+ }
+ }
+
+ private record IntegrationFindPlugin(Path root) implements DimseFindServicePlugin {
+
+ @Override
+ public PluginMetadata metadata() {
+ return new PluginMetadata("integration-find", "Integration Find", "1.0.0", "query-index");
+ }
+
+ @Override
+ public List find(FindRequest request) throws IOException {
+ List out = new ArrayList<>();
+ BooleanSupplier cancelRequested = request.cancelRequested();
+ List files =
+ Files.walk(root)
+ .filter(
+ path ->
+ Files.isRegularFile(path) && path.getFileName().toString().endsWith(".dcm"))
+ .toList();
+
+ for (Path file : files) {
+ if (cancelRequested != null && cancelRequested.getAsBoolean()) {
+ break;
+ }
+ try (DicomInputStream dis = new DicomInputStream(Files.newInputStream(file))) {
+ Attributes attrs = dis.readDataset();
+ if (!matches(attrs, request)) {
+ continue;
+ }
+ out.add(attrs);
+ }
+ }
+ return out;
+ }
+
+ private boolean matches(Attributes attrs, FindRequest request) {
+ String expectedName = request.keys().getString(Tag.PatientName, null);
+ if (expectedName != null && !expectedName.isBlank()) {
+ String actualName = attrs.getString(Tag.PatientName, "");
+ if (request.fuzzyMatchingEnabled()) {
+ String lhs = actualName.toLowerCase().replaceAll("[^a-z0-9]", "");
+ String rhs = expectedName.toLowerCase().replaceAll("[^a-z0-9]", "");
+ if (!lhs.contains(rhs)) {
+ return false;
+ }
+ } else if (!actualName.equalsIgnoreCase(expectedName)) {
+ return false;
+ }
+ }
+
+ String expectedDateTime = request.keys().getString(Tag.AcquisitionDateTime, null);
+ if (expectedDateTime != null && !expectedDateTime.isBlank()) {
+ if (!request.dateTimeMatchingEnabled()) {
+ return false;
+ }
+ String actualDateTime = attrs.getString(Tag.AcquisitionDateTime, "");
+ int dash = expectedDateTime.indexOf('-');
+ if (dash < 0) {
+ return actualDateTime.equals(expectedDateTime);
+ }
+ String start = expectedDateTime.substring(0, dash).trim();
+ String end = expectedDateTime.substring(dash + 1).trim();
+ if (!start.isBlank() && actualDateTime.compareTo(start) < 0) {
+ return false;
+ }
+ if (!end.isBlank() && actualDateTime.compareTo(end) > 0) {
+ return false;
+ }
+ }
+ return true;
+ }
+ }
+}
diff --git a/backend/dicoogle-next-sdk/src/main/java/org/dicoogle/sdk/query/DimseFindServicePlugin.java b/backend/dicoogle-next-sdk/src/main/java/org/dicoogle/sdk/query/DimseFindServicePlugin.java
index 8de7907..eaa3703 100644
--- a/backend/dicoogle-next-sdk/src/main/java/org/dicoogle/sdk/query/DimseFindServicePlugin.java
+++ b/backend/dicoogle-next-sdk/src/main/java/org/dicoogle/sdk/query/DimseFindServicePlugin.java
@@ -28,7 +28,6 @@ record FindRequest(
java.util.Map keywordFilters,
boolean fuzzyMatchingEnabled,
boolean dateTimeMatchingEnabled,
- boolean relationalMatchingEnabled,
BooleanSupplier cancelRequested) {}
List find(FindRequest request) throws IOException;
diff --git a/backend/dicoogle-next-storage-file-ro/src/main/java/org/dicoogle/storage/filero/FileReadOnlyStoragePlugin.java b/backend/dicoogle-next-storage-file-ro/src/main/java/org/dicoogle/storage/filero/FileReadOnlyStoragePlugin.java
deleted file mode 100644
index e204b47..0000000
--- a/backend/dicoogle-next-storage-file-ro/src/main/java/org/dicoogle/storage/filero/FileReadOnlyStoragePlugin.java
+++ /dev/null
@@ -1,129 +0,0 @@
-package org.dicoogle.storage.filero;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.URI;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Optional;
-import org.dicoogle.sdk.PluginMetadata;
-import org.dicoogle.sdk.query.QueryIndexStorageLocator;
-import org.dicoogle.sdk.storage.DicomInstanceLocator;
-import org.dicoogle.sdk.storage.ReadableStoragePlugin;
-
-public class FileReadOnlyStoragePlugin
- implements ReadableStoragePlugin, DicomInstanceLocator, QueryIndexStorageLocator {
-
- private static final PluginMetadata METADATA =
- new PluginMetadata("storage-file-ro", "Filesystem Read-Only Storage", "0.1.0", "storage");
- private final Path rootDirectory;
-
- public FileReadOnlyStoragePlugin() {
- this(Path.of("./data/storage"));
- }
-
- public FileReadOnlyStoragePlugin(Path rootDirectory) {
- this.rootDirectory = rootDirectory.toAbsolutePath().normalize();
- }
-
- @Override
- public PluginMetadata metadata() {
- return METADATA;
- }
-
- @Override
- public String scheme() {
- return "file";
- }
-
- @Override
- public InputStream openForRead(URI location) throws IOException {
- Path path = Path.of(location);
- return Files.newInputStream(path);
- }
-
- @Override
- public Optional locateInstance(
- String studyInstanceUid, String seriesInstanceUid, String sopInstanceUid) throws IOException {
- String safeStudy = safeSegment(studyInstanceUid);
- String safeSeries = safeSegment(seriesInstanceUid);
- String safeSop = safeSegment(sopInstanceUid) + ".dcm";
-
- if (!Files.exists(rootDirectory)) {
- return Optional.empty();
- }
-
- try (var patientDirs = Files.list(rootDirectory)) {
- return patientDirs
- .filter(Files::isDirectory)
- .map(patientDir -> patientDir.resolve(safeStudy).resolve(safeSeries).resolve(safeSop))
- .filter(Files::exists)
- .map(Path::toUri)
- .findFirst();
- }
- }
-
- @Override
- public List listStudyInstances(String studyInstanceUid) throws IOException {
- String safeStudy = safeSegment(studyInstanceUid);
- List results = new ArrayList<>();
-
- if (!Files.exists(rootDirectory)) {
- return results;
- }
-
- try (var patientDirs = Files.list(rootDirectory)) {
- for (Path patientDir : patientDirs.filter(Files::isDirectory).toList()) {
- Path studyDir = patientDir.resolve(safeStudy);
- if (!Files.isDirectory(studyDir)) {
- continue;
- }
- collectDicomFiles(studyDir, results);
- }
- }
-
- return results;
- }
-
- @Override
- public List listSeriesInstances(String studyInstanceUid, String seriesInstanceUid)
- throws IOException {
- String safeStudy = safeSegment(studyInstanceUid);
- String safeSeries = safeSegment(seriesInstanceUid);
- List results = new ArrayList<>();
-
- if (!Files.exists(rootDirectory)) {
- return results;
- }
-
- try (var patientDirs = Files.list(rootDirectory)) {
- for (Path patientDir : patientDirs.filter(Files::isDirectory).toList()) {
- Path seriesDir = patientDir.resolve(safeStudy).resolve(safeSeries);
- if (!Files.isDirectory(seriesDir)) {
- continue;
- }
- collectDicomFiles(seriesDir, results);
- }
- }
-
- return results;
- }
-
- private String safeSegment(String value) {
- if (value == null || value.isBlank()) {
- return "UNKNOWN";
- }
- return value.replaceAll("[^A-Za-z0-9._-]", "_");
- }
-
- private void collectDicomFiles(Path directory, List output) throws IOException {
- try (var walk = Files.walk(directory)) {
- walk.filter(Files::isRegularFile)
- .filter(path -> path.getFileName().toString().toLowerCase().endsWith(".dcm"))
- .map(Path::toUri)
- .forEach(output::add);
- }
- }
-}
diff --git a/backend/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStorageConfig.java b/backend/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStorageConfig.java
deleted file mode 100644
index 75b12d3..0000000
--- a/backend/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStorageConfig.java
+++ /dev/null
@@ -1,18 +0,0 @@
-package org.dicoogle.storage.filerw;
-
-import java.nio.file.Path;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.boot.context.properties.EnableConfigurationProperties;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
-
-@Configuration
-@EnableConfigurationProperties(FileReadWriteStorageProperties.class)
-public class FileReadWriteStorageConfig {
-
- @Bean
- @ConditionalOnProperty(prefix = "app.storage.file-rw", name = "enabled", havingValue = "true")
- FileReadWriteStoragePlugin fileReadWriteStoragePlugin(FileReadWriteStorageProperties properties) {
- return new FileReadWriteStoragePlugin(Path.of(properties.getRootDir()), properties.getScheme());
- }
-}
diff --git a/backend/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStoragePlugin.java b/backend/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStoragePlugin.java
deleted file mode 100644
index 58b50c7..0000000
--- a/backend/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStoragePlugin.java
+++ /dev/null
@@ -1,211 +0,0 @@
-package org.dicoogle.storage.filerw;
-
-import java.io.ByteArrayInputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.URI;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.StandardCopyOption;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Objects;
-import java.util.Optional;
-import java.util.regex.Pattern;
-import org.dcm4che3.data.Attributes;
-import org.dcm4che3.data.Tag;
-import org.dcm4che3.io.DicomInputStream;
-import org.dicoogle.sdk.PluginMetadata;
-import org.dicoogle.sdk.query.QueryIndexStorageLocator;
-import org.dicoogle.sdk.storage.DicomInstanceLocator;
-import org.dicoogle.sdk.storage.ReadableStoragePlugin;
-import org.dicoogle.sdk.storage.StoredObject;
-import org.dicoogle.sdk.storage.WritableStoragePlugin;
-
-public class FileReadWriteStoragePlugin
- implements ReadableStoragePlugin,
- WritableStoragePlugin,
- DicomInstanceLocator,
- QueryIndexStorageLocator {
-
- private static final String UNKNOWN = "UNKNOWN";
- private static final Pattern SAFE_SEGMENT_PATTERN = Pattern.compile("[^A-Za-z0-9._-]");
- private static final PluginMetadata METADATA =
- new PluginMetadata("storage-file-rw", "Filesystem Read-Write Storage", "0.1.0", "storage");
-
- private final Path rootDirectory;
- private final String scheme;
-
- public FileReadWriteStoragePlugin(Path rootDirectory, String scheme) {
- this.rootDirectory = Objects.requireNonNull(rootDirectory).toAbsolutePath().normalize();
- this.scheme = (scheme == null || scheme.isBlank()) ? "file" : scheme;
- }
-
- @Override
- public PluginMetadata metadata() {
- return METADATA;
- }
-
- @Override
- public String scheme() {
- return scheme;
- }
-
- @Override
- public InputStream openForRead(URI location) throws IOException {
- if (!location.getScheme().equalsIgnoreCase(scheme)) {
- throw new IOException("Unsupported URI scheme: " + location.getScheme());
- }
-
- Path path = toPath(location);
- return Files.newInputStream(path);
- }
-
- @Override
- public StoredObject store(InputStream data, String contentType) throws IOException {
- byte[] bytes = data.readAllBytes();
- if (bytes.length == 0) {
- throw new IOException("Cannot store empty payload");
- }
-
- DicomHierarchy hierarchy = extractHierarchy(bytes);
-
- Path relativePath =
- Path.of(
- safeSegment(hierarchy.patientId()),
- safeSegment(hierarchy.studyInstanceUid()),
- safeSegment(hierarchy.seriesInstanceUid()),
- safeSegment(hierarchy.sopInstanceUid()) + ".dcm");
-
- Path target = rootDirectory.resolve(relativePath).normalize();
- if (!target.startsWith(rootDirectory)) {
- throw new IOException("Resolved path escaped root directory");
- }
-
- Files.createDirectories(target.getParent());
-
- Path tmp = target.resolveSibling(target.getFileName() + ".tmp");
- Files.write(tmp, bytes);
- Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
-
- URI location =
- scheme.equalsIgnoreCase("file")
- ? target.toUri()
- : URI.create(scheme + ":" + target.toAbsolutePath());
-
- return new StoredObject(location, bytes.length, contentType);
- }
-
- @Override
- public Optional locateInstance(
- String studyInstanceUid, String seriesInstanceUid, String sopInstanceUid) throws IOException {
- String safeStudy = safeSegment(studyInstanceUid);
- String safeSeries = safeSegment(seriesInstanceUid);
- String safeSop = safeSegment(sopInstanceUid) + ".dcm";
-
- if (!Files.exists(rootDirectory)) {
- return Optional.empty();
- }
-
- try (var patientDirs = Files.list(rootDirectory)) {
- return patientDirs
- .filter(Files::isDirectory)
- .map(patientDir -> patientDir.resolve(safeStudy).resolve(safeSeries).resolve(safeSop))
- .filter(Files::exists)
- .map(Path::toUri)
- .findFirst();
- }
- }
-
- @Override
- public List listStudyInstances(String studyInstanceUid) throws IOException {
- String safeStudy = safeSegment(studyInstanceUid);
- List results = new ArrayList<>();
-
- if (!Files.exists(rootDirectory)) {
- return results;
- }
-
- try (var patientDirs = Files.list(rootDirectory)) {
- for (Path patientDir : patientDirs.filter(Files::isDirectory).toList()) {
- Path studyDir = patientDir.resolve(safeStudy);
- if (!Files.isDirectory(studyDir)) {
- continue;
- }
- collectDicomFiles(studyDir, results);
- }
- }
-
- return results;
- }
-
- @Override
- public List listSeriesInstances(String studyInstanceUid, String seriesInstanceUid)
- throws IOException {
- String safeStudy = safeSegment(studyInstanceUid);
- String safeSeries = safeSegment(seriesInstanceUid);
- List results = new ArrayList<>();
-
- if (!Files.exists(rootDirectory)) {
- return results;
- }
-
- try (var patientDirs = Files.list(rootDirectory)) {
- for (Path patientDir : patientDirs.filter(Files::isDirectory).toList()) {
- Path seriesDir = patientDir.resolve(safeStudy).resolve(safeSeries);
- if (!Files.isDirectory(seriesDir)) {
- continue;
- }
- collectDicomFiles(seriesDir, results);
- }
- }
-
- return results;
- }
-
- private DicomHierarchy extractHierarchy(byte[] bytes) throws IOException {
- try (DicomInputStream dis = new DicomInputStream(new ByteArrayInputStream(bytes))) {
- Attributes attrs = dis.readDataset();
-
- String patientId = attrs.getString(Tag.PatientID, UNKNOWN);
- String study = attrs.getString(Tag.StudyInstanceUID, UNKNOWN);
- String series = attrs.getString(Tag.SeriesInstanceUID, UNKNOWN);
- String sop = attrs.getString(Tag.SOPInstanceUID, UNKNOWN);
-
- return new DicomHierarchy(patientId, study, series, sop);
- }
- }
-
- private Path toPath(URI location) throws IOException {
- Path candidate;
- if (location.getScheme().equalsIgnoreCase("file")) {
- candidate = Path.of(location);
- } else {
- String schemeSpecificPart = location.getSchemeSpecificPart();
- candidate = Path.of(schemeSpecificPart);
- }
-
- candidate = candidate.toAbsolutePath().normalize();
- if (!candidate.startsWith(rootDirectory)) {
- throw new IOException("Path outside configured storage root");
- }
- return candidate;
- }
-
- private String safeSegment(String value) {
- String normalized = (value == null || value.isBlank()) ? UNKNOWN : value;
- return SAFE_SEGMENT_PATTERN.matcher(normalized).replaceAll("_");
- }
-
- private void collectDicomFiles(Path directory, List output) throws IOException {
- try (var walk = Files.walk(directory)) {
- walk.filter(Files::isRegularFile)
- .filter(path -> path.getFileName().toString().toLowerCase().endsWith(".dcm"))
- .map(Path::toUri)
- .forEach(output::add);
- }
- }
-
- private record DicomHierarchy(
- String patientId, String studyInstanceUid, String seriesInstanceUid, String sopInstanceUid) {}
-}
diff --git a/backend/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStorageProperties.java b/backend/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStorageProperties.java
deleted file mode 100644
index d01f226..0000000
--- a/backend/dicoogle-next-storage-file-rw/src/main/java/org/dicoogle/storage/filerw/FileReadWriteStorageProperties.java
+++ /dev/null
@@ -1,35 +0,0 @@
-package org.dicoogle.storage.filerw;
-
-import org.springframework.boot.context.properties.ConfigurationProperties;
-
-@ConfigurationProperties(prefix = "app.storage.file-rw")
-public class FileReadWriteStorageProperties {
-
- private boolean enabled;
- private String rootDir = "./data/storage";
- private String scheme = "file";
-
- public boolean isEnabled() {
- return enabled;
- }
-
- public void setEnabled(boolean enabled) {
- this.enabled = enabled;
- }
-
- public String getRootDir() {
- return rootDir;
- }
-
- public void setRootDir(String rootDir) {
- this.rootDir = rootDir;
- }
-
- public String getScheme() {
- return scheme;
- }
-
- public void setScheme(String scheme) {
- this.scheme = scheme;
- }
-}
diff --git a/backend/dicoogle-next-storage-file-rw/src/test/java/org/dicoogle/storage/filerw/FileReadWriteStoragePluginTest.java b/backend/dicoogle-next-storage-file-rw/src/test/java/org/dicoogle/storage/filerw/FileReadWriteStoragePluginTest.java
deleted file mode 100644
index 4740374..0000000
--- a/backend/dicoogle-next-storage-file-rw/src/test/java/org/dicoogle/storage/filerw/FileReadWriteStoragePluginTest.java
+++ /dev/null
@@ -1,70 +0,0 @@
-package org.dicoogle.storage.filerw;
-
-import static org.junit.jupiter.api.Assertions.assertArrayEquals;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import org.dcm4che3.data.Attributes;
-import org.dcm4che3.data.Tag;
-import org.dcm4che3.data.UID;
-import org.dcm4che3.data.VR;
-import org.dcm4che3.io.DicomOutputStream;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.io.TempDir;
-
-class FileReadWriteStoragePluginTest {
-
- @TempDir Path tempDir;
-
- @Test
- void storesUsingOldStyleHierarchyAndReadsBack() throws Exception {
- FileReadWriteStoragePlugin plugin = new FileReadWriteStoragePlugin(tempDir, "file");
- byte[] dicom = createValidDicom();
-
- var stored = plugin.store(new java.io.ByteArrayInputStream(dicom), "application/dicom");
-
- Path expected =
- tempDir
- .resolve("PATIENT-1")
- .resolve("1.2.826.0.1.3680043.2.1125.2")
- .resolve("1.2.826.0.1.3680043.2.1125.3")
- .resolve("1.2.826.0.1.3680043.2.1125.1.dcm");
-
- assertTrue(Files.exists(expected));
-
- byte[] readBack;
- try (var is = plugin.openForRead(stored.location())) {
- readBack = is.readAllBytes();
- }
-
- assertArrayEquals(dicom, readBack);
- }
-
- private byte[] createValidDicom() {
- try {
- Attributes fmi = new Attributes();
- fmi.setString(Tag.TransferSyntaxUID, VR.UI, UID.ExplicitVRLittleEndian);
- fmi.setString(Tag.MediaStorageSOPClassUID, VR.UI, UID.SecondaryCaptureImageStorage);
- fmi.setString(Tag.MediaStorageSOPInstanceUID, VR.UI, "1.2.826.0.1.3680043.2.1125.1");
- fmi.setString(Tag.ImplementationClassUID, VR.UI, "1.2.826.0.1.3680043.2.1125.99");
-
- Attributes attrs = new Attributes();
- attrs.setString(Tag.SOPClassUID, VR.UI, UID.SecondaryCaptureImageStorage);
- attrs.setString(Tag.SOPInstanceUID, VR.UI, "1.2.826.0.1.3680043.2.1125.1");
- attrs.setString(Tag.StudyInstanceUID, VR.UI, "1.2.826.0.1.3680043.2.1125.2");
- attrs.setString(Tag.SeriesInstanceUID, VR.UI, "1.2.826.0.1.3680043.2.1125.3");
- attrs.setString(Tag.PatientID, VR.LO, "PATIENT-1");
-
- ByteArrayOutputStream output = new ByteArrayOutputStream();
- try (DicomOutputStream dos = new DicomOutputStream(output, UID.ExplicitVRLittleEndian)) {
- dos.writeDataset(fmi, attrs);
- }
- return output.toByteArray();
- } catch (IOException ex) {
- throw new IllegalStateException(ex);
- }
- }
-}
diff --git a/backend/plugins/dicoogle-next-query-file/src/main/java/org/dicoogle/query/file/FileReadWriteDimseFindPlugin.java b/backend/plugins/dicoogle-next-query-file/src/main/java/org/dicoogle/query/file/FileReadWriteDimseFindPlugin.java
index 6541969..cd3a53a 100644
--- a/backend/plugins/dicoogle-next-query-file/src/main/java/org/dicoogle/query/file/FileReadWriteDimseFindPlugin.java
+++ b/backend/plugins/dicoogle-next-query-file/src/main/java/org/dicoogle/query/file/FileReadWriteDimseFindPlugin.java
@@ -10,6 +10,7 @@
import java.util.Set;
import java.util.regex.Pattern;
import org.dcm4che3.data.Attributes;
+import org.dcm4che3.data.ElementDictionary;
import org.dcm4che3.data.Sequence;
import org.dcm4che3.data.Tag;
import org.dcm4che3.data.VR;
@@ -75,7 +76,7 @@ && hasText(seriesUid)) {
if (!matchesFreeText(attrs, freeText)) {
continue;
}
- if (!matchesKeywordFilters(attrs, filters)) {
+ if (!matchesKeywordFilters(attrs, filters, request)) {
continue;
}
if (!matchesDicomKeys(attrs, request.keys(), request)) {
@@ -110,35 +111,49 @@ private boolean matchesFreeText(Attributes attrs, String freeText) {
return haystack.contains(freeText);
}
- private boolean matchesKeywordFilters(Attributes attrs, Map filters) {
+ private boolean matchesKeywordFilters(
+ Attributes attrs, Map filters, FindRequest request) {
if (filters == null || filters.isEmpty()) {
return true;
}
for (Map.Entry entry : filters.entrySet()) {
- int tag = mapKeywordToTag(entry.getKey());
+ int tag = resolveKeywordToTag(entry.getKey(), attrs);
if (tag == -1) {
- continue;
+ return false;
}
- String actual = attrs.getString(tag, "");
- if (!actual.equalsIgnoreCase(entry.getValue())) {
+ VR vr = attrs.getVR(tag);
+ if (!matchesTagValue(attrs, entry.getValue(), tag, vr == null ? VR.LO : vr, request)) {
return false;
}
}
return true;
}
- private int mapKeywordToTag(String keyword) {
- return switch (keyword.toLowerCase(Locale.ROOT)) {
- case "patientid" -> Tag.PatientID;
- case "patientname" -> Tag.PatientName;
- case "studyinstanceuid" -> Tag.StudyInstanceUID;
- case "seriesinstanceuid" -> Tag.SeriesInstanceUID;
- case "sopinstanceuid" -> Tag.SOPInstanceUID;
- case "accessionnumber" -> Tag.AccessionNumber;
- case "modality" -> Tag.Modality;
- default -> -1;
- };
+ private int resolveKeywordToTag(String keyword, Attributes attrs) {
+ int direct = ElementDictionary.tagForKeyword(keyword, null);
+ if (direct >= 0) {
+ return direct;
+ }
+
+ String normalized = normalizeKeyword(keyword);
+ for (int tag : attrs.tags()) {
+ String candidate = ElementDictionary.keywordOf(tag, null);
+ if (candidate != null && normalizeKeyword(candidate).equals(normalized)) {
+ return tag;
+ }
+ }
+ return -1;
+ }
+
+ private String normalizeKeyword(String keyword) {
+ StringBuilder out = new StringBuilder(keyword.length());
+ for (char c : keyword.toCharArray()) {
+ if (Character.isLetterOrDigit(c)) {
+ out.append(Character.toLowerCase(c));
+ }
+ }
+ return out.toString();
}
private boolean matchesDicomKeys(Attributes attrs, Attributes keys, FindRequest request) {
diff --git a/backend/plugins/dicoogle-next-query-file/src/test/java/org/dicoogle/query/file/FileReadWriteDimseFindPluginTest.java b/backend/plugins/dicoogle-next-query-file/src/test/java/org/dicoogle/query/file/FileReadWriteDimseFindPluginTest.java
index 292ea68..211a4d2 100644
--- a/backend/plugins/dicoogle-next-query-file/src/test/java/org/dicoogle/query/file/FileReadWriteDimseFindPluginTest.java
+++ b/backend/plugins/dicoogle-next-query-file/src/test/java/org/dicoogle/query/file/FileReadWriteDimseFindPluginTest.java
@@ -45,7 +45,6 @@ void supportsTextAndKeywordFilters() throws Exception {
java.util.Map.of("modality", "MR"),
false,
false,
- false,
() -> false);
assertFalse(plugin.find(request).isEmpty());
@@ -58,6 +57,36 @@ void parserKeepsTextAndKeywordsSeparated() {
assertTrue(FileReadWriteDimseFindPlugin.extractFreeText(raw).contains("brain"));
}
+ @Test
+ void supportsArbitraryDicomKeywordFilter() throws Exception {
+ Path root = Files.createTempDirectory("query-file-test-any-keyword");
+ FileReadWriteStoragePlugin storage = new FileReadWriteStoragePlugin(root, "file");
+ storage.store(
+ new java.io.ByteArrayInputStream(createDicom("P1", "FELIX", "MR", "A1", "20240101")),
+ "application/dicom");
+
+ FileReadWriteDimseFindPlugin plugin = new FileReadWriteDimseFindPlugin(storage);
+
+ Attributes keys = new Attributes();
+ keys.setString(Tag.QueryRetrieveLevel, VR.CS, "STUDY");
+
+ var request =
+ new DimseFindServicePlugin.FindRequest(
+ DimseFindServicePlugin.InformationModel.STUDY_ROOT,
+ DimseFindServicePlugin.QueryRetrieveLevel.STUDY,
+ "CALLING",
+ "CALLED",
+ 10,
+ keys,
+ null,
+ java.util.Map.of("StudyDate", "20240101"),
+ false,
+ false,
+ () -> false);
+
+ assertEquals(1, plugin.find(request).size());
+ }
+
@Test
void supportsWildcardAndMultiValueMatching() throws Exception {
Path root = Files.createTempDirectory("query-file-test-wildcards");
@@ -89,7 +118,6 @@ void supportsWildcardAndMultiValueMatching() throws Exception {
java.util.Map.of(),
false,
false,
- false,
() -> false);
assertEquals(1, plugin.find(request).size());
@@ -124,7 +152,6 @@ void supportsDateRangeMatching() throws Exception {
java.util.Map.of(),
false,
false,
- false,
() -> false);
assertEquals(1, plugin.find(request).size());
@@ -162,7 +189,6 @@ void supportsTimeAndDateTimeRangeMatchingWithNegotiation() throws Exception {
java.util.Map.of(),
false,
false,
- false,
() -> false);
assertEquals(0, plugin.find(requestNoDateTimeNegotiation).size());
@@ -178,7 +204,6 @@ void supportsTimeAndDateTimeRangeMatchingWithNegotiation() throws Exception {
java.util.Map.of(),
false,
true,
- false,
() -> false);
assertEquals(1, plugin.find(requestWithDateTimeNegotiation).size());
}
@@ -210,7 +235,6 @@ void supportsFuzzyPersonNameWhenNegotiated() throws Exception {
java.util.Map.of(),
false,
false,
- false,
() -> false);
assertEquals(0, plugin.find(requestWithoutFuzzy).size());
@@ -226,7 +250,6 @@ void supportsFuzzyPersonNameWhenNegotiated() throws Exception {
java.util.Map.of(),
true,
false,
- false,
() -> false);
assertEquals(1, plugin.find(requestWithFuzzy).size());
}
@@ -261,7 +284,6 @@ void supportsNestedSequenceMatching() throws Exception {
java.util.Map.of(),
false,
false,
- false,
() -> false);
assertEquals(1, plugin.find(request).size());
}
@@ -291,7 +313,6 @@ void stopsEarlyWhenCancelRequested() throws Exception {
java.util.Map.of(),
false,
false,
- false,
() -> true);
assertEquals(0, plugin.find(request).size());