changeset 4717:783f8a048035 openssl-3.x

integration mainline->openssl-3.x
author Sebastien Jodogne <s.jodogne@gmail.com>
date Wed, 23 Jun 2021 16:23:23 +0200
parents 816a9ecc6ea1 (current diff) 758fe3ffb336 (diff)
children b11900508dc0
files NEWS OrthancServer/Sources/OrthancRestApi/OrthancRestAnonymizeModify.cpp OrthancServer/Sources/OrthancRestApi/OrthancRestResources.cpp OrthancServer/Sources/ServerJobs/MergeStudyJob.cpp OrthancServer/Sources/ServerJobs/MergeStudyJob.h OrthancServer/Sources/ServerJobs/SplitStudyJob.cpp OrthancServer/Sources/ServerJobs/SplitStudyJob.h
diffstat 8 files changed, 239 insertions(+), 80 deletions(-) [+]
line wrap: on
line diff
--- a/NEWS	Tue Jun 22 10:40:28 2021 +0200
+++ b/NEWS	Wed Jun 23 16:23:23 2021 +0200
@@ -41,6 +41,8 @@
   - GET /patients/{id}/module, GET /patients/{id}/patient-module
   - GET /series/{id}/module, GET /studies/{id}/module, GET /instances/{id}/module
   - POST /tools/find
+* "/studies/{id}/split" accepts "Instances" parameter to split instances instead of series
+* "/studies/{id}/merge" accepts instances inside its "Resources" parameter
 
 Maintenance
 -----------
--- a/OrthancServer/Resources/RunCppCheck.sh	Tue Jun 22 10:40:28 2021 +0200
+++ b/OrthancServer/Resources/RunCppCheck.sh	Wed Jun 23 16:23:23 2021 +0200
@@ -19,12 +19,14 @@
 stlFindInsert:../../OrthancFramework/Sources/RestApi/RestApiCallDocumentation.cpp:72
 stlFindInsert:../../OrthancServer/Sources/Database/StatelessDatabaseOperations.cpp:384
 stlFindInsert:../../OrthancServer/Sources/OrthancWebDav.cpp:386
+stlFindInsert:../../OrthancServer/Sources/ServerJobs/MergeStudyJob.cpp:51
+stlFindInsert:../../OrthancServer/Sources/ServerJobs/SplitStudyJob.cpp:201
 syntaxError:../../OrthancFramework/Sources/SQLite/FunctionContext.h:50
 syntaxError:../../OrthancFramework/UnitTestsSources/ZipTests.cpp:131
 syntaxError:../../OrthancServer/UnitTestsSources/UnitTestsMain.cpp:321
 uninitMemberVar:../../OrthancServer/Sources/ServerJobs/StorageCommitmentScpJob.cpp:427
 unreadVariable:../../OrthancFramework/Sources/FileStorage/StorageAccessor.cpp
-unreadVariable:../../OrthancServer/Sources/OrthancRestApi/OrthancRestModalities.cpp:1121
+unreadVariable:../../OrthancServer/Sources/OrthancRestApi/OrthancRestModalities.cpp:1123
 unusedFunction
 useInitializationList:../../OrthancFramework/Sources/Images/PngReader.cpp:89
 useInitializationList:../../OrthancFramework/Sources/Images/PngWriter.cpp:97
--- a/OrthancServer/Sources/OrthancRestApi/OrthancRestAnonymizeModify.cpp	Tue Jun 22 10:40:28 2021 +0200
+++ b/OrthancServer/Sources/OrthancRestApi/OrthancRestAnonymizeModify.cpp	Wed Jun 23 16:23:23 2021 +0200
@@ -38,6 +38,24 @@
   "Starting with Orthanc 1.9.4, paths to subsequences can be provided using the "\
   "same syntax as the `dcmodify` command-line tool (wildcards are supported as well)."
 
+
+static const char* const CONTENT = "Content";
+static const char* const FORCE = "Force";
+static const char* const INSTANCES = "Instances";
+static const char* const INTERPRET_BINARY_TAGS = "InterpretBinaryTags";
+static const char* const KEEP = "Keep";
+static const char* const KEEP_PRIVATE_TAGS = "KeepPrivateTags";
+static const char* const KEEP_SOURCE = "KeepSource";
+static const char* const PARENT = "Parent";
+static const char* const PRIVATE_CREATOR = "PrivateCreator";
+static const char* const REMOVE = "Remove";
+static const char* const REPLACE = "Replace";
+static const char* const RESOURCES = "Resources";
+static const char* const SERIES = "Series";
+static const char* const TAGS = "Tags";
+static const char* const TRANSCODE = "Transcode";
+
+
 namespace Orthanc
 {
   // Modification of DICOM instances ------------------------------------------
@@ -54,23 +72,23 @@
   {
     // Check out "DicomModification::ParseModifyRequest()"
     call.GetDocumentation()
-      .SetRequestField("Transcode", RestApiCallDocumentation::Type_String,
+      .SetRequestField(TRANSCODE, RestApiCallDocumentation::Type_String,
                        "Transcode the DICOM instances to the provided DICOM transfer syntax: "
                        "https://book.orthanc-server.com/faq/transcoding.html", false)
-      .SetRequestField("Force", RestApiCallDocumentation::Type_Boolean,
+      .SetRequestField(FORCE, RestApiCallDocumentation::Type_Boolean,
                        "Allow the modification of tags related to DICOM identifiers, at the risk of "
                        "breaking the DICOM model of the real world", false)
       .SetRequestField("RemovePrivateTags", RestApiCallDocumentation::Type_Boolean,
                        "Remove the private tags from the DICOM instances (defaults to `false`)", false)
-      .SetRequestField("Replace", RestApiCallDocumentation::Type_JsonObject,
+      .SetRequestField(REPLACE, RestApiCallDocumentation::Type_JsonObject,
                        "Associative array to change the value of some DICOM tags in the DICOM instances. " INFO_SUBSEQUENCES, false)
-      .SetRequestField("Remove", RestApiCallDocumentation::Type_JsonListOfStrings,
+      .SetRequestField(REMOVE, RestApiCallDocumentation::Type_JsonListOfStrings,
                        "List of tags that must be removed from the DICOM instances. " INFO_SUBSEQUENCES, false)
-      .SetRequestField("Keep", RestApiCallDocumentation::Type_JsonListOfStrings,
+      .SetRequestField(KEEP, RestApiCallDocumentation::Type_JsonListOfStrings,
                        "Keep the original value of the specified tags, to be chosen among the `StudyInstanceUID`, "
                        "`SeriesInstanceUID` and `SOPInstanceUID` tags. Avoid this feature as much as possible, "
                        "as this breaks the DICOM model of the real world.", false)
-      .SetRequestField("PrivateCreator", RestApiCallDocumentation::Type_String,
+      .SetRequestField(PRIVATE_CREATOR, RestApiCallDocumentation::Type_String,
                        "The private creator to be used for private tags in `Replace`", false);
   }
 
@@ -79,21 +97,21 @@
   {
     // Check out "DicomModification::ParseAnonymizationRequest()"
     call.GetDocumentation()
-      .SetRequestField("Force", RestApiCallDocumentation::Type_Boolean,
+      .SetRequestField(FORCE, RestApiCallDocumentation::Type_Boolean,
                        "Allow the modification of tags related to DICOM identifiers, at the risk of "
                        "breaking the DICOM model of the real world", false)
       .SetRequestField("DicomVersion", RestApiCallDocumentation::Type_String,
                        "Version of the DICOM standard to be used for anonymization. Check out "
                        "configuration option `DeidentifyLogsDicomVersion` for possible values.", false)
-      .SetRequestField("KeepPrivateTags", RestApiCallDocumentation::Type_Boolean,
+      .SetRequestField(KEEP_PRIVATE_TAGS, RestApiCallDocumentation::Type_Boolean,
                        "Keep the private tags from the DICOM instances (defaults to `false`)", false)
-      .SetRequestField("Replace", RestApiCallDocumentation::Type_JsonObject,
+      .SetRequestField(REPLACE, RestApiCallDocumentation::Type_JsonObject,
                        "Associative array to change the value of some DICOM tags in the DICOM instances. " INFO_SUBSEQUENCES, false)
-      .SetRequestField("Remove", RestApiCallDocumentation::Type_JsonListOfStrings,
+      .SetRequestField(REMOVE, RestApiCallDocumentation::Type_JsonListOfStrings,
                        "List of additional tags to be removed from the DICOM instances. " INFO_SUBSEQUENCES, false)
-      .SetRequestField("Keep", RestApiCallDocumentation::Type_JsonListOfStrings,
+      .SetRequestField(KEEP, RestApiCallDocumentation::Type_JsonListOfStrings,
                        "List of DICOM tags whose value must not be destroyed by the anonymization. " INFO_SUBSEQUENCES, false)
-      .SetRequestField("PrivateCreator", RestApiCallDocumentation::Type_String,
+      .SetRequestField(PRIVATE_CREATOR, RestApiCallDocumentation::Type_String,
                        "The private creator to be used for private tags in `Replace`", false);
   }
 
@@ -241,7 +259,6 @@
 
     modification.SetLevel(DetectModifyLevel(modification));
 
-    static const char* TRANSCODE = "Transcode";
     if (request.isMember(TRANSCODE))
     {
       std::string s = SerializationToolbox::ReadString(request, TRANSCODE);
@@ -293,7 +310,6 @@
   static void SetKeepSource(CleaningInstancesJob& job,
                             const Json::Value& body)
   {
-    static const char* KEEP_SOURCE = "KeepSource";
     if (body.isMember(KEEP_SOURCE))
     {
       job.SetKeepSource(SerializationToolbox::ReadBoolean(body, KEEP_SOURCE));
@@ -325,7 +341,6 @@
     job->SetOrigin(call);
     SetKeepSource(*job, body);
 
-    static const char* TRANSCODE = "Transcode";
     if (body.isMember(TRANSCODE))
     {
       job->SetTranscode(SerializationToolbox::ReadString(body, TRANSCODE));
@@ -365,7 +380,7 @@
                             const Json::Value& body)
   {
     std::set<std::string> resources;
-    SerializationToolbox::ReadSetOfStrings(resources, body, "Resources");
+    SerializationToolbox::ReadSetOfStrings(resources, body, RESOURCES);
 
     SubmitModificationJob(modification, isAnonymization,
                           call, body, ResourceType_Instance /* arbitrary value, unused */,
@@ -414,7 +429,7 @@
       call.GetDocumentation()
         .SetTag("System")
         .SetSummary("Modify a set of resources")
-        .SetRequestField("Resources", RestApiCallDocumentation::Type_JsonListOfStrings,
+        .SetRequestField(RESOURCES, RestApiCallDocumentation::Type_JsonListOfStrings,
                          "List of the Orthanc identifiers of the patients/studies/series/instances of interest.", true)
         .SetDescription("Start a job that will modify all the DICOM patients, studies, series or instances "
                         "whose identifiers are provided in the `Resources` field.")
@@ -472,7 +487,7 @@
       call.GetDocumentation()
         .SetTag("System")
         .SetSummary("Anonymize a set of resources")
-        .SetRequestField("Resources", RestApiCallDocumentation::Type_JsonListOfStrings,
+        .SetRequestField(RESOURCES, RestApiCallDocumentation::Type_JsonListOfStrings,
                          "List of the Orthanc identifiers of the patients/studies/series/instances of interest.", true)
         .SetDescription("Start a job that will anonymize all the DICOM patients, studies, series or instances "
                         "whose identifiers are provided in the `Resources` field.")
@@ -626,16 +641,16 @@
         }
         else if (content[i].type() == Json::objectValue)
         {
-          if (!content[i].isMember("Content"))
+          if (!content[i].isMember(CONTENT))
           {
             throw OrthancException(ErrorCode_CreateDicomNoPayload);
           }
 
-          payload = &content[i]["Content"];
+          payload = &content[i][CONTENT];
 
-          if (content[i].isMember("Tags"))
+          if (content[i].isMember(TAGS))
           {
-            InjectTags(*dicom, content[i]["Tags"], decodeBinaryTags, privateCreator, force);
+            InjectTags(*dicom, content[i][TAGS], decodeBinaryTags, privateCreator, force);
           }
         }
 
@@ -677,13 +692,7 @@
   static void CreateDicomV2(RestApiPostCall& call,
                             const Json::Value& request)
   {
-    static const char* const CONTENT = "Content";
-    static const char* const FORCE = "Force";
-    static const char* const INTERPRET_BINARY_TAGS = "InterpretBinaryTags";
-    static const char* const PARENT = "Parent";
-    static const char* const PRIVATE_CREATOR = "PrivateCreator";
     static const char* const SPECIFIC_CHARACTER_SET_2 = "SpecificCharacterSet";
-    static const char* const TAGS = "Tags";
     static const char* const TYPE = "Type";
     static const char* const VALUE = "Value";
     
@@ -757,7 +766,7 @@
 
       // Choose the same encoding as the parent resource
       {
-        static const char* SPECIFIC_CHARACTER_SET = "0008,0005";
+        static const char* const SPECIFIC_CHARACTER_SET = "0008,0005";
 
         if (siblingTags.isMember(SPECIFIC_CHARACTER_SET))
         {
@@ -936,25 +945,25 @@
         .SetTag("System")
         .SetSummary("Create one DICOM instance")
         .SetDescription("Create one DICOM instance, and store it into Orthanc")
-        .SetRequestField("Tags", RestApiCallDocumentation::Type_JsonObject,
+        .SetRequestField(TAGS, RestApiCallDocumentation::Type_JsonObject,
                          "Associative array containing the tags of the new instance to be created", true)
-        .SetRequestField("Content", RestApiCallDocumentation::Type_String,
+        .SetRequestField(CONTENT, RestApiCallDocumentation::Type_String,
                          "This field can be used to embed an image (pixel data) or a PDF inside the created DICOM instance. "
                          "The PNG image, the JPEG image or the PDF file must be provided using their "
                          "[data URI scheme encoding](https://en.wikipedia.org/wiki/Data_URI_scheme). "
                          "This field can possibly contain a JSON array, in which case a DICOM series is created "
                          "containing one DICOM instance for each item in the `Content` field.", false)
-        .SetRequestField("Parent", RestApiCallDocumentation::Type_String,
+        .SetRequestField(PARENT, RestApiCallDocumentation::Type_String,
                          "If present, the newly created instance will be attached to the parent DICOM resource "
                          "whose Orthanc identifier is contained in this field. The DICOM tags of the parent "
                          "modules in the DICOM hierarchy will be automatically copied to the newly created instance.", false)
-        .SetRequestField("InterpretBinaryTags", RestApiCallDocumentation::Type_Boolean,
+        .SetRequestField(INTERPRET_BINARY_TAGS, RestApiCallDocumentation::Type_Boolean,
                          "If some value in the `Tags` associative array is formatted according to some "
                          "[data URI scheme encoding](https://en.wikipedia.org/wiki/Data_URI_scheme), "
                          "whether this value is decoded to a binary value or kept as such (`true` by default)", false)
-        .SetRequestField("PrivateCreator", RestApiCallDocumentation::Type_String,
+        .SetRequestField(PRIVATE_CREATOR, RestApiCallDocumentation::Type_String,
                          "The private creator to be used for private tags in `Tags`", false)
-        .SetRequestField("Force", RestApiCallDocumentation::Type_Boolean,
+        .SetRequestField(FORCE, RestApiCallDocumentation::Type_Boolean,
                          "Avoid the consistency checks for the DICOM tags that enforce the DICOM model of the real-world. "
                          "You can notably use this flag if you need to manually set the tags `StudyInstanceUID`, "
                          "`SeriesInstanceUID`, or `SOPInstanceUID`. Be careful with this feature.", false)
@@ -970,7 +979,7 @@
       throw OrthancException(ErrorCode_BadRequest);
     }
 
-    if (request.isMember("Tags"))
+    if (request.isMember(TAGS))
     {
       CreateDicomV2(call, request);
     }
@@ -995,22 +1004,25 @@
         .SetTag("Studies")
         .SetSummary("Split study")
         .SetDescription("Start a new job so as to split the DICOM study whose Orthanc identifier is provided in the URL, "
-                        "by taking some of its children series out of it and putting them into a brand new study (this "
-                        "new study is created by setting the `StudyInstanceUID` tag to a random identifier): "
+                        "by taking some of its children series or instances out of it and putting them into a brand new study "
+                        "(this new study is created by setting the `StudyInstanceUID` tag to a random identifier): "
                         "https://book.orthanc-server.com/users/anonymization.html#splitting")
         .SetUriArgument("id", "Orthanc identifier of the study of interest")
-        .SetRequestField("Series", RestApiCallDocumentation::Type_JsonListOfStrings,
-                         "The list of series to be separated from the parent study (mandatory option). "
-                         "These series must all be children of the same source study, that is specified in the URI.", true)
-        .SetRequestField("Replace", RestApiCallDocumentation::Type_JsonObject,
+        .SetRequestField(SERIES, RestApiCallDocumentation::Type_JsonListOfStrings,
+                         "The list of series to be separated from the parent study. "
+                         "These series must all be children of the same source study, that is specified in the URI.", false)
+        .SetRequestField(REPLACE, RestApiCallDocumentation::Type_JsonObject,
                          "Associative array to change the value of some DICOM tags in the new study. "
                          "These tags must be part of the \"Patient Module Attributes\" or the \"General Study "
                          "Module Attributes\", as specified by the DICOM 2011 standard in Tables C.7-1 and C.7-3.", false)
-        .SetRequestField("Remove", RestApiCallDocumentation::Type_JsonListOfStrings,
+        .SetRequestField(REMOVE, RestApiCallDocumentation::Type_JsonListOfStrings,
                          "List of tags that must be removed in the new study (from the same modules as in the `Replace` option)", false)
-        .SetRequestField("KeepSource", RestApiCallDocumentation::Type_Boolean,
-                         "If set to `true`, instructs Orthanc to keep a copy of the original series in the source study. "
-                         "By default, the original series are deleted from Orthanc.", false);
+        .SetRequestField(KEEP_SOURCE, RestApiCallDocumentation::Type_Boolean,
+                         "If set to `true`, instructs Orthanc to keep a copy of the original series/instances in the source study. "
+                         "By default, the original series/instances are deleted from Orthanc.", false)
+        .SetRequestField(INSTANCES, RestApiCallDocumentation::Type_JsonListOfStrings,
+                         "The list of instances to be separated from the parent study. "
+                         "These instances must all be children of the same source study, that is specified in the URI.", false);
       return;
     }
     
@@ -1028,19 +1040,40 @@
     std::unique_ptr<SplitStudyJob> job(new SplitStudyJob(context, study));    
     job->SetOrigin(call);
 
-    std::vector<std::string> series;
-    SerializationToolbox::ReadArrayOfStrings(series, request, "Series");
+    bool ok = false;
+    if (request.isMember(SERIES))
+    {
+      std::vector<std::string> series;
+      SerializationToolbox::ReadArrayOfStrings(series, request, SERIES);
+
+      for (size_t i = 0; i < series.size(); i++)
+      {
+        job->AddSourceSeries(series[i]);
+        ok = true;
+      }
+    }
 
-    for (size_t i = 0; i < series.size(); i++)
+    if (request.isMember(INSTANCES))
     {
-      job->AddSourceSeries(series[i]);
+      std::vector<std::string> instances;
+      SerializationToolbox::ReadArrayOfStrings(instances, request, INSTANCES);
+
+      for (size_t i = 0; i < instances.size(); i++)
+      {
+        job->AddSourceInstance(instances[i]);
+        ok = true;
+      }
     }
+
+    if (!ok)
+    {
+      throw OrthancException(ErrorCode_BadRequest, "Both the \"Series\" and the \"Instances\" fields are missing");
+    }    
     
     job->AddTrailingStep();
 
     SetKeepSource(*job, request);
 
-    static const char* REMOVE = "Remove";
     if (request.isMember(REMOVE))
     {
       if (request[REMOVE].type() != Json::arrayValue)
@@ -1061,7 +1094,6 @@
       }
     }
 
-    static const char* REPLACE = "Replace";
     if (request.isMember(REPLACE))
     {
       if (request[REPLACE].type() != Json::objectValue)
@@ -1099,13 +1131,13 @@
       call.GetDocumentation()
         .SetTag("Studies")
         .SetSummary("Merge study")
-        .SetDescription("Start a new job so as to move some DICOM series into the DICOM study whose Orthanc identifier "
+        .SetDescription("Start a new job so as to move some DICOM resources into the DICOM study whose Orthanc identifier "
                         "is provided in the URL: https://book.orthanc-server.com/users/anonymization.html#merging")
         .SetUriArgument("id", "Orthanc identifier of the study of interest")
-        .SetRequestField("Resources", RestApiCallDocumentation::Type_JsonListOfStrings,
-                         "The list of DICOM resources (patients, studies, series, and/or instances) to be merged "
+        .SetRequestField(RESOURCES, RestApiCallDocumentation::Type_JsonListOfStrings,
+                         "The list of DICOM resources (studies, series, and/or instances) to be merged "
                          "into the study of interest (mandatory option)", true)
-        .SetRequestField("KeepSource", RestApiCallDocumentation::Type_Boolean,
+        .SetRequestField(KEEP_SOURCE, RestApiCallDocumentation::Type_Boolean,
                          "If set to `true`, instructs Orthanc to keep a copy of the original resources in their source study. "
                          "By default, the original resources are deleted from Orthanc.", false);
       return;
@@ -1126,7 +1158,7 @@
     job->SetOrigin(call);
 
     std::vector<std::string> resources;
-    SerializationToolbox::ReadArrayOfStrings(resources, request, "Resources");
+    SerializationToolbox::ReadArrayOfStrings(resources, request, RESOURCES);
 
     for (size_t i = 0; i < resources.size(); i++)
     {
--- a/OrthancServer/Sources/OrthancRestApi/OrthancRestResources.cpp	Tue Jun 22 10:40:28 2021 +0200
+++ b/OrthancServer/Sources/OrthancRestApi/OrthancRestResources.cpp	Wed Jun 23 16:23:23 2021 +0200
@@ -2535,7 +2535,7 @@
       DicomToJsonFormat       format_;
 
     public:
-      FindVisitor(DicomToJsonFormat format) :
+      explicit FindVisitor(DicomToJsonFormat format) :
         isComplete_(false),
         format_(format)
       {
@@ -3101,8 +3101,29 @@
   }
 
 
+  static void AddMetadata(Json::Value& target,
+                          ServerIndex& index,
+                          const std::string& resource,
+                          ResourceType level)
+  {
+    target = Json::objectValue;
+    
+    std::map<MetadataType, std::string> content;
+    index.GetAllMetadata(content, resource, level);
+    
+    for (std::map<MetadataType, std::string>::const_iterator
+           it = content.begin(); it != content.end(); ++it)
+    {
+      target[EnumerationToString(it->first)] = it->second;
+    }
+  }
+
+
   static void BulkContent(RestApiPostCall& call)
   {
+    static const char* const LEVEL = "Level";
+    static const char* const METADATA = "Metadata";
+      
     if (call.IsDocumentation())
     {
       OrthancRestApi::DocumentDicomFormat(call, DicomToJsonFormat_Human);
@@ -3112,10 +3133,12 @@
         .SetSummary("Describe a set of instances")
         .SetRequestField("Resources", RestApiCallDocumentation::Type_JsonListOfStrings,
                          "List of the Orthanc identifiers of the patients/studies/series/instances of interest.", true)
-        .SetRequestField("Level", RestApiCallDocumentation::Type_String,
+        .SetRequestField(LEVEL, RestApiCallDocumentation::Type_String,
                          "This optional argument specifies the level of interest (can be `Patient`, `Study`, `Series` or "
                          "`Instance`). Orthanc will loop over the items inside `Resources`, and explorer upward or "
                          "downward in the DICOM hierarchy in order to find the level of interest.", false)
+        .SetRequestField(METADATA, RestApiCallDocumentation::Type_Boolean,
+                         "If set to `true` (default value), the metadata associated with the resources will also be retrieved.", false)
         .SetDescription("Get the content all the DICOM patients, studies, series or instances "
                         "whose identifiers are provided in the `Resources` field, in one single call.");
       return;
@@ -3130,10 +3153,14 @@
     }
     else
     {
-      static const char* const LEVEL = "Level";      
-      
       const DicomToJsonFormat format = OrthancRestApi::GetDicomFormat(request, DicomToJsonFormat_Human);
 
+      bool metadata = true;
+      if (request.isMember(METADATA))
+      {
+        metadata = SerializationToolbox::ReadBoolean(request, METADATA);
+      }
+
       ServerIndex& index = OrthancRestApi::GetIndex(call);
       
       Json::Value answer = Json::arrayValue;
@@ -3178,9 +3205,9 @@
                 if (type == level)
                 {
                   for (std::set<std::string>::const_iterator
-                         it = children.begin(); it != children.end(); ++it)
+                         it2 = children.begin(); it2 != children.end(); ++it2)
                   {
-                    interest.insert(*it);
+                    interest.insert(*it2);
                   }
 
                   break;  // done
@@ -3231,6 +3258,11 @@
           Json::Value item;
           if (index.ExpandResource(item, *it, level, format))
           {
+            if (metadata)
+            {
+              AddMetadata(item[METADATA], index, *it, level);
+            }
+
             answer.append(item);
           }
         }
@@ -3244,11 +3276,16 @@
         for (std::list<std::string>::const_iterator
                it = resources.begin(); it != resources.end(); ++it)
         {
-          ResourceType type;
+          ResourceType level;
           Json::Value item;
-          if (index.LookupResourceType(type, *it) &&
-              index.ExpandResource(item, *it, type, format))
+          if (index.LookupResourceType(level, *it) &&
+              index.ExpandResource(item, *it, level, format))
           {
+            if (metadata)
+            {
+              AddMetadata(item[METADATA], index, *it, level);
+            }
+
             answer.append(item);
           }
           else
--- a/OrthancServer/Sources/ServerJobs/MergeStudyJob.cpp	Tue Jun 22 10:40:28 2021 +0200
+++ b/OrthancServer/Sources/ServerJobs/MergeStudyJob.cpp	Wed Jun 23 16:23:23 2021 +0200
@@ -30,10 +30,20 @@
 
 namespace Orthanc
 {
+  static void RegisterSeries(std::map<std::string, std::string>& target,
+                             const std::string& series)
+  {
+    // Generate a target SeriesInstanceUID for this series
+    if (target.find(series) == target.end())
+    {
+      target[series] = FromDcmtkBridge::GenerateUniqueIdentifier(ResourceType_Series);
+    }
+  }
+  
+
   void MergeStudyJob::AddSourceSeriesInternal(const std::string& series)
   {
-    // Generate a target SeriesInstanceUID for this series
-    seriesUidMap_[series] = FromDcmtkBridge::GenerateUniqueIdentifier(ResourceType_Series);
+    RegisterSeries(seriesUidMap_, series);
 
     // Add all the instances of the series as to be processed
     std::list<std::string> instances;
@@ -228,7 +238,7 @@
   }
 
 
-  void MergeStudyJob::AddSource(const std::string& studyOrSeries)
+  void MergeStudyJob::AddSource(const std::string& publicId)
   {
     ResourceType level;
     
@@ -236,28 +246,31 @@
     {
       throw OrthancException(ErrorCode_BadSequenceOfCalls);
     }
-    else if (!GetContext().GetIndex().LookupResourceType(level, studyOrSeries))
+    else if (!GetContext().GetIndex().LookupResourceType(level, publicId))
     {
       throw OrthancException(ErrorCode_UnknownResource,
-                             "Cannot find this resource: " + studyOrSeries);
+                             "Cannot find this resource: " + publicId);
     }
     else
     {
       switch (level)
       {
         case ResourceType_Study:
-          AddSourceStudyInternal(studyOrSeries);
+          AddSourceStudyInternal(publicId);
           break;
           
         case ResourceType_Series:
-          AddSourceSeries(studyOrSeries);
+          AddSourceSeries(publicId);
+          break;
+          
+        case ResourceType_Instance:
+          AddSourceInstance(publicId);
           break;
           
         default:
           throw OrthancException(ErrorCode_UnknownResource,
-                                 "This resource is neither a study, nor a series: " +
-                                 studyOrSeries + " is a " +
-                                 std::string(EnumerationToString(level)));
+                                 "This resource is neither a study, nor a series, nor an instance: " +
+                                 publicId + " is a " + std::string(EnumerationToString(level)));
       }
     }    
   }
@@ -310,6 +323,34 @@
   }
 
 
+  void MergeStudyJob::AddSourceInstance(const std::string& instance)
+  {
+    std::string parentStudy, parentSeries;
+
+    if (IsStarted())
+    {
+      throw OrthancException(ErrorCode_BadSequenceOfCalls);
+    }
+    else if (!GetContext().GetIndex().LookupParent(parentSeries, instance, ResourceType_Series) ||
+             !GetContext().GetIndex().LookupParent(parentStudy, parentSeries, ResourceType_Study))
+    {
+      throw OrthancException(ErrorCode_UnknownResource,
+                             "This resource is not an instance: " + instance);
+    }
+    else if (parentStudy == targetStudy_)
+    {
+      throw OrthancException(ErrorCode_UnknownResource,
+                             "Cannot merge instance " + instance +
+                             " into its parent study " + targetStudy_);
+    }
+    else
+    {
+      RegisterSeries(seriesUidMap_, parentSeries);
+      AddInstance(instance);
+    }    
+  }
+  
+
   void MergeStudyJob::GetPublicContent(Json::Value& value)
   {
     CleaningInstancesJob::GetPublicContent(value);
--- a/OrthancServer/Sources/ServerJobs/MergeStudyJob.h	Tue Jun 22 10:40:28 2021 +0200
+++ b/OrthancServer/Sources/ServerJobs/MergeStudyJob.h	Wed Jun 23 16:23:23 2021 +0200
@@ -47,6 +47,10 @@
 
     void AddSourceStudyInternal(const std::string& study);
 
+    // Make setter methods private to prevent incorrect calls
+    using SetOfInstancesJob::AddParentResource;
+    using SetOfInstancesJob::AddInstance;
+    
   protected:
     virtual bool HandleInstance(const std::string& instance) ORTHANC_OVERRIDE;
 
@@ -62,12 +66,14 @@
       return targetStudy_;
     }
 
-    void AddSource(const std::string& studyOrSeries);
+    void AddSource(const std::string& publicId);
 
     void AddSourceStudy(const std::string& study);
 
     void AddSourceSeries(const std::string& series);
 
+    void AddSourceInstance(const std::string& instance);  // New in Orthanc 1.9.4
+
     void SetOrigin(const DicomInstanceOrigin& origin);
 
     void SetOrigin(const RestApiCall& call);
--- a/OrthancServer/Sources/ServerJobs/SplitStudyJob.cpp	Tue Jun 22 10:40:28 2021 +0200
+++ b/OrthancServer/Sources/ServerJobs/SplitStudyJob.cpp	Wed Jun 23 16:23:23 2021 +0200
@@ -180,6 +180,17 @@
   }
 
 
+  static void RegisterSeries(std::map<std::string, std::string>& target,
+                             const std::string& series)
+  {
+    // Generate a target SeriesInstanceUID for this series
+    if (target.find(series) == target.end())
+    {
+      target[series] = FromDcmtkBridge::GenerateUniqueIdentifier(ResourceType_Series);
+    }
+  }
+  
+
   void SplitStudyJob::AddSourceSeries(const std::string& series)
   {
     std::string parent;
@@ -196,8 +207,7 @@
     }
     else
     {
-      // Generate a target SeriesInstanceUID for this series
-      seriesUidMap_[series] = FromDcmtkBridge::GenerateUniqueIdentifier(ResourceType_Series);
+      RegisterSeries(seriesUidMap_, series);
 
       // Add all the instances of the series as to be processed
       std::list<std::string> instances;
@@ -212,6 +222,29 @@
   }
 
 
+  void SplitStudyJob::AddSourceInstance(const std::string& instance)
+  {
+    std::string study, series;
+
+    if (IsStarted())
+    {
+      throw OrthancException(ErrorCode_BadSequenceOfCalls);
+    }
+    else if (!GetContext().GetIndex().LookupParent(series, instance, ResourceType_Series) ||
+             !GetContext().GetIndex().LookupParent(study, series, ResourceType_Study) ||
+             study != sourceStudy_)
+    {
+      throw OrthancException(ErrorCode_UnknownResource,
+                             "This instance does not belong to the study to be split: " + instance);
+    }
+    else
+    {
+      RegisterSeries(seriesUidMap_, series);
+      AddInstance(instance);
+    }    
+  }
+
+
   bool SplitStudyJob::LookupTargetSeriesUid(std::string& uid,
                                             const std::string& series) const
   {
--- a/OrthancServer/Sources/ServerJobs/SplitStudyJob.h	Tue Jun 22 10:40:28 2021 +0200
+++ b/OrthancServer/Sources/ServerJobs/SplitStudyJob.h	Wed Jun 23 16:23:23 2021 +0200
@@ -48,6 +48,10 @@
     void CheckAllowedTag(const DicomTag& tag) const;
     
     void Setup();
+
+    // Make setter methods private to prevent incorrect calls
+    using SetOfInstancesJob::AddParentResource;
+    using SetOfInstancesJob::AddInstance;
     
   protected:
     virtual bool HandleInstance(const std::string& instance) ORTHANC_OVERRIDE;
@@ -76,6 +80,8 @@
 
     void AddSourceSeries(const std::string& series);
 
+    void AddSourceInstance(const std::string& instance);  // New in Orthanc 1.9.4
+    
     bool LookupTargetSeriesUid(std::string& uid,
                                const std::string& series) const;