changeset 5268:cdeb7f027c1b db-protobuf

integration mainline->db-protobuf
author Sebastien Jodogne <s.jodogne@gmail.com>
date Wed, 12 Apr 2023 18:04:24 +0200
parents a856daf71745 (diff) 043bf0958614 (current diff)
children c3f425e78539 39180a8ce03c
files OrthancServer/CMakeLists.txt
diffstat 38 files changed, 1532 insertions(+), 285 deletions(-) [+]
line wrap: on
line diff
--- a/NEWS	Wed Apr 12 18:04:05 2023 +0200
+++ b/NEWS	Wed Apr 12 18:04:24 2023 +0200
@@ -1,11 +1,21 @@
 Pending changes in the mainline
 ===============================
 
+General
+-------
+
+* Support for labels associated with patients, studies, series, and instances
+
 REST API
 --------
 
 * API version upgraded to 20
-* /system: added "UserMetadata"
+* New URIs "/.../{id}/labels/{label}" to test/set/remove labels
+* "/patients/{id}", "/studies/{id}", "/series/{id}" and "/instances/{id}"
+  contain the "Labels" field
+* "/tools/find" now accepts the "Labels" and "LabelsConstraint" arguments
+* "/tools/labels" lists all the labels that are associated with any resource
+* "/system": added "UserMetadata" and "HasLabels"
 
 Plugins
 -------
@@ -16,6 +26,7 @@
 Orthanc Explorer
 ----------------
 
+* Added support for labels
 * Added buttons to copy the URL of ZIP archives and DICOM files to the clipboard
 
 Maintenance
@@ -23,7 +34,7 @@
 
 * Enforce the existence of the patient/study/instance while creating its archive
 * Security: New configuration option "RestApiWriteToFileSystemEnabled"
-  to allow "/instances/../export" that is now disabled by default
+  to allow "/instances/../export" (the latter is now disabled by default)
 * Fix issue 214: VOILUTSequence is not returned in Wado-RS
 * Fix /tools/reset crashing when ExtraMainDicomTags were defined
 * Fix Housekeeper plugin infinite loop if Orthanc is empty.
@@ -45,7 +56,7 @@
   AcceptedTransferSyntaxes.
 * Made the default SQLite DB more robust wrt future updates like
   adding new columns in DB.
-* Made the HTTP Client errors more verbose by including the url in the logs.
+* Made the HTTP Client errors more verbose by including the URL in the logs.
 * Optimization: now using multiple threads to transcode files for
   asynchronous download of studies archive.
 * New configuration "KeepAliveTimeout" with a default value of 1 second.
@@ -156,7 +167,7 @@
 * Housekeeper plugin: Fix resume of previous processing
 * Added missing MOVEPatientRootQueryRetrieveInformationModel in 
   DicomControlUserConnection::SetupPresentationContexts()
-* Improved HttpClient error logging (add method + url)
+* Improved HttpClient error logging (add method + URL)
 
 REST API
 --------
--- a/OrthancFramework/Sources/SerializationToolbox.cpp	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancFramework/Sources/SerializationToolbox.cpp	Wed Apr 12 18:04:24 2023 +0200
@@ -72,6 +72,21 @@
   }
 
 
+  std::string SerializationToolbox::ReadString(const Json::Value& value,
+                                               const std::string& field,
+                                               const std::string& defaultValue)
+  {
+    if (value.isMember(field.c_str()))
+    {
+      return ReadString(value, field);
+    }
+    else
+    {
+      return defaultValue;
+    }
+  }
+
+
   int SerializationToolbox::ReadInteger(const Json::Value& value,
                                         const std::string& field)
   {
--- a/OrthancFramework/Sources/SerializationToolbox.h	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancFramework/Sources/SerializationToolbox.h	Wed Apr 12 18:04:24 2023 +0200
@@ -38,6 +38,10 @@
     static std::string ReadString(const Json::Value& value,
                                   const std::string& field);
 
+    static std::string ReadString(const Json::Value& value,
+                                  const std::string& field,
+                                  const std::string& defaultValue);
+
     static int ReadInteger(const Json::Value& value,
                            const std::string& field);
 
--- a/OrthancFramework/UnitTestsSources/DicomMapTests.cpp	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancFramework/UnitTestsSources/DicomMapTests.cpp	Wed Apr 12 18:04:24 2023 +0200
@@ -716,7 +716,7 @@
     DicomMap sequencesOnly;
     m.ExtractSequences(sequencesOnly);
 
-    ASSERT_EQ(1, sequencesOnly.GetSize());
+    ASSERT_EQ(1u, sequencesOnly.GetSize());
     ASSERT_TRUE(sequencesOnly.HasTag(0x0008, 0x1111));
     ASSERT_TRUE(sequencesOnly.GetValue(0x0008, 0x1111).GetSequenceContent()[0].isMember("0008,1150"));
 
@@ -724,7 +724,7 @@
     DicomMap sequencesCopy;
     sequencesCopy.SetValue(0x0008, 0x1111, sequencesOnly.GetValue(0x0008, 0x1111));
 
-    ASSERT_EQ(1, sequencesCopy.GetSize());
+    ASSERT_EQ(1u, sequencesCopy.GetSize());
     ASSERT_TRUE(sequencesCopy.HasTag(0x0008, 0x1111));
     ASSERT_TRUE(sequencesCopy.GetValue(0x0008, 0x1111).GetSequenceContent()[0].isMember("0008,1150"));
   }
--- a/OrthancServer/CMakeLists.txt	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/CMakeLists.txt	Wed Apr 12 18:04:24 2023 +0200
@@ -228,16 +228,15 @@
 #####################################################################
 
 set(ORTHANC_EMBEDDED_FILES
-  CONFIGURATION_SAMPLE         ${CMAKE_SOURCE_DIR}/Resources/Configuration.json
-  DICOM_CONFORMANCE_STATEMENT  ${CMAKE_SOURCE_DIR}/Resources/DicomConformanceStatement.txt
-  FONT_UBUNTU_MONO_BOLD_16     ${CMAKE_SOURCE_DIR}/Resources/Fonts/UbuntuMonoBold-16.json
-  LUA_TOOLBOX                  ${CMAKE_SOURCE_DIR}/Resources/Toolbox.lua
-  PREPARE_DATABASE             ${CMAKE_SOURCE_DIR}/Sources/Database/PrepareDatabase.sql
-  UPGRADE_DATABASE_3_TO_4      ${CMAKE_SOURCE_DIR}/Sources/Database/Upgrade3To4.sql
-  UPGRADE_DATABASE_4_TO_5      ${CMAKE_SOURCE_DIR}/Sources/Database/Upgrade4To5.sql
-
-  INSTALL_TRACK_ATTACHMENTS_SIZE
-  ${CMAKE_SOURCE_DIR}/Sources/Database/InstallTrackAttachmentsSize.sql
+  CONFIGURATION_SAMPLE            ${CMAKE_SOURCE_DIR}/Resources/Configuration.json
+  DICOM_CONFORMANCE_STATEMENT     ${CMAKE_SOURCE_DIR}/Resources/DicomConformanceStatement.txt
+  FONT_UBUNTU_MONO_BOLD_16        ${CMAKE_SOURCE_DIR}/Resources/Fonts/UbuntuMonoBold-16.json
+  LUA_TOOLBOX                     ${CMAKE_SOURCE_DIR}/Resources/Toolbox.lua
+  PREPARE_DATABASE                ${CMAKE_SOURCE_DIR}/Sources/Database/PrepareDatabase.sql
+  UPGRADE_DATABASE_3_TO_4         ${CMAKE_SOURCE_DIR}/Sources/Database/Upgrade3To4.sql
+  UPGRADE_DATABASE_4_TO_5         ${CMAKE_SOURCE_DIR}/Sources/Database/Upgrade4To5.sql
+  INSTALL_TRACK_ATTACHMENTS_SIZE  ${CMAKE_SOURCE_DIR}/Sources/Database/InstallTrackAttachmentsSize.sql
+  INSTALL_LABELS_TABLE            ${CMAKE_SOURCE_DIR}/Sources/Database/InstallLabelsTable.sql
   )
 
 if (STANDALONE_BUILD)
--- a/OrthancServer/OrthancExplorer/explorer.css	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/OrthancExplorer/explorer.css	Wed Apr 12 18:04:24 2023 +0200
@@ -63,3 +63,22 @@
 .switch-container .ui-slider-switch {
     width: 100%;
 }
+
+.label {
+    display: inline-block;
+    background-color: gray;
+    margin: 5px;
+    padding: 5px;
+    border-radius: 10px;
+}
+
+.label button {
+    background-color: transparent;
+    border: 0px;
+    cursor: pointer;
+    border-radius: 10px;
+}
+
+.label button:hover {
+    background-color: lightgray;
+}
--- a/OrthancServer/OrthancExplorer/explorer.html	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/OrthancExplorer/explorer.html	Wed Apr 12 18:04:24 2023 +0200
@@ -81,6 +81,11 @@
           <input type="text" name="lookup-study-description" id="lookup-study-description" value="" />
         </div>
 
+        <div data-role="fieldcontain" id="lookup-study-labels-div">
+          <label for="lookup-study-labels">Labels:</label>
+          <input type="text" name="lookup-study-description" id="lookup-study-labels" value="" />
+        </div>
+
         <div data-role="fieldcontain">
           <label for="lookup-study-date">Study Date:</label>
           <select name="lookup-study-date" id="lookup-study-date">
@@ -429,8 +434,8 @@
                 <a href="#" id="instance-modified-from">Before modification</a>
               </li>
               <li data-icon="arrow-d"><a href="#" id="instance-download-dicom">Download the DICOM file</a></li>
-              <li data-icon="arrow-d"><a href="#" id="instance-download-link">Copy link to the DICOM file</a></li>
               <li data-icon="arrow-d"><a href="#" id="instance-download-json">Download the JSON file</a></li>
+              <li data-icon="gear"><a href="#" id="instance-download-link">Copy link to the DICOM file</a></li>
               <li data-icon="search"><a href="#" id="instance-preview">Preview the instance</a></li>
             </ul>
           </div>
--- a/OrthancServer/OrthancExplorer/explorer.js	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/OrthancExplorer/explorer.js	Wed Apr 12 18:04:24 2023 +0200
@@ -65,6 +65,12 @@
 var MODIFIED_FROM = 'ModifiedFrom';
 
 
+function IsAlphanumeric(s)
+{
+  return s.match(/^[0-9a-zA-Z]+$/);
+}
+
+
 function DeepCopy(obj)
 {
   return jQuery.extend(true, {}, obj);
@@ -479,6 +485,14 @@
       } else {
         $('.warning-insecure').hide();
       }
+
+      // New in Orthanc 1.12.0
+      if ('HasLabels' in s &&
+          s.HasLabels) {
+        $('#lookup-study-labels-div').show();
+      } else {
+        $('#lookup-study-labels-div').hide();
+      }
     }
   });
 });
@@ -550,6 +564,10 @@
       else if (input.id == 'lookup-study-date-specific') {
         // Ignore
       }
+      else if (input.id == 'lookup-study-labels') {
+        // New in Orthanc 1.12.0
+        lookup['Labels'] = input.value.split(' ');
+      }
       else {
         console.error('Unknown lookup field: ' + input.id);
       }
@@ -701,9 +719,94 @@
     }
     target.listview('refresh');
   });
+}
 
+
+
+function RefreshLabels(nodeLabels, resourceLevel, resourceId)
+{
+  GetResource('/' + resourceLevel + '/' + resourceId + '/labels', function(labels) {
+    nodeLabels.empty();
+    
+    if (labels.length > 0) {
+      nodeLabels.css('display', 'block');
+
+      for (var i = 0; i < labels.length; i++) {
+        var removeButton = $('<button>').text('X').attr('title', 'Remove label "' + labels[i] + '"');
+
+        removeButton.click({
+          label : labels[i],
+          nodeLabels : nodeLabels
+        }, function(s) {
+          $.ajax({
+            url: '../' + resourceLevel + '/' + resourceId + '/labels/' + s.data.label,
+            dataType: 'json',
+            type: 'DELETE',
+            success: function(ss) {
+              RefreshLabels(nodeLabels, resourceLevel, resourceId);
+            }
+          });
+        });
+        
+        nodeLabels.append($('<span>').text(labels[i] + ' ').addClass('label').append(removeButton));
+      }
+    } else {
+      nodeLabels.css('display', 'none');
+    }
+  });
 }
 
+
+function ConfigureLabels(target, system, resourceLevel, resourceId)
+{
+  if (system.HasLabels === true) {
+    var nodeLabels = $('<li>').append($('<div>'));
+    var addLabelButton = $('<a>').text('Add label');
+
+    RefreshLabels(nodeLabels, resourceLevel, resourceId);
+
+    addLabelButton.click(function(s) {
+      $('#dialog').simpledialog2({
+        mode: 'button',
+        animate: false,
+        headerText: 'Add label',
+        headerClose: true,
+        buttonPrommpt: 'Enter the new label',
+        buttonInput: true,
+        width: '100%',
+        buttons : {
+          'OK': {
+            click: function () {
+              var label = $.mobile.sdLastInput;
+              if (label.length > 0) {
+                if (IsAlphanumeric(label)) {
+                  $.ajax({
+                    url: '../' + resourceLevel + '/' + resourceId + '/labels/' + label,
+                    dataType: 'json',
+                    type: 'PUT',
+                    data: '',
+                    success: function(ss) {
+                      RefreshLabels(nodeLabels, resourceLevel, resourceId);
+                    }
+                  });
+                } else {
+                  alert('Error: Labels can only contain alphanumeric characters');
+                }
+              }
+            }
+          },
+        }
+      });
+    });
+
+    target
+      .append('<li data-role="list-divider">Labels</li>')
+      .append(nodeLabels)
+      .append($('<li>').attr('data-icon', 'plus').append(addLabelButton));
+  }
+}
+
+
 function RefreshPatient()
 {
   var pageData, target, v;
@@ -711,53 +814,57 @@
   if ($.mobile.pageData) {
     pageData = DeepCopy($.mobile.pageData);
 
-    GetResource('/patients/' + pageData.uuid + '?full', function(patient) {
-      GetResource('/patients/' + pageData.uuid + '/studies?full', function(studies) {
-        SortOnDicomTag(studies, STUDY_DATE, false, true);
+    GetResource('/system', function(system) {
+      GetResource('/patients/' + pageData.uuid + '?full', function(patient) {
+        GetResource('/patients/' + pageData.uuid + '/studies?full', function(studies) {
+          SortOnDicomTag(studies, STUDY_DATE, false, true);
 
-        $('#patient-info li').remove();
-        $('#patient-info')
-          .append('<li data-role="list-divider">Patient</li>')
-          .append(FormatPatient(patient))
-          .listview('refresh');
+          $('#patient-info li').remove();
+
+          var info = $('#patient-info')
+              .append('<li data-role="list-divider">Patient</li>')
+              .append(FormatPatient(patient));
+          ConfigureLabels(info, system, 'patients', patient.ID);
+          info.listview('refresh');
 
-        target = $('#list-studies');
-        $('li', target).remove();
-        
-        for (var i = 0; i < studies.length; i++) {
-          if (i == 0 ||
-              GetMainDicomTag(studies[i].MainDicomTags, STUDY_DATE) !=
-              GetMainDicomTag(studies[i - 1].MainDicomTags, STUDY_DATE))
-          {
-            target.append($('<li>')
-                          .attr('data-role', 'list-divider')
-                          .text(FormatDicomDate(GetMainDicomTag(studies[i].MainDicomTags, STUDY_DATE))));
+          target = $('#list-studies');
+          $('li', target).remove();
+          
+          for (var i = 0; i < studies.length; i++) {
+            if (i == 0 ||
+                GetMainDicomTag(studies[i].MainDicomTags, STUDY_DATE) !=
+                GetMainDicomTag(studies[i - 1].MainDicomTags, STUDY_DATE))
+            {
+              target.append($('<li>')
+                            .attr('data-role', 'list-divider')
+                            .text(FormatDicomDate(GetMainDicomTag(studies[i].MainDicomTags, STUDY_DATE))));
+            }
+
+            target.append(FormatStudy(studies[i], '#study?uuid=' + studies[i].ID));
           }
 
-          target.append(FormatStudy(studies[i], '#study?uuid=' + studies[i].ID));
-        }
+          SetupAnonymizedOrModifiedFrom('#patient-anonymized-from', patient, 'patient', ANONYMIZED_FROM);
+          SetupAnonymizedOrModifiedFrom('#patient-modified-from', patient, 'patient', MODIFIED_FROM);
+          SetupAttachments('#patient-access', 'patient-attachment', pageData.uuid, 'patients');
 
-        SetupAnonymizedOrModifiedFrom('#patient-anonymized-from', patient, 'patient', ANONYMIZED_FROM);
-        SetupAnonymizedOrModifiedFrom('#patient-modified-from', patient, 'patient', MODIFIED_FROM);
-        SetupAttachments('#patient-access', 'patient-attachment', pageData.uuid, 'patients');
-
-        target.listview('refresh');
+          target.listview('refresh');
 
-        // Check whether this patient is protected
-        $.ajax({
-          url: '../patients/' + pageData.uuid + '/protected',
-          type: 'GET',
-          dataType: 'text',
-          async: false,
-          cache: false,
-          success: function (s) {
-            v = (s == '1') ? 'on' : 'off';
-            $('#protection').val(v).slider('refresh');
-          }
+          // Check whether this patient is protected
+          $.ajax({
+            url: '../patients/' + pageData.uuid + '/protected',
+            type: 'GET',
+            dataType: 'text',
+            async: false,
+            cache: false,
+            success: function (s) {
+              v = (s == '1') ? 'on' : 'off';
+              $('#protection').val(v).slider('refresh');
+            }
+          });
+
+          currentPage = 'patient';
+          currentUuid = pageData.uuid;
         });
-
-        currentPage = 'patient';
-        currentUuid = pageData.uuid;
       });
     });
   }
@@ -771,43 +878,47 @@
   if ($.mobile.pageData) {
     pageData = DeepCopy($.mobile.pageData);
 
-    GetResource('/studies/' + pageData.uuid + '?full', function(study) {
-      GetResource('/patients/' + study.ParentPatient + '?full', function(patient) {
-        GetResource('/studies/' + pageData.uuid + '/series?full', function(series) {
-          SortOnDicomTag(series, SERIES_DATE, false, true);
+    GetResource('/system', function(system) {
+      GetResource('/studies/' + pageData.uuid + '?full', function(study) {
+        GetResource('/patients/' + study.ParentPatient + '?full', function(patient) {
+          GetResource('/studies/' + pageData.uuid + '/series?full', function(series) {
+            SortOnDicomTag(series, SERIES_DATE, false, true);
+            
+            $('#study .patient-link').attr('href', '#patient?uuid=' + patient.ID);
+            $('#study-info li').remove();
 
-          $('#study .patient-link').attr('href', '#patient?uuid=' + patient.ID);
-          $('#study-info li').remove();
-          $('#study-info')
-            .append('<li data-role="list-divider">Patient</li>')
-            .append(FormatPatient(patient, '#patient?uuid=' + patient.ID, true))
-            .append('<li data-role="list-divider">Study</li>')
-            .append(FormatStudy(study))
-            .listview('refresh');
+            var info = $('#study-info')
+                .append('<li data-role="list-divider">Patient</li>')
+                .append(FormatPatient(patient, '#patient?uuid=' + patient.ID, true))
+                .append('<li data-role="list-divider">Study</li>')
+                .append(FormatStudy(study));
+            ConfigureLabels(info, system, 'studies', study.ID);
+            info.listview('refresh');
 
-          SetupAnonymizedOrModifiedFrom('#study-anonymized-from', study, 'study', ANONYMIZED_FROM);
-          SetupAnonymizedOrModifiedFrom('#study-modified-from', study, 'study', MODIFIED_FROM);
-          SetupAttachments('#study-access', 'study-attachment', pageData.uuid, 'studies');
+            SetupAnonymizedOrModifiedFrom('#study-anonymized-from', study, 'study', ANONYMIZED_FROM);
+            SetupAnonymizedOrModifiedFrom('#study-modified-from', study, 'study', MODIFIED_FROM);
+            SetupAttachments('#study-access', 'study-attachment', pageData.uuid, 'studies');
 
-          target = $('#list-series');
-          $('li', target).remove();
-          for (var i = 0; i < series.length; i++) {
-            if (i == 0 ||
-                GetMainDicomTag(series[i].MainDicomTags, SERIES_DATE) !=
-                GetMainDicomTag(series[i - 1].MainDicomTags, SERIES_DATE))
-            {
-              target.append($('<li>')
-                            .attr('data-role', 'list-divider')
-                            .text(FormatDicomDate(GetMainDicomTag(series[i].MainDicomTags, SERIES_DATE))));
+            target = $('#list-series');
+            $('li', target).remove();
+            for (var i = 0; i < series.length; i++) {
+              if (i == 0 ||
+                  GetMainDicomTag(series[i].MainDicomTags, SERIES_DATE) !=
+                  GetMainDicomTag(series[i - 1].MainDicomTags, SERIES_DATE))
+              {
+                target.append($('<li>')
+                              .attr('data-role', 'list-divider')
+                              .text(FormatDicomDate(GetMainDicomTag(series[i].MainDicomTags, SERIES_DATE))));
+              }
+              
+              target.append(FormatSeries(series[i], '#series?uuid=' + series[i].ID));
             }
-            
-            target.append(FormatSeries(series[i], '#series?uuid=' + series[i].ID));
-          }
-          target.listview('refresh');
+            target.listview('refresh');
 
 
-          currentPage = 'study';
-          currentUuid = pageData.uuid;
+            currentPage = 'study';
+            currentUuid = pageData.uuid;
+          });
         });
       });
     });
@@ -822,38 +933,41 @@
   if ($.mobile.pageData) {
     pageData = DeepCopy($.mobile.pageData);
 
-    GetResource('/series/' + pageData.uuid + '?full', function(series) {
-      GetResource('/studies/' + series.ParentStudy + '?full', function(study) {
-        GetResource('/patients/' + study.ParentPatient + '?full', function(patient) {
-          GetResource('/series/' + pageData.uuid + '/instances?full', function(instances) {
-            Sort(instances, function(x) { return x.IndexInSeries; }, true, false);
+    GetResource('/system', function(system) {
+      GetResource('/series/' + pageData.uuid + '?full', function(series) {
+        GetResource('/studies/' + series.ParentStudy + '?full', function(study) {
+          GetResource('/patients/' + study.ParentPatient + '?full', function(patient) {
+            GetResource('/series/' + pageData.uuid + '/instances?full', function(instances) {
+              Sort(instances, function(x) { return x.IndexInSeries; }, true, false);
 
-            $('#series .patient-link').attr('href', '#patient?uuid=' + patient.ID);
-            $('#series .study-link').attr('href', '#study?uuid=' + study.ID);
+              $('#series .patient-link').attr('href', '#patient?uuid=' + patient.ID);
+              $('#series .study-link').attr('href', '#study?uuid=' + study.ID);
 
-            $('#series-info li').remove();
-            $('#series-info')
-              .append('<li data-role="list-divider">Patient</li>')
-              .append(FormatPatient(patient, '#patient?uuid=' + patient.ID, true))
-              .append('<li data-role="list-divider">Study</li>')
-              .append(FormatStudy(study, '#study?uuid=' + study.ID, true))
-              .append('<li data-role="list-divider">Series</li>')
-              .append(FormatSeries(series))
-              .listview('refresh');
+              $('#series-info li').remove();
+              var info = $('#series-info')
+                  .append('<li data-role="list-divider">Patient</li>')
+                  .append(FormatPatient(patient, '#patient?uuid=' + patient.ID, true))
+                  .append('<li data-role="list-divider">Study</li>')
+                  .append(FormatStudy(study, '#study?uuid=' + study.ID, true))
+                  .append('<li data-role="list-divider">Series</li>')
+                  .append(FormatSeries(series));
+              ConfigureLabels(info, system, 'series', series.ID);
+              info.listview('refresh');
 
-            SetupAnonymizedOrModifiedFrom('#series-anonymized-from', series, 'series', ANONYMIZED_FROM);
-            SetupAnonymizedOrModifiedFrom('#series-modified-from', series, 'series', MODIFIED_FROM);
-            SetupAttachments('#series-access', 'series-attachment', pageData.uuid, 'series');
+              SetupAnonymizedOrModifiedFrom('#series-anonymized-from', series, 'series', ANONYMIZED_FROM);
+              SetupAnonymizedOrModifiedFrom('#series-modified-from', series, 'series', MODIFIED_FROM);
+              SetupAttachments('#series-access', 'series-attachment', pageData.uuid, 'series');
 
-            target = $('#list-instances');
-            $('li', target).remove();
-            for (var i = 0; i < instances.length; i++) {
-              target.append(FormatInstance(instances[i], '#instance?uuid=' + instances[i].ID));
-            }
-            target.listview('refresh');
+              target = $('#list-instances');
+              $('li', target).remove();
+              for (var i = 0; i < instances.length; i++) {
+                target.append(FormatInstance(instances[i], '#instance?uuid=' + instances[i].ID));
+              }
+              target.listview('refresh');
 
-            currentPage = 'series';
-            currentUuid = pageData.uuid;
+              currentPage = 'series';
+              currentUuid = pageData.uuid;
+            });
           });
         });
       });
@@ -935,51 +1049,54 @@
   if ($.mobile.pageData) {
     pageData = DeepCopy($.mobile.pageData);
 
-    GetResource('/instances/' + pageData.uuid + '?full', function(instance) {
-      GetResource('/series/' + instance.ParentSeries + '?full', function(series) {
-        GetResource('/studies/' + series.ParentStudy + '?full', function(study) {
-          GetResource('/patients/' + study.ParentPatient + '?full', function(patient) {
+    GetResource('/system', function(system) {
+      GetResource('/instances/' + pageData.uuid + '?full', function(instance) {
+        GetResource('/series/' + instance.ParentSeries + '?full', function(series) {
+          GetResource('/studies/' + series.ParentStudy + '?full', function(study) {
+            GetResource('/patients/' + study.ParentPatient + '?full', function(patient) {
 
-            $('#instance .patient-link').attr('href', '#patient?uuid=' + patient.ID);
-            $('#instance .study-link').attr('href', '#study?uuid=' + study.ID);
-            $('#instance .series-link').attr('href', '#series?uuid=' + series.ID);
-            
-            $('#instance-info li').remove();
-            $('#instance-info')
-              .append('<li data-role="list-divider">Patient</li>')
-              .append(FormatPatient(patient, '#patient?uuid=' + patient.ID, true))
-              .append('<li data-role="list-divider">Study</li>')
-              .append(FormatStudy(study, '#study?uuid=' + study.ID, true))
-              .append('<li data-role="list-divider">Series</li>')
-              .append(FormatSeries(series, '#series?uuid=' + series.ID, true))
-              .append('<li data-role="list-divider">Instance</li>')
-              .append(FormatInstance(instance))
-              .listview('refresh');
+              $('#instance .patient-link').attr('href', '#patient?uuid=' + patient.ID);
+              $('#instance .study-link').attr('href', '#study?uuid=' + study.ID);
+              $('#instance .series-link').attr('href', '#series?uuid=' + series.ID);
+              
+              $('#instance-info li').remove();
+              var info = $('#instance-info')
+                  .append('<li data-role="list-divider">Patient</li>')
+                  .append(FormatPatient(patient, '#patient?uuid=' + patient.ID, true))
+                  .append('<li data-role="list-divider">Study</li>')
+                  .append(FormatStudy(study, '#study?uuid=' + study.ID, true))
+                  .append('<li data-role="list-divider">Series</li>')
+                  .append(FormatSeries(series, '#series?uuid=' + series.ID, true))
+                  .append('<li data-role="list-divider">Instance</li>')
+                  .append(FormatInstance(instance));
+              ConfigureLabels(info, system, 'instances', instance.ID);
+              info.listview('refresh');
 
-            GetResource('/instances/' + instance.ID + '/tags', function(s) {
-              $('#dicom-tree').tree('loadData', ConvertForTree(s));
-            });
+              GetResource('/instances/' + instance.ID + '/tags', function(s) {
+                $('#dicom-tree').tree('loadData', ConvertForTree(s));
+              });
 
-            GetResource('/instances/' + instance.ID + '/header', function(s) {
-              $('#dicom-metaheader').tree('loadData', ConvertForTree(s));
-            });
+              GetResource('/instances/' + instance.ID + '/header', function(s) {
+                $('#dicom-metaheader').tree('loadData', ConvertForTree(s));
+              });
 
-            $('#transfer-syntax').hide();
-            GetResource('/instances/' + instance.ID + '/metadata?expand', function(s) {
-              transferSyntax = s['TransferSyntax'];
-              if (transferSyntax !== undefined) {
-                $('#transfer-syntax').show();
-                $('#transfer-syntax-text').text(transferSyntax);
-              }
+              $('#transfer-syntax').hide();
+              GetResource('/instances/' + instance.ID + '/metadata?expand', function(s) {
+                transferSyntax = s['TransferSyntax'];
+                if (transferSyntax !== undefined) {
+                  $('#transfer-syntax').show();
+                  $('#transfer-syntax-text').text(transferSyntax);
+                }
+              });
+
+              SetupAnonymizedOrModifiedFrom('#instance-anonymized-from', instance, 'instance', ANONYMIZED_FROM);
+              SetupAnonymizedOrModifiedFrom('#instance-modified-from', instance, 'instance', MODIFIED_FROM);
+
+              SetupAttachments('#instance-access', 'instance-attachment', pageData.uuid, 'instances');
+
+              currentPage = 'instance';
+              currentUuid = pageData.uuid;
             });
-
-            SetupAnonymizedOrModifiedFrom('#instance-anonymized-from', instance, 'instance', ANONYMIZED_FROM);
-            SetupAnonymizedOrModifiedFrom('#instance-modified-from', instance, 'instance', MODIFIED_FROM);
-
-            SetupAttachments('#instance-access', 'instance-attachment', pageData.uuid, 'instances');
-
-            currentPage = 'instance';
-            currentUuid = pageData.uuid;
           });
         });
       });
--- a/OrthancServer/Plugins/Engine/OrthancPluginDatabase.cpp	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/Plugins/Engine/OrthancPluginDatabase.cpp	Wed Apr 12 18:04:24 2023 +0200
@@ -565,8 +565,15 @@
                                       std::list<std::string>* instancesId,
                                       const std::vector<DatabaseConstraint>& lookup,
                                       ResourceType queryLevel,
+                                      const std::set<std::string>& labels,
+                                      LabelsConstraint labelsConstraint,
                                       uint32_t limit) ORTHANC_OVERRIDE
     {
+      if (!labels.empty())
+      {
+        throw OrthancException(ErrorCode_InternalError);  // "HasLabelsSupport()" has returned "false"
+      }
+      
       if (that_.extensions_.lookupResources == NULL)
       {
         // Fallback to compatibility mode
@@ -1413,6 +1420,33 @@
         CheckSuccess(that_.extensions_.tagMostRecentPatient(that_.payload_, patient));
       }
     }
+
+
+    virtual void AddLabel(int64_t resource,
+                          const std::string& label) ORTHANC_OVERRIDE
+    {
+      throw OrthancException(ErrorCode_InternalError);  // Not supported
+    }
+
+
+    virtual void RemoveLabel(int64_t resource,
+                             const std::string& label) ORTHANC_OVERRIDE
+    {
+      throw OrthancException(ErrorCode_InternalError);  // Not supported
+    }
+
+
+    virtual void ListLabels(std::set<std::string>& target,
+                            int64_t resource) ORTHANC_OVERRIDE
+    {
+      throw OrthancException(ErrorCode_InternalError);  // Not supported
+    }
+    
+
+    virtual void ListAllLabels(std::set<std::string>& target) ORTHANC_OVERRIDE
+    {
+      throw OrthancException(ErrorCode_InternalError);  // Not supported
+    }
   };
 
 
--- a/OrthancServer/Plugins/Engine/OrthancPluginDatabase.h	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/Plugins/Engine/OrthancPluginDatabase.h	Wed Apr 12 18:04:24 2023 +0200
@@ -113,6 +113,11 @@
       return false;  // No support for revisions in old API
     }
 
+    virtual bool HasLabelsSupport() const ORTHANC_OVERRIDE
+    {
+      return false;
+    }
+
     void AnswerReceived(const _OrthancPluginDatabaseAnswer& answer);
   };
 }
--- a/OrthancServer/Plugins/Engine/OrthancPluginDatabaseV3.cpp	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/Plugins/Engine/OrthancPluginDatabaseV3.cpp	Wed Apr 12 18:04:24 2023 +0200
@@ -800,8 +800,15 @@
                                       std::list<std::string>* instancesId, // Can be NULL if not needed
                                       const std::vector<DatabaseConstraint>& lookup,
                                       ResourceType queryLevel,
+                                      const std::set<std::string>& labels,
+                                      LabelsConstraint labelsConstraint,
                                       uint32_t limit) ORTHANC_OVERRIDE
     {
+      if (!labels.empty())
+      {
+        throw OrthancException(ErrorCode_InternalError);  // "HasLabelsSupport()" has returned "false"
+      }
+      
       std::vector<OrthancPluginDatabaseConstraint> constraints;
       std::vector< std::vector<const char*> > constraintsValues;
 
@@ -1027,6 +1034,33 @@
         return false;
       }
     }
+
+
+    virtual void AddLabel(int64_t resource,
+                          const std::string& label) ORTHANC_OVERRIDE
+    {
+      throw OrthancException(ErrorCode_InternalError);  // Not supported
+    }
+
+
+    virtual void RemoveLabel(int64_t resource,
+                             const std::string& label) ORTHANC_OVERRIDE
+    {
+      throw OrthancException(ErrorCode_InternalError);  // Not supported
+    }
+
+
+    virtual void ListLabels(std::set<std::string>& target,
+                            int64_t resource) ORTHANC_OVERRIDE
+    {
+      throw OrthancException(ErrorCode_InternalError);  // Not supported
+    }
+    
+
+    virtual void ListAllLabels(std::set<std::string>& target) ORTHANC_OVERRIDE
+    {
+      throw OrthancException(ErrorCode_InternalError);  // Not supported
+    }
   };
 
   
--- a/OrthancServer/Plugins/Engine/OrthancPluginDatabaseV3.h	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/Plugins/Engine/OrthancPluginDatabaseV3.h	Wed Apr 12 18:04:24 2023 +0200
@@ -82,6 +82,11 @@
                          IStorageArea& storageArea) ORTHANC_OVERRIDE;    
 
     virtual bool HasRevisionsSupport() const ORTHANC_OVERRIDE;
+
+    virtual bool HasLabelsSupport() const ORTHANC_OVERRIDE
+    {
+      return false;
+    }
   };
 }
 
--- a/OrthancServer/Plugins/Engine/OrthancPluginDatabaseV4.cpp	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/Plugins/Engine/OrthancPluginDatabaseV4.cpp	Wed Apr 12 18:04:24 2023 +0200
@@ -27,10 +27,12 @@
 #  error The plugin support is disabled
 #endif
 
+#include "../../../OrthancFramework/Sources/DicomParsing/FromDcmtkBridge.h"
 #include "../../../OrthancFramework/Sources/Logging.h"
 #include "../../../OrthancFramework/Sources/OrthancException.h"
 #include "../../Sources/Database/ResourcesContent.h"
 #include "../../Sources/Database/VoidDatabaseListener.h"
+#include "../../Sources/ServerToolbox.h"
 #include "PluginsEnumerations.h"
 
 #include "OrthancDatabasePlugin.pb.h"  // Auto-generated file
@@ -223,6 +225,33 @@
     }
 
 
+    void ListLabelsInternal(std::set<std::string>& target,
+                            bool isSingleResource,
+                            int64_t resource)
+    {
+      if (database_.HasLabelsSupport())
+      {
+        DatabasePluginMessages::TransactionRequest request;
+        request.mutable_list_labels()->set_single_resource(isSingleResource);
+        request.mutable_list_labels()->set_id(resource);
+
+        DatabasePluginMessages::TransactionResponse response;
+        ExecuteTransaction(response, DatabasePluginMessages::OPERATION_LIST_LABELS, request);
+
+        target.clear();
+        for (int i = 0; i < response.list_labels().labels().size(); i++)
+        {
+          target.insert(response.list_labels().labels(i));
+        }
+      }
+      else
+      {
+        // This method shouldn't have been called
+        throw OrthancException(ErrorCode_InternalError);
+      }
+    }
+    
+
   public:
     Transaction(OrthancPluginDatabaseV4& database,
                 IDatabaseListener& listener,
@@ -915,8 +944,16 @@
                                       std::list<std::string>* instancesId, // Can be NULL if not needed
                                       const std::vector<DatabaseConstraint>& lookup,
                                       ResourceType queryLevel,
+                                      const std::set<std::string>& labels,
+                                      LabelsConstraint labelsConstraint,
                                       uint32_t limit) ORTHANC_OVERRIDE
     {
+      if (!database_.HasLabelsSupport() &&
+          !labels.empty())
+      {
+        throw OrthancException(ErrorCode_InternalError);
+      }
+
       DatabasePluginMessages::TransactionRequest request;
       request.mutable_lookup_resources()->set_query_level(Convert(queryLevel));
       request.mutable_lookup_resources()->set_limit(limit);
@@ -966,6 +1003,29 @@
             throw OrthancException(ErrorCode_ParameterOutOfRange);
         }
       }
+
+      for (std::set<std::string>::const_iterator it = labels.begin(); it != labels.end(); ++it)
+      {
+        request.mutable_lookup_resources()->add_labels(*it);
+      }
+
+      switch (labelsConstraint)
+      {
+        case LabelsConstraint_All:
+          request.mutable_lookup_resources()->set_labels_constraint(DatabasePluginMessages::LABELS_CONSTRAINT_ALL);
+          break;
+            
+        case LabelsConstraint_Any:
+          request.mutable_lookup_resources()->set_labels_constraint(DatabasePluginMessages::LABELS_CONSTRAINT_ANY);
+          break;
+            
+        case LabelsConstraint_None:
+          request.mutable_lookup_resources()->set_labels_constraint(DatabasePluginMessages::LABELS_CONSTRAINT_NONE);
+          break;
+            
+        default:
+          throw OrthancException(ErrorCode_ParameterOutOfRange);
+      }
       
       DatabasePluginMessages::TransactionResponse response;
       ExecuteTransaction(response, DatabasePluginMessages::OPERATION_LOOKUP_RESOURCES, request);
@@ -1132,6 +1192,57 @@
         return false;
       }
     }
+
+    
+    virtual void AddLabel(int64_t resource,
+                          const std::string& label) ORTHANC_OVERRIDE
+    {
+      if (database_.HasLabelsSupport())
+      {
+        DatabasePluginMessages::TransactionRequest request;
+        request.mutable_add_label()->set_id(resource);
+        request.mutable_add_label()->set_label(label);
+
+        ExecuteTransaction(DatabasePluginMessages::OPERATION_ADD_LABEL, request);
+      }
+      else
+      {
+        // This method shouldn't have been called
+        throw OrthancException(ErrorCode_InternalError);
+      }
+    }
+
+
+    virtual void RemoveLabel(int64_t resource,
+                             const std::string& label) ORTHANC_OVERRIDE
+    {
+      if (database_.HasLabelsSupport())
+      {
+        DatabasePluginMessages::TransactionRequest request;
+        request.mutable_remove_label()->set_id(resource);
+        request.mutable_remove_label()->set_label(label);
+
+        ExecuteTransaction(DatabasePluginMessages::OPERATION_REMOVE_LABEL, request);
+      }
+      else
+      {
+        // This method shouldn't have been called
+        throw OrthancException(ErrorCode_InternalError);
+      }
+    }
+
+
+    virtual void ListLabels(std::set<std::string>& target,
+                            int64_t resource) ORTHANC_OVERRIDE
+    {
+      ListLabelsInternal(target, true, resource);
+    }
+
+    
+    virtual void ListAllLabels(std::set<std::string>& target) ORTHANC_OVERRIDE
+    {
+      ListLabelsInternal(target, false, -1);
+    }
   };
 
 
@@ -1146,7 +1257,8 @@
     open_(false),
     databaseVersion_(0),
     hasFlushToDisk_(false),
-    hasRevisionsSupport_(false)
+    hasRevisionsSupport_(false),
+    hasLabelsSupport_(false)
   {
     CLOG(INFO, PLUGINS) << "Identifier of this Orthanc server for the global properties "
                         << "of the custom database: \"" << serverIdentifier << "\"";
@@ -1165,6 +1277,31 @@
     definition_.finalize(definition_.backend);
   }
 
+
+  static void AddIdentifierTags(DatabasePluginMessages::Open::Request& request,
+                                ResourceType level)
+  {
+    const DicomTag* tags = NULL;
+    size_t size;
+
+    ServerToolbox::LoadIdentifiers(tags, size, level);
+
+    if (tags == NULL ||
+        size == 0)
+    {
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
+    }
+
+    for (size_t i = 0; i < size; i++)
+    {
+      DatabasePluginMessages::Open_Request_IdentifierTag* tag = request.add_identifier_tags();
+      tag->set_level(Convert(level));
+      tag->set_group(tags[i].GetGroup());
+      tag->set_element(tags[i].GetElement());
+      tag->set_name(FromDcmtkBridge::GetTagName(tags[i], ""));
+    }
+  }
+
   
   void OrthancPluginDatabaseV4::Open()
   {
@@ -1175,6 +1312,11 @@
     
     {
       DatabasePluginMessages::DatabaseRequest request;
+      AddIdentifierTags(*request.mutable_open(), ResourceType_Patient);
+      AddIdentifierTags(*request.mutable_open(), ResourceType_Study);
+      AddIdentifierTags(*request.mutable_open(), ResourceType_Series);
+      AddIdentifierTags(*request.mutable_open(), ResourceType_Instance);
+
       DatabasePluginMessages::DatabaseResponse response;
       ExecuteDatabase(response, *this, DatabasePluginMessages::OPERATION_OPEN, request);
     }
@@ -1186,9 +1328,10 @@
       databaseVersion_ = response.get_system_information().database_version();
       hasFlushToDisk_ = response.get_system_information().supports_flush_to_disk();
       hasRevisionsSupport_ = response.get_system_information().supports_revisions();
+      hasLabelsSupport_ = response.get_system_information().supports_labels();
     }
 
-    open_ = true;    
+    open_ = true;
   }
 
 
@@ -1307,4 +1450,17 @@
       return hasRevisionsSupport_;
     }
   }
+
+  
+  bool OrthancPluginDatabaseV4::HasLabelsSupport() const
+  {
+    if (!open_)
+    {
+      throw OrthancException(ErrorCode_BadSequenceOfCalls);
+    }
+    else
+    {
+      return hasLabelsSupport_;
+    }
+  }
 }
--- a/OrthancServer/Plugins/Engine/OrthancPluginDatabaseV4.h	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/Plugins/Engine/OrthancPluginDatabaseV4.h	Wed Apr 12 18:04:24 2023 +0200
@@ -44,6 +44,7 @@
     unsigned int                            databaseVersion_;
     bool                                    hasFlushToDisk_;
     bool                                    hasRevisionsSupport_;
+    bool                                    hasLabelsSupport_;
 
     void CheckSuccess(OrthancPluginErrorCode code) const;
 
@@ -93,6 +94,8 @@
                          IStorageArea& storageArea) ORTHANC_OVERRIDE;    
 
     virtual bool HasRevisionsSupport() const ORTHANC_OVERRIDE;
+
+    virtual bool HasLabelsSupport() const ORTHANC_OVERRIDE;
   };
 }
 
--- a/OrthancServer/Plugins/Include/orthanc/OrthancDatabasePlugin.proto	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/Plugins/Include/orthanc/OrthancDatabasePlugin.proto	Wed Apr 12 18:04:24 2023 +0200
@@ -71,6 +71,12 @@
   CONSTRAINT_LIST = 4;
 }
 
+enum LabelsConstraintType {
+  LABELS_CONSTRAINT_ALL = 0;
+  LABELS_CONSTRAINT_ANY = 1;
+  LABELS_CONSTRAINT_NONE = 2;
+}
+
 message ServerIndexChange {
   int64         seq = 1;
   int32         change_type = 2;   // opaque "ChangeType" in Orthanc
@@ -129,11 +135,19 @@
     uint32 database_version = 1;
     bool supports_flush_to_disk = 2;
     bool supports_revisions = 3;
+    bool supports_labels = 4;
   }
 }
 
 message Open {
   message Request {
+    message IdentifierTag {
+      ResourceType level = 1;
+      uint32 group = 2;
+      uint32 element = 3;
+      string name = 4;
+    }
+    repeated IdentifierTag identifier_tags = 1;
   }
   message Response {
   }
@@ -258,6 +272,9 @@
   OPERATION_GET_CHILDREN_METADATA = 42;
   OPERATION_GET_LAST_CHANGE_INDEX = 43;
   OPERATION_LOOKUP_RESOURCE_AND_PARENT = 44;
+  OPERATION_ADD_LABEL = 45;        // New in Orthanc 1.12.0
+  OPERATION_REMOVE_LABEL = 46;     // New in Orthanc 1.12.0
+  OPERATION_LIST_LABELS = 47;      // New in Orthanc 1.12.0
 }
 
 message Rollback {
@@ -654,6 +671,8 @@
     ResourceType query_level = 2;
     uint32 limit = 3;
     bool retrieve_instances_ids = 4;
+    repeated string labels = 5;                  // New in Orthanc 1.12.0
+    LabelsConstraintType labels_constraint = 6;  // New in Orthanc 1.12.0
   }
   message Response {
     repeated string resources_ids = 1;
@@ -735,6 +754,34 @@
   }
 }
 
+message AddLabel {
+  message Request {
+    int64 id = 1;
+    string label = 2;
+  }
+  message Response {
+  }
+}
+
+message RemoveLabel {
+  message Request {
+    int64 id = 1;
+    string label = 2;
+  }
+  message Response {
+  }
+}
+
+message ListLabels {
+  message Request {
+    bool single_resource = 1;
+    int64 id = 2;  // Only if "single_resource" is "true"
+  }
+  message Response {
+    repeated string labels = 1;
+  }
+}
+
 message TransactionRequest {
   sfixed64              transaction = 1;
   TransactionOperation  operation = 2;
@@ -784,6 +831,9 @@
   GetChildrenMetadata.Request             get_children_metadata = 142;
   GetLastChangeIndex.Request              get_last_change_index = 143;
   LookupResourceAndParent.Request         lookup_resource_and_parent = 144;
+  AddLabel.Request                        add_label = 145;
+  RemoveLabel.Request                     remove_label = 146;
+  ListLabels.Request                      list_labels = 147;
 }
 
 message TransactionResponse {
@@ -832,6 +882,9 @@
   GetChildrenMetadata.Response             get_children_metadata = 142;
   GetLastChangeIndex.Response              get_last_change_index = 143;
   LookupResourceAndParent.Response         lookup_resource_and_parent = 144;
+  AddLabel.Response                        add_label = 145;
+  RemoveLabel.Response                     remove_label = 146;
+  ListLabels.Response                      list_labels = 147;
 }
 
 enum RequestType {
--- a/OrthancServer/Resources/Configuration.json	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/Resources/Configuration.json	Wed Apr 12 18:04:24 2023 +0200
@@ -433,8 +433,8 @@
     //  "AllowStore" : true,
     //  "AllowStorageCommitment" : false,  // new in 1.6.0
     //  "AllowTranscoding" : true,         // new in 1.7.0
-    //  "UseDicomTls" : false              // new in 1.9.0
-    //  "LocalAet" : "HELLO"               // new in 1.9.0
+    //  "UseDicomTls" : false,             // new in 1.9.0
+    //  "LocalAet" : "HELLO",              // new in 1.9.0
     //  "Timeout" : 60                     // new in 1.9.1
     //}
   },
--- a/OrthancServer/Sources/Database/IDatabaseWrapper.h	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/Sources/Database/IDatabaseWrapper.h	Wed Apr 12 18:04:24 2023 +0200
@@ -26,6 +26,7 @@
 #include "../../../OrthancFramework/Sources/FileStorage/FileInfo.h"
 #include "../../../OrthancFramework/Sources/FileStorage/IStorageArea.h"
 #include "../ExportedResource.h"
+#include "../Search/ISqlLookupFormatter.h"
 #include "../ServerIndexChange.h"
 #include "IDatabaseListener.h"
 
@@ -200,6 +201,8 @@
                                         std::list<std::string>* instancesId, // Can be NULL if not needed
                                         const std::vector<DatabaseConstraint>& lookup,
                                         ResourceType queryLevel,
+                                        const std::set<std::string>& labels,
+                                        LabelsConstraint labelsConstraint,
                                         uint32_t limit) = 0;
 
       // Returns "true" iff. the instance is new and has been inserted
@@ -236,6 +239,24 @@
                                            ResourceType& type,
                                            std::string& parentPublicId,
                                            const std::string& publicId) = 0;
+
+
+      /**
+       * Primitives introduced in Orthanc 1.12.0
+       **/
+
+      virtual void AddLabel(int64_t resource,
+                            const std::string& label) = 0;
+
+      virtual void RemoveLabel(int64_t resource,
+                               const std::string& label) = 0;
+
+      // List the labels of one single resource
+      virtual void ListLabels(std::set<std::string>& target,
+                              int64_t resource) = 0;
+
+      // List all the labels that are present in any resource
+      virtual void ListAllLabels(std::set<std::string>& target) = 0;
     };
 
 
@@ -260,5 +281,7 @@
                          IStorageArea& storageArea) = 0;
 
     virtual bool HasRevisionsSupport() const = 0;
+
+    virtual bool HasLabelsSupport() const = 0;
   };
 }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OrthancServer/Sources/Database/InstallLabelsTable.sql	Wed Apr 12 18:04:24 2023 +0200
@@ -0,0 +1,28 @@
+-- Orthanc - A Lightweight, RESTful DICOM Store
+-- Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
+-- Department, University Hospital of Liege, Belgium
+-- Copyright (C) 2017-2023 Osimis S.A., Belgium
+-- Copyright (C) 2021-2023 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
+--
+-- This program is free software: you can redistribute it and/or
+-- modify it under the terms of the GNU 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
+-- General Public License for more details.
+--
+-- You should have received a copy of the GNU General Public License
+-- along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+CREATE TABLE Labels(
+       id INTEGER REFERENCES Resources(internalId) ON DELETE CASCADE,
+       label TEXT NOT NULL,
+       PRIMARY KEY(id, label)  -- Prevents duplicates
+       );
+
+CREATE INDEX LabelsIndex1 ON Labels(id);
+CREATE INDEX LabelsIndex2 ON Labels(label);  -- This index allows efficient lookups
--- a/OrthancServer/Sources/Database/PrepareDatabase.sql	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/Sources/Database/PrepareDatabase.sql	Wed Apr 12 18:04:24 2023 +0200
@@ -91,6 +91,13 @@
        patientId INTEGER REFERENCES Resources(internalId) ON DELETE CASCADE
        );
 
+-- New in Orthanc 1.12.0
+CREATE TABLE Labels(
+       id INTEGER REFERENCES Resources(internalId) ON DELETE CASCADE,
+       label TEXT NOT NULL,
+       PRIMARY KEY(id, label)  -- Prevents duplicates
+       );
+
 CREATE INDEX ChildrenIndex ON Resources(parentId);
 CREATE INDEX PublicIndex ON Resources(publicId);
 CREATE INDEX ResourceTypeIndex ON Resources(resourceType);
@@ -108,6 +115,10 @@
 
 CREATE INDEX ChangesIndex ON Changes(internalId);
 
+-- New in Orthanc 1.12.0
+CREATE INDEX LabelsIndex1 ON Labels(id);
+CREATE INDEX LabelsIndex2 ON Labels(label);  -- This index allows efficient lookups
+
 CREATE TRIGGER AttachedFileDeleted
 AFTER DELETE ON AttachedFiles
 BEGIN
--- a/OrthancServer/Sources/Database/SQLiteDatabaseWrapper.cpp	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/Sources/Database/SQLiteDatabaseWrapper.cpp	Wed Apr 12 18:04:24 2023 +0200
@@ -341,12 +341,14 @@
                                       std::list<std::string>* instancesId,
                                       const std::vector<DatabaseConstraint>& lookup,
                                       ResourceType queryLevel,
+                                      const std::set<std::string>& labels,
+                                      LabelsConstraint labelsConstraint,
                                       uint32_t limit) ORTHANC_OVERRIDE
     {
       LookupFormatter formatter;
 
       std::string sql;
-      LookupFormatter::Apply(sql, formatter, lookup, queryLevel, limit);
+      LookupFormatter::Apply(sql, formatter, lookup, queryLevel, labels, labelsConstraint, limit);
 
       sql = "CREATE TEMPORARY TABLE Lookup AS " + sql;
     
@@ -1071,6 +1073,70 @@
         s.Run();
       }
     }
+
+
+    virtual void AddLabel(int64_t resource,
+                          const std::string& label) ORTHANC_OVERRIDE
+    {
+      if (label.empty())
+      {
+        throw OrthancException(ErrorCode_ParameterOutOfRange);
+      }
+      else
+      {
+        SQLite::Statement s(db_, SQLITE_FROM_HERE, "INSERT OR IGNORE INTO Labels (id, label) VALUES(?, ?)");
+        s.BindInt64(0, resource);
+        s.BindString(1, label);
+        s.Run();
+      }
+    }
+
+
+    virtual void RemoveLabel(int64_t resource,
+                             const std::string& label) ORTHANC_OVERRIDE
+    {
+      if (label.empty())
+      {
+        throw OrthancException(ErrorCode_ParameterOutOfRange);
+      }
+      else
+      {
+        SQLite::Statement s(db_, SQLITE_FROM_HERE, "DELETE FROM Labels WHERE id=? AND label=?");
+        s.BindInt64(0, resource);
+        s.BindString(1, label);
+        s.Run();
+      }
+    }
+
+
+    virtual void ListLabels(std::set<std::string>& target,
+                            int64_t resource) ORTHANC_OVERRIDE
+    {
+      target.clear();
+
+      SQLite::Statement s(db_, SQLITE_FROM_HERE, 
+                          "SELECT label FROM Labels WHERE id=?");
+      s.BindInt64(0, resource);
+
+      while (s.Step())
+      {
+        target.insert(s.ColumnString(0));
+      }
+    }
+
+
+    virtual void ListAllLabels(std::set<std::string>& target) ORTHANC_OVERRIDE
+    {
+      target.clear();
+
+      SQLite::Statement s(db_, SQLITE_FROM_HERE, 
+                          "SELECT DISTINCT label FROM Labels");
+
+      while (s.Step())
+      {
+        target.insert(s.ColumnString(0));
+      }
+    }
   };
 
 
@@ -1344,9 +1410,9 @@
                                "Incompatible version of the Orthanc database: " + tmp);
       }
 
-      // New in Orthanc 1.5.1
       if (version_ == 6)
       {
+        // New in Orthanc 1.5.1
         if (!transaction->LookupGlobalProperty(tmp, GlobalProperty_GetTotalSizeIsFast, true /* unused in SQLite */) ||
             tmp != "1")
         {
@@ -1355,6 +1421,15 @@
           ServerResources::GetFileResource(query, ServerResources::INSTALL_TRACK_ATTACHMENTS_SIZE);
           db_.Execute(query);
         }
+
+        // New in Orthanc 1.12.0
+        if (!db_.DoesTableExist("Labels"))
+        {
+          LOG(INFO) << "Installing the \"Labels\" table";
+          std::string query;
+          ServerResources::GetFileResource(query, ServerResources::INSTALL_LABELS_TABLE);
+          db_.Execute(query);
+        }
       }
 
       transaction->Commit(0);
--- a/OrthancServer/Sources/Database/SQLiteDatabaseWrapper.h	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/Sources/Database/SQLiteDatabaseWrapper.h	Wed Apr 12 18:04:24 2023 +0200
@@ -97,6 +97,11 @@
       return false;  // TODO - REVISIONS
     }
 
+    virtual bool HasLabelsSupport() const ORTHANC_OVERRIDE
+    {
+      return true;
+    }
+
 
     /**
      * The "StartTransaction()" method is guaranteed to return a class
--- a/OrthancServer/Sources/Database/StatelessDatabaseOperations.cpp	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/Sources/Database/StatelessDatabaseOperations.cpp	Wed Apr 12 18:04:24 2023 +0200
@@ -607,7 +607,7 @@
           
           Transaction transaction(db_, *factory_, TransactionType_ReadOnly);  // TODO - Only if not "TransactionType_Implicit"
           {
-            ReadOnlyTransaction t(transaction.GetDatabaseTransaction(), transaction.GetContext());
+            ReadOnlyTransaction t(transaction.GetDatabaseTransaction(), transaction.GetContext(), db_.HasLabelsSupport());
             readOperations->Apply(t);
           }
           transaction.Commit();
@@ -618,7 +618,7 @@
           
           Transaction transaction(db_, *factory_, TransactionType_ReadWrite);
           {
-            ReadWriteTransaction t(transaction.GetDatabaseTransaction(), transaction.GetContext());
+            ReadWriteTransaction t(transaction.GetDatabaseTransaction(), transaction.GetContext(), db_.HasLabelsSupport());
             writeOperations->Apply(t);
           }
           transaction.Commit();
@@ -715,10 +715,10 @@
                                                    const std::string& publicId,
                                                    ResourceType level,
                                                    const std::set<DicomTag>& requestedTags,
-                                                   ExpandResourceDbFlags expandFlags)
+                                                   ExpandResourceFlags expandFlags)
   {    
     class Operations : public ReadOnlyOperationsT6<
-      bool&, ExpandedResource&, const std::string&, ResourceType, const std::set<DicomTag>&, ExpandResourceDbFlags>
+      bool&, ExpandedResource&, const std::string&, ResourceType, const std::set<DicomTag>&, ExpandResourceFlags>
     {
     private:
   
@@ -778,7 +778,7 @@
         else
         {
           ExpandedResource& target = tuple.get<1>();
-          ExpandResourceDbFlags expandFlags = tuple.get<5>();
+          ExpandResourceFlags expandFlags = tuple.get<5>();
 
           // Set information about the parent resource (if it exists)
           if (type == ResourceType_Patient)
@@ -798,16 +798,15 @@
             target.parentId_ = parent;
           }
 
-          target.type_ = type;
-          target.id_ = tuple.get<2>();
-
-          if (expandFlags & ExpandResourceDbFlags_IncludeChildren)
+          target.SetResource(type, tuple.get<2>());
+
+          if (expandFlags & ExpandResourceFlags_IncludeChildren)
           {
             // List the children resources
             transaction.GetChildrenPublicId(target.childrenIds_, internalId);
           }
 
-          if (expandFlags & ExpandResourceDbFlags_IncludeMetadata)
+          if (expandFlags & ExpandResourceFlags_IncludeMetadata)
           {
             // Extract the metadata
             transaction.GetAllMetadata(target.metadata_, internalId);
@@ -869,10 +868,10 @@
             LookupStringMetadata(target.mainDicomTagsSignature_, target.metadata_, MetadataType_MainDicomTagsSignature);
           }
 
-          if (expandFlags & ExpandResourceDbFlags_IncludeMainDicomTags)
+          if (expandFlags & ExpandResourceFlags_IncludeMainDicomTags)
           {
             // read all tags from DB
-            transaction.GetMainDicomTags(target.tags_, internalId);
+            transaction.GetMainDicomTags(target.GetMainDicomTags(), internalId);
 
             // read all main sequences from DB
             std::string serializedSequences;
@@ -882,7 +881,7 @@
               Toolbox::ReadJson(jsonMetadata, serializedSequences);
 
               assert(jsonMetadata["Version"].asInt() == 1);
-              target.tags_.FromDicomAsJson(jsonMetadata["Sequences"], true /* append */, true /* parseSequences */);
+              target.GetMainDicomTags().FromDicomAsJson(jsonMetadata["Sequences"], true /* append */, true /* parseSequences */);
             }
 
             // check if we have access to all requestedTags or if we must get tags from parents
@@ -895,7 +894,7 @@
               FromDcmtkBridge::ParseListOfTags(savedMainDicomTags, target.mainDicomTagsSignature_);
 
               // read parent main dicom tags as long as we have not gathered all requested tags
-              ResourceType currentLevel = target.type_;
+              ResourceType currentLevel = target.GetLevel();
               int64_t currentInternalId = internalId;
               Toolbox::GetMissingsFromSet(target.missingRequestedTags_, requestedTags, savedMainDicomTags);
 
@@ -931,7 +930,7 @@
                   DicomMap parentTags;
                   transaction.GetMainDicomTags(parentTags, currentParentId);
 
-                  target.tags_.Merge(parentTags);
+                  target.GetMainDicomTags().Merge(parentTags);
                 }
 
                 currentInternalId = currentParentId;
@@ -939,6 +938,12 @@
             }
           }
 
+          if ((expandFlags & ExpandResourceFlags_IncludeLabels) &&
+              transaction.HasLabelsSupport())
+          {
+            transaction.ListLabels(target.labels_, internalId);
+          }
+
           std::string tmp;
 
           if (LookupStringMetadata(tmp, target.metadata_, MetadataType_AnonymizedFrom))
@@ -1066,7 +1071,7 @@
   void StatelessDatabaseOperations::GetAllUuids(std::list<std::string>& target,
                                                 ResourceType resourceType,
                                                 size_t since,
-                                                size_t limit)
+                                                uint32_t limit)
   {
     if (limit == 0)
     {
@@ -1646,7 +1651,8 @@
       {
         // TODO - CANDIDATE FOR "TransactionType_Implicit"
         std::list<std::string> tmp;
-        transaction.ApplyLookupResources(tmp, NULL, query_, level_, 0);
+        std::set<std::string> labels;
+        transaction.ApplyLookupResources(tmp, NULL, query_, level_, labels, LabelsConstraint_Any, 0);
         CopyListToVector(result_, tmp);
       }
     };
@@ -1915,9 +1921,12 @@
                                                          std::vector<std::string>* instancesId,
                                                          const DatabaseLookup& lookup,
                                                          ResourceType queryLevel,
-                                                         size_t limit)
+                                                         const std::set<std::string>& labels,
+                                                         LabelsConstraint labelsConstraint,
+                                                         uint32_t limit)
   {
-    class Operations : public ReadOnlyOperationsT4<bool, const std::vector<DatabaseConstraint>&, ResourceType, size_t>
+    class Operations : public ReadOnlyOperationsT6<bool, const std::vector<DatabaseConstraint>&, ResourceType,
+                                                   const std::set<std::string>&, LabelsConstraint, size_t>
     {
     private:
       std::list<std::string>  resourcesList_;
@@ -1940,21 +1949,33 @@
         // TODO - CANDIDATE FOR "TransactionType_Implicit"
         if (tuple.get<0>())
         {
-          transaction.ApplyLookupResources(resourcesList_, &instancesList_, tuple.get<1>(), tuple.get<2>(), tuple.get<3>());
+          transaction.ApplyLookupResources(
+            resourcesList_, &instancesList_, tuple.get<1>(), tuple.get<2>(), tuple.get<3>(), tuple.get<4>(), tuple.get<5>());
         }
         else
         {
-          transaction.ApplyLookupResources(resourcesList_, NULL, tuple.get<1>(), tuple.get<2>(), tuple.get<3>());
+          transaction.ApplyLookupResources(
+            resourcesList_, NULL, tuple.get<1>(), tuple.get<2>(), tuple.get<3>(), tuple.get<4>(), tuple.get<5>());
         }
       }
     };
 
+    if (!labels.empty() &&
+        !db_.HasLabelsSupport())
+    {
+      throw OrthancException(ErrorCode_NotImplemented, "The database backend doesn't support labels");
+    }
+
+    for (std::set<std::string>::const_iterator it = labels.begin(); it != labels.end(); ++it)
+    {
+      ServerToolbox::CheckValidLabel(*it);
+    }
 
     std::vector<DatabaseConstraint> normalized;
     NormalizeLookup(normalized, lookup, queryLevel);
 
     Operations operations;
-    operations.Apply(*this, (instancesId != NULL), normalized, queryLevel, limit);
+    operations.Apply(*this, (instancesId != NULL), normalized, queryLevel, labels, labelsConstraint, limit);
     
     CopyListToVector(resourcesId, operations.GetResourcesList());
 
@@ -3526,4 +3547,117 @@
     Apply(operations);
     return operations.GetStatus();
   }
+
+
+  void StatelessDatabaseOperations::ListLabels(std::set<std::string>& target,
+                                               const std::string& publicId,
+                                               ResourceType level)
+  {
+    class Operations : public ReadOnlyOperationsT3<std::set<std::string>&, const std::string&, ResourceType>
+    {
+    public:
+      virtual void ApplyTuple(ReadOnlyTransaction& transaction,
+                              const Tuple& tuple) ORTHANC_OVERRIDE
+      {
+        ResourceType type;
+        int64_t id;
+        if (!transaction.LookupResource(id, type, tuple.get<1>()) ||
+            tuple.get<2>() != type)
+        {
+          throw OrthancException(ErrorCode_UnknownResource);
+        }
+        else
+        {
+          transaction.ListLabels(tuple.get<0>(), id);
+        }
+      }
+    };
+
+    Operations operations;
+    operations.Apply(*this, target, publicId, level);
+  }
+
+
+  void StatelessDatabaseOperations::ListAllLabels(std::set<std::string>& target)
+  {
+    class Operations : public ReadOnlyOperationsT1<std::set<std::string>& >
+    {
+    public:
+      virtual void ApplyTuple(ReadOnlyTransaction& transaction,
+                              const Tuple& tuple) ORTHANC_OVERRIDE
+      {
+        transaction.ListAllLabels(tuple.get<0>());
+      }
+    };
+
+    Operations operations;
+    operations.Apply(*this, target);
+  }
+  
+
+  void StatelessDatabaseOperations::ModifyLabel(const std::string& publicId,
+                                                ResourceType level,
+                                                const std::string& label,
+                                                LabelOperation operation)
+  {
+    class Operations : public IReadWriteOperations
+    {
+    private:
+      const std::string& publicId_;
+      ResourceType       level_;
+      const std::string& label_;
+      LabelOperation     operation_;
+
+    public:
+      Operations(const std::string& publicId,
+                 ResourceType level,
+                 const std::string& label,
+                 LabelOperation operation) :
+        publicId_(publicId),
+        level_(level),
+        label_(label),
+        operation_(operation)
+      {
+      }
+
+      virtual void Apply(ReadWriteTransaction& transaction) ORTHANC_OVERRIDE
+      {
+        ResourceType type;
+        int64_t id;
+        if (!transaction.LookupResource(id, type, publicId_) ||
+            level_ != type)
+        {
+          throw OrthancException(ErrorCode_UnknownResource);
+        }
+        else
+        {
+          switch (operation_)
+          {
+            case LabelOperation_Add:
+              transaction.AddLabel(id, label_);
+              break;
+
+            case LabelOperation_Remove:
+              transaction.RemoveLabel(id, label_);
+              break;
+
+            default:
+              throw OrthancException(ErrorCode_ParameterOutOfRange);
+          }
+        }
+      }
+    };
+
+    ServerToolbox::CheckValidLabel(label);
+    
+    Operations operations(publicId, level, label, operation);
+    Apply(operations);
+  }
+
+
+  bool StatelessDatabaseOperations::HasLabelsSupport()
+  {
+    boost::shared_lock<boost::shared_mutex> lock(mutex_);
+    return db_.HasLabelsSupport();
+  }
 }
--- a/OrthancServer/Sources/Database/StatelessDatabaseOperations.h	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/Sources/Database/StatelessDatabaseOperations.h	Wed Apr 12 18:04:24 2023 +0200
@@ -37,15 +37,18 @@
   class ParsedDicomFile;
   struct ServerIndexChange;
 
-  struct ExpandedResource : public boost::noncopyable
+  class ExpandedResource : public boost::noncopyable
   {
+  private:
     std::string                         id_;
-    DicomMap                            tags_;          // all main tags and main sequences from DB
+    ResourceType                        level_;
+    DicomMap                            tags_;  // all main tags and main sequences from DB
+
+  public:
     std::string                         mainDicomTagsSignature_;
     std::string                         parentId_;
     std::list<std::string>              childrenIds_;
     std::map<MetadataType, std::string> metadata_;
-    ResourceType                        type_;
     std::string                         anonymizedFrom_;
     std::string                         modifiedFrom_;
     std::string                         lastUpdate_;
@@ -62,18 +65,51 @@
     size_t                              fileSize_;
     std::string                         fileUuid_;
     int                                 indexInSeries_;
+
+    // New in Orthanc 1.12.0
+    std::set<std::string>               labels_;
+
+  public:
+    void SetResource(ResourceType level,
+                     const std::string& id)
+    {
+      level_ = level;
+      id_ = id;
+    }
+
+    const std::string& GetPublicId() const
+    {
+      return id_;
+    }
+
+    ResourceType GetLevel() const
+    {
+      return level_;
+    }
+
+    DicomMap& GetMainDicomTags()
+    {
+      return tags_;
+    }
+
+    const DicomMap& GetMainDicomTags() const
+    {
+      return tags_;
+    }
   };
 
-  enum ExpandResourceDbFlags
+  enum ExpandResourceFlags
   {
-    ExpandResourceDbFlags_None                    = 0,
-    ExpandResourceDbFlags_IncludeMetadata         = (1 << 0),
-    ExpandResourceDbFlags_IncludeChildren         = (1 << 1),
-    ExpandResourceDbFlags_IncludeMainDicomTags    = (1 << 2),
+    ExpandResourceFlags_None                    = 0,
+    ExpandResourceFlags_IncludeMetadata         = (1 << 0),
+    ExpandResourceFlags_IncludeChildren         = (1 << 1),
+    ExpandResourceFlags_IncludeMainDicomTags    = (1 << 2),
+    ExpandResourceFlags_IncludeLabels           = (1 << 3),
 
-    ExpandResourceDbFlags_Default = (ExpandResourceDbFlags_IncludeMetadata |
-                                     ExpandResourceDbFlags_IncludeChildren |
-                                     ExpandResourceDbFlags_IncludeMainDicomTags)
+    ExpandResourceFlags_Default = (ExpandResourceFlags_IncludeMetadata |
+                                     ExpandResourceFlags_IncludeChildren |
+                                     ExpandResourceFlags_IncludeMainDicomTags |
+                                     ExpandResourceFlags_IncludeLabels)
   };
 
   class StatelessDatabaseOperations : public boost::noncopyable
@@ -82,6 +118,12 @@
     typedef std::list<FileInfo> Attachments;
     typedef std::map<std::pair<ResourceType, MetadataType>, std::string>  MetadataMap;
 
+    enum LabelOperation
+    {
+      LabelOperation_Add,
+      LabelOperation_Remove
+    };
+
     class ITransactionContext : public IDatabaseListener
     {
     public:
@@ -124,14 +166,17 @@
     {
     private:
       ITransactionContext&  context_;
+      bool                  hasLabelsSupport_;
       
     protected:
       IDatabaseWrapper::ITransaction&  transaction_;
       
     public:
       explicit ReadOnlyTransaction(IDatabaseWrapper::ITransaction& transaction,
-                                   ITransactionContext& context) :
+                                   ITransactionContext& context,
+                                   bool hasLabelsSupport) :
         context_(context),
+        hasLabelsSupport_(hasLabelsSupport),
         transaction_(transaction)
       {
       }
@@ -141,6 +186,11 @@
         return context_;
       }
 
+      bool HasLabelsSupport() const
+      {
+        return hasLabelsSupport_;
+      }
+
       /**
        * Higher-level constructions
        **/
@@ -157,9 +207,12 @@
                                 std::list<std::string>* instancesId, // Can be NULL if not needed
                                 const std::vector<DatabaseConstraint>& lookup,
                                 ResourceType queryLevel,
-                                size_t limit)
+                                const std::set<std::string>& labels,  // New in Orthanc 1.12.0
+                                LabelsConstraint labelsConstraint,    // New in Orthanc 1.12.0
+                                uint32_t limit)
       {
-        return transaction_.ApplyLookupResources(resourcesId, instancesId, lookup, queryLevel, limit);
+        return transaction_.ApplyLookupResources(resourcesId, instancesId, lookup, queryLevel,
+                                                 labels, labelsConstraint, limit);
       }
 
       void GetAllMetadata(std::map<MetadataType, std::string>& target,
@@ -177,7 +230,7 @@
       void GetAllPublicIds(std::list<std::string>& target,
                            ResourceType resourceType,
                            size_t since,
-                           size_t limit)
+                           uint32_t limit)
       {
         return transaction_.GetAllPublicIds(target, resourceType, since, limit);
       }  
@@ -185,9 +238,9 @@
       void GetChanges(std::list<ServerIndexChange>& target /*out*/,
                       bool& done /*out*/,
                       int64_t since,
-                      uint32_t maxResults)
+                      uint32_t limit)
       {
-        transaction_.GetChanges(target, done, since, maxResults);
+        transaction_.GetChanges(target, done, since, limit);
       }
 
       void GetChildrenInternalId(std::list<int64_t>& target,
@@ -205,9 +258,9 @@
       void GetExportedResources(std::list<ExportedResource>& target /*out*/,
                                 bool& done /*out*/,
                                 int64_t since,
-                                uint32_t maxResults)
+                                uint32_t limit)
       {
-        return transaction_.GetExportedResources(target, done, since, maxResults);
+        return transaction_.GetExportedResources(target, done, since, limit);
       }
 
       void GetLastChange(std::list<ServerIndexChange>& target /*out*/)
@@ -310,6 +363,17 @@
       {
         return transaction_.LookupResourceAndParent(id, type, parentPublicId, publicId);
       }
+
+      void ListLabels(std::set<std::string>& target,
+                      int64_t id)
+      {
+        transaction_.ListLabels(target, id);
+      }
+
+      void ListAllLabels(std::set<std::string>& target)
+      {
+        transaction_.ListAllLabels(target);
+      }
     };
 
 
@@ -317,8 +381,9 @@
     {
     public:
       ReadWriteTransaction(IDatabaseWrapper::ITransaction& transaction,
-                           ITransactionContext& context) :
-        ReadOnlyTransaction(transaction, context)
+                           ITransactionContext& context,
+                           bool hasLabelsSupport) :
+        ReadOnlyTransaction(transaction, context, hasLabelsSupport)
       {
       }
 
@@ -422,6 +487,18 @@
                              unsigned int maximumPatients,
                              uint64_t addedInstanceSize,
                              const std::string& newPatientId);
+
+      void AddLabel(int64_t id,
+                    const std::string& label)
+      {
+        transaction_.AddLabel(id, label);
+      }
+
+      void RemoveLabel(int64_t id,
+                    const std::string& label)
+      {
+        transaction_.RemoveLabel(id, label);
+      }
     };
 
 
@@ -503,7 +580,7 @@
                         const std::string& publicId,
                         ResourceType level,
                         const std::set<DicomTag>& requestedTags,
-                        ExpandResourceDbFlags expandFlags);
+                        ExpandResourceFlags expandFlags);
 
     void GetAllMetadata(std::map<MetadataType, std::string>& target,
                         const std::string& publicId,
@@ -515,7 +592,7 @@
     void GetAllUuids(std::list<std::string>& target,
                      ResourceType resourceType,
                      size_t since,
-                     size_t limit);
+                     uint32_t limit);
 
     void GetGlobalStatistics(/* out */ uint64_t& diskSize,
                              /* out */ uint64_t& uncompressedSize,
@@ -531,13 +608,13 @@
 
     void GetChanges(Json::Value& target,
                     int64_t since,
-                    unsigned int maxResults);
+                    uint32_t limit);
 
     void GetLastChange(Json::Value& target);
 
     void GetExportedResources(Json::Value& target,
                               int64_t since,
-                              unsigned int maxResults);
+                              uint32_t limit);
 
     void GetLastExportedResource(Json::Value& target);
 
@@ -605,7 +682,9 @@
                               std::vector<std::string>* instancesId,  // Can be NULL if not needed
                               const DatabaseLookup& lookup,
                               ResourceType queryLevel,
-                              size_t limit);
+                              const std::set<std::string>& labels,
+                              LabelsConstraint labelsConstraint,
+                              uint32_t limit);
 
     bool DeleteResource(Json::Value& remainingAncestor /* out */,
                         const std::string& uuid,
@@ -683,5 +762,18 @@
                               bool hasOldRevision,
                               int64_t oldRevision,
                               const std::string& oldMd5);
+
+    void ListLabels(std::set<std::string>& target,
+                    const std::string& publicId,
+                    ResourceType level);
+
+    void ListAllLabels(std::set<std::string>& target);
+
+    void ModifyLabel(const std::string& publicId,
+                     ResourceType level,
+                     const std::string& label,
+                     LabelOperation operation);
+
+    bool HasLabelsSupport();
   };
 }
--- a/OrthancServer/Sources/OrthancFindRequestHandler.cpp	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/Sources/OrthancFindRequestHandler.cpp	Wed Apr 12 18:04:24 2023 +0200
@@ -59,7 +59,8 @@
     requestedTags.erase(DICOM_TAG_QUERY_RETRIEVE_LEVEL); // this is not part of the answer
 
     // reuse ExpandResource to get missing tags and computed tags (ModalitiesInStudy ...).  This code is therefore shared between C-Find, tools/find, list-resources and QIDO-RS
-    context.ExpandResource(resource, publicId, mainDicomTags, instanceId, dicomAsJson, level, requestedTags, ExpandResourceDbFlags_IncludeMainDicomTags, allowStorageAccess);
+    context.ExpandResource(resource, publicId, mainDicomTags, instanceId, dicomAsJson,
+                           level, requestedTags, ExpandResourceFlags_IncludeMainDicomTags, allowStorageAccess);
 
     DicomMap result;
 
@@ -84,7 +85,7 @@
       else
       {
         const DicomTag& tag = query.GetElement(i).GetTag();
-        const DicomValue* value = resource.tags_.TestAndGetValue(tag);
+        const DicomValue* value = resource.GetMainDicomTags().TestAndGetValue(tag);
 
         if (value != NULL &&
             !value->IsNull() &&
--- a/OrthancServer/Sources/OrthancRestApi/OrthancRestModalities.cpp	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/Sources/OrthancRestApi/OrthancRestModalities.cpp	Wed Apr 12 18:04:24 2023 +0200
@@ -1431,7 +1431,7 @@
                          "Host that is used for this commands, defaults to `Host` configuration option. "
                          "Allows you to overwrite the destination host for a specific operation.", false)
         .SetRequestField(KEY_PORT, RestApiCallDocumentation::Type_Number,
-                         "Port that is used for this commands, defaults to `Port` configuration option. "
+                         "Port that is used for this command, defaults to `Port` configuration option. "
                          "Allows you to overwrite the destination port for a specific operation.", false)
         .SetRequestField(KEY_MOVE_ORIGINATOR_AET, RestApiCallDocumentation::Type_String,
                          "Move originator AET that is used for this commands, in order to fake a C-MOVE SCU", false)
--- a/OrthancServer/Sources/OrthancRestApi/OrthancRestResources.cpp	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/Sources/OrthancRestApi/OrthancRestResources.cpp	Wed Apr 12 18:04:24 2023 +0200
@@ -1971,6 +1971,129 @@
 
 
 
+  // Handling of labels -------------------------------------------------------
+
+  static void ListLabels(RestApiGetCall& call)
+  {
+    if (call.IsDocumentation())
+    {
+      ResourceType t = StringToResourceType(call.GetFullUri()[0].c_str());
+      std::string r = GetResourceTypeText(t, false /* plural */, false /* upper case */);
+      call.GetDocumentation()
+        .SetTag(GetResourceTypeText(t, true /* plural */, true /* upper case */))
+        .SetSummary("List labels (new in Orthanc 1.12.0)")
+        .SetDescription("Get the labels that are associated with the given " + r)
+        .SetUriArgument("id", "Orthanc identifier of the " + r + " of interest")
+        .AddAnswerType(MimeType_Json, "JSON array containing the names of the labels")
+        .SetHttpGetSample(GetDocumentationSampleResource(t) + "/labels", true);
+      return;
+    }
+
+    assert(!call.GetFullUri().empty());
+    const std::string publicId = call.GetUriComponent("id", "");
+    ResourceType level = StringToResourceType(call.GetFullUri() [0].c_str());
+
+    std::set<std::string> labels;
+    OrthancRestApi::GetIndex(call).ListLabels(labels, publicId, level);
+
+    Json::Value result = Json::arrayValue;
+
+    for (std::set<std::string>::const_iterator it = labels.begin(); it != labels.end(); ++it)
+    {
+      result.append(*it);
+    }
+
+    call.GetOutput().AnswerJson(result);
+  }
+  
+
+  static void GetLabel(RestApiGetCall& call)
+  {
+    if (call.IsDocumentation())
+    {
+      ResourceType t = StringToResourceType(call.GetFullUri()[0].c_str());
+      std::string r = GetResourceTypeText(t, false /* plural */, false /* upper case */);
+      call.GetDocumentation()
+        .SetTag(GetResourceTypeText(t, true /* plural */, true /* upper case */))
+        .SetSummary("Test label")
+        .SetDescription("Test whether the " + r + " is associated with the given label")
+        .SetUriArgument("id", "Orthanc identifier of the " + r + " of interest")
+        .SetUriArgument("label", "The label of interest")
+        .AddAnswerType(MimeType_PlainText, "Empty string is returned in the case of presence, error 404 in the case of absence");
+      return;
+    }
+
+    CheckValidResourceType(call);
+
+    assert(!call.GetFullUri().empty());
+    const std::string publicId = call.GetUriComponent("id", "");
+    const ResourceType level = StringToResourceType(call.GetFullUri() [0].c_str());
+
+    std::string label = call.GetUriComponent("label", "");
+
+    std::set<std::string> labels;
+    OrthancRestApi::GetIndex(call).ListLabels(labels, publicId, level);
+    
+    if (labels.find(label) != labels.end())
+    {
+      call.GetOutput().AnswerBuffer("", MimeType_PlainText);
+    }
+  }
+
+
+  static void AddLabel(RestApiPutCall& call)
+  {
+    if (call.IsDocumentation())
+    {
+      ResourceType t = StringToResourceType(call.GetFullUri()[0].c_str());
+      std::string r = GetResourceTypeText(t, false /* plural */, false /* upper case */);
+      call.GetDocumentation()
+        .SetTag(GetResourceTypeText(t, true /* plural */, true /* upper case */))
+        .SetSummary("Add label")
+        .SetDescription("Associate a label with a " + r)
+        .SetUriArgument("id", "Orthanc identifier of the " + r + " of interest")
+        .SetUriArgument("label", "The label to be added");
+      return;
+    }
+
+    CheckValidResourceType(call);
+
+    std::string publicId = call.GetUriComponent("id", "");
+    const ResourceType level = StringToResourceType(call.GetFullUri() [0].c_str());
+
+    std::string label = call.GetUriComponent("label", "");
+    OrthancRestApi::GetIndex(call).ModifyLabel(publicId, level, label, StatelessDatabaseOperations::LabelOperation_Add);
+
+    call.GetOutput().AnswerBuffer("", MimeType_PlainText);
+  }
+
+
+  static void RemoveLabel(RestApiDeleteCall& call)
+  {
+    if (call.IsDocumentation())
+    {
+      ResourceType t = StringToResourceType(call.GetFullUri()[0].c_str());
+      std::string r = GetResourceTypeText(t, false /* plural */, false /* upper case */);
+      call.GetDocumentation()
+        .SetTag(GetResourceTypeText(t, true /* plural */, true /* upper case */))
+        .SetSummary("Remove label")
+        .SetDescription("Remove a label associated with a " + r)
+        .SetUriArgument("id", "Orthanc identifier of the " + r + " of interest")
+        .SetUriArgument("label", "The label to be removed");
+      return;
+    }
+
+    CheckValidResourceType(call);
+
+    std::string publicId = call.GetUriComponent("id", "");
+    const ResourceType level = StringToResourceType(call.GetFullUri() [0].c_str());
+
+    std::string label = call.GetUriComponent("label", "");
+    OrthancRestApi::GetIndex(call).ModifyLabel(publicId, level, label, StatelessDatabaseOperations::LabelOperation_Remove);
+
+    call.GetOutput().AnswerBuffer("", MimeType_PlainText);
+  }
+  
 
   // Handling of attached files -----------------------------------------------
 
@@ -2949,6 +3072,8 @@
     static const char* const KEY_QUERY = "Query";
     static const char* const KEY_REQUESTED_TAGS = "RequestedTags";
     static const char* const KEY_SINCE = "Since";
+    static const char* const KEY_LABELS = "Labels";                       // New in Orthanc 1.12.0
+    static const char* const KEY_LABELS_CONSTRAINT = "LabelsConstraint";  // New in Orthanc 1.12.0
 
     if (call.IsDocumentation())
     {
@@ -2978,6 +3103,10 @@
                          "all Main Dicom Tags to keep backward compatibility with Orthanc prior to 1.11.0.", false)
         .SetRequestField(KEY_QUERY, RestApiCallDocumentation::Type_JsonObject,
                          "Associative array containing the filter on the values of the DICOM tags", true)
+        .SetRequestField(KEY_LABELS, RestApiCallDocumentation::Type_JsonListOfStrings,
+                         "List of strings specifying which labels to look for in the resources (new in Orthanc 1.12.0)", true)
+        .SetRequestField(KEY_LABELS_CONSTRAINT, RestApiCallDocumentation::Type_String,
+                         "Constraint on the labels, can be `All`, `Any`, or `None` (defaults to `All`, new in Orthanc 1.12.0)", true)
         .AddAnswerType(MimeType_Json, "JSON array containing either the Orthanc identifiers, or detailed information "
                        "about the reported resources (if `Expand` argument is `true`)");
       return;
@@ -3008,25 +3137,37 @@
              request[KEY_CASE_SENSITIVE].type() != Json::booleanValue)
     {
       throw OrthancException(ErrorCode_BadRequest, 
-                             "Field \"" + std::string(KEY_CASE_SENSITIVE) + "\" should be a Boolean");
+                             "Field \"" + std::string(KEY_CASE_SENSITIVE) + "\" must be a Boolean");
     }
     else if (request.isMember(KEY_LIMIT) && 
              request[KEY_LIMIT].type() != Json::intValue)
     {
       throw OrthancException(ErrorCode_BadRequest, 
-                             "Field \"" + std::string(KEY_LIMIT) + "\" should be an integer");
+                             "Field \"" + std::string(KEY_LIMIT) + "\" must be an integer");
     }
     else if (request.isMember(KEY_SINCE) &&
              request[KEY_SINCE].type() != Json::intValue)
     {
       throw OrthancException(ErrorCode_BadRequest, 
-                             "Field \"" + std::string(KEY_SINCE) + "\" should be an integer");
+                             "Field \"" + std::string(KEY_SINCE) + "\" must be an integer");
     }
     else if (request.isMember(KEY_REQUESTED_TAGS) &&
              request[KEY_REQUESTED_TAGS].type() != Json::arrayValue)
     {
       throw OrthancException(ErrorCode_BadRequest, 
-                             "Field \"" + std::string(KEY_REQUESTED_TAGS) + "\" should be an array");
+                             "Field \"" + std::string(KEY_REQUESTED_TAGS) + "\" must be an array");
+    }
+    else if (request.isMember(KEY_LABELS) &&
+             request[KEY_LABELS].type() != Json::arrayValue)
+    {
+      throw OrthancException(ErrorCode_BadRequest, 
+                             "Field \"" + std::string(KEY_LABELS) + "\" must be an array of strings");
+    }
+    else if (request.isMember(KEY_LABELS_CONSTRAINT) &&
+             request[KEY_LABELS_CONSTRAINT].type() != Json::stringValue)
+    {
+      throw OrthancException(ErrorCode_BadRequest, 
+                             "Field \"" + std::string(KEY_LABELS_CONSTRAINT) + "\" must be an array of strings");
     }
     else
     {
@@ -3049,7 +3190,7 @@
         if (tmp < 0)
         {
           throw OrthancException(ErrorCode_ParameterOutOfRange,
-                                 "Field \"" + std::string(KEY_LIMIT) + "\" should be a positive integer");
+                                 "Field \"" + std::string(KEY_LIMIT) + "\" must be a positive integer");
         }
 
         limit = static_cast<size_t>(tmp);
@@ -3062,7 +3203,7 @@
         if (tmp < 0)
         {
           throw OrthancException(ErrorCode_ParameterOutOfRange,
-                                 "Field \"" + std::string(KEY_SINCE) + "\" should be a positive integer");
+                                 "Field \"" + std::string(KEY_SINCE) + "\" must be a positive integer");
         }
 
         since = static_cast<size_t>(tmp);
@@ -3085,7 +3226,7 @@
         if (request[KEY_QUERY][members[i]].type() != Json::stringValue)
         {
           throw OrthancException(ErrorCode_BadRequest,
-                                 "Tag \"" + members[i] + "\" should be associated with a string");
+                                 "Tag \"" + members[i] + "\" must be associated with a string");
         }
 
         const std::string value = request[KEY_QUERY][members[i]].asString();
@@ -3100,6 +3241,44 @@
         }
       }
 
+      if (request.isMember(KEY_LABELS))  // New in Orthanc 1.12.0
+      {
+        for (Json::Value::ArrayIndex i = 0; i < request[KEY_LABELS].size(); i++)
+        {
+          if (request[KEY_LABELS][i].type() != Json::stringValue)
+          {
+            throw OrthancException(ErrorCode_BadRequest, "Field \"" + std::string(KEY_LABELS) + "\" must contain strings");
+          }
+          else
+          {
+            query.AddLabel(request[KEY_LABELS][i].asString());
+          }
+        }
+      }
+
+      query.SetLabelsConstraint(LabelsConstraint_All);
+      
+      if (request.isMember(KEY_LABELS_CONSTRAINT))
+      {
+        const std::string& s = request[KEY_LABELS_CONSTRAINT].asString();
+        if (s == "All")
+        {
+          query.SetLabelsConstraint(LabelsConstraint_All);
+        }
+        else if (s == "Any")
+        {
+          query.SetLabelsConstraint(LabelsConstraint_Any);
+        }
+        else if (s == "None")
+        {
+          query.SetLabelsConstraint(LabelsConstraint_None);
+        }
+        else
+        {
+          throw OrthancException(ErrorCode_BadRequest, "Field \"" + std::string(KEY_LABELS_CONSTRAINT) + "\" must be \"All\", \"Any\", or \"None\"");
+        }
+      }
+      
       FindVisitor visitor(OrthancRestApi::GetDicomFormat(request, DicomToJsonFormat_Human), context.GetFindStorageAccessMode());
       context.Apply(visitor, query, level, since, limit);
       visitor.Answer(call.GetOutput(), context, level, expand, requestedTags);
@@ -3854,6 +4033,12 @@
       Register("/" + resourceTypes[i] + "/{id}/metadata/{name}", GetMetadata);
       Register("/" + resourceTypes[i] + "/{id}/metadata/{name}", SetMetadata);
 
+      // New in Orthanc 1.12.0
+      Register("/" + resourceTypes[i] + "/{id}/labels", ListLabels);
+      Register("/" + resourceTypes[i] + "/{id}/labels/{label}", GetLabel);
+      Register("/" + resourceTypes[i] + "/{id}/labels/{label}", RemoveLabel);
+      Register("/" + resourceTypes[i] + "/{id}/labels/{label}", AddLabel);
+
       Register("/" + resourceTypes[i] + "/{id}/attachments", ListAttachments);
       Register("/" + resourceTypes[i] + "/{id}/attachments/{name}", DeleteAttachment);
       Register("/" + resourceTypes[i] + "/{id}/attachments/{name}", GetAttachmentOperations);
--- a/OrthancServer/Sources/OrthancRestApi/OrthancRestSystem.cpp	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/Sources/OrthancRestApi/OrthancRestSystem.cpp	Wed Apr 12 18:04:24 2023 +0200
@@ -89,6 +89,7 @@
     static const char* const MAXIMUM_STORAGE_SIZE = "MaximumStorageSize";
     static const char* const MAXIMUM_STORAGE_MODE = "MaximumStorageMode";
     static const char* const USER_METADATA = "UserMetadata";
+    static const char* const HAS_LABELS = "HasLabels";
 
     if (call.IsDocumentation())
     {
@@ -131,6 +132,8 @@
                         "The configured MaximumStorageMode (new in Orthanc 1.11.3)")
         .SetAnswerField(USER_METADATA, RestApiCallDocumentation::Type_JsonObject,
                         "The configured UserMetadata (new in Orthanc 1.12.0)")
+        .SetAnswerField(HAS_LABELS, RestApiCallDocumentation::Type_Boolean,
+                        "Whether the database back-end supports labels (new in Orthanc 1.12.0)")
         .SetHttpGetSample("https://demo.orthanc-server.com/system", true);
       return;
     }
@@ -187,6 +190,8 @@
     result[USER_METADATA] = Json::objectValue;
     GetUserMetadataConfiguration(result[USER_METADATA]);
 
+    result[HAS_LABELS] = OrthancRestApi::GetIndex(call).HasLabelsSupport();
+    
     call.GetOutput().AnswerJson(result);
   }
 
@@ -1042,6 +1047,31 @@
   }
 
 
+  static void ListAllLabels(RestApiGetCall& call)
+  {
+    if (call.IsDocumentation())
+    {
+      call.GetDocumentation()
+        .SetTag("System")
+        .SetSummary("Get all the used labels")
+        .SetDescription("List all the labels that are associated with any resource of the Orthanc database")
+        .AddAnswerType(MimeType_Json, "JSON array containing the labels");
+      return;
+    }
+
+    std::set<std::string> labels;
+    OrthancRestApi::GetIndex(call).ListAllLabels(labels);
+
+    Json::Value json = Json::arrayValue;
+    for (std::set<std::string>::const_iterator it = labels.begin(); it != labels.end(); ++it)
+    {
+      json.append(*it);
+    }
+    
+    call.GetOutput().AnswerJson(json);
+   }
+
+
   void OrthancRestApi::RegisterSystem(bool orthancExplorerEnabled)
   {
     if (orthancExplorerEnabled)
@@ -1089,5 +1119,7 @@
     Register("/tools/accepted-transfer-syntaxes", SetAcceptedTransferSyntaxes);
     Register("/tools/unknown-sop-class-accepted", GetUnknownSopClassAccepted);
     Register("/tools/unknown-sop-class-accepted", SetUnknownSopClassAccepted);
+
+    Register("/tools/labels", ListAllLabels);  // New in Orthanc 1.12.0
   }
 }
--- a/OrthancServer/Sources/Search/DatabaseLookup.cpp	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/Sources/Search/DatabaseLookup.cpp	Wed Apr 12 18:04:24 2023 +0200
@@ -368,4 +368,14 @@
 
     return clone.release();
   }
+
+
+  void DatabaseLookup::AddLabel(const std::string& label)
+  {
+    if (!label.empty())
+    {
+      ServerToolbox::CheckValidLabel(label);
+      labels_.insert(label);
+    }
+  }
 }
--- a/OrthancServer/Sources/Search/DatabaseLookup.h	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/Sources/Search/DatabaseLookup.h	Wed Apr 12 18:04:24 2023 +0200
@@ -22,6 +22,7 @@
 
 #pragma once
 
+#include "../Search/ISqlLookupFormatter.h"
 #include "DicomTagConstraint.h"
 
 class DcmItem;
@@ -32,6 +33,8 @@
   {
   private:
     std::vector<DicomTagConstraint*>  constraints_;
+    std::set<std::string>             labels_;
+    LabelsConstraint                  labelsConstraint_;
 
     void AddDicomConstraintInternal(const DicomTag& tag,
                                     ValueRepresentation vr,
@@ -42,7 +45,8 @@
     void AddConstraintInternal(DicomTagConstraint* constraint);  // Takes ownership
 
   public:
-    DatabaseLookup()
+    DatabaseLookup() :
+      labelsConstraint_(LabelsConstraint_All)
     {
     }
 
@@ -92,5 +96,22 @@
     bool HasTag(const DicomTag& tag) const;
 
     void RemoveConstraint(const DicomTag& tag);
+
+    void AddLabel(const std::string& label);
+
+    void SetLabelsConstraint(LabelsConstraint constraint)
+    {
+      labelsConstraint_ = constraint;
+    }
+
+    const std::set<std::string>& GetLabels() const
+    {
+      return labels_;
+    }
+
+    LabelsConstraint GetLabelsConstraint() const
+    {
+      return labelsConstraint_;
+    }
   };
 }
--- a/OrthancServer/Sources/Search/ISqlLookupFormatter.cpp	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/Sources/Search/ISqlLookupFormatter.cpp	Wed Apr 12 18:04:24 2023 +0200
@@ -39,6 +39,7 @@
 #include "DatabaseConstraint.h"
 
 #include <boost/lexical_cast.hpp>
+#include <list>
 
 
 namespace Orthanc
@@ -268,12 +269,46 @@
                " AND " + tag + ".tagElement = " +
                boost::lexical_cast<std::string>(constraint.GetTag().GetElement()));
   }
+
+
+  static std::string Join(const std::list<std::string>& values,
+                          const std::string& prefix,
+                          const std::string& separator)
+  {
+    if (values.empty())
+    {
+      return "";
+    }
+    else
+    {
+      std::string s = prefix;
+
+      bool first = true;
+      for (std::list<std::string>::const_iterator it = values.begin(); it != values.end(); ++it)
+      {
+        if (first)
+        {
+          first = false;
+        }
+        else
+        {
+          s += separator;
+        }
+
+        s += *it;
+      }
+
+      return s;
+    }
+  }
   
 
   void ISqlLookupFormatter::Apply(std::string& sql,
                                   ISqlLookupFormatter& formatter,
                                   const std::vector<DatabaseConstraint>& lookup,
                                   ResourceType queryLevel,
+                                  const std::set<std::string>& labels,
+                                  LabelsConstraint labelsConstraint,
                                   size_t limit)
   {
     assert(ResourceType_Patient < ResourceType_Study &&
@@ -346,9 +381,50 @@
               FormatLevel(static_cast<ResourceType>(level - 1)) + ".internalId=" +
               FormatLevel(static_cast<ResourceType>(level)) + ".parentId");
     }
+
+    std::list<std::string> where;
+    where.push_back(FormatLevel(queryLevel) + ".resourceType = " +
+                    formatter.FormatResourceType(queryLevel) + comparisons);
+
+    if (!labels.empty())
+    {
+      /**
+       * "In SQL Server, NOT EXISTS and NOT IN predicates are the best
+       * way to search for missing values, as long as both columns in
+       * question are NOT NULL."
+       * https://explainextended.com/2009/09/15/not-in-vs-not-exists-vs-left-join-is-null-sql-server/
+       **/
+
+      std::list<std::string> formattedLabels;
+      for (std::set<std::string>::const_iterator it = labels.begin(); it != labels.end(); ++it)
+      {
+        formattedLabels.push_back(formatter.GenerateParameter(*it));
+      }
+
+      std::string condition;
+      switch (labelsConstraint)
+      {
+        case LabelsConstraint_Any:
+          condition = "> 0";
+          break;
+          
+        case LabelsConstraint_All:
+          condition = "= " + boost::lexical_cast<std::string>(labels.size());
+          break;
+          
+        case LabelsConstraint_None:
+          condition = "= 0";
+          break;
+          
+        default:
+          throw OrthancException(ErrorCode_ParameterOutOfRange);
+      }
       
-    sql += (joins + " WHERE " + FormatLevel(queryLevel) + ".resourceType = " +
-            formatter.FormatResourceType(queryLevel) + comparisons);
+      where.push_back("(SELECT COUNT(1) FROM Labels AS selectedLabels WHERE selectedLabels.id = " + FormatLevel(queryLevel) +
+                      ".internalId AND selectedLabels.label IN (" + Join(formattedLabels, "", ", ") + ")) " + condition);
+    }
+
+    sql += joins + Join(where, " WHERE ", " AND ");
 
     if (limit != 0)
     {
--- a/OrthancServer/Sources/Search/ISqlLookupFormatter.h	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/Sources/Search/ISqlLookupFormatter.h	Wed Apr 12 18:04:24 2023 +0200
@@ -35,6 +35,13 @@
 {
   class DatabaseConstraint;
   
+  enum LabelsConstraint
+  {
+    LabelsConstraint_All,
+    LabelsConstraint_Any,
+    LabelsConstraint_None
+  };
+
   // This class is also used by the "orthanc-databases" project
   class ISqlLookupFormatter : public boost::noncopyable
   {
@@ -60,6 +67,8 @@
                       ISqlLookupFormatter& formatter,
                       const std::vector<DatabaseConstraint>& lookup,
                       ResourceType queryLevel,
+                      const std::set<std::string>& labels,  // New in Orthanc 1.12.0
+                      LabelsConstraint labelsConstraint,    // New in Orthanc 1.12.0
                       size_t limit);
   };
 }
--- a/OrthancServer/Sources/ServerContext.cpp	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/Sources/ServerContext.cpp	Wed Apr 12 18:04:24 2023 +0200
@@ -1448,8 +1448,9 @@
     }
 
     {
-      const size_t lookupLimit = (databaseLimit == 0 ? 0 : databaseLimit + 1);      
-      GetIndex().ApplyLookupResources(resources, &instances, *fastLookup, queryLevel, lookupLimit);
+      const size_t lookupLimit = (databaseLimit == 0 ? 0 : databaseLimit + 1);
+      GetIndex().ApplyLookupResources(resources, &instances, *fastLookup, queryLevel,
+                                      lookup.GetLabels(), lookup.GetLabelsConstraint(), lookupLimit);
     }
 
     bool complete = (databaseLimit == 0 ||
@@ -1542,7 +1543,7 @@
           ComputeStudyTags(resource, *this, resources[i], requestedTags);
 
           std::vector<std::string> modalities;
-          Toolbox::TokenizeString(modalities, resource.tags_.GetValue(DICOM_TAG_MODALITIES_IN_STUDY).GetContent(), '\\');
+          Toolbox::TokenizeString(modalities, resource.GetMainDicomTags().GetValue(DICOM_TAG_MODALITIES_IN_STUDY).GetContent(), '\\');
           bool hasAtLeastOneModalityMatching = false;
           for (size_t m = 0; m < modalities.size(); m++)
           {
@@ -1551,7 +1552,7 @@
 
           isMatch = isMatch && hasAtLeastOneModalityMatching;
           // copy the value of ModalitiesInStudy such that it can be reused to build the answer
-          allMainDicomTagsFromDB.SetValue(DICOM_TAG_MODALITIES_IN_STUDY, resource.tags_.GetValue(DICOM_TAG_MODALITIES_IN_STUDY));
+          allMainDicomTagsFromDB.SetValue(DICOM_TAG_MODALITIES_IN_STUDY, resource.GetMainDicomTags().GetValue(DICOM_TAG_MODALITIES_IN_STUDY));
         }
 
         if (isMatch)
@@ -1990,10 +1991,10 @@
   {
     target = Json::objectValue;
 
-    target["Type"] = GetResourceTypeText(resource.type_, false, true);
-    target["ID"] = resource.id_;
+    target["Type"] = GetResourceTypeText(resource.GetLevel(), false, true);
+    target["ID"] = resource.GetPublicId();
 
-    switch (resource.type_)
+    switch (resource.GetLevel())
     {
       case ResourceType_Patient:
         break;
@@ -2014,7 +2015,7 @@
         throw OrthancException(ErrorCode_InternalError);
     }
 
-    switch (resource.type_)
+    switch (resource.GetLevel())
     {
       case ResourceType_Patient:
       case ResourceType_Study:
@@ -2028,11 +2029,11 @@
           c.append(*it);
         }
 
-        if (resource.type_ == ResourceType_Patient)
+        if (resource.GetLevel() == ResourceType_Patient)
         {
           target["Studies"] = c;
         }
-        else if (resource.type_ == ResourceType_Study)
+        else if (resource.GetLevel() == ResourceType_Study)
         {
           target["Series"] = c;
         }
@@ -2050,7 +2051,7 @@
         throw OrthancException(ErrorCode_InternalError);
     }
 
-    switch (resource.type_)
+    switch (resource.GetLevel())
     {
       case ResourceType_Patient:
       case ResourceType_Study:
@@ -2099,9 +2100,9 @@
       target["ModifiedFrom"] = resource.modifiedFrom_;
     }
 
-    if (resource.type_ == ResourceType_Patient ||
-        resource.type_ == ResourceType_Study ||
-        resource.type_ == ResourceType_Series)
+    if (resource.GetLevel() == ResourceType_Patient ||
+        resource.GetLevel() == ResourceType_Study ||
+        resource.GetLevel() == ResourceType_Series)
     {
       target["IsStable"] = resource.isStable_;
 
@@ -2117,15 +2118,15 @@
     static const char* const PATIENT_MAIN_DICOM_TAGS = "PatientMainDicomTags";
 
     DicomMap mainDicomTags;
-    resource.tags_.ExtractResourceInformation(mainDicomTags, resource.type_);
+    resource.GetMainDicomTags().ExtractResourceInformation(mainDicomTags, resource.GetLevel());
 
     target[MAIN_DICOM_TAGS] = Json::objectValue;
     FromDcmtkBridge::ToJson(target[MAIN_DICOM_TAGS], mainDicomTags, format);
     
-    if (resource.type_ == ResourceType_Study)
+    if (resource.GetLevel() == ResourceType_Study)
     {
       DicomMap patientMainDicomTags;
-      resource.tags_.ExtractPatientInformation(patientMainDicomTags);
+      resource.GetMainDicomTags().ExtractPatientInformation(patientMainDicomTags);
 
       target[PATIENT_MAIN_DICOM_TAGS] = Json::objectValue;
       FromDcmtkBridge::ToJson(target[PATIENT_MAIN_DICOM_TAGS], patientMainDicomTags, format);
@@ -2136,13 +2137,23 @@
       static const char* const REQUESTED_TAGS = "RequestedTags";
 
       DicomMap tags;
-      resource.tags_.ExtractTags(tags, requestedTags);
+      resource.GetMainDicomTags().ExtractTags(tags, requestedTags);
 
       target[REQUESTED_TAGS] = Json::objectValue;
       FromDcmtkBridge::ToJson(target[REQUESTED_TAGS], tags, format);
 
     }
 
+    {
+      Json::Value labels = Json::arrayValue;
+
+      for (std::set<std::string>::const_iterator it = resource.labels_.begin(); it != resource.labels_.end(); ++it)
+      {
+        labels.append(*it);
+      }
+
+      target["Labels"] = labels;
+    }
   }
 
 
@@ -2153,7 +2164,7 @@
   {
     if (requestedTags.count(DICOM_TAG_INSTANCE_AVAILABILITY) > 0)
     {
-      resource.tags_.SetValue(DICOM_TAG_INSTANCE_AVAILABILITY, "ONLINE", false);
+      resource.GetMainDicomTags().SetValue(DICOM_TAG_INSTANCE_AVAILABILITY, "ONLINE", false);
       resource.missingRequestedTags_.erase(DICOM_TAG_INSTANCE_AVAILABILITY);
     }
   }
@@ -2171,7 +2182,7 @@
 
       index.GetChildren(instances, seriesPublicId);
 
-      resource.tags_.SetValue(DICOM_TAG_NUMBER_OF_SERIES_RELATED_INSTANCES,
+      resource.GetMainDicomTags().SetValue(DICOM_TAG_NUMBER_OF_SERIES_RELATED_INSTANCES,
                               boost::lexical_cast<std::string>(instances.size()), false);
       resource.missingRequestedTags_.erase(DICOM_TAG_NUMBER_OF_SERIES_RELATED_INSTANCES);
     }
@@ -2216,13 +2227,13 @@
       std::string modalities;
       Toolbox::JoinStrings(modalities, values, "\\");
 
-      resource.tags_.SetValue(DICOM_TAG_MODALITIES_IN_STUDY, modalities, false);
+      resource.GetMainDicomTags().SetValue(DICOM_TAG_MODALITIES_IN_STUDY, modalities, false);
       resource.missingRequestedTags_.erase(DICOM_TAG_MODALITIES_IN_STUDY);
     }
 
     if (hasNbRelatedSeries)
     {
-      resource.tags_.SetValue(DICOM_TAG_NUMBER_OF_STUDY_RELATED_SERIES,
+      resource.GetMainDicomTags().SetValue(DICOM_TAG_NUMBER_OF_STUDY_RELATED_SERIES,
                               boost::lexical_cast<std::string>(series.size()), false);
       resource.missingRequestedTags_.erase(DICOM_TAG_NUMBER_OF_STUDY_RELATED_SERIES);
     }
@@ -2240,7 +2251,7 @@
 
       if (hasNbRelatedInstances)
       {
-        resource.tags_.SetValue(DICOM_TAG_NUMBER_OF_STUDY_RELATED_INSTANCES,
+        resource.GetMainDicomTags().SetValue(DICOM_TAG_NUMBER_OF_STUDY_RELATED_INSTANCES,
                                 boost::lexical_cast<std::string>(instances.size()), false);      
         resource.missingRequestedTags_.erase(DICOM_TAG_NUMBER_OF_STUDY_RELATED_INSTANCES);
       }
@@ -2264,7 +2275,7 @@
         {
           std::string sopClassUids;
           Toolbox::JoinStrings(sopClassUids, values, "\\");
-          resource.tags_.SetValue(DICOM_TAG_SOP_CLASSES_IN_STUDY, sopClassUids, false);
+          resource.GetMainDicomTags().SetValue(DICOM_TAG_SOP_CLASSES_IN_STUDY, sopClassUids, false);
         }
 
         resource.missingRequestedTags_.erase(DICOM_TAG_SOP_CLASSES_IN_STUDY);
@@ -2291,7 +2302,7 @@
 
     if (hasNbRelatedStudies)
     {
-      resource.tags_.SetValue(DICOM_TAG_NUMBER_OF_PATIENT_RELATED_STUDIES,
+      resource.GetMainDicomTags().SetValue(DICOM_TAG_NUMBER_OF_PATIENT_RELATED_STUDIES,
                               boost::lexical_cast<std::string>(studies.size()), false);
       resource.missingRequestedTags_.erase(DICOM_TAG_NUMBER_OF_PATIENT_RELATED_STUDIES);
     }
@@ -2308,7 +2319,7 @@
 
       if (hasNbRelatedSeries)
       {
-        resource.tags_.SetValue(DICOM_TAG_NUMBER_OF_PATIENT_RELATED_SERIES,
+        resource.GetMainDicomTags().SetValue(DICOM_TAG_NUMBER_OF_PATIENT_RELATED_SERIES,
                                 boost::lexical_cast<std::string>(series.size()), false);
         resource.missingRequestedTags_.erase(DICOM_TAG_NUMBER_OF_PATIENT_RELATED_SERIES);
       }
@@ -2324,7 +2335,7 @@
         instances.splice(instances.end(), thisInstancesIds);
       }
 
-      resource.tags_.SetValue(DICOM_TAG_NUMBER_OF_PATIENT_RELATED_INSTANCES,
+      resource.GetMainDicomTags().SetValue(DICOM_TAG_NUMBER_OF_PATIENT_RELATED_INSTANCES,
                               boost::lexical_cast<std::string>(instances.size()), false);
       resource.missingRequestedTags_.erase(DICOM_TAG_NUMBER_OF_PATIENT_RELATED_INSTANCES);
     }
@@ -2388,7 +2399,7 @@
   {
     ExpandedResource resource;
 
-    if (ExpandResource(resource, publicId, mainDicomTags, instanceId, dicomAsJson, level, requestedTags, ExpandResourceDbFlags_Default, allowStorageAccess))
+    if (ExpandResource(resource, publicId, mainDicomTags, instanceId, dicomAsJson, level, requestedTags, ExpandResourceFlags_Default, allowStorageAccess))
     {
       SerializeExpandedResource(target, resource, format, requestedTags);
       return true;
@@ -2404,57 +2415,63 @@
                                      const Json::Value* dicomAsJson,   // optional: the dicom-as-json for the resource (if already available)
                                      ResourceType level,
                                      const std::set<DicomTag>& requestedTags,
-                                     ExpandResourceDbFlags expandFlags,
+                                     ExpandResourceFlags expandFlags,
                                      bool allowStorageAccess)
   {
     // first try to get the tags from what is already available
     
-    if ((expandFlags & ExpandResourceDbFlags_IncludeMainDicomTags)
-      && (mainDicomTags.GetSize() > 0)
-      && (dicomAsJson != NULL))
+    if ((expandFlags & ExpandResourceFlags_IncludeMainDicomTags) &&
+        mainDicomTags.GetSize() > 0 &&
+        dicomAsJson != NULL)
     {
       
-      resource.tags_.Merge(mainDicomTags);
+      resource.GetMainDicomTags().Merge(mainDicomTags);
 
       if (dicomAsJson->isObject())
       {
-        resource.tags_.FromDicomAsJson(*dicomAsJson);
+        resource.GetMainDicomTags().FromDicomAsJson(*dicomAsJson);
       }
 
       std::set<DicomTag> retrievedTags;
       std::set<DicomTag> missingTags;
-      resource.tags_.GetTags(retrievedTags);
+      resource.GetMainDicomTags().GetTags(retrievedTags);
 
       Toolbox::GetMissingsFromSet(missingTags, requestedTags, retrievedTags);
 
       // if all possible tags have been read, no need to get them from DB anymore
       if (missingTags.size() == 0 || DicomMap::HasOnlyComputedTags(missingTags))
       {
-        expandFlags = static_cast<ExpandResourceDbFlags>(expandFlags & ~ExpandResourceDbFlags_IncludeMainDicomTags);
+        expandFlags = static_cast<ExpandResourceFlags>(expandFlags & ~ExpandResourceFlags_IncludeMainDicomTags);
       }
 
-      if (missingTags.size() == 0 && expandFlags == ExpandResourceDbFlags_None)  // we have already retrieved anything we need
+      if (missingTags.size() == 0 && expandFlags == ExpandResourceFlags_None)  // we have already retrieved anything we need
       {
         return true;
       }
     }
 
-    if (expandFlags != ExpandResourceDbFlags_None
-        && GetIndex().ExpandResource(resource, publicId, level, requestedTags, static_cast<ExpandResourceDbFlags>(expandFlags | ExpandResourceDbFlags_IncludeMetadata)))  // we always need the metadata to get the mainDicomTagsSignature
+    if (expandFlags != ExpandResourceFlags_None &&
+        GetIndex().ExpandResource(resource, publicId, level, requestedTags,
+                                  static_cast<ExpandResourceFlags>(expandFlags | ExpandResourceFlags_IncludeMetadata)))  // we always need the metadata to get the mainDicomTagsSignature
     {
       // check the main dicom tags list has not changed since the resource was stored
-      if (resource.mainDicomTagsSignature_ != DicomMap::GetMainDicomTagsSignature(resource.type_))
+      if (resource.mainDicomTagsSignature_ != DicomMap::GetMainDicomTagsSignature(resource.GetLevel()))
       {
         OrthancConfiguration::ReaderLock lock;
         if (lock.GetConfiguration().IsWarningEnabled(Warnings_002_InconsistentDicomTagsInDb))
         {
-          LOG(WARNING) << "W002: " << Orthanc::GetResourceTypeText(resource.type_, false , false) << " has been stored with another version of Main Dicom Tags list, you should POST to /" << Orthanc::GetResourceTypeText(resource.type_, true, false) << "/" << resource.id_ << "/reconstruct to update the list of tags saved in DB.  Some MainDicomTags might be missing from this answer.";
+          LOG(WARNING) << "W002: " << Orthanc::GetResourceTypeText(resource.GetLevel(), false , false)
+                       << " has been stored with another version of Main Dicom Tags list, you should POST to /"
+                       << Orthanc::GetResourceTypeText(resource.GetLevel(), true, false)
+                       << "/" << resource.GetPublicId()
+                       << "/reconstruct to update the list of tags saved in DB.  Some MainDicomTags might be missing from this answer.";
         }
       }
 
       // possibly merge missing requested tags from dicom-as-json
-      if (allowStorageAccess
-          && !resource.missingRequestedTags_.empty() && !DicomMap::HasOnlyComputedTags(resource.missingRequestedTags_))
+      if (allowStorageAccess &&
+          !resource.missingRequestedTags_.empty() &&
+          !DicomMap::HasOnlyComputedTags(resource.missingRequestedTags_))
       {
         OrthancConfiguration::ReaderLock lock;
         if (lock.GetConfiguration().IsWarningEnabled(Warnings_001_TagsBeingReadFromStorage))
@@ -2472,7 +2489,9 @@
           std::string missings;
           FromDcmtkBridge::FormatListOfTags(missings, missingTags);
 
-          LOG(WARNING) << "W001: Accessing Dicom tags from storage when accessing " << Orthanc::GetResourceTypeText(resource.type_, false , false) << " : " << missings;
+          LOG(WARNING) << "W001: Accessing Dicom tags from storage when accessing "
+                       << Orthanc::GetResourceTypeText(resource.GetLevel(), false, false)
+                       << " : " << missings;
         }
 
 
@@ -2508,7 +2527,7 @@
           tagsFromJson.FromDicomAsJson(*dicomAsJson, false /* append */, true /* parseSequences*/);
         }
 
-        resource.tags_.Merge(tagsFromJson);
+        resource.GetMainDicomTags().Merge(tagsFromJson);
       }
 
       // compute the requested tags
--- a/OrthancServer/Sources/ServerContext.h	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/Sources/ServerContext.h	Wed Apr 12 18:04:24 2023 +0200
@@ -585,7 +585,7 @@
                         const Json::Value* dicomAsJson,   // optional: the dicom-as-json for the resource
                         ResourceType level,
                         const std::set<DicomTag>& requestedTags,
-                        ExpandResourceDbFlags expandFlags,
+                        ExpandResourceFlags expandFlags,
                         bool allowStorageAccess);
 
     FindStorageAccessMode GetFindStorageAccessMode() const
--- a/OrthancServer/Sources/ServerJobs/ResourceModificationJob.cpp	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/Sources/ServerJobs/ResourceModificationJob.cpp	Wed Apr 12 18:04:24 2023 +0200
@@ -675,9 +675,12 @@
       replacePatientMainDicomTags |= DicomMap::IsMainDicomTag(*it, ResourceType_Patient);
     }
 
-    if ((modificationLevel == ResourceType_Study || modificationLevel == ResourceType_Patient)
-        && !modification_->IsReplaced(DICOM_TAG_PATIENT_ID) 
-        && modification_->IsKept(DICOM_TAG_STUDY_INSTANCE_UID) && modification_->IsKept(DICOM_TAG_SERIES_INSTANCE_UID) && modification_->IsKept(DICOM_TAG_SOP_INSTANCE_UID))
+    if ((modificationLevel == ResourceType_Study ||
+         modificationLevel == ResourceType_Patient) &&
+        !modification_->IsReplaced(DICOM_TAG_PATIENT_ID) &&
+        modification_->IsKept(DICOM_TAG_STUDY_INSTANCE_UID) &&
+        modification_->IsKept(DICOM_TAG_SERIES_INSTANCE_UID) &&
+        modification_->IsKept(DICOM_TAG_SOP_INSTANCE_UID))
     {
       // if we keep the SOPInstanceUID, it very likely means that we are modifying existing resources 'in place'
 
@@ -715,9 +718,9 @@
         else
         {
           ExpandedResource originalStudy;
-          if (GetContext().GetIndex().ExpandResource(originalStudy, *studyId, ResourceType_Study, emptyRequestedTags, ExpandResourceDbFlags_IncludeMainDicomTags))
+          if (GetContext().GetIndex().ExpandResource(originalStudy, *studyId, ResourceType_Study, emptyRequestedTags, ExpandResourceFlags_IncludeMainDicomTags))
           {
-            targetPatientId = originalStudy.tags_.GetStringValue(DICOM_TAG_PATIENT_ID, "", false);
+            targetPatientId = originalStudy.GetMainDicomTags().GetStringValue(DICOM_TAG_PATIENT_ID, "", false);
           }
           else
           {
@@ -734,7 +737,7 @@
         {
           ExpandedResource targetPatient;
           
-          if (GetContext().GetIndex().ExpandResource(targetPatient, lookupPatientResult[0], ResourceType_Patient, emptyRequestedTags, static_cast<ExpandResourceDbFlags>(ExpandResourceDbFlags_IncludeMainDicomTags | ExpandResourceDbFlags_IncludeChildren)))
+          if (GetContext().GetIndex().ExpandResource(targetPatient, lookupPatientResult[0], ResourceType_Patient, emptyRequestedTags, static_cast<ExpandResourceFlags>(ExpandResourceFlags_IncludeMainDicomTags | ExpandResourceFlags_IncludeChildren)))
           {
             const std::list<std::string> childrenIds = targetPatient.childrenIds_;
             bool targetPatientHasOtherStudies = childrenIds.size() > 1;
@@ -747,7 +750,7 @@
             {
               // this is allowed if all patient replacedTags do match the target patient tags
               DicomMap targetPatientTags;
-              targetPatient.tags_.ExtractPatientInformation(targetPatientTags);
+              targetPatient.GetMainDicomTags().ExtractPatientInformation(targetPatientTags);
 
               std::set<DicomTag> mainPatientTags;
               DicomMap::GetMainDicomTags(mainPatientTags, ResourceType_Patient);
@@ -755,9 +758,9 @@
               for (std::set<DicomTag>::const_iterator mainPatientTag = mainPatientTags.begin();
                    mainPatientTag != mainPatientTags.end(); ++mainPatientTag)
               {
-                if (targetPatientTags.HasTag(*mainPatientTag) 
-                    && (!modification_->IsReplaced(*mainPatientTag) 
-                        || modification_->GetReplacementAsString(*mainPatientTag) != targetPatientTags.GetStringValue(*mainPatientTag, "", false)))
+                if (targetPatientTags.HasTag(*mainPatientTag) &&
+                    (!modification_->IsReplaced(*mainPatientTag) ||
+                     modification_->GetReplacementAsString(*mainPatientTag) != targetPatientTags.GetStringValue(*mainPatientTag, "", false)))
                 {
                   throw OrthancException(ErrorCode_BadRequest, std::string("Trying to change patient tags in a study.  The Patient already exists and has other studies.  All the 'Replace' tags should match the existing patient main dicom tags.  Try using /patients/../modify instead to modify the patient. Failing tag: ") + mainPatientTag->Format());
                 }
@@ -769,8 +772,7 @@
             }
           }
         }
-      }
-      
+      }      
     }
   }
 }
--- a/OrthancServer/Sources/ServerToolbox.cpp	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/Sources/ServerToolbox.cpp	Wed Apr 12 18:04:24 2023 +0200
@@ -287,5 +287,47 @@
         }
       }
     }
+
+    
+    bool IsValidLabel(const std::string& label)
+    {
+      if (label.empty())
+      {
+        return false;
+      }
+
+      if (label.size() > 64)
+      {
+        // This limitation is for MySQL, which cannot use a TEXT
+        // column of undefined length as a primary key
+        return false;
+      }
+      
+      for (size_t i = 0; i < label.size(); i++)
+      {
+        if (!(label[i] == '_' ||
+              label[i] == '-' ||
+              (label[i] >= 'a' && label[i] <= 'z') ||
+              (label[i] >= 'A' && label[i] <= 'Z') ||
+              (label[i] >= '0' && label[i] <= '9')))
+        {
+          return false;
+        }
+      }
+
+      return true;
+    }
+
+
+    void CheckValidLabel(const std::string& label)
+    {
+      if (!IsValidLabel(label))
+      {
+        throw OrthancException(ErrorCode_ParameterOutOfRange,
+                               "A label must be a non-empty, alphanumeric string, "
+                               "possibly with '_' or '-' characters, "
+                               "with maximum 64 characters, but got: " + label);
+      }
+    }
   }
 }
--- a/OrthancServer/Sources/ServerToolbox.h	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/Sources/ServerToolbox.h	Wed Apr 12 18:04:24 2023 +0200
@@ -56,5 +56,9 @@
     void ReconstructResource(ServerContext& context,
                              const std::string& resource,
                              bool reconstructFiles);
+
+    bool IsValidLabel(const std::string& label);
+
+    void CheckValidLabel(const std::string& label);
   }
 }
--- a/OrthancServer/Sources/main.cpp	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/Sources/main.cpp	Wed Apr 12 18:04:24 2023 +0200
@@ -1664,8 +1664,12 @@
     }
   }
 
-  bool success = ConfigureServerContext
-    (database, storageArea, plugins, loadJobsFromDatabase);
+  if (!database.HasLabelsSupport())
+  {
+    LOG(WARNING) << "The custom database back-end has *no* support for labels";
+  }
+
+  bool success = ConfigureServerContext(database, storageArea, plugins, loadJobsFromDatabase);
 
   database.Close();
 
--- a/OrthancServer/UnitTestsSources/ServerIndexTests.cpp	Wed Apr 12 18:04:05 2023 +0200
+++ b/OrthancServer/UnitTestsSources/ServerIndexTests.cpp	Wed Apr 12 18:04:24 2023 +0200
@@ -167,8 +167,9 @@
       
       std::vector<DatabaseConstraint> lookup;
       lookup.push_back(c.ConvertToDatabaseConstraint(level, DicomTagType_Identifier));
-      
-      transaction_->ApplyLookupResources(result, NULL, lookup, level, 0 /* no limit */);
+
+      std::set<std::string> noLabel;
+      transaction_->ApplyLookupResources(result, NULL, lookup, level, noLabel, LabelsConstraint_All, 0 /* no limit */);
     }    
 
     void DoLookupIdentifier2(std::list<std::string>& result,
@@ -188,7 +189,8 @@
       lookup.push_back(c1.ConvertToDatabaseConstraint(level, DicomTagType_Identifier));
       lookup.push_back(c2.ConvertToDatabaseConstraint(level, DicomTagType_Identifier));
       
-      transaction_->ApplyLookupResources(result, NULL, lookup, level, 0 /* no limit */);
+      std::set<std::string> noLabel;
+      transaction_->ApplyLookupResources(result, NULL, lookup, level, noLabel, LabelsConstraint_All, 0 /* no limit */);
     }
   };
 }
@@ -1038,3 +1040,15 @@
     }
   }
 }
+
+
+TEST(ServerToolbox, ValidLabels)
+{
+  ASSERT_TRUE(ServerToolbox::IsValidLabel("abcdefghijklmnopqrstuvwxyz"
+                                          "ABCDEFGHIJKLMNOPQRSTUVWXYZ"));
+  ASSERT_TRUE(ServerToolbox::IsValidLabel("0123456789-_"));
+  ASSERT_FALSE(ServerToolbox::IsValidLabel(""));
+  ASSERT_FALSE(ServerToolbox::IsValidLabel(" "));
+  ASSERT_FALSE(ServerToolbox::IsValidLabel("&"));
+  ASSERT_FALSE(ServerToolbox::IsValidLabel("."));
+}