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());