changeset 307:5a9238e974ea refactoring

first version of the DICOMweb client
author Sebastien Jodogne <s.jodogne@gmail.com>
date Tue, 18 Jun 2019 18:38:30 +0200
parents 4a0b759019ac
children 78c9a7d207b8
files CMakeLists.txt Plugin/Plugin.cpp Resources/CMake/JavaScriptLibraries.cmake WebApplication/app.js WebApplication/index.html
diffstat 5 files changed, 781 insertions(+), 1 deletions(-) [+]
line wrap: on
line diff
--- a/CMakeLists.txt	Tue Jun 18 15:34:29 2019 +0200
+++ b/CMakeLists.txt	Tue Jun 18 18:38:30 2019 +0200
@@ -45,6 +45,10 @@
 set(ORTHANC_SDK_VERSION "1.5.4" CACHE STRING "Version of the Orthanc plugin SDK to use, if not using the system version (can be \"1.5.4\", or \"framework\")")
 
 
+set(BUILD_BOOTSTRAP_VUE ON CACHE BOOL "Compile Bootstrap-Vue from sources")
+set(BUILD_BABEL_POLYFILL ON CACHE BOOL "Retrieve babel-polyfill from npm")
+
+
 
 # Download and setup the Orthanc framework
 include(${CMAKE_SOURCE_DIR}/Resources/Orthanc/DownloadOrthancFramework.cmake)
@@ -61,6 +65,7 @@
 include_directories(${ORTHANC_ROOT})
 
 include(${CMAKE_SOURCE_DIR}/Resources/CMake/GdcmConfiguration.cmake)
+include(${CMAKE_SOURCE_DIR}/Resources/CMake/JavaScriptLibraries.cmake)
 
 
 if (STATIC_BUILD OR NOT USE_SYSTEM_ORTHANC_SDK)
@@ -109,11 +114,18 @@
 endif()
 
 
+EmbedResources(
+  --no-upcase-check
+  JAVASCRIPT_LIBS  ${JAVASCRIPT_LIBS_DIR}
+  )
+
+
 include_directories(${ORTHANC_ROOT}/Core)  # To access "OrthancException.h"
 
 add_definitions(
   -DHAS_ORTHANC_EXCEPTION=1
   -DORTHANC_ENABLE_LOGGING_PLUGIN=1
+  -DDICOMWEB_CLIENT_PATH="${CMAKE_SOURCE_DIR}/WebApplication/"
   )
 
 set(CORE_SOURCES
--- a/Plugin/Plugin.cpp	Tue Jun 18 15:34:29 2019 +0200
+++ b/Plugin/Plugin.cpp	Tue Jun 18 18:38:30 2019 +0200
@@ -18,7 +18,6 @@
  * along with this program. If not, see <http://www.gnu.org/licenses/>.
  **/
 
-
 #include "DicomWebClient.h"
 #include "DicomWebServers.h"
 #include "GdcmParsedDicomFile.h"
@@ -28,8 +27,11 @@
 #include "WadoUri.h"
 
 #include <Plugins/Samples/Common/OrthancPluginCppWrapper.h>
+#include <Core/SystemToolbox.h>
 #include <Core/Toolbox.h>
 
+#include <EmbeddedResources.h>
+
 
 bool RequestHasKey(const OrthancPluginHttpRequest* request, const char* key)
 {
@@ -529,6 +531,54 @@
 }
 
 
+template <enum Orthanc::EmbeddedResources::DirectoryResourceId folder>
+void ServeEmbeddedFolder(OrthancPluginRestOutput* output,
+                         const char* url,
+                         const OrthancPluginHttpRequest* request)
+{
+  OrthancPluginContext* context = OrthancPlugins::GetGlobalContext();
+
+  if (request->method != OrthancPluginHttpMethod_Get)
+  {
+    OrthancPluginSendMethodNotAllowed(context, output, "GET");
+  }
+  else
+  {
+    std::string path = "/" + std::string(request->groups[0]);
+    const char* mime = Orthanc::EnumerationToString(Orthanc::SystemToolbox::AutodetectMimeType(path));
+
+    std::string s;
+    Orthanc::EmbeddedResources::GetDirectoryResource(s, folder, path.c_str());
+
+    const char* resource = s.size() ? s.c_str() : NULL;
+    OrthancPluginAnswerBuffer(context, output, resource, s.size(), mime);
+  }
+}
+
+
+void ServeDicomWebClient(OrthancPluginRestOutput* output,
+                         const char* url,
+                         const OrthancPluginHttpRequest* request)
+{
+  OrthancPluginContext* context = OrthancPlugins::GetGlobalContext();
+
+  if (request->method != OrthancPluginHttpMethod_Get)
+  {
+    OrthancPluginSendMethodNotAllowed(context, output, "GET");
+  }
+  else
+  {
+    const std::string path = std::string(DICOMWEB_CLIENT_PATH) + std::string(request->groups[0]);
+    const char* mime = Orthanc::EnumerationToString(Orthanc::SystemToolbox::AutodetectMimeType(path));
+
+    OrthancPlugins::MemoryBuffer f;
+    f.ReadFile(path);
+
+    OrthancPluginAnswerBuffer(context, output, f.GetData(), f.GetSize(), mime);
+  }
+}
+
+
 extern "C"
 {
   ORTHANC_PLUGINS_API int32_t OrthancPluginInitialize(OrthancPluginContext* context)
@@ -599,6 +649,15 @@
         OrthancPlugins::RegisterRestCallback<RetrieveFromServer>(root + "servers/([^/]*)/retrieve", true);
         OrthancPlugins::RegisterRestCallback<QidoClient>(root + "servers/([^/]*)/qido", true);
         OrthancPlugins::RegisterRestCallback<DeleteClient>(root + "servers/([^/]*)/delete", true);
+
+        OrthancPlugins::RegisterRestCallback
+          <ServeEmbeddedFolder<Orthanc::EmbeddedResources::JAVASCRIPT_LIBS> >
+          (root + "app/libs/(.*)", true);
+
+        OrthancPlugins::RegisterRestCallback<ServeDicomWebClient>(root + "app/client/(.*)", true);
+
+        std::string uri = root + "app/client/index.html";
+        OrthancPluginSetRootUri(context, uri.c_str());
       }
       else
       {
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Resources/CMake/JavaScriptLibraries.cmake	Tue Jun 18 18:38:30 2019 +0200
@@ -0,0 +1,173 @@
+# Orthanc - A Lightweight, RESTful DICOM Store
+# Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
+# Department, University Hospital of Liege, Belgium
+# Copyright (C) 2017-2019 Osimis S.A., Belgium
+#
+# This program is free software: you can redistribute it and/or
+# modify it under the terms of the GNU Affero General Public License
+# as published by the Free Software Foundation, either version 3 of
+# the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Affero General Public License for more details.
+# 
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+set(BASE_URL "http://orthanc.osimis.io/ThirdPartyDownloads/dicom-web")
+
+DownloadPackage(
+  "da0189f7c33bf9f652ea65401e0a3dc9"
+  "${BASE_URL}/bootstrap-4.3.1.zip"
+  "${CMAKE_CURRENT_BINARY_DIR}/bootstrap-4.3.1")
+
+DownloadPackage(
+  "8242afdc5bd44105d9dc9e6535315484"
+  "${BASE_URL}/vuejs-2.6.10.tar.gz"
+  "${CMAKE_CURRENT_BINARY_DIR}/vue-2.6.10")
+
+DownloadPackage(
+  "3e2b4e1522661f7fcf8ad49cb933296c"
+  "${BASE_URL}/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"
+  "${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@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)
+
+  if (NOT IS_DIRECTORY "${BABEL_POLYFILL_SOURCES_DIR}")
+    execute_process(
+      COMMAND ${NPM_EXECUTABLE} install babel-polyfill
+      WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}
+      RESULT_VARIABLE Failure
+      OUTPUT_QUIET
+      )
+    
+    if (Failure)
+      message(FATAL_ERROR "Error while running 'npm install' on Bootstrap-Vue")
+    endif()
+  endif()
+else()
+
+  ## curl -L https://unpkg.com/babel-polyfill@6.26.0/dist/polyfill.min.js | gzip > babel-polyfill-6.26.0.min.js.gz
+
+  set(BABEL_POLYFILL_SOURCES_DIR ${CMAKE_CURRENT_BINARY_DIR})
+  DownloadCompressedFile(
+    "49f7bad4176d715ce145e75c903988ef"
+    "${BASE_URL}/babel-polyfill-6.26.0.min.js.gz"
+    "${CMAKE_CURRENT_BINARY_DIR}/polyfill.min.js")
+
+endif()
+
+
+set(JAVASCRIPT_LIBS_DIR  ${CMAKE_CURRENT_BINARY_DIR}/javascript-libs)
+file(MAKE_DIRECTORY ${JAVASCRIPT_LIBS_DIR})
+
+file(COPY
+  ${BABEL_POLYFILL_SOURCES_DIR}/polyfill.min.js
+  ${BOOTSTRAP_VUE_SOURCES_DIR}/dist/bootstrap-vue.min.js
+  ${CMAKE_CURRENT_BINARY_DIR}/axios-0.19.0/dist/axios.min.js
+  ${CMAKE_CURRENT_BINARY_DIR}/bootstrap-4.3.1/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
+  ${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
+  DESTINATION
+  ${JAVASCRIPT_LIBS_DIR}/css
+  )
+
+file(COPY
+  ${CMAKE_CURRENT_BINARY_DIR}/Font-Awesome-4.7.0/fonts/FontAwesome.otf
+  ${CMAKE_CURRENT_BINARY_DIR}/Font-Awesome-4.7.0/fonts/fontawesome-webfont.eot
+  ${CMAKE_CURRENT_BINARY_DIR}/Font-Awesome-4.7.0/fonts/fontawesome-webfont.svg
+  ${CMAKE_CURRENT_BINARY_DIR}/Font-Awesome-4.7.0/fonts/fontawesome-webfont.ttf
+  ${CMAKE_CURRENT_BINARY_DIR}/Font-Awesome-4.7.0/fonts/fontawesome-webfont.woff
+  ${CMAKE_CURRENT_BINARY_DIR}/Font-Awesome-4.7.0/fonts/fontawesome-webfont.woff2
+  DESTINATION
+  ${JAVASCRIPT_LIBS_DIR}/fonts
+  )
+
+file(COPY
+  ${ORTHANC_ROOT}/Resources/OrthancLogo.png
+  DESTINATION
+  ${JAVASCRIPT_LIBS_DIR}/img
+  )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/WebApplication/app.js	Tue Jun 18 18:38:30 2019 +0200
@@ -0,0 +1,327 @@
+var DICOM_TAG_ACCESSION_NUMBER = '00080050';
+var DICOM_TAG_MODALITY = '00080060';
+var DICOM_TAG_PATIENT_ID = '00100020';
+var DICOM_TAG_PATIENT_NAME = '00100010';
+var DICOM_TAG_SERIES_DESCRIPTION = '0008103E';
+var DICOM_TAG_SERIES_INSTANCE_UID = '0020000E';
+var DICOM_TAG_SOP_INSTANCE_UID = '00080018';
+var DICOM_TAG_STUDY_DATE = '00080020';
+var DICOM_TAG_STUDY_ID = '00200010';
+var DICOM_TAG_STUDY_INSTANCE_UID = '0020000D';
+var DEFAULT_PREVIEW = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==';
+var MAX_RESULTS = 100;
+
+var app = new Vue({
+  el: '#app',
+  computed: {
+    studiesCount() {
+      return this.studies.length
+    },
+    seriesCount() {
+      return this.series.length
+    }
+  },
+  data: {
+    previewFailure: true,
+    preview: DEFAULT_PREVIEW,
+    showTruncatedStudies: false,
+    showNoServer: false,
+    showStudies: false,
+    showSeries: false,
+    maxResults: MAX_RESULTS,
+    currentPage: 0,
+    perPage: 10,
+    servers: [ ],
+    serversInfo: { },
+    lookup: { },
+    studies: [ ],
+    currentStudy: null,
+    studiesFields: [
+      {
+        key: DICOM_TAG_PATIENT_ID + '.Value',
+        label: 'Patient ID',
+        sortable: true
+      },
+      {
+        key: DICOM_TAG_PATIENT_NAME + '.Value',
+        label: 'Patient name',
+        sortable: true
+      },
+      {
+        key: DICOM_TAG_ACCESSION_NUMBER + '.Value',
+        label: 'Accession number',
+        sortable: true
+      },
+      {
+        key: DICOM_TAG_STUDY_DATE + '.Value',
+        label: 'Study date',
+        sortable: true
+      },
+      {
+        key: 'operations',
+        label: ''
+      }
+    ],
+    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: 'operations',
+        label: ''
+      }
+    ],
+    seriesToDelete: null,
+    seriesTags: [ ],    
+    seriesTagsFields: [
+      {
+        key: 'Tag',
+        sortable: true
+      },
+      {
+        key: 'Name',
+        label: 'Description',
+        sortable: true
+      },
+      {
+        key: 'Value',
+        sortable: true
+      }
+    ],
+    scrollToSeries: false,
+    scrollToStudies: false
+  },
+  mounted: () => {
+    axios
+      .get('../../servers?expand')
+      .then(response => {
+        app.serversInfo = response.data;
+        app.servers = Object.keys(response.data).map(i => i);
+        app.Clear();
+      })
+  },
+  methods: {
+    ScrollToRef: function(refName) {
+      var element = app.$refs[refName];
+      window.scrollTo(0, element.offsetTop + 200);
+    },
+
+    /**
+     * Studies
+     **/
+
+    SetStudies: function(response) {
+      if (response.data.length > app.maxResults) {
+        app.showTruncatedStudies = true;
+        app.studies = response.data.splice(0, app.maxResults);
+      } else {
+        app.showTruncatedStudies = false;
+        app.studies = response.data;
+      }
+      app.showStudies = true;
+      app.showSeries = false;
+      app.studyToDelete = null;
+      app.scrollToStudies = true;
+    },
+    ExecuteLookup: function() {
+      var args = { 
+        'fuzzymatching' : 'true',
+        'limit' : (app.maxResults + 1).toString()
+      };
+
+      if ('patientName' in app.lookup) {
+        args[DICOM_TAG_PATIENT_NAME] = app.lookup.patientName;
+      }
+
+      if ('patientID' in app.lookup) {
+        args[DICOM_TAG_PATIENT_ID] = app.lookup.patientID;
+      }
+
+      if ('studyDate' in app.lookup) {
+        args[DICOM_TAG_STUDY_DATE] = app.lookup.studyDate;
+      }
+
+      if ('accessionNumber' in app.lookup) {
+        args[DICOM_TAG_ACCESSION_NUMBER] = app.lookup.accessionNumber;
+      }
+
+      axios
+        .post('../../servers/' + app.lookup.server + '/qido', {
+          'Uri' : '/studies',
+          'Arguments' : args,
+        })
+        .then(app.SetStudies);
+    },
+    Clear: function() {
+      app.lookup = {};
+      currentStudy = null;
+      app.showSeries = false;
+      app.showStudies = false;
+      if (app.servers.length == 0) {
+        app.showNoServer = true;
+      } else {
+        app.showNoServer = false;
+        app.lookup.server = app.servers[0];
+      }
+    },
+    OnAllStudies: function (event) {
+      event.preventDefault();
+      axios
+        .post('../../servers/' + app.lookup.server + '/qido', {
+          'Uri' : '/studies',
+          'Arguments' : { 'limit' : (app.maxResults + 1).toString() }
+        })
+        .then(app.SetStudies);
+    },
+    OnLookup: function(event) {
+      event.preventDefault();
+      app.ExecuteLookup();
+    },
+    OnReset: function(event) {
+      event.preventDefault();
+      app.Clear();
+    },
+    OpenStudyDetails: function(study) {
+      app.studyTags = Object.keys(study).map(i => {
+        var item = study[i];
+        item['Tag'] = i;
+        return item;
+      });
+      
+      app.$refs['study-details'].show();
+    },
+    ConfirmDeleteStudy: function(study) {
+      app.studyToDelete = study;
+      app.$bvModal.show('study-delete-confirm');
+    },
+    ExecuteDeleteStudy: function(study) {
+      axios
+        .post('../../servers/' + app.lookup.server + '/delete', {
+          'Level': 'Study',
+          'StudyInstanceUID': app.studyToDelete[DICOM_TAG_STUDY_INSTANCE_UID].Value
+        })
+        .then(app.ExecuteLookup);
+    },
+
+    
+    /**
+     * Series
+     **/
+
+    LoadSeriesOfCurrentStudy: function() {
+      axios
+        .post('../../servers/' + app.lookup.server + '/qido', {
+          'Uri' : '/studies/' + app.currentStudy + '/series'
+        })
+        .then(response => {
+          app.series = response.data;
+          app.showSeries = true;
+          app.seriesToDelete = null;
+          app.scrollToSeries = true;
+        })
+        .catch(app.ExecuteLookup);   // Parent study was deleted
+    }, 
+    OpenSeries: function(series) {
+      app.currentStudy = series[DICOM_TAG_STUDY_INSTANCE_UID].Value;
+      app.LoadSeriesOfCurrentStudy();
+    },
+    OpenSeriesDetails: function(series) {
+      app.seriesTags = Object.keys(series).map(i => {
+        var item = series[i];
+        item['Tag'] = i;
+        return item;
+      });
+      
+      app.$refs['series-details'].show();
+    },
+    OpenSeriesPreview: function(series) {
+      axios
+        .post('../../servers/' + app.lookup.server + '/get', {
+          'Uri' : ('/studies/' + app.currentStudy + '/series/' + 
+                   series[DICOM_TAG_SERIES_INSTANCE_UID].Value + '/instances')
+        })
+        .then(response => {
+          var instance = response.data[Math.floor(response.data.length / 2)];
+
+          axios
+            .post('../../servers/' + app.lookup.server + '/get', {
+              'Uri' : ('/studies/' + app.currentStudy + '/series/' + 
+                       series[DICOM_TAG_SERIES_INSTANCE_UID].Value + '/instances/' +
+                       instance[DICOM_TAG_SOP_INSTANCE_UID].Value + '/rendered')
+            }, {
+              responseType: 'arraybuffer'
+            })
+            .then(response => {
+              // https://github.com/axios/axios/issues/513
+              var image = btoa(new Uint8Array(response.data)
+                               .reduce((data, byte) => data + String.fromCharCode(byte), ''));
+              app.preview = ("data:" + 
+                             response.headers['content-type'].toLowerCase() + 
+                             ";base64," + image);
+              app.previewFailure = false;
+            })
+            .catch(response => {
+              app.previewFailure = true;
+            })
+              .finally(function() {
+                app.$refs['series-preview'].show();
+              })
+        });
+    },
+    ConfirmDeleteSeries: function(series) {
+      app.seriesToDelete = series;
+      app.$bvModal.show('series-delete-confirm');
+    },
+    ExecuteDeleteSeries: function(series) {
+      axios
+        .post('../../servers/' + app.lookup.server + '/delete', {
+          'Level': 'Series',
+          'StudyInstanceUID': app.currentStudy,
+          'SeriesInstanceUID': app.seriesToDelete[DICOM_TAG_SERIES_INSTANCE_UID].Value
+        })
+        .then(app.LoadSeriesOfCurrentStudy);
+    }
+  },
+
+  updated: function () {
+    this.$nextTick(function () {
+      // Code that will run only after the
+      // entire view has been re-rendered
+
+      if (app.scrollToStudies) {
+        app.scrollToStudies = false;
+        app.ScrollToRef('studies-top');
+      }
+
+      if (app.scrollToSeries) {
+        app.scrollToSeries = false;
+        app.ScrollToRef('series-top');
+      }
+    })
+  }
+});
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/WebApplication/index.html	Tue Jun 18 18:38:30 2019 +0200
@@ -0,0 +1,209 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+
+    <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>
+   
+    <!-- CSS style to truncate long text in tables, provided they have
+         class "table-layout:fixed;" or attribute ":fixed=true" -->
+    <style>
+      table td { 
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      }
+    </style>
+    
+  </head>
+  <body>
+    <div class="container" id="app">
+      <p style="height:1em"></p>
+
+      <div class="jumbotron">
+        <div class="row">
+          <div class="col-sm-8">
+            <h1 class="display-4">DICOMweb client</h1>
+            <p class="lead">
+              This is a simple interface to the DICOMweb servers that are
+              configured in Orthanc.
+            </p>
+            <p>
+              <a class="btn btn-primary btn-lg"
+                 href="https://book.orthanc-server.com/plugins/dicomweb.html"
+                 target="_blank" role="button">Open documentation</a>
+            </p>
+          </div>
+          <div class="col-sm-4">
+            <img class="img-fluid" alt="Orthanc" src="../libs/img/OrthancLogo.png" />
+          </div>
+        </div>
+      </div>
+
+
+
+      <!-- LOOKUP -->
+
+      <div class="row">
+        <b-alert variant="danger" dismissible v-model="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>
+          <p class="pull-right">
+          <b-button type="submit" variant="success" @click="OnLookup" 
+                    size="lg">Do lookup</b-button>
+          <b-button type="success" variant="primary" @click="OnAllStudies" 
+                    size="lg">All studies</b-button>
+          <b-button type="reset" variant="outline-danger" @click="OnReset" 
+                    size="lg">Reset</b-button>
+          </p>
+        </b-form>
+      </div>
+      <hr/>
+
+
+      <!-- STUDIES -->
+
+      <div ref="studies-top"></div>
+
+      <div class="row" v-show="showStudies">
+        <h1>Studies</h1>
+      </div>
+      <div class="row" v-show="showStudies">
+        <b-alert variant="warning" dismissible v-model="showTruncatedStudies">
+          More than {{ maxResults }} matching studies, results have been truncated!
+        </b-alert>
+      </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 title="Retrieve study using WADO-RS"><i class="fa fa-cloud-download"></i></b-button>
+            <b-button @click="ConfirmDeleteStudy(data.item)"
+                      v-if="serversInfo[lookup.server].HasDelete" title="Delete remote study">
+              <i class="fa fa-times"></i>
+            </b-button>
+          </template>
+        </b-table>
+
+        <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>
+        </b-modal>
+
+        <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>
+        </b-modal>
+      </div>
+
+
+      <!-- SERIES -->
+
+      <div ref="series-top"></div>
+
+      <div class="row" v-show="showSeries">
+        <h1>Series</h1>
+      </div>
+      <div class="row" v-show="showSeries">
+        <b-pagination v-model="currentPage" :per-page="perPage" :total-rows="seriesCount"></b-pagination>
+        <b-table striped hover :current-page="currentPage" :per-page="perPage"
+                 :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 title="Retrieve series using WADO-RS"><i class="fa fa-cloud-download"></i></b-button>
+            <b-button @click="ConfirmDeleteSeries(data.item)"
+                      v-if="serversInfo[lookup.server].HasDelete" title="Delete remote series">
+              <i class="fa fa-times"></i>
+            </b-button>
+          </template>
+        </b-table>
+
+        <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>
+        </b-modal>
+
+        <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>
+        </b-modal>
+
+        <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>
+        </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/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>
+</html>