[med-svn] [Git][med-team/orthanc-dicomweb][upstream] New upstream version 1.18+dfsg

Sebastien Jodogne (@jodogne-guest) gitlab at salsa.debian.org
Fri Dec 20 09:24:10 GMT 2024



Sebastien Jodogne pushed to branch upstream at Debian Med / orthanc-dicomweb


Commits:
bee0a78e by jodogne-guest at 2024-12-20T09:30:31+01:00
New upstream version 1.18+dfsg
- - - - -


21 changed files:

- .hg_archival.txt
- CITATION.cff
- CMakeLists.txt
- NEWS
- Plugin/Configuration.cpp
- Plugin/Configuration.h
- Plugin/DicomWebClient.cpp
- Plugin/Plugin.cpp
- Plugin/QidoRs.cpp
- Plugin/StowRs.cpp
- Plugin/WadoRs.cpp
- Plugin/WadoRs.h
- Plugin/WadoRsRetrieveFrames.cpp
- Resources/CMake/JavaScriptLibraries.cmake
- Resources/Orthanc/CMake/Compiler.cmake
- Resources/Orthanc/CMake/DownloadOrthancFramework.cmake
- Resources/Orthanc/Plugins/OrthancPluginCppWrapper.cpp
- Resources/Orthanc/Plugins/OrthancPluginCppWrapper.h
- TODO
- WebApplication/app.js
- WebApplication/index.html


Changes:

=====================================
.hg_archival.txt
=====================================
@@ -1,6 +1,6 @@
 repo: d5f45924411123cfd02d035fd50b8e37536eadef
-node: 599ef9f8918ab8981f6ae33dcadcd734307c324b
-branch: OrthancDicomWeb-1.17
+node: 9535bcd7fa8b6223f03d20921f90c860f8257531
+branch: OrthancDicomWeb-1.18
 latesttag: null
-latesttagdistance: 587
-changessincelatesttag: 632
+latesttagdistance: 609
+changessincelatesttag: 658


=====================================
CITATION.cff
=====================================
@@ -10,5 +10,5 @@ authors:
 doi: "10.1007/s10278-018-0082-y"
 license: "GPL-3.0-or-later"
 repository-code: "https://orthanc.uclouvain.be/hg/orthanc/"
-version: 1.12.4
-date-released: 2024-06-05
+version: 1.12.5
+date-released: 2024-12-18


=====================================
CMakeLists.txt
=====================================
@@ -23,13 +23,13 @@ cmake_minimum_required(VERSION 2.8)
 
 project(OrthancDicomWeb)
 
-set(ORTHANC_DICOM_WEB_VERSION "1.17")
+set(ORTHANC_DICOM_WEB_VERSION "1.18")
 
 if (ORTHANC_DICOM_WEB_VERSION STREQUAL "mainline")
   set(ORTHANC_FRAMEWORK_DEFAULT_VERSION "mainline")
   set(ORTHANC_FRAMEWORK_DEFAULT_SOURCE "hg")
 else()
-  set(ORTHANC_FRAMEWORK_DEFAULT_VERSION "1.12.4")
+  set(ORTHANC_FRAMEWORK_DEFAULT_VERSION "1.12.5")
   set(ORTHANC_FRAMEWORK_DEFAULT_SOURCE "web")
 endif()
 
@@ -51,7 +51,6 @@ set(ORTHANC_FRAMEWORK_STATIC OFF CACHE BOOL "If linking against the Orthanc fram
 mark_as_advanced(ORTHANC_FRAMEWORK_STATIC)
 
 
-set(BUILD_BOOTSTRAP_VUE OFF CACHE BOOL "Compile Bootstrap-Vue from sources")
 set(BUILD_BABEL_POLYFILL OFF CACHE BOOL "Retrieve babel-polyfill from npm")
 
 
@@ -101,9 +100,7 @@ if (STATIC_BUILD OR NOT USE_SYSTEM_ORTHANC_SDK)
   if (ORTHANC_SDK_VERSION STREQUAL "1.12.1")
     include_directories(${CMAKE_SOURCE_DIR}/Resources/Orthanc/Sdk-1.12.1)
   elseif (ORTHANC_SDK_VERSION STREQUAL "framework")
-    set(tmp ${ORTHANC_FRAMEWORK_ROOT}/../../OrthancServer/Plugins/Include/)
-    message(${tmp})
-    include_directories(${tmp})
+    include_directories(${ORTHANC_FRAMEWORK_ROOT}/../../OrthancServer/Plugins/Include/)
   else()
     message(FATAL_ERROR "Unsupported version of the Orthanc plugin SDK: ${ORTHANC_SDK_VERSION}")
   endif()


=====================================
NEWS
=====================================
@@ -2,6 +2,22 @@ Pending changes in the mainline
 ===============================
 
 
+Version 1.18 (2024-12-18)
+=========================
+
+* Added a "Server" entry in the DICOMweb job content
+* Fixed parsing of numerical values in QIDO-RS response that prevented, among other,
+  the retrieval of "NumberOfStudyRelatedInstances", "NumberOfStudyRelatedSeries",...
+* Fixed non-Latin "PatientName" values that were empty in some QIDO-RS responses
+* Optimized the retrieval of single frame in WADO-RS when no transcoding is required,
+  which greatly improves the download time of multi-frame images in OHIF
+* Optimization when running with an Orthanc that supports the "ExtendedFind" primitive
+* Added support for Orthanc running in "ReadOnly" mode
+* Fix handling of revisions for cached data
+* Removed dependency on bootstrap-vue
+* Upgraded to Bootstrap 5.3.3
+
+
 Version 1.17 (2024-06-05)
 =========================
 
@@ -278,7 +294,7 @@ Version 0.4 (2017-07-19)
 * WADO-RS client supports quoted Content-Type header in HTTP answers
 * Added "Arguments" to WADO-RS and STOW-RS client to handle query arguments in uri
 * Using MIME types of DICOM version 2017c in WADO RetrieveFrames
-* Fix issue #53 (DICOMWeb plugin support for "limit" and "offset" parameters in QIDO-RS)
+* Fix issue #53 (DICOMweb plugin support for "limit" and "offset" parameters in QIDO-RS)
 * Fix issue #28 (Non-compliant enumerations for "accept" header for WADO RetrieveFrames)
 
 


=====================================
Plugin/Configuration.cpp
=====================================
@@ -278,6 +278,8 @@ namespace OrthancPlugins
         Orthanc::ResourceType level = Orthanc::StringToResourceType(levels[i].c_str());
 
         const Json::Value& content = configuration[EXTRA_MAIN_DICOM_TAGS][levels[i]];
+        std::set<Orthanc::DicomTag> defaultTags;
+        Orthanc::DicomMap::GetMainDicomTags(defaultTags, level);
 
         if (content.size() > 0)
         {
@@ -286,7 +288,10 @@ namespace OrthancPlugins
             const std::string& tagName = content[t].asString();
             Orthanc::DicomTag tag(0, 0);
             OrthancPlugins::ParseTag(tag, tagName);
-            Orthanc::DicomMap::AddMainDicomTag(tag, level);
+            if (defaultTags.find(tag) == defaultTags.end()) // don't generate an error if the ExtraMainDicomTags is now a default one
+            {
+              Orthanc::DicomMap::AddMainDicomTag(tag, level);
+            }
           }
         }
       }
@@ -470,11 +475,10 @@ namespace OrthancPlugins
 
 
     static bool LookupHttpHeader2(std::string& value,
-                                  const HttpClient::HttpHeaders& headers,
+                                  const HttpHeaders& headers,
                                   const std::string& name)
     {
-      for (HttpClient::HttpHeaders::const_iterator
-             it = headers.begin(); it != headers.end(); ++it)
+      for (HttpHeaders::const_iterator it = headers.begin(); it != headers.end(); ++it)
       {
         if (boost::iequals(it->first, name))
         {
@@ -487,7 +491,7 @@ namespace OrthancPlugins
     }
 
 
-    std::string GetBasePublicUrl(const HttpClient::HttpHeaders& headers)
+    std::string GetBasePublicUrl(const HttpHeaders& headers)
     {
       assert(dicomWebConfiguration_.get() != NULL);
       std::string host = dicomWebConfiguration_->GetStringValue("Host", "");
@@ -558,7 +562,7 @@ namespace OrthancPlugins
 
     std::string GetBasePublicUrl(const OrthancPluginHttpRequest* request)
     {
-      HttpClient::HttpHeaders headers;
+      HttpHeaders headers;
 
       std::string value;
       if (LookupHttpHeader(value, request, "forwarded"))
@@ -682,6 +686,10 @@ namespace OrthancPlugins
       return GetBooleanValue("EnableMetadataCache", true);
     }
 
+    bool IsReadOnly()
+    {
+      return globalConfiguration_->GetBooleanValue("ReadOnly", false);
+    }
     
     MetadataMode GetMetadataMode(Orthanc::ResourceType level)
     {


=====================================
Plugin/Configuration.h
=====================================
@@ -142,5 +142,7 @@ namespace OrthancPlugins
     unsigned int GetMetadataWorkerThreadsCount();
 
     bool IsMetadataCacheEnabled();
+
+    bool IsReadOnly();
   }
 }


=====================================
Plugin/DicomWebClient.cpp
=====================================
@@ -501,26 +501,8 @@ static void CheckStowAnswer(const Json::Value& response,
 }
 
 
-// static void AddResourceForJobContent(Json::Value resourcesForJobContent /* out */, const char* resourceType, const std::string& resourceId)
 static void AddResourceForJobContent(Json::Value& resourcesForJobContent /* out */, Orthanc::ResourceType resourceType, const std::string& resourceId)
 {
-  // const char* resourceGroup = "Instances";
-  // if (resourceType == "Study")
-  // {
-  //   resourceGroup = "Studies";
-  // }
-  // else if (resourceType == "Series")
-  // {
-  //   resourceGroup = "Series";
-  // }
-  // else if (resourceType == "Patient")
-  // {
-  //   resourceGroup = "Patients";
-  // }
-  // else if (resourceType == "Instance")
-  // {
-  //   resourceGroup = "Instances";
-  // }
   const char* resourceGroup = Orthanc::GetResourceTypeText(resourceType, true, true);
 
   if (!resourcesForJobContent.isMember(resourceGroup))
@@ -616,16 +598,16 @@ private:
     Action_Cancel
   };
   
-  boost::mutex                             mutex_;
-  std::string                              serverName_;
-  std::vector<std::string>                 instances_;
-  OrthancPlugins::HttpClient::HttpHeaders  headers_;
-  std::string                              boundary_;
-  size_t                                   position_;
-  Action                                   action_;
-  size_t                                   networkSize_;
-  bool                                     debug_;
-  Json::Value                              resourcesForJobContent_;
+  boost::mutex                 mutex_;
+  std::string                  serverName_;
+  std::vector<std::string>     instances_;
+  OrthancPlugins::HttpHeaders  headers_;
+  std::string                  boundary_;
+  size_t                       position_;
+  Action                       action_;
+  size_t                       networkSize_;
+  bool                         debug_;
+  Json::Value                  resourcesForJobContent_;
 
   bool ReadNextInstance(std::string& dicom,
                         JobContext& context)
@@ -745,6 +727,7 @@ private:
         boost::mutex::scoped_lock lock(that_.mutex_);
         context.SetContent("InstancesCount", boost::lexical_cast<std::string>(that_.instances_.size()));
         context.SetContent("Resources", that_.GetResourcesForJobContent());
+        context.SetContent("Server", that_.GetServerName());
         serverName = that_.serverName_;
         
         startPosition = that_.position_;        
@@ -757,7 +740,7 @@ private:
         client->AddHeaders(that_.headers_);
       }
 
-      OrthancPlugins::HttpClient::HttpHeaders answerHeaders;
+      OrthancPlugins::HttpHeaders answerHeaders;
       Json::Value answerBody;
 
       assert(client.get() != NULL);
@@ -839,7 +822,7 @@ private:
 public:
   StowClientJob(const std::string& serverName,
                 const std::list<std::string>& instances,
-                const OrthancPlugins::HttpClient::HttpHeaders& headers,
+                const OrthancPlugins::HttpHeaders& headers,
                 const Json::Value& resourcesForJobContent) :
     SingleFunctionJob("DicomWebStowClient"),
     serverName_(serverName),
@@ -889,6 +872,11 @@ public:
   {
     return resourcesForJobContent_;
   }
+
+  const std::string& GetServerName()
+  {
+    return serverName_;
+  }
 };
 
 


=====================================
Plugin/Plugin.cpp
=====================================
@@ -42,7 +42,10 @@
 #define ORTHANC_CORE_MINIMAL_REVISION  0
 
 static const char* const HAS_DELETE = "HasDelete";
-
+static const char* const SYSTEM_CAPABILITIES = "Capabilities";
+static const char* const SYSTEM_CAPABILITIES_HAS_EXTENDED_FIND = "HasExtendedFind";
+static const char* const READ_ONLY = "ReadOnly";
+bool isReadOnly_ = false;
 
 
 bool RequestHasKey(const OrthancPluginHttpRequest* request, const char* key)
@@ -256,6 +259,10 @@ void QidoClient(OrthancPluginRestOutput* output,
 
             switch (content.type())
             {
+              case Json::intValue:
+                value["Value"] = content;
+                break;
+
               case Json::stringValue:
                 value["Value"] = content.asString();
                 break;
@@ -467,11 +474,41 @@ static OrthancPluginErrorCode OnChangeCallback(OrthancPluginChangeType changeTyp
     switch (changeType)
     {
       case OrthancPluginChangeType_OrthancStarted:
+      {
         OrthancPlugins::Configuration::LoadDicomWebServers();
-        break;
+
+        Json::Value system;
+        if (OrthancPlugins::RestApiGet(system, "/system", false))
+        {
+          bool hasExtendedFind = system.isMember(SYSTEM_CAPABILITIES) 
+                                      && system[SYSTEM_CAPABILITIES].isMember(SYSTEM_CAPABILITIES_HAS_EXTENDED_FIND)
+                                      && system[SYSTEM_CAPABILITIES][SYSTEM_CAPABILITIES_HAS_EXTENDED_FIND].asBool();
+          if (hasExtendedFind)
+          {
+            LOG(INFO) << "Orthanc supports ExtendedFind.";
+            SetPluginCanUseExtendedFile(true);
+          }
+          else
+          {
+            LOG(WARNING) << "Orthanc does not support ExtendedFind. The DICOMweb plugin will not benefit from some optimizations.";
+          }
+
+          bool isReadOnly = system.isMember(READ_ONLY) && system[READ_ONLY].asBool();
+
+          if (isReadOnly)
+          {
+            LOG(INFO) << "Orthanc is ReadOnly.";
+            SetSystemIsReadOnly(true);
+          }
+        }
+
+      }; break;
 
       case OrthancPluginChangeType_StableSeries:
-        CacheSeriesMetadata(resourceId);
+        if (!OrthancPlugins::Configuration::IsReadOnly())
+        {
+          CacheSeriesMetadata(resourceId);
+        }
         break;
 
       default:
@@ -564,13 +601,27 @@ extern "C"
 
         LOG(WARNING) << "URI to the DICOMweb REST API: " << root;
 
-        OrthancPlugins::ChunkedRestRegistration<
-          SearchForStudies /* TODO => Rename as QIDO-RS */,
-          OrthancPlugins::StowServer::PostCallback>::Apply(root + "studies");
 
-        OrthancPlugins::ChunkedRestRegistration<
-          RetrieveDicomStudy /* TODO => Rename as WADO-RS */,
-          OrthancPlugins::StowServer::PostCallback>::Apply(root + "studies/([^/]*)");
+        if (!OrthancPlugins::Configuration::IsReadOnly())
+        {
+          OrthancPlugins::ChunkedRestRegistration<
+            SearchForStudies /* TODO => Rename as QIDO-RS */,
+            OrthancPlugins::StowServer::PostCallback>::Apply(root + "studies");
+
+          OrthancPlugins::ChunkedRestRegistration<
+            RetrieveDicomStudy /* TODO => Rename as WADO-RS */,
+            OrthancPlugins::StowServer::PostCallback>::Apply(root + "studies/([^/]*)");
+        }
+        else
+        {
+          LOG(WARNING) << "READ-ONLY SYSTEM: deactivating STOW-RS routes";
+          
+          OrthancPlugins::ChunkedRestRegistration<
+            SearchForStudies /* TODO => Rename as QIDO-RS */>::Apply(root + "studies");
+
+          OrthancPlugins::ChunkedRestRegistration<
+            RetrieveDicomStudy /* TODO => Rename as WADO-RS */>::Apply(root + "studies/([^/]*)");
+        }
 
         OrthancPlugins::RegisterRestCallback<SearchForInstances>(root + "instances", true);
         OrthancPlugins::RegisterRestCallback<SearchForSeries>(root + "series", true);    
@@ -588,10 +639,19 @@ extern "C"
 
         OrthancPlugins::RegisterRestCallback<ListServers>(root + "servers", true);
         OrthancPlugins::RegisterRestCallback<ListServerOperations>(root + "servers/([^/]*)", true);
-        OrthancPlugins::RegisterRestCallback<StowClient>(root + "servers/([^/]*)/stow", true);
+        
+        if (!OrthancPlugins::Configuration::IsReadOnly())
+        {
+          OrthancPlugins::RegisterRestCallback<RetrieveFromServer>(root + "servers/([^/]*)/retrieve", true);
+        }
+        else
+        {
+          LOG(WARNING) << "READ-ONLY SYSTEM: deactivating 'servers/../retrieve' route";
+        }
+
         OrthancPlugins::RegisterRestCallback<WadoRetrieveClient>(root + "servers/([^/]*)/wado", true);
+        OrthancPlugins::RegisterRestCallback<StowClient>(root + "servers/([^/]*)/stow", true);
         OrthancPlugins::RegisterRestCallback<GetFromServer>(root + "servers/([^/]*)/get", true);
-        OrthancPlugins::RegisterRestCallback<RetrieveFromServer>(root + "servers/([^/]*)/retrieve", true);
         OrthancPlugins::RegisterRestCallback<QidoClient>(root + "servers/([^/]*)/qido", true);
         OrthancPlugins::RegisterRestCallback<DeleteClient>(root + "servers/([^/]*)/delete", true);
 
@@ -606,7 +666,10 @@ extern "C"
         OrthancPlugins::RegisterRestCallback<RetrieveInstanceRendered>(root + "studies/([^/]*)/series/([^/]*)/instances/([^/]*)/rendered", true);
         OrthancPlugins::RegisterRestCallback<RetrieveFrameRendered>(root + "studies/([^/]*)/series/([^/]*)/instances/([^/]*)/frames/([^/]*)/rendered", true);
 
-        OrthancPlugins::RegisterRestCallback<UpdateSeriesMetadataCache>("/studies/([^/]*)/update-dicomweb-cache", true);
+        if (!OrthancPlugins::Configuration::IsReadOnly())
+        {
+          OrthancPlugins::RegisterRestCallback<UpdateSeriesMetadataCache>("/studies/([^/]*)/update-dicomweb-cache", true);
+        }
 
         OrthancPluginRegisterOnChangeCallback(context, OnChangeCallback);
 
@@ -644,7 +707,7 @@ extern "C"
         OrthancPlugins::SetRootUri(ORTHANC_DICOM_WEB_NAME, uri.c_str());
 
         std::string publicUrlRoot = OrthancPlugins::Configuration::GetPublicRoot();
-        LOG(WARNING) << "DICOMWeb PublicRoot: " << publicUrlRoot;
+        LOG(WARNING) << "DICOMweb public root: " << publicUrlRoot;
       }
       else
       {


=====================================
Plugin/QidoRs.cpp
=====================================
@@ -132,6 +132,16 @@ namespace
       for (uint32_t i = 0; i < request->getCount; i++)
       {
         std::string key(request->getKeys[i]);
+        if (key.empty())
+        {
+          /**
+           * This case can happen in Weasis, which uses the following
+           * URL if doing a basic QIDO-RS request (note the "?&"):
+           * http://localhost:8042/dicom-web/studies?&includefield=00080020,00080030,00080050,00080061,00080090,00081030,00100010,00100020,00100021,00100030,00100040,0020000D,00200010&limit=10&offset=0
+           **/
+          continue;
+        }
+
         std::string value(request->getValues[i]);
         args += " [" + key + "=" + value + "]";
 
@@ -423,6 +433,9 @@ static void ApplyMatcher(OrthancPluginRestOutput* output,
 
     Orthanc::DicomMap target;
 
+    // since we are populating the target with values from JSON, all string are actually UTF-8
+    target.SetValue(Orthanc::DICOM_TAG_SPECIFIC_CHARACTER_SET, "ISO_IR 192", false);
+
     matcher.ExtractFields(target, source, wadoBasePublicUrl, level);
     writer.AddOrthancMap(target);
   }


=====================================
Plugin/StowRs.cpp
=====================================
@@ -187,9 +187,9 @@ namespace OrthancPlugins
         MemoryBuffer tmp;
 
         // make sure to forward the auth headers in the request that is sent to Orthanc (to allow usage of the auth plugin)
-        // since we do not know which header is being used, we include all the headers from the Stow-RS request (headers_), replace
+        // since we do not know which header is being used, we include all the headers from the STOW-RS request (headers_), replace
         // the "content-disposition" + "content-type" that are rebuilt from the multi-part message and remove the headers that might
-        // be mi-interpreted by Orthanc core (like "content-length" that is actually the "content-length" from the whole Stow-RS request, not the length of this file)
+        // be mi-interpreted by Orthanc core (like "content-length" that is actually the "content-length" from the whole STOW-RS request, not the length of this file)
         Orthanc::MultipartStreamReader::HttpHeaders mergedHeaders = headers_;
         Orthanc::MultipartStreamReader::HttpHeaders::iterator foundContentLength = mergedHeaders.find("content-length");
         if (foundContentLength != mergedHeaders.end())


=====================================
Plugin/WadoRs.cpp
=====================================
@@ -50,6 +50,28 @@ static const char* const PATIENT_MAIN_DICOM_TAGS = "PatientMainDicomTags";
 static std::string instancesMainDicomTagsList;
 static boost::mutex mainDicomTagsListMutex;
 
+static bool pluginCanUseExtendedFind_ = false;
+static bool isSystemReadOnly_ = false;
+
+void SetPluginCanUseExtendedFile(bool enable)
+{
+  pluginCanUseExtendedFind_ = enable;
+}
+
+bool CanUseExtendedFile()
+{
+  return pluginCanUseExtendedFind_;
+}
+
+void SetSystemIsReadOnly(bool isReadOnly)
+{
+  isSystemReadOnly_ = isReadOnly;
+}
+
+bool IsSystemReadOnly()
+{
+  return isSystemReadOnly_;
+}
 
 static std::string GetResourceUri(Orthanc::ResourceType level,
                                   const std::string& publicId)
@@ -900,7 +922,15 @@ static void WriteInstanceMetadata(OrthancPlugins::DicomWebFormatter::HttpWriter&
           false /* JSON */, OrthancPluginDicomWebBinaryMode_Ignore, "");
       }
 
-      buffer.RestApiPut("/instances/" + orthancId + "/attachments/4444", dicomweb, false);
+      try
+      {
+        buffer.RestApiPut("/instances/" + orthancId + "/attachments/4444", dicomweb, false);
+      }
+      catch (Orthanc::OrthancException& e)
+      {
+        // An exception might occur if another writer has concurrently created the attachment, ignore this case
+      }
+
       writer.AddDicomWebInstanceSerializedJson(dicomweb.c_str(), dicomweb.size());
     }
   }
@@ -1348,15 +1378,13 @@ void RetrieveSeriesMetadataInternal(std::set<std::string>& instancesIds,
                                     const std::string& wadoBase)
 {
   const unsigned int workersCount =  OrthancPlugins::Configuration::GetMetadataWorkerThreadsCount();
-  const bool oneLargeQuery = false;  // we keep this code here for future use once we'll have optimized Orthanc API /series/.../instances?full to minimize the SQL queries
-                                // right now, it is faster to call /instances/..?full in each worker but, later, it should be more efficient with a large SQL query in Orthanc
 
   if (workersCount > 1 && mode == OrthancPlugins::MetadataMode_Full)
   {
     ChildrenMainDicomMaps instancesDicomMaps;
     std::string seriesDicomUid;
 
-    if (oneLargeQuery)
+    if (CanUseExtendedFile()) // in this case, /series/.../instances?full has been optimized to minimize the SQL queries
     {
       GetChildrenMainDicomTags(instancesDicomMaps, seriesDicomUid, Orthanc::ResourceType_Series, seriesOrthancId);
       for (ChildrenMainDicomMaps::const_iterator it = instancesDicomMaps.begin(); it != instancesDicomMaps.end(); ++it)
@@ -1382,7 +1410,7 @@ void RetrieveSeriesMetadataInternal(std::set<std::string>& instancesIds,
       instancesWorkers.push_back(boost::shared_ptr<boost::thread>(new boost::thread(InstanceWorkerThread, threadData)));
     }
 
-    if (oneLargeQuery)
+    if (CanUseExtendedFile())  // we must correct the bulkRoot
     {
       for (ChildrenMainDicomMaps::const_iterator i = instancesDicomMaps.begin(); i != instancesDicomMaps.end(); ++i)
       {
@@ -1448,20 +1476,39 @@ void CacheSeriesMetadataInternal(std::string& serializedSeriesMetadata,
   RetrieveSeriesMetadataInternal(instancesIds, writer, cache, OrthancPlugins::MetadataMode_Full, false /* isXml */, seriesOrthancId, studyInstanceUid, seriesInstanceUid, WADO_BASE_PLACEHOLDER);
   writer.CloseAndGetJsonOutput(serializedSeriesMetadata);
 
-  // save in attachments for future use
-  Orthanc::IBufferCompressor::Compress(compressedSeriesMetadata, compressor, serializedSeriesMetadata);
-  std::string instancesMd5;
-  Orthanc::Toolbox::ComputeMD5(instancesMd5, instancesIds);
+  if (!IsSystemReadOnly())
+  {
+    // save in attachments for future use
+    Orthanc::IBufferCompressor::Compress(compressedSeriesMetadata, compressor, serializedSeriesMetadata);
+    std::string instancesMd5;
+    Orthanc::Toolbox::ComputeMD5(instancesMd5, instancesIds);
+
+    std::string cacheContent = "2;" + instancesMd5 + ";" + compressedSeriesMetadata; 
 
-  std::string cacheContent = "2;" + instancesMd5 + ";" + compressedSeriesMetadata; 
+    Json::Value putResult;
+    std::string attachmentUrl = "/series/" + seriesOrthancId + "/attachments/" + SERIES_METADATA_ATTACHMENT_ID;
 
-  Json::Value putResult;
-  std::string attachmentUrl = "/series/" + seriesOrthancId + "/attachments/" + SERIES_METADATA_ATTACHMENT_ID;
-  if (!OrthancPlugins::RestApiPut(putResult, attachmentUrl, cacheContent, false))
-  {
-    LOG(WARNING) << "DicomWEB: failed to write series metadata attachment";
-  }
+    OrthancPlugins::RestApiClient client;
+    client.SetMethod(OrthancPluginHttpMethod_Get);
+    client.SetPath(attachmentUrl);
 
+    std::string etag;
+    bool hasRevision = (client.Execute() &&
+                        client.LookupAnswerHeader(etag, "etag"));
+
+    client.SetMethod(OrthancPluginHttpMethod_Put);
+    client.SwapRequestBody(cacheContent);
+
+    if (hasRevision)
+    {
+      client.AddRequestHeader("If-Match", etag);
+    }
+
+    if (!client.Execute())
+    {
+      LOG(WARNING) << "DicomWEB: failed to write series metadata attachment";
+    }
+  }
 }
 
 void CacheSeriesMetadata(const std::string& seriesOrthancId)


=====================================
Plugin/WadoRs.h
=====================================
@@ -102,4 +102,8 @@ void RetrieveStudyRendered(OrthancPluginRestOutput* output,
                            const char* url,
                            const OrthancPluginHttpRequest* request);
 
-void SetPluginCanDownloadTranscodedFile(bool enable);
\ No newline at end of file
+void SetPluginCanDownloadTranscodedFile(bool enable);
+
+void SetPluginCanUseExtendedFile(bool enable);
+
+void SetSystemIsReadOnly(bool isReadOnly);
\ No newline at end of file


=====================================
Plugin/WadoRsRetrieveFrames.cpp
=====================================
@@ -433,6 +433,43 @@ static void AnswerFrames(OrthancPluginRestOutput* output,
 }
 
 
+static void AnswerFrame(OrthancPluginRestOutput* output,
+                        const OrthancPluginHttpRequest* request,
+                        const OrthancPlugins::MemoryBuffer& instanceContent,
+                        const std::string& studyInstanceUid,
+                        const std::string& seriesInstanceUid,
+                        const std::string& sopInstanceUid,
+                        unsigned int frame,
+                        Orthanc::DicomTransferSyntax outputSyntax)
+{
+  if (OrthancPluginStartMultipartAnswer(
+        OrthancPlugins::GetGlobalContext(),
+        output, "related", GetMimeType(outputSyntax)) != OrthancPluginErrorCode_Success)
+  {
+    throw Orthanc::OrthancException(Orthanc::ErrorCode_Plugin,
+                                    "Cannot start a multipart answer");
+  }
+
+  OrthancPluginErrorCode error;
+
+#if HAS_SEND_MULTIPART_ITEM_2 == 1
+  const std::string base = OrthancPlugins::Configuration::GetBasePublicUrl(request);
+  std::string location = (
+    OrthancPlugins::Configuration::GetWadoUrl(base, studyInstanceUid, seriesInstanceUid, sopInstanceUid) +
+    "frames/" + boost::lexical_cast<std::string>(frame + 1));
+  const char *keys[] = { "Content-Location" };
+  const char *values[] = { location.c_str() };
+  error = OrthancPluginSendMultipartItem2(OrthancPlugins::GetGlobalContext(), output, instanceContent.GetData(), instanceContent.GetSize(), 1, keys, values);
+#else
+  error = OrthancPluginSendMultipartItem(OrthancPlugins::GetGlobalContext(), instanceContent.GetData(), instanceContent.GetSize(), size);
+#endif
+
+  if (error != OrthancPluginErrorCode_Success)
+  {
+    throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol);
+  }
+}
+
 static void RetrieveFrames(OrthancPluginRestOutput* output,
                            const OrthancPluginHttpRequest* request,
                            bool allFrames,
@@ -466,7 +503,7 @@ static void RetrieveFrames(OrthancPluginRestOutput* output,
     std::string currentSyntaxString;
     if (!OrthancPlugins::RestApiGetString(currentSyntaxString, "/instances/" + orthancId + "/metadata/TransferSyntax", false))
     {
-      throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError, "DICOMWeb: Unable to get TransferSyntax for instance " + orthancId);
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError, "DICOMweb: Unable to get TransferSyntax for instance " + orthancId);
     }
 
     if (!Orthanc::LookupTransferSyntax(currentSyntax, currentSyntaxString))
@@ -481,7 +518,7 @@ static void RetrieveFrames(OrthancPluginRestOutput* output,
       // note: these 2 syntaxes are not supposed to be used in retrieve frames
       // according to https://dicom.nema.org/MEDICAL/dicom/2019a/output/chtml/part18/chapter_6.html#table_6.1.1.8-3b
       // "The Implicit VR Little Endian (1.2.840.10008.1.2), and Explicit VR Big Endian (1.2.840.10008.1.2.2) transfer syntaxes shall not be used with Web Services."
-      LOG(INFO) << "The file is in a transfer syntax " << currentSyntaxString << " that is not allowed by the DICOMWeb standard -> it will be transcoded to Little Endian Explicit";
+      LOG(INFO) << "The file is in a transfer syntax " << currentSyntaxString << " that is not allowed by the DICOMweb standard -> it will be transcoded to Little Endian Explicit";
       targetSyntax = Orthanc::DicomTransferSyntax_LittleEndianExplicit;
     }    
 
@@ -497,15 +534,29 @@ static void RetrieveFrames(OrthancPluginRestOutput* output,
     {
       if (!content.RestApiGet("/instances/" + orthancId + "/file?transcode=" + Orthanc::GetTransferSyntaxUid(targetSyntax), false))
       {
-        throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError, "DICOMWeb: Unable to get transcoded file for instance " + orthancId);
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError, "DICOMweb: Unable to get transcoded file for instance " + orthancId);
       }
+
+      // TODO-OPTI: this takes a huge amount of time; e.g: 1.5s for a 600MB file while the DicomInstance usually already exists in the Orthanc core
+      //            call /instances/../frames/../transcoded (to be implemented in future Orthanc release)
       instance.reset(new OrthancPlugins::DicomInstance(content.GetData(), content.GetSize()));
     }
-    else // pre 1.12.2 code (or no transcoding needed)
+    else if (!allFrames && frames.size() == 1 && !transcodeThisInstance) // no transcoding needed, let's retrieve the raw frame directly from the core to avoid Orthanc to recreate a DicomInstance for every frame
+    {
+      if (!content.RestApiGet("/instances/" + orthancId + "/frames/" + boost::lexical_cast<std::string>(frames.front()) + "/raw", false))
+      {
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError, "DICOMweb: Unable to get file for instance " + orthancId);
+      }
+
+      AnswerFrame(output, request, content, studyInstanceUid, seriesInstanceUid,
+                  sopInstanceUid, frames.front(), targetSyntax);
+      return;
+    }
+    else
     {
       if (!content.RestApiGet("/instances/" + orthancId + "/file", false))
       {
-        throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError, "DICOMWeb: Unable to get file for instance " + orthancId);
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError, "DICOMweb: Unable to get file for instance " + orthancId);
       }
       instance.reset(new OrthancPlugins::DicomInstance(content.GetData(), content.GetSize()));
 


=====================================
Resources/CMake/JavaScriptLibraries.cmake
=====================================
@@ -19,95 +19,29 @@
 # along with this program. If not, see <http://www.gnu.org/licenses/>.
 
 
-set(BASE_URL "https://orthanc.uclouvain.be/downloads/third-party-downloads/dicom-web")
+set(BASE_URL "https://orthanc.uclouvain.be/downloads/third-party-downloads")
 
 DownloadPackage(
-  "da0189f7c33bf9f652ea65401e0a3dc9"
-  "${BASE_URL}/bootstrap-4.3.1.zip"
-  "${CMAKE_CURRENT_BINARY_DIR}/bootstrap-4.3.1")
+  "102a4386a022f26a3b604e3852fffba8"
+  "${BASE_URL}/bootstrap-5.3.3.zip"
+  "${CMAKE_CURRENT_BINARY_DIR}/bootstrap-5.3.3")
 
 DownloadPackage(
   "8242afdc5bd44105d9dc9e6535315484"
-  "${BASE_URL}/vuejs-2.6.10.tar.gz"
+  "${BASE_URL}/dicom-web/vuejs-2.6.10.tar.gz"
   "${CMAKE_CURRENT_BINARY_DIR}/vue-2.6.10")
 
 DownloadPackage(
   "3e2b4e1522661f7fcf8ad49cb933296c"
-  "${BASE_URL}/axios-0.19.0.tar.gz"
+  "${BASE_URL}/dicom-web/axios-0.19.0.tar.gz"
   "${CMAKE_CURRENT_BINARY_DIR}/axios-0.19.0")
 
 DownloadPackage(
   "a6145901f233f7d54165d8ade779082e"
-  "${BASE_URL}/Font-Awesome-4.7.0.tar.gz"
+  "${BASE_URL}/dicom-web/Font-Awesome-4.7.0.tar.gz"
   "${CMAKE_CURRENT_BINARY_DIR}/Font-Awesome-4.7.0")
 
 
-set(BOOTSTRAP_VUE_SOURCES_DIR ${CMAKE_CURRENT_BINARY_DIR}/bootstrap-vue-2.0.0-rc.24)
-
-if (BUILD_BOOTSTRAP_VUE OR
-    BUILD_BABEL_POLYFILL)
-  find_program(NPM_EXECUTABLE npm)
-  if (${NPM_EXECUTABLE} MATCHES "NPM_EXECUTABLE-NOTFOUND")
-    message(FATAL_ERROR "Please install the 'npm' standard command-line tool")
-  endif()
-endif()
-
-if (BUILD_BOOTSTRAP_VUE)
-  DownloadPackage(
-    "36ab31495ab94162e159619532e8def5"
-    "${BASE_URL}/bootstrap-vue-2.0.0-rc.24.tar.gz"
-    "${BOOTSTRAP_VUE_SOURCES_DIR}")
-
-  if (NOT IS_DIRECTORY "${BOOTSTRAP_VUE_SOURCES_DIR}/node_modules")
-    execute_process(
-      COMMAND ${NPM_EXECUTABLE} install
-      WORKING_DIRECTORY ${BOOTSTRAP_VUE_SOURCES_DIR}
-      RESULT_VARIABLE Failure
-      OUTPUT_QUIET
-      )
-    
-    if (Failure)
-      message(FATAL_ERROR "Error while running 'npm install' on Bootstrap-Vue")
-    endif()
-  endif()
-
-  if (NOT IS_DIRECTORY "${BOOTSTRAP_VUE_SOURCES_DIR}/dist")
-    execute_process(
-      COMMAND ${NPM_EXECUTABLE} run build
-      WORKING_DIRECTORY ${BOOTSTRAP_VUE_SOURCES_DIR}
-      RESULT_VARIABLE Failure
-      OUTPUT_QUIET
-      )
-    
-    if (Failure)
-      message(FATAL_ERROR "Error while running 'npm build' on Bootstrap-Vue")
-    endif()
-  endif()
-
-else()
-
-  ##
-  ## Generation of the precompiled Bootstrap-Vue package:
-  ##
-  ## Possibility 1 (build from sources):
-  ##  $ cmake -DBUILD_BOOTSTRAP_VUE=ON .
-  ##  $ tar cvfz bootstrap-vue-2.0.0-rc.24-dist.tar.gz bootstrap-vue-2.0.0-rc.24/dist/
-  ##
-  ## Possibility 2 (download from CDN):
-  ##  $ mkdir /tmp/i && cd /tmp/i
-  ##  $ wget -r --no-parent https://unpkg.com/bootstrap-vue@2.0.0-rc.24/dist/
-  ##  $ mv unpkg.com/bootstrap-vue at 2.0.0-rc.24/ bootstrap-vue-2.0.0-rc.24
-  ##  $ rm bootstrap-vue-2.0.0-rc.24/dist/index.html
-  ##  $ tar cvfz bootstrap-vue-2.0.0-rc.24-dist.tar.gz bootstrap-vue-2.0.0-rc.24/dist/
-
-  DownloadPackage(
-    "ba0e67b1f0b4ce64e072b42b17f6c578"
-    "${BASE_URL}/bootstrap-vue-2.0.0-rc.24-dist.tar.gz"
-    "${BOOTSTRAP_VUE_SOURCES_DIR}")
-
-endif()
-
-
 if (BUILD_BABEL_POLYFILL)
   set(BABEL_POLYFILL_SOURCES_DIR ${CMAKE_CURRENT_BINARY_DIR}/node_modules/babel-polyfill/dist)
 
@@ -120,7 +54,7 @@ if (BUILD_BABEL_POLYFILL)
       )
     
     if (Failure)
-      message(FATAL_ERROR "Error while running 'npm install' on Bootstrap-Vue")
+      message(FATAL_ERROR "Error while running 'npm install' on babel-polyfill")
     endif()
   endif()
 else()
@@ -130,7 +64,7 @@ else()
   set(BABEL_POLYFILL_SOURCES_DIR ${CMAKE_CURRENT_BINARY_DIR})
   DownloadCompressedFile(
     "49f7bad4176d715ce145e75c903988ef"
-    "${BASE_URL}/babel-polyfill-6.26.0.min.js.gz"
+    "${BASE_URL}/dicom-web/babel-polyfill-6.26.0.min.js.gz"
     "${CMAKE_CURRENT_BINARY_DIR}/polyfill.min.js")
 
 endif()
@@ -141,22 +75,18 @@ file(MAKE_DIRECTORY ${JAVASCRIPT_LIBS_DIR})
 
 file(COPY
   ${BABEL_POLYFILL_SOURCES_DIR}/polyfill.min.js
-  ${BOOTSTRAP_VUE_SOURCES_DIR}/dist/bootstrap-vue.min.js
-  ${BOOTSTRAP_VUE_SOURCES_DIR}/dist/bootstrap-vue.min.js.map
   ${CMAKE_CURRENT_BINARY_DIR}/axios-0.19.0/dist/axios.min.js
   ${CMAKE_CURRENT_BINARY_DIR}/axios-0.19.0/dist/axios.min.map
-  ${CMAKE_CURRENT_BINARY_DIR}/bootstrap-4.3.1/dist/js/bootstrap.min.js
+  ${CMAKE_CURRENT_BINARY_DIR}/bootstrap-5.3.3/dist/js/bootstrap.min.js
   ${CMAKE_CURRENT_BINARY_DIR}/vue-2.6.10/dist/vue.min.js
   DESTINATION
   ${JAVASCRIPT_LIBS_DIR}/js
   )
 
 file(COPY
-  ${BOOTSTRAP_VUE_SOURCES_DIR}/dist/bootstrap-vue.min.css
-  ${BOOTSTRAP_VUE_SOURCES_DIR}/dist/bootstrap-vue.min.css.map
   ${CMAKE_CURRENT_BINARY_DIR}/Font-Awesome-4.7.0/css/font-awesome.min.css
-  ${CMAKE_CURRENT_BINARY_DIR}/bootstrap-4.3.1/dist/css/bootstrap.min.css
-  ${CMAKE_CURRENT_BINARY_DIR}/bootstrap-4.3.1/dist/css/bootstrap.min.css.map
+  ${CMAKE_CURRENT_BINARY_DIR}/bootstrap-5.3.3/dist/css/bootstrap.min.css
+  ${CMAKE_CURRENT_BINARY_DIR}/bootstrap-5.3.3/dist/css/bootstrap.min.css.map
   DESTINATION
   ${JAVASCRIPT_LIBS_DIR}/css
   )


=====================================
Resources/Orthanc/CMake/Compiler.cmake
=====================================
@@ -232,6 +232,10 @@ elseif(${CMAKE_SYSTEM_NAME} STREQUAL "Windows")
   endif()
 
 elseif (${CMAKE_SYSTEM_NAME} STREQUAL "Darwin")
+
+  # fix this error that appears with recent compilers on MacOS: boost/mpl/aux_/integral_wrapper.hpp:73:31: error: integer value -1 is outside the valid range of values [0, 3] for this enumeration type [-Wenum-constexpr-conversion]
+  SET(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wno-enum-constexpr-conversion")
+
   add_definitions(
     -D_XOPEN_SOURCE=1
     )


=====================================
Resources/Orthanc/CMake/DownloadOrthancFramework.cmake
=====================================
@@ -165,6 +165,8 @@ if (ORTHANC_FRAMEWORK_SOURCE STREQUAL "hg" OR
         set(ORTHANC_FRAMEWORK_MD5 "975f5bf2142c22cb1777b4f6a0a614c5")
       elseif (ORTHANC_FRAMEWORK_VERSION STREQUAL "1.12.4")
         set(ORTHANC_FRAMEWORK_MD5 "1e61779ea4a7cd705720bdcfed8a6a73")
+      elseif (ORTHANC_FRAMEWORK_VERSION STREQUAL "1.12.5")
+        set(ORTHANC_FRAMEWORK_MD5 "5bb69f092981fdcfc11dec0a0f9a7db3")
 
       # Below this point are development snapshots that were used to
       # release some plugin, before an official release of the Orthanc


=====================================
Resources/Orthanc/Plugins/OrthancPluginCppWrapper.cpp
=====================================
@@ -334,9 +334,9 @@ namespace OrthancPlugins
     std::vector<const char*> headersValues_;
 
   public:
-    explicit PluginHttpHeaders(const std::map<std::string, std::string>& httpHeaders)
+    explicit PluginHttpHeaders(const HttpHeaders& httpHeaders)
     {
-      for (std::map<std::string, std::string>::const_iterator
+      for (HttpHeaders::const_iterator
              it = httpHeaders.begin(); it != httpHeaders.end(); ++it)
       {
         headersKeys_.push_back(it->first.c_str());
@@ -361,7 +361,7 @@ namespace OrthancPlugins
   };
 
   bool MemoryBuffer::RestApiGet(const std::string& uri,
-                                const std::map<std::string, std::string>& httpHeaders,
+                                const HttpHeaders& httpHeaders,
                                 bool applyPlugins)
   {
     Clear();
@@ -400,7 +400,7 @@ namespace OrthancPlugins
   bool MemoryBuffer::RestApiPost(const std::string& uri,
                                  const void* body,
                                  size_t bodySize,
-                                 const std::map<std::string, std::string>& httpHeaders,
+                                 const HttpHeaders& httpHeaders,
                                  bool applyPlugins)
   {
     MemoryBuffer answerHeaders;
@@ -422,7 +422,7 @@ namespace OrthancPlugins
 
   bool MemoryBuffer::RestApiPost(const std::string& uri,
                                  const Json::Value& body,
-                                 const std::map<std::string, std::string>& httpHeaders,
+                                 const HttpHeaders& httpHeaders,
                                  bool applyPlugins)
   {
     std::string s;
@@ -1490,7 +1490,7 @@ namespace OrthancPlugins
 
   bool RestApiGetString(std::string& result,
                         const std::string& uri,
-                        const std::map<std::string, std::string>& httpHeaders,
+                        const HttpHeaders& httpHeaders,
                         bool applyPlugins)
   {
     MemoryBuffer answer;
@@ -1508,7 +1508,7 @@ namespace OrthancPlugins
 
   bool RestApiGet(Json::Value& result,
                   const std::string& uri,
-                  const std::map<std::string, std::string>& httpHeaders,
+                  const HttpHeaders& httpHeaders,
                   bool applyPlugins)
   {
     MemoryBuffer answer;
@@ -1598,7 +1598,7 @@ namespace OrthancPlugins
   bool RestApiPost(Json::Value& result,
                    const std::string& uri,
                    const Json::Value& body,
-                   const std::map<std::string, std::string>& httpHeaders,
+                   const HttpHeaders& httpHeaders,
                    bool applyPlugins)
   {
     MemoryBuffer answer;
@@ -1963,7 +1963,7 @@ namespace OrthancPlugins
   bool OrthancPeers::DoGet(MemoryBuffer& target,
                            size_t index,
                            const std::string& uri,
-                           const std::map<std::string, std::string>& headers) const
+                           const HttpHeaders& headers) const
   {
     if (index >= index_.size())
     {
@@ -1994,7 +1994,7 @@ namespace OrthancPlugins
   bool OrthancPeers::DoGet(MemoryBuffer& target,
                            const std::string& name,
                            const std::string& uri,
-                           const std::map<std::string, std::string>& headers) const
+                           const HttpHeaders& headers) const
   {
     size_t index;
     return (LookupName(index, name) &&
@@ -2005,7 +2005,7 @@ namespace OrthancPlugins
   bool OrthancPeers::DoGet(Json::Value& target,
                            size_t index,
                            const std::string& uri,
-                           const std::map<std::string, std::string>& headers) const
+                           const HttpHeaders& headers) const
   {
     MemoryBuffer buffer;
 
@@ -2024,7 +2024,7 @@ namespace OrthancPlugins
   bool OrthancPeers::DoGet(Json::Value& target,
                            const std::string& name,
                            const std::string& uri,
-                           const std::map<std::string, std::string>& headers) const
+                           const HttpHeaders& headers) const
   {
     MemoryBuffer buffer;
 
@@ -2044,7 +2044,7 @@ namespace OrthancPlugins
                             const std::string& name,
                             const std::string& uri,
                             const std::string& body,
-                            const std::map<std::string, std::string>& headers) const
+                            const HttpHeaders& headers) const
   {
     size_t index;
     return (LookupName(index, name) &&
@@ -2056,7 +2056,7 @@ namespace OrthancPlugins
                             size_t index,
                             const std::string& uri,
                             const std::string& body,
-                            const std::map<std::string, std::string>& headers) const
+                            const HttpHeaders& headers) const
   {
     MemoryBuffer buffer;
 
@@ -2076,7 +2076,7 @@ namespace OrthancPlugins
                             const std::string& name,
                             const std::string& uri,
                             const std::string& body,
-                            const std::map<std::string, std::string>& headers) const
+                            const HttpHeaders& headers) const
   {
     MemoryBuffer buffer;
 
@@ -2096,7 +2096,7 @@ namespace OrthancPlugins
                             size_t index,
                             const std::string& uri,
                             const std::string& body,
-                            const std::map<std::string, std::string>& headers) const
+                            const HttpHeaders& headers) const
   {
     if (index >= index_.size())
     {
@@ -2133,7 +2133,7 @@ namespace OrthancPlugins
   bool OrthancPeers::DoPut(size_t index,
                            const std::string& uri,
                            const std::string& body,
-                           const std::map<std::string, std::string>& headers) const
+                           const HttpHeaders& headers) const
   {
     if (index >= index_.size())
     {
@@ -2169,7 +2169,7 @@ namespace OrthancPlugins
   bool OrthancPeers::DoPut(const std::string& name,
                            const std::string& uri,
                            const std::string& body,
-                           const std::map<std::string, std::string>& headers) const
+                           const HttpHeaders& headers) const
   {
     size_t index;
     return (LookupName(index, name) &&
@@ -2179,7 +2179,7 @@ namespace OrthancPlugins
 
   bool OrthancPeers::DoDelete(size_t index,
                               const std::string& uri,
-                              const std::map<std::string, std::string>& headers) const
+                              const HttpHeaders& headers) const
   {
     if (index >= index_.size())
     {
@@ -2208,7 +2208,7 @@ namespace OrthancPlugins
 
   bool OrthancPeers::DoDelete(const std::string& name,
                               const std::string& uri,
-                              const std::map<std::string, std::string>& headers) const
+                              const HttpHeaders& headers) const
   {
     size_t index;
     return (LookupName(index, name) &&
@@ -2923,12 +2923,12 @@ namespace OrthancPlugins
       std::vector<const char*>  headersValues_;
 
     public:
-      HeadersWrapper(const HttpClient::HttpHeaders& headers)
+      HeadersWrapper(const HttpHeaders& headers)
       {
         headersKeys_.reserve(headers.size());
         headersValues_.reserve(headers.size());
 
-        for (HttpClient::HttpHeaders::const_iterator it = headers.begin(); it != headers.end(); ++it)
+        for (HttpHeaders::const_iterator it = headers.begin(); it != headers.end(); ++it)
         {
           headersKeys_.push_back(it->first.c_str());
           headersValues_.push_back(it->second.c_str());
@@ -3076,11 +3076,11 @@ namespace OrthancPlugins
     class MemoryAnswer : public HttpClient::IAnswer
     {
     private:
-      HttpClient::HttpHeaders  headers_;
-      ChunkedBuffer            body_;
+      HttpHeaders    headers_;
+      ChunkedBuffer  body_;
 
     public:
-      const HttpClient::HttpHeaders& GetHeaders() const
+      const HttpHeaders& GetHeaders() const
       {
         return headers_;
       }
@@ -3168,6 +3168,35 @@ namespace OrthancPlugins
 #endif    
 
 
+  static void DecodeHttpHeaders(HttpHeaders& target,
+                                const MemoryBuffer& source)
+  {
+    Json::Value v;
+    source.ToJson(v);
+
+    if (v.type() != Json::objectValue)
+    {
+      ORTHANC_PLUGINS_THROW_EXCEPTION(InternalError);
+    }
+
+    Json::Value::Members members = v.getMemberNames();
+    target.clear();
+
+    for (size_t i = 0; i < members.size(); i++)
+    {
+      const Json::Value& h = v[members[i]];
+      if (h.type() != Json::stringValue)
+      {
+        ORTHANC_PLUGINS_THROW_EXCEPTION(InternalError);
+      }
+      else
+      {
+        target[members[i]] = h.asString();
+      }
+    }
+  }
+
+
   void HttpClient::ExecuteWithoutStream(uint16_t& httpStatus,
                                         HttpHeaders& answerHeaders,
                                         std::string& answerBody,
@@ -3208,30 +3237,7 @@ namespace OrthancPlugins
       ORTHANC_PLUGINS_THROW_PLUGIN_ERROR_CODE(error);
     }
 
-    Json::Value v;
-    answerHeadersBuffer.ToJson(v);
-
-    if (v.type() != Json::objectValue)
-    {
-      ORTHANC_PLUGINS_THROW_EXCEPTION(InternalError);
-    }
-
-    Json::Value::Members members = v.getMemberNames();
-    answerHeaders.clear();
-
-    for (size_t i = 0; i < members.size(); i++)
-    {
-      const Json::Value& h = v[members[i]];
-      if (h.type() != Json::stringValue)
-      {
-        ORTHANC_PLUGINS_THROW_EXCEPTION(InternalError);
-      }
-      else
-      {
-        answerHeaders[members[i]] = h.asString();
-      }
-    }
-
+    DecodeHttpHeaders(answerHeaders, answerHeadersBuffer);
     answerBodyBuffer.ToString(answerBody);
   }
 
@@ -4061,7 +4067,7 @@ namespace OrthancPlugins
   }
 #endif
 
-  void GetHttpHeaders(std::map<std::string, std::string>& result, const OrthancPluginHttpRequest* request)
+  void GetHttpHeaders(HttpHeaders& result, const OrthancPluginHttpRequest* request)
   {
     result.clear();
 
@@ -4114,4 +4120,135 @@ namespace OrthancPlugins
     SetPluginProperty(pluginIdentifier, _OrthancPluginProperty_OrthancExplorer, javascript);
 #endif
   }
+
+
+#if HAS_ORTHANC_PLUGIN_GENERIC_CALL_REST_API == 1
+  RestApiClient::RestApiClient() :
+    method_(OrthancPluginHttpMethod_Get),
+    path_("/"),
+    afterPlugins_(false),
+    httpStatus_(0)
+  {
+  }
+#endif
+
+
+#if HAS_ORTHANC_PLUGIN_GENERIC_CALL_REST_API == 1
+  void RestApiClient::AddRequestHeader(const std::string& key,
+                                       const std::string& value)
+  {
+    if (requestHeaders_.find(key) == requestHeaders_.end())
+    {
+      requestHeaders_[key] = value;
+    }
+    else
+    {
+      ORTHANC_PLUGINS_THROW_EXCEPTION(BadSequenceOfCalls);
+    }
+  }
+#endif
+
+
+#if HAS_ORTHANC_PLUGIN_GENERIC_CALL_REST_API == 1
+  bool RestApiClient::Execute()
+  {
+    if (requestBody_.size() > 0xffffffffu)
+    {
+      ORTHANC_PLUGINS_LOG_ERROR("Cannot handle body size > 4GB");
+      ORTHANC_PLUGINS_THROW_EXCEPTION(InternalError);
+    }
+
+    PluginHttpHeaders converted(requestHeaders_);
+
+    MemoryBuffer body;
+    MemoryBuffer headers;
+
+    OrthancPluginErrorCode code = OrthancPluginCallRestApi(GetGlobalContext(), *body, *headers, &httpStatus_, method_, path_.c_str(),
+                                                           requestHeaders_.size(), converted.GetKeys(), converted.GetValues(),
+                                                           requestBody_.c_str(), requestBody_.size(), afterPlugins_ ? 1 : 0);
+
+    answerHeaders_.clear();
+    answerBody_.clear();
+
+    if (code == OrthancPluginErrorCode_Success)
+    {
+      if (httpStatus_ == 0)
+      {
+        ORTHANC_PLUGINS_THROW_EXCEPTION(InternalError);
+      }
+
+      DecodeHttpHeaders(answerHeaders_, headers);
+      body.ToString(answerBody_);
+      return true;
+    }
+    else
+    {
+      if (code == OrthancPluginErrorCode_UnknownResource ||
+          code == OrthancPluginErrorCode_InexistentItem)
+      {
+        httpStatus_ = 404;
+        return false;
+      }
+      else
+      {
+        ORTHANC_PLUGINS_THROW_PLUGIN_ERROR_CODE(code);
+      }
+    }
+  }
+#endif
+
+
+#if HAS_ORTHANC_PLUGIN_GENERIC_CALL_REST_API == 1
+  uint16_t RestApiClient::GetHttpStatus() const
+  {
+    if (httpStatus_ == 0)
+    {
+      ORTHANC_PLUGINS_THROW_EXCEPTION(BadSequenceOfCalls);
+    }
+    else
+    {
+      return httpStatus_;
+    }
+  }
+#endif
+
+
+#if HAS_ORTHANC_PLUGIN_GENERIC_CALL_REST_API == 1
+  bool RestApiClient::LookupAnswerHeader(std::string& value,
+                                         const std::string& key) const
+  {
+    if (httpStatus_ == 0)
+    {
+      ORTHANC_PLUGINS_THROW_EXCEPTION(BadSequenceOfCalls);
+    }
+    else
+    {
+      HttpHeaders::const_iterator found = answerHeaders_.find(key);
+      if (found == answerHeaders_.end())
+      {
+        return false;
+      }
+      else
+      {
+        value = found->second;
+        return true;
+      }
+    }
+  }
+#endif
+
+
+#if HAS_ORTHANC_PLUGIN_GENERIC_CALL_REST_API == 1
+  const std::string& RestApiClient::GetAnswerBody() const
+  {
+    if (httpStatus_ == 0)
+    {
+      ORTHANC_PLUGINS_THROW_EXCEPTION(BadSequenceOfCalls);
+    }
+    else
+    {
+      return answerBody_;
+    }
+  }
+#endif
 }


=====================================
Resources/Orthanc/Plugins/OrthancPluginCppWrapper.h
=====================================
@@ -170,6 +170,8 @@
 
 namespace OrthancPlugins
 {
+  typedef std::map<std::string, std::string>  HttpHeaders;
+
   typedef void (*RestCallback) (OrthancPluginRestOutput* output,
                                 const char* url,
                                 const OrthancPluginHttpRequest* request);
@@ -257,7 +259,7 @@ namespace OrthancPlugins
                     bool applyPlugins);
 
     bool RestApiGet(const std::string& uri,
-                    const std::map<std::string, std::string>& httpHeaders,
+                    const HttpHeaders& httpHeaders,
                     bool applyPlugins);
 
     bool RestApiPost(const std::string& uri,
@@ -277,13 +279,13 @@ namespace OrthancPlugins
 #if HAS_ORTHANC_PLUGIN_GENERIC_CALL_REST_API == 1
     bool RestApiPost(const std::string& uri,
                      const Json::Value& body,
-                     const std::map<std::string, std::string>& httpHeaders,
+                     const HttpHeaders& httpHeaders,
                      bool applyPlugins);
 
     bool RestApiPost(const std::string& uri,
                      const void* body,
                      size_t bodySize,
-                     const std::map<std::string, std::string>& httpHeaders,
+                     const HttpHeaders& httpHeaders,
                      bool applyPlugins);
 #endif
 
@@ -581,7 +583,7 @@ namespace OrthancPlugins
 
   bool RestApiGet(Json::Value& result,
                   const std::string& uri,
-                  const std::map<std::string, std::string>& httpHeaders,
+                  const HttpHeaders& httpHeaders,
                   bool applyPlugins);
 
   bool RestApiGetString(std::string& result,
@@ -590,7 +592,7 @@ namespace OrthancPlugins
 
   bool RestApiGetString(std::string& result,
                         const std::string& uri,
-                        const std::map<std::string, std::string>& httpHeaders,
+                        const HttpHeaders& httpHeaders,
                         bool applyPlugins);
 
   bool RestApiPost(std::string& result,
@@ -609,7 +611,7 @@ namespace OrthancPlugins
   bool RestApiPost(Json::Value& result,
                    const std::string& uri,
                    const Json::Value& body,
-                   const std::map<std::string, std::string>& httpHeaders,
+                   const HttpHeaders& httpHeaders,
                    bool applyPlugins);
 #endif
 
@@ -829,64 +831,64 @@ namespace OrthancPlugins
     bool DoGet(MemoryBuffer& target,
                size_t index,
                const std::string& uri,
-               const std::map<std::string, std::string>& headers) const;
+               const HttpHeaders& headers) const;
 
     bool DoGet(MemoryBuffer& target,
                const std::string& name,
                const std::string& uri,
-               const std::map<std::string, std::string>& headers) const;
+               const HttpHeaders& headers) const;
 
     bool DoGet(Json::Value& target,
                size_t index,
                const std::string& uri,
-               const std::map<std::string, std::string>& headers) const;
+               const HttpHeaders& headers) const;
 
     bool DoGet(Json::Value& target,
                const std::string& name,
                const std::string& uri,
-               const std::map<std::string, std::string>& headers) const;
+               const HttpHeaders& headers) const;
 
     bool DoPost(MemoryBuffer& target,
                 size_t index,
                 const std::string& uri,
                 const std::string& body,
-                const std::map<std::string, std::string>& headers) const;
+                const HttpHeaders& headers) const;
 
     bool DoPost(MemoryBuffer& target,
                 const std::string& name,
                 const std::string& uri,
                 const std::string& body,
-                const std::map<std::string, std::string>& headers) const;
+                const HttpHeaders& headers) const;
 
     bool DoPost(Json::Value& target,
                 size_t index,
                 const std::string& uri,
                 const std::string& body,
-                const std::map<std::string, std::string>& headers) const;
+                const HttpHeaders& headers) const;
 
     bool DoPost(Json::Value& target,
                 const std::string& name,
                 const std::string& uri,
                 const std::string& body,
-                const std::map<std::string, std::string>& headers) const;
+                const HttpHeaders& headers) const;
 
     bool DoPut(size_t index,
                const std::string& uri,
                const std::string& body,
-               const std::map<std::string, std::string>& headers) const;
+               const HttpHeaders& headers) const;
 
     bool DoPut(const std::string& name,
                const std::string& uri,
                const std::string& body,
-               const std::map<std::string, std::string>& headers) const;
+               const HttpHeaders& headers) const;
 
     bool DoDelete(size_t index,
                   const std::string& uri,
-                  const std::map<std::string, std::string>& headers) const;
+                  const HttpHeaders& headers) const;
 
     bool DoDelete(const std::string& name,
                   const std::string& uri,
-                  const std::map<std::string, std::string>& headers) const;
+                  const HttpHeaders& headers) const;
   };
 #endif
 
@@ -996,8 +998,6 @@ namespace OrthancPlugins
   class HttpClient : public boost::noncopyable
   {
   public:
-    typedef std::map<std::string, std::string>  HttpHeaders;
-
     class IRequestBody : public boost::noncopyable
     {
     public:
@@ -1397,7 +1397,7 @@ namespace OrthancPlugins
   };
 
 // helper method to convert Http headers from the plugin SDK to a std::map
-void GetHttpHeaders(std::map<std::string, std::string>& result, const OrthancPluginHttpRequest* request);
+void GetHttpHeaders(HttpHeaders& result, const OrthancPluginHttpRequest* request);
 
 #if HAS_ORTHANC_PLUGIN_WEBDAV == 1
   class IWebDavCollection : public boost::noncopyable
@@ -1508,4 +1508,88 @@ void GetHttpHeaders(std::map<std::string, std::string>& result, const OrthancPlu
 
   void ExtendOrthancExplorer(const std::string& pluginIdentifier,
                              const std::string& javascript);
+
+
+#if HAS_ORTHANC_PLUGIN_GENERIC_CALL_REST_API == 1
+  class RestApiClient : public boost::noncopyable
+  {
+  private:
+    // Request
+    OrthancPluginHttpMethod  method_;
+    std::string              path_;
+    HttpHeaders              requestHeaders_;
+    std::string              requestBody_;
+    bool                     afterPlugins_;
+
+    // Answer
+    uint16_t                 httpStatus_;
+    HttpHeaders              answerHeaders_;
+    std::string              answerBody_;
+
+  public:
+    RestApiClient();
+
+    void SetMethod(OrthancPluginHttpMethod method)
+    {
+      method_ = method;
+    }
+
+    OrthancPluginHttpMethod GetMethod() const
+    {
+      return method_;
+    }
+
+    void SetPath(const std::string& path)
+    {
+      path_ = path;
+    }
+
+    const std::string& GetPath() const
+    {
+      return path_;
+    }
+
+    void AddRequestHeader(const std::string& key,
+                          const std::string& value);
+
+    const HttpHeaders& GetRequestHeaders() const
+    {
+      return requestHeaders_;
+    }
+
+    void SetRequestBody(const std::string& body)
+    {
+      requestBody_ = body;
+    }
+
+    void SwapRequestBody(std::string& body)
+    {
+      requestBody_.swap(body);
+    }
+
+    void SetAfterPlugins(bool afterPlugins)
+    {
+      afterPlugins_ = afterPlugins;
+    }
+
+    bool IsAfterPlugins() const
+    {
+      return afterPlugins_;
+    }
+
+    const std::string& GetRequestBody() const
+    {
+      return requestBody_;
+    }
+
+    bool Execute();
+
+    uint16_t GetHttpStatus() const;
+
+    bool LookupAnswerHeader(std::string& value,
+                            const std::string& key) const;
+
+    const std::string& GetAnswerBody() const;
+  };
+#endif
 }


=====================================
TODO
=====================================
@@ -10,6 +10,8 @@
   curl -H "Accept: multipart/related; type=application/octet-stream" http://localhost:8043/dicom-web/studies/1.2.156.112536.1.2143.25015081191207.14610300430.5/series/1.2.156.112536.1.2143.25015081191207.14610300430.6/instances/1.2.156.112536.1.2143.25015081191207.14610309990.44/frames/3 --output /tmp/out.bin
   check for these logs: DICOMweb RetrieveFrames: Transcoding instance a7aec17a-e296e51f-2abe8ad8-bc95d57b-4de269d0 to transfer syntax 1.2.840.10008.1.2.1
 
+  Note: This has been partially handled in 1.18: Tf no transcoding is needed, we avoid downloading the full instance from Orthanc
+
   We should very likely implement a cache in the DicomWEB plugin and make sure that, if 3 clients are requesting the same instance at the same time, we only
   request one transcoding.
 
@@ -18,6 +20,7 @@
   https://discourse.orthanc-server.org/t/possible-memory-leak-with-multiframe-dicom-orthanc-ohif/3988/12
 
 
+
 * Implement capabilities: https://www.dicomstandard.org/using/dicomweb/capabilities/
   from https://groups.google.com/d/msgid/orthanc-users/c60227f2-c6da-4fd9-9b03-3ce9bf7d1af5n%40googlegroups.com?utm_medium=email&utm_source=footer
 


=====================================
WebApplication/app.js
=====================================
@@ -35,8 +35,6 @@ var app = new Vue({
     showStudies: false,
     showSeries: false,
     maxResults: MAX_RESULTS,
-    currentPage: 0,
-    perPage: 10,
     servers: [ ],
     serversInfo: { },
     activeServer: '',
@@ -49,81 +47,37 @@ var app = new Vue({
     jobDetails: '',
     studiesFields: [
       {
-        key: DICOM_TAG_PATIENT_ID + '.Value',
-        label: 'Patient ID',
-        sortable: true
+        key: DICOM_TAG_PATIENT_ID,
+        label: 'Patient ID'
       },
       {
-        key: DICOM_TAG_PATIENT_NAME + '.Value',
-        label: 'Patient name',
-        sortable: true
+        key: DICOM_TAG_PATIENT_NAME,
+        label: 'Patient name'
       },
       {
-        key: DICOM_TAG_ACCESSION_NUMBER + '.Value',
-        label: 'Accession number',
-        sortable: true
+        key: DICOM_TAG_ACCESSION_NUMBER,
+        label: 'Accession number'
       },
       {
-        key: DICOM_TAG_STUDY_DATE + '.Value',
-        label: 'Study date',
-        sortable: true
-      },
-      {
-        key: 'operations',
-        label: ''
+        key: DICOM_TAG_STUDY_DATE,
+        label: 'Study date'
       }
     ],
     studyToDelete: null,
     studyTags: [ ],    
-    studyTagsFields: [
-      {
-        key: 'Tag',
-        sortable: true
-      },
-      {
-        key: 'Name',
-        label: 'Description',
-        sortable: true
-      },
-      {
-        key: 'Value',
-        sortable: true
-      }
-    ],
     series: [ ],
     seriesFields: [
       {
-        key: DICOM_TAG_SERIES_DESCRIPTION + '.Value',
-        label: 'Series description',
-        sortable: true
-      },
-      {
-        key: DICOM_TAG_MODALITY + '.Value',
-        label: 'Modality',
-        sortable: true
+        key: DICOM_TAG_SERIES_DESCRIPTION,
+        label: 'Series description'
       },
       {
-        key: 'operations',
-        label: ''
+        key: DICOM_TAG_MODALITY,
+        label: 'Modality'
       }
     ],
     seriesToDelete: null,
-    seriesTags: [ ],    
-    seriesTagsFields: [
-      {
-        key: 'Tag',
-        sortable: true
-      },
-      {
-        key: 'Name',
-        label: 'Description',
-        sortable: true
-      },
-      {
-        key: 'Value',
-        sortable: true
-      }
-    ],
+    seriesTags: [ ],
     scrollToSeries: false,
     scrollToStudies: false
   },
@@ -155,7 +109,7 @@ var app = new Vue({
       window.scrollTo(0, element.offsetTop);
     },
     ShowErrorModal: function() {
-      app.$refs['modal-error'].show();
+      bootstrap.Modal.getOrCreateInstance('#modal-error').show();
     },
     RefreshJobDetails: function() {
       axios
@@ -247,8 +201,8 @@ var app = new Vue({
         item['Tag'] = i;
         return item;
       });
-      
-      app.$refs['study-details'].show();
+
+      bootstrap.Modal.getOrCreateInstance('#study-details').show();
     },
     RetrieveStudy: function(study) {
       var base = '../../servers/';
@@ -260,13 +214,13 @@ var app = new Vue({
           app.jobLevel = 'study';
           app.jobId = response.data.ID;
           app.jobUri = base + response.data.Path;
-          app.$refs['retrieve-job'].show();
+          bootstrap.Modal.getOrCreateInstance('#retrieve-job').show();
           app.RefreshJobDetails();
         });
     },
     ConfirmDeleteStudy: function(study) {
       app.studyToDelete = study;
-      app.$bvModal.show('study-delete-confirm');
+      bootstrap.Modal.getOrCreateInstance('#study-delete-confirm').show();
     },
     ExecuteDeleteStudy: function(study) {
       axios
@@ -312,7 +266,7 @@ var app = new Vue({
         return item;
       });
       
-      app.$refs['series-details'].show();
+      bootstrap.Modal.getOrCreateInstance('#series-details').show();
     },
     RetrieveSeries: function(series) {
       var base = '../../servers/';
@@ -325,7 +279,7 @@ var app = new Vue({
           app.jobLevel = 'series';
           app.jobId = response.data.ID;
           app.jobUri = base + response.data.Path;
-          app.$refs['retrieve-job'].show();
+          bootstrap.Modal.getOrCreateInstance('#retrieve-job').show();
           app.RefreshJobDetails();
         });
     },
@@ -359,13 +313,13 @@ var app = new Vue({
               app.previewFailure = true;
             })
               .finally(function() {
-                app.$refs['series-preview'].show();
+                bootstrap.Modal.getOrCreateInstance('#series-preview').show();
               })
         })
     },
     ConfirmDeleteSeries: function(series) {
       app.seriesToDelete = series;
-      app.$bvModal.show('series-delete-confirm');
+      bootstrap.Modal.getOrCreateInstance('#series-delete-confirm').show();
     },
     ExecuteDeleteSeries: function(series) {
       axios


=====================================
WebApplication/index.html
=====================================
@@ -6,9 +6,7 @@
 
     <title>Orthanc - DICOMweb client</title>
 
-    <!-- Add Bootstrap and Bootstrap-Vue CSS to the <head> section -->
     <link type="text/css" rel="stylesheet" href="../libs/css/bootstrap.min.css"/>
-    <link type="text/css" rel="stylesheet" href="../libs/css/bootstrap-vue.min.css"/>
     <link type="text/css" rel="stylesheet" href="../libs/css/font-awesome.min.css"/>
     
     <script src="../libs/js/polyfill.min.js"></script>
@@ -16,6 +14,10 @@
     <!-- CSS style to truncate long text in tables, provided they have
          class "table-layout:fixed;" or attribute ":fixed=true" -->
     <style>
+      table {
+      table-layout:fixed;
+      }
+
       table td { 
       white-space: nowrap;
       overflow: hidden;
@@ -28,7 +30,7 @@
     <div class="container" id="app">
       <p style="height:1em"></p>
 
-      <div class="jumbotron">
+      <div class="bg-light mb-4 rounded-2 py-5 px-3">
         <div class="row">
           <div class="col-sm-8">
             <h1 class="display-4">DICOMweb client</h1>
@@ -60,47 +62,70 @@
       </div>
 
 
-      <b-modal ref="modal-error" size="xl" ok-only="true">
-        <template slot="modal-title">
-          Connection error
-        </template>
-        <div class="d-block">
-          <p>
-            There was an error connecting to "{{ activeServer }}" server.
-          </p>
+      <div class="modal" tabindex="-1" id="modal-error">
+        <div class="modal-dialog modal-xl">
+          <div class="modal-content">
+            <div class="modal-header">
+              <h5 class="modal-title">Connection error</h5>
+              <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+            </div>
+            <div class="modal-body">
+              <p>
+                There was an error connecting to "{{ activeServer }}" server.
+              </p>
+            </div>
+            <div class="modal-footer">
+              <button type="button" class="btn btn-primary" data-bs-dismiss="modal">OK</button>
+            </div>
+          </div>
         </div>
-      </b-modal>
+      </div>
+
 
-      
       <!-- LOOKUP -->
 
       <div class="row">
-        <b-alert variant="danger" dismissible v-model="showNoServer">
+        <div class="alert alert-danger alert-dismissible" v-if="showNoServer">
           No DICOMweb server is configured!
-        </b-alert>
-        <b-form style="width:100%;padding:5px;">
-          <b-form-group label="DICOMweb server:" label-cols-sm="4" label-cols-lg="3">
-            <b-form-select v-model="lookup.server" :options="servers"></b-form-select>
-          </b-form-group>
-          <b-form-group label="Patient ID:" label-cols-sm="4" label-cols-lg="3">
-            <b-form-input v-model="lookup.patientID"></b-form-input>
-          </b-form-group>
-          <b-form-group label="Patient name:" label-cols-sm="4" label-cols-lg="3">
-            <b-form-input v-model="lookup.patientName"></b-form-input>
-          </b-form-group>
-          <b-form-group label="Accession number:" label-cols-sm="4" label-cols-lg="3">
-            <b-form-input v-model="lookup.accessionNumber"></b-form-input>
-          </b-form-group>
-          <b-form-group label="Study date:" label-cols-sm="4" label-cols-lg="3">
-            <b-form-input v-model="lookup.studyDate"></b-form-input>
-          </b-form-group>
+        </div>
+        <form style="width:100%;padding:5px;">
+          <div class="mb-3 row">
+            <label class="col-form-label col-sm-3 cols-lg-3">DICOMweb server:</label>
+            <div class="col-sm-9 cols-lg-9">
+              <select class="form-select" v-model="lookup.server">
+                <option v-for="option in servers">{{ option }}</option>
+              </select>
+            </div>
+          </div>
+          <div class="mb-3 row">
+            <label class="col-form-label col-sm-3 cols-lg-3">Patient ID:</label>
+            <div class="col-sm-9 cols-lg-9">
+              <input type="text" class="form-control" v-model="lookup.patientID"></input>
+            </div>
+          </div>
+          <div class="mb-3 row">
+            <label class="col-form-label col-sm-3 cols-lg-3">Patient name:</label>
+            <div class="col-sm-9 cols-lg-9">
+              <input type="text" class="form-control" v-model="lookup.patientName"></input>
+            </div>
+          </div>
+          <div class="mb-3 row">
+            <label class="col-form-label col-sm-3 cols-lg-3">Accession number:</label>
+            <div class="col-sm-9 cols-lg-9">
+              <input type="text" class="form-control" v-model="lookup.accessionNumber"></input>
+            </div>
+          </div>
+          <div class="mb-3 row">
+            <label class="col-form-label col-sm-3 cols-lg-3">Study date:</label>
+            <div class="col-sm-9 cols-lg-9">
+              <input type="text" class="form-control" v-model="lookup.studyDate"></input>
+            </div>
+          </div>
           <p class="pull-right">
-          <b-button type="submit" variant="success" @click="OnLookup" 
-                    size="lg">Do lookup</b-button>
-          <b-button type="reset" variant="outline-danger" @click="OnReset" 
-                    size="lg">Reset</b-button>
+            <button type="button" class="btn btn-lg btn-success" @click="OnLookup">Do lookup</button>
+            <button type="button" class="btn btn-lg btn-outline-danger" @click="OnReset">Reset</button>
           </p>
-        </b-form>
+        </form>
       </div>
 
 
@@ -111,54 +136,96 @@
         <h1>Studies</h1>
       </div>
       <div class="row" v-show="showStudies">
-        <b-alert variant="warning" dismissible v-model="showTruncatedStudies">
+        <div class="alert alert-warning alert-dismissible" v-if="showTruncatedStudies">
           More than {{ maxResults }} matching studies, results have been truncated!
-        </b-alert>
+        </div>
       </div>
       <div class="row" v-show="showStudies">
-        <b-pagination v-model="currentPage" :per-page="perPage" :total-rows="studiesCount"></b-pagination>
-        <b-table striped hover :current-page="currentPage" :per-page="perPage"
-                 :items="studies" :fields="studiesFields" :fixed="false">
-          <template slot="operations" slot-scope="data">
-            <b-button @click="OpenSeries(data.item)" title="Open series">
-              <i class="fa fa-folder-open"></i>
-            </b-button>
-            <b-button @click="OpenStudyDetails(data.item)" title="Open tags">
-              <i class="fa fa-address-card"></i>
-            </b-button>
-            <b-button @click="RetrieveStudy(data.item)" title="Retrieve study using WADO-RS">
-              <i class="fa fa-cloud-download"></i>
-            </b-button>
-            <b-button @click="ConfirmDeleteStudy(data.item)"
-                      v-if="serversInfo[activeServer].HasDelete == '1'" title="Delete remote study">
-              <i class="fa fa-trash"></i>
-            </b-button>
-          </template>
-        </b-table>
+        <table class="table table-striped">
+          <thead>
+            <tr>
+              <th scope="col" v-for="column in studiesFields">{{column.label}}</th>
+              <th></th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-for="s in studies">
+              <td v-for="column in studiesFields">{{column.key in s ? s[column.key].Value : ''}}</td>
+              <td>
+                <button type="button" class="btn btn-secondary" @click="OpenSeries(s)" title="Open series">
+                  <i class="fa fa-folder-open"></i>
+                </button>
+                <button type="button" class="btn btn-secondary" @click="OpenStudyDetails(s)" title="Open tags">
+                  <i class="fa fa-address-card"></i>
+                </button>
+                <button type="button" class="btn btn-secondary" @click="RetrieveStudy(s)" title="Retrieve study using WADO-RS">
+                  <i class="fa fa-cloud-download"></i>
+                </button>
+                <button type="button" class="btn btn-secondary" @click="ConfirmDeleteStudy(s)" title="Delete remote study"
+                        v-if="serversInfo[activeServer].HasDelete == '1'">
+                  <i class="fa fa-trash"></i>
+                </button>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
 
-        <b-modal ref="study-details" size="xl" ok-only="true">
-          <template slot="modal-title">
-            Details of study
-          </template>
-          <div class="d-block text-center">
-            <b-table striped :items="studyTags" :fields="studyTagsFields" :fixed="true">
-            </b-table>
+
+      <div class="modal" tabindex="-1" id="study-details">
+        <div class="modal-dialog modal-xl">
+          <div class="modal-content">
+            <div class="modal-header">
+              <h5 class="modal-title">Details of study</h5>
+              <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+            </div>
+            <div class="modal-body">
+              <table class="table table-striped">
+                <thead>
+                  <tr>
+                    <th scope="col" class="col-sm-2">Tag</th>
+                    <th scope="col" class="col-sm-3">Description</th>
+                    <th scope="col" class="col-sm-7">Value</th>
+                  </tr>
+                </thead>
+                <tbody>
+                  <tr v-for="s in studyTags">
+                    <td class="col-sm-2">{{'Tag' in s ? s.Tag : ''}}</td>
+                    <td class="col-sm-3">{{'Name' in s ? s.Name : ''}}</td>
+                    <td class="col-sm-7">{{'Value' in s ? s.Value : ''}}</td>
+                  </tr>
+                </tbody>
+              </table>
+            </div>
+            <div class="modal-footer">
+              <button type="button" class="btn btn-primary" data-bs-dismiss="modal">OK</button>
+            </div>
           </div>
-        </b-modal>
+        </div>
+      </div>
 
-        <b-modal id="study-delete-confirm" size="xl" @ok="ExecuteDeleteStudy">
-          <template slot="modal-title">
-            Confirm deletion
-          </template>
-          <div class="d-block">
-            <p>
-              Are you sure you want to remove this study from the remote server?
-            </p>
-            <p>
-              Patient name: {{ studyToDelete && studyToDelete['00100010'] && studyToDelete['00100010'].Value }}
-            </p>
+
+      <div class="modal" tabindex="-1" id="study-delete-confirm">
+        <div class="modal-dialog modal-xl">
+          <div class="modal-content">
+            <div class="modal-header">
+              <h5 class="modal-title">Confirm deletion</h5>
+              <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+            </div>
+            <div class="modal-body">
+              <p>
+                Are you sure you want to remove this study from the remote server?
+              </p>
+              <p>
+                Patient name: {{ studyToDelete && studyToDelete['00100010'] && studyToDelete['00100010'].Value }}
+              </p>
+            </div>
+            <div class="modal-footer">
+              <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
+              <button type="button" class="btn btn-primary" data-bs-dismiss="modal" @click="ExecuteDeleteStudy">Confirm</button>
+            </div>
           </div>
-        </b-modal>
+        </div>
       </div>
 
 
@@ -169,92 +236,154 @@
         <h1>Series</h1>
       </div>
       <div class="row" v-show="showSeries">
-        <b-table striped hover :items="series" :fields="seriesFields" :fixed="false">
-          <template slot="operations" slot-scope="data">
-            <b-button @click="OpenSeriesPreview(data.item)" title="Preview">
-              <i class="fa fa-eye"></i>
-            </b-button>
-            <b-button @click="OpenSeriesDetails(data.item)" title="Open tags">
-              <i class="fa fa-address-card"></i>
-            </b-button>
-            <b-button @click="RetrieveSeries(data.item)" title="Retrieve series using WADO-RS">
-              <i class="fa fa-cloud-download"></i>
-            </b-button>
-            <b-button @click="ConfirmDeleteSeries(data.item)"
-                      v-if="serversInfo[activeServer].HasDelete" title="Delete remote series">
-              <i class="fa fa-trash"></i>
-            </b-button>
-          </template>
-        </b-table>
+        <table class="table table-striped">
+          <thead>
+            <tr>
+              <th scope="col" v-for="column in seriesFields">{{column.label}}</th>
+              <th></th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-for="s in series">
+              <td v-for="column in seriesFields">{{column.key in s ? s[column.key].Value : ''}}</td>
+              <td>
+                <button type="button" class="btn btn-secondary" @click="OpenSeriesPreview(s)" title="Preview">
+                  <i class="fa fa-eye"></i>
+                </button>
+                <button type="button" class="btn btn-secondary" @click="OpenSeriesDetails(s)" title="Open tags">
+                  <i class="fa fa-address-card"></i>
+                </button>
+                <button type="button" class="btn btn-secondary" @click="RetrieveSeries(s)" title="Retrieve series using WADO-RS">
+                  <i class="fa fa-cloud-download"></i>
+                </button>
+                <button type="button" class="btn btn-secondary" @click="ConfirmDeleteSeries(s)" title="Delete remote series"
+                        v-if="serversInfo[activeServer].HasDelete == '1'">
+                  <i class="fa fa-trash"></i>
+                </button>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
 
-        <b-modal ref="series-details" size="xl" ok-only="true">
-          <template slot="modal-title">
-            Details of series
-          </template>
-          <div class="d-block text-center">
-            <b-table striped :items="seriesTags" :fields="seriesTagsFields" :fixed="true">
-            </b-table>
+
+      <div class="modal" tabindex="-1" id="series-preview">
+        <div class="modal-dialog modal-xl">
+          <div class="modal-content">
+            <div class="modal-header">
+              <h5 class="modal-title">Preview of series</h5>
+              <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+            </div>
+            <div class="modal-body">
+              <div class="alert alert-danger alert-dismissible" v-if="previewFailure">
+                The remote DICOMweb server cannot generate a preview for this image.
+              </div>
+              <div class="text-center">
+                <img class="img-fluid" v-if="!previewFailure" v-bind:src="preview">
+              </div>
+            </div>
+            <div class="modal-footer">
+              <button type="button" class="btn btn-primary" data-bs-dismiss="modal">OK</button>
+            </div>
           </div>
-        </b-modal>
+        </div>
+      </div>
 
-        <b-modal ref="series-preview" size="xl" ok-only="true">
-          <template slot="modal-title">
-            Preview of series
-          </template>
-          <div class="d-block text-center">
-            <b-alert variant="danger" v-model="previewFailure">
-              The remote DICOMweb server cannot generate a preview for this image.
-            </b-alert>
-            <b-img v-if="!previewFailure" :src="preview" fluid alt=""></b-img>
+
+      <div class="modal" tabindex="-1" id="series-details">
+        <div class="modal-dialog modal-xl">
+          <div class="modal-content">
+            <div class="modal-header">
+              <h5 class="modal-title">Details of series</h5>
+              <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+            </div>
+            <div class="modal-body">
+              <table class="table table-striped">
+                <thead>
+                  <tr>
+                    <th scope="col" class="col-sm-2">Tag</th>
+                    <th scope="col" class="col-sm-3">Description</th>
+                    <th scope="col" class="col-sm-7">Value</th>
+                  </tr>
+                </thead>
+                <tbody>
+                  <tr v-for="s in seriesTags">
+                    <td class="col-sm-2">{{'Tag' in s ? s.Tag : ''}}</td>
+                    <td class="col-sm-3">{{'Name' in s ? s.Name : ''}}</td>
+                    <td class="col-sm-7">{{'Value' in s ? s.Value : ''}}</td>
+                  </tr>
+                </tbody>
+              </table>
+            </div>
+            <div class="modal-footer">
+              <button type="button" class="btn btn-primary" data-bs-dismiss="modal">OK</button>
+            </div>
           </div>
-        </b-modal>
+        </div>
+      </div>
 
-        <b-modal id="series-delete-confirm" size="xl" @ok="ExecuteDeleteSeries">
-          <template slot="modal-title">
-            Confirm deletion
-          </template>
-          <div class="d-block">
-            <p>
-              Are you sure you want to remove this series from the remote server?
-            </p>
-            <p>
-              Series description: {{ seriesToDelete && seriesToDelete['0008103E'] && seriesToDelete['0008103E'].Value }}
-            </p>
+
+      <div class="modal" tabindex="-1" id="series-delete-confirm">
+        <div class="modal-dialog modal-xl">
+          <div class="modal-content">
+            <div class="modal-header">
+              <h5 class="modal-title">Confirm deletion</h5>
+              <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+            </div>
+            <div class="modal-body">
+              <p>
+                Are you sure you want to remove this series from the remote server?
+              </p>
+              <p>
+                Series description: {{ seriesToDelete && seriesToDelete['0008103E'] && seriesToDelete['0008103E'].Value }}
+              </p>
+            </div>
+            <div class="modal-footer">
+              <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
+              <button type="button" class="btn btn-primary" data-bs-dismiss="modal" @click="ExecuteDeleteSeries">Confirm</button>
+            </div>
           </div>
-        </b-modal>
+        </div>
       </div>
 
 
-      <b-modal ref="retrieve-job" size="xl" ok-only="true">
-        <template slot="modal-title">
-          Retrieving {{ jobLevel }}
-        </template>
-        <div class="d-block">
-          <p>
-            Orthanc is now running a background job to retrieve the
-            {{ jobLevel }} from remote server "{{ activeServer }}" using
-            WADO-RS.
-          </p>
-          <p>
-            Job ID: <tt>{{ jobId }}</tt>
-          </p>
-          <p>
-            Job details:
-          </p>
-          <pre>{{ jobDetails }}</pre>
-          <p>
-            <b-button variant="success" @click="RefreshJobDetails()">Refresh job details</b-button>
-          </p>
+      <div class="modal" tabindex="-1" id="retrieve-job">
+        <div class="modal-dialog modal-xl">
+          <div class="modal-content">
+            <div class="modal-header">
+              <h5 class="modal-title">Retrieving {{ jobLevel }}</h5>
+              <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+            </div>
+            <div class="modal-body">
+              <p>
+                Orthanc is now running a background job to retrieve the
+                {{ jobLevel }} from remote server "{{ activeServer }}" using
+                WADO-RS.
+              </p>
+              <p>
+                Job ID: <tt>{{ jobId }}</tt>
+              </p>
+              <p>
+                Job details:
+              </p>
+              <pre>{{ jobDetails }}</pre>
+              <p>
+                <button type="button" class="btn btn-success" @click="RefreshJobDetails()">Refresh job details</button>
+              </p>
+            </div>
+            <div class="modal-footer">
+              <button type="button" class="btn btn-primary" data-bs-dismiss="modal">OK</button>
+            </div>
+          </div>
         </div>
-      </b-modal>
+      </div>
 
 
       <p style="height:5em"></p>
     </div>
 
-    <!-- Add Vue and Bootstrap-Vue JS just before the closing </body> tag -->
+    <script src="../libs/js/bootstrap.min.js"></script>
     <script src="../libs/js/vue.min.js"></script>
-    <script src="../libs/js/bootstrap-vue.min.js"></script>
     <script src="../libs/js/axios.min.js"></script>
     <script type="text/javascript" src="app.js"></script>
   </body>



View it on GitLab: https://salsa.debian.org/med-team/orthanc-dicomweb/-/commit/bee0a78e91ca2f0735b1a5195e28db4eefbe0987

-- 
View it on GitLab: https://salsa.debian.org/med-team/orthanc-dicomweb/-/commit/bee0a78e91ca2f0735b1a5195e28db4eefbe0987
You're receiving this email because of your account on salsa.debian.org.


-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://alioth-lists.debian.net/pipermail/debian-med-commit/attachments/20241220/eb4cde50/attachment-0001.htm>


More information about the debian-med-commit mailing list