changeset 574:41f3ecfa81bd annotations

reorganization
author Sebastien Jodogne <s.jodogne@gmail.com>
date Wed, 02 Sep 2026 16:30:04 +0200
parents bcf6c5f43f1d
children 86082b569b99
files ViewerPlugin/Annotations/AnnotationsRestApi.cpp ViewerPlugin/Annotations/AnnotationsWorkspace.cpp ViewerPlugin/Annotations/AnnotationsWorkspace.h ViewerPlugin/Annotations/CachedAnnotationsWorkspace.cpp ViewerPlugin/Annotations/CachedAnnotationsWorkspace.h ViewerPlugin/Annotations/UserFeatures.cpp ViewerPlugin/Annotations/UserFeatures.h ViewerPlugin/CMakeLists.txt
diffstat 8 files changed, 869 insertions(+), 157 deletions(-) [+]
line wrap: on
line diff
--- a/ViewerPlugin/Annotations/AnnotationsRestApi.cpp	Wed Sep 02 16:09:00 2026 +0200
+++ b/ViewerPlugin/Annotations/AnnotationsRestApi.cpp	Wed Sep 02 16:30:04 2026 +0200
@@ -26,11 +26,11 @@
 
 #include "../ViewerConfiguration.h"
 #include "../ViewerToolbox.h"
-#include "AnnotationsWorkspace.h"
+#include "CachedAnnotationsWorkspace.h"
 #include "IAuthenticatedUser.h"
+#include "UserFeatures.h"
 
 #include <Cache/SharedObjectCache.h>
-#include <Compression/GzipCompressor.h>
 #include <Logging.h>
 #include <SerializationToolbox.h>
 
@@ -42,56 +42,11 @@
   static const char* const KEY_FEATURES = "features";
   static const char* const KEY_ID = "id";
   static const char* const KEY_LAYERS = "layers";
-  static const char* const KEY_LAYER_ID = "layer-id";
   static const char* const KEY_NAME = "name";
   static const char* const KEY_PUBLIC = "public";
-  static const char* const KEY_TYPE = "type";
-  static const char* const KEY_VERSION = "version";
   static const char* const KEY_PROJECT_NAME = "project-name";
   static const char* const KEY_PROJECT_DESCRIPTION = "project-description";
-
-
-  class CachedAnnotationsWorkspace : public boost::noncopyable
-  {
-  private:
-    boost::shared_ptr<Orthanc::IDynamicObject>  cached_;
-
-    static Orthanc::SharedObjectCache& GetCache()
-    {
-      static boost::mutex  mutex;
-      static std::unique_ptr<Orthanc::SharedObjectCache>  cache;
-
-      {
-        boost::mutex::scoped_lock lock(mutex);
-
-        if (cache.get() == NULL)
-        {
-          cache.reset(new Orthanc::SharedObjectCache(ViewerConfiguration::GetInstance().GetAnnotationsCacheSize()));
-        }
-
-        return *cache;
-      }
-    }
-
-  public:
-    CachedAnnotationsWorkspace(const AnnotationsWorkspaceId& id)
-    {
-      const std::string key = id.GetInfoKey();
-
-      cached_ = GetCache().GetCachedValue(key);
-
-      if (cached_.get() == NULL)
-      {
-        cached_.reset(new AnnotationsWorkspace(id));
-        GetCache().Store(key, cached_, 1);
-      }
-    }
-
-    AnnotationsWorkspace& GetContent() const
-    {
-      return dynamic_cast<AnnotationsWorkspace&>(*cached_);
-    }
-  };
+  static const char* const KEY_LAYER_ID = "layer-id";
 
 
   class AnnotationsCommandContext : public boost::noncopyable
@@ -296,115 +251,6 @@
   }
 
 
-  class UserFeatures : public Orthanc::IDynamicObject
-  {
-  private:
-    Orthanc::ReaderWriterLock  mutex_;
-    std::string                key_;
-    Json::Value                content_;
-
-    void Load()
-    {
-      content_ = Json::arrayValue;
-
-      std::string compressed;
-      if (ViewerToolbox::LookupKeyValueStore(compressed, key_))
-      {
-        std::string uncompressed;
-        Orthanc::GzipCompressor compressor;
-        Orthanc::IBufferCompressor::Uncompress(uncompressed, compressor, compressed);
-
-        Json::Value unserialized;
-
-        if (!Orthanc::Toolbox::ReadJson(unserialized, uncompressed) ||
-            !unserialized.isObject() ||
-            !unserialized.isMember(KEY_FEATURES) ||
-            !unserialized[KEY_FEATURES].isArray())
-        {
-          throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
-        }
-
-        const unsigned int version = Orthanc::SerializationToolbox::ReadUnsignedInteger(unserialized, KEY_VERSION);
-
-        if (version == ORTHANC_WSI_ANNOTATIONS_VERSION)
-        {
-          content_ = unserialized[KEY_FEATURES];
-        }
-        else
-        {
-          switch (version)
-          {
-            // Implement version conversion here
-
-          default:
-            throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented, "Cannot load annotations from version: " +
-                                            boost::lexical_cast<std::string>(version));
-          }
-        }
-      }
-    }
-
-    void Save() const
-    {
-      assert(content_.isArray());
-
-      Json::Value unserialized;
-      unserialized[KEY_VERSION] = static_cast<unsigned int>(ORTHANC_WSI_ANNOTATIONS_VERSION);
-      unserialized[KEY_FEATURES] = content_;
-
-      std::string serialized;
-      Orthanc::Toolbox::WriteFastJson(serialized, unserialized);
-
-      std::string compressed;
-      Orthanc::GzipCompressor compressor;
-      Orthanc::IBufferCompressor::Compress(compressed, compressor, serialized);
-
-      ViewerToolbox::SetKeyValueStore(key_, compressed);
-    }
-
-  public:
-    UserFeatures(const std::string& key) :
-      key_(key)
-    {
-      Load();
-    }
-
-    void GetContent(Json::Value& target)
-    {
-      Orthanc::ReaderWriterLock::ReadLock lock(mutex_);
-
-      assert(content_.isArray());
-      target = content_;
-    }
-
-    void SetContent(const Json::Value& content)
-    {
-      if (!content.isArray())
-      {
-        throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat);
-      }
-
-      for (Json::Value::ArrayIndex i = 0; i < content.size(); i++)
-      {
-        if (!content[i].isObject() ||
-            !content[i].isMember(KEY_LAYER_ID) ||
-            !content[i].isMember(KEY_TYPE) ||
-            !content[i][KEY_LAYER_ID].isString() ||
-            !content[i][KEY_TYPE].isString())
-        {
-          throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat);
-        }
-      }
-
-      {
-        Orthanc::ReaderWriterLock::WriteLock lock(mutex_);
-        content_ = content;
-        Save();
-      }
-    }
-  };
-
-
   class CachedUserFeatures : public boost::noncopyable
   {
   private:
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/ViewerPlugin/Annotations/AnnotationsWorkspace.cpp	Wed Sep 02 16:30:04 2026 +0200
@@ -0,0 +1,420 @@
+/**
+ * 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) 2024-2026 Orthanc Team SRL, Belgium
+ * Copyright (C) 2021-2026 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
+ *
+ * This program is free software: you can redistribute it and/or
+ * modify it under the terms of the GNU Affero General Public License
+ * as published by the Free Software Foundation, either version 3 of
+ * the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Affero General Public License for more details.
+ * 
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ **/
+
+
+#include "../../Framework/PrecompiledHeadersWSI.h"
+#include "AnnotationsWorkspace.h"
+
+#include "../ViewerToolbox.h"
+
+#include <OrthancException.h>
+
+#include <boost/regex.hpp>
+
+
+static const char* const KEY_ACTIVE_USERS = "active-users";
+
+
+namespace OrthancWSI
+{
+  class AnnotationsWorkspace::PersistentInfo : public ISerializable
+  {
+  private:
+    std::set<UserId>   activeUsers_;
+
+  public:
+    PersistentInfo()
+    {
+    }
+
+
+    PersistentInfo(const Json::Value& serialized)
+    {
+      const Json::Value& users = serialized[KEY_ACTIVE_USERS];
+
+      if (!users.isArray())
+      {
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat);
+      }
+
+      for (Json::Value::ArrayIndex i = 0; i < users.size(); i++)
+      {
+        activeUsers_.insert(UserId(users[i]));
+      }
+    }
+
+
+    // Return "true" iff. the user was not already tagged as active
+    bool AddActiveUser(const UserId& user)
+    {
+      if (activeUsers_.find(user) == activeUsers_.end())
+      {
+        activeUsers_.insert(user);
+        return true;
+      }
+      else
+      {
+        return false;
+      }
+    }
+
+
+    const std::set<UserId>& GetActiveUsers() const
+    {
+      return activeUsers_;
+    }
+
+
+    virtual void Serialize(Json::Value& serialized) const ORTHANC_OVERRIDE
+    {
+      Json::Value users = Json::arrayValue;
+      for (std::set<UserId>::const_iterator it = activeUsers_.begin(); it != activeUsers_.end(); ++it)
+      {
+        Json::Value user;
+        it->Serialize(user);
+        users.append(user);
+      }
+
+      serialized = Json::objectValue;
+      serialized[KEY_ACTIVE_USERS] = users;
+    }
+  };
+
+
+  void AnnotationsWorkspace::Load(const UserId& user)
+  {
+    const std::string key = id_.GetSettingsKey(user);
+
+    Json::Value layers;
+    if (ViewerToolbox::LookupKeyValueStore(layers, key))
+    {
+      std::unique_ptr<UserAnnotationsSettings> item(new UserAnnotationsSettings(layers));
+
+      if (content_.find(user) == content_.end())  // Should never be false
+      {
+        content_[user] = item.release();
+      }
+    }
+  }
+
+
+  AnnotationsWorkspace::AnnotationsWorkspace(const AnnotationsWorkspaceId& id) :
+    id_(id),
+    projectInformation_(id.GetProjectId())
+  {
+    const std::string key = id.GetInfoKey();
+
+    Json::Value info;
+
+    if (ViewerToolbox::LookupKeyValueStore(info, key))
+    {
+      persistentInfo_.reset(new PersistentInfo(info));
+
+      for (std::set<UserId>::const_iterator it = persistentInfo_->GetActiveUsers().begin();
+           it != persistentInfo_->GetActiveUsers().end(); ++it)
+      {
+        Load(*it);
+      }
+    }
+    else
+    {
+      persistentInfo_.reset(new PersistentInfo);
+      persistentInfo_->Serialize(info);
+      ViewerToolbox::SetKeyValueStore(key, info);
+    }
+  }
+
+
+  AnnotationsWorkspace::~AnnotationsWorkspace()
+  {
+    for (Content::iterator it = content_.begin(); it != content_.end(); ++it)
+    {
+      assert(it->second != NULL);
+      delete it->second;
+    }
+  }
+
+
+  void AnnotationsWorkspace::SearchActiveUsers(std::set<UserId>& target,
+                                               const std::string& query)
+  {
+    Orthanc::ReaderWriterLock::ReadLock lock(mutex_);
+
+    target.clear();
+
+    const boost::regex re(query);
+    const std::set<UserId>& activeUsers = persistentInfo_->GetActiveUsers();
+
+    for (std::set<UserId>::const_iterator it = activeUsers.begin(); it != activeUsers.end(); ++it)
+    {
+      if (boost::regex_search(it->GetName(), re))
+      {
+        target.insert(*it);
+      }
+    }
+  }
+
+
+  void AnnotationsWorkspace::ListUsersSharingLayerWith(std::set<UserId>& target,
+                                                       const UserId& user)
+  {
+    Orthanc::ReaderWriterLock::ReadLock lock(mutex_);
+
+    target.clear();
+
+    // Loop over all the users in this workspace
+    for (Content::const_iterator it = content_.begin(); it != content_.end(); ++it)
+    {
+      assert(it->second != NULL);
+      if (!user.Equals(it->first))  // Don't add self
+      {
+        LayersCollection::Iterator iterator(it->second->GetUserLayers());
+
+        // Loop over all the user layers in this workspace
+        while (!iterator.IsDone())
+        {
+          const UserLayer& layer = dynamic_cast<const UserLayer&>(iterator.GetLayer());
+
+          if (layer.IsSharedWith(user))
+          {
+            target.insert(it->first);
+            break;
+          }
+          else
+          {
+            iterator.Next();
+          }
+        }
+      }
+    }
+  }
+
+
+  AnnotationsWorkspace::UserReader::UserReader(AnnotationsWorkspace& that,
+                                               const UserId& userId) :
+    lock_(that.mutex_),
+    that_(that),
+    userId_(userId)
+  {
+    Content::const_iterator found = that.content_.find(userId);
+
+    if (found == that.content_.end())
+    {
+      userSettings_ = NULL;
+    }
+    else
+    {
+      assert(found->second != NULL);
+      userSettings_ = found->second;
+    }
+  }
+
+
+  void AnnotationsWorkspace::UserReader::ListLayers(Json::Value& serialized) const
+  {
+    serialized = Json::objectValue;
+
+    if (IsValid())
+    {
+      userSettings_->Serialize(serialized);
+    }
+    else
+    {
+      UserAnnotationsSettings empty;
+      empty.Serialize(serialized);
+    }
+  }
+
+
+  void AnnotationsWorkspace::UserReader::ListLayersSharedWith(Json::Value& target,
+                                                              const UserId& user) const
+  {
+    target = Json::arrayValue;
+
+    if (IsValid())
+    {
+      LayersCollection::Iterator iterator(userSettings_->GetUserLayers());
+
+      while (!iterator.IsDone())
+      {
+        const UserLayer& layer = dynamic_cast<const UserLayer&>(iterator.GetLayer());
+
+        if (layer.IsSharedWith(user))
+        {
+          Json::Value item;
+          layer.Serialize(item);
+          target.append(item);
+        }
+
+        iterator.Next();
+      }
+    }
+  }
+
+
+  void AnnotationsWorkspace::UserReader::ListImportedLayers(std::set<UserId>& authors,
+                                                            std::set<std::string>& layerIds) const
+  {
+    authors.clear();
+    layerIds.clear();
+
+    if (IsValid())
+    {
+      LayersCollection::Iterator iterator(userSettings_->GetImportedLayers());
+
+      while (!iterator.IsDone())
+      {
+        const ImportedLayer& layer = dynamic_cast<const ImportedLayer&>(iterator.GetLayer());
+
+        Content::const_iterator found = that_.content_.find(layer.GetAuthor());
+
+        if (found != that_.content_.end())
+        {
+          assert(found->second != NULL);
+
+          // Check that the layer has not been deleted in the meantime by its author,
+          // and that the layer is still shared with this user
+          if (found->second->GetUserLayers().HasLayer(layer.GetId()))
+          {
+            const UserLayer& authorLayer = found->second->GetUserLayer(layer.GetId());
+
+            if (authorLayer.IsSharedWith(userId_))
+            {
+              authors.insert(layer.GetAuthor());
+              layerIds.insert(layer.GetId());
+            }
+          }
+        }
+
+        iterator.Next();
+      }
+    }
+  }
+
+
+  void AnnotationsWorkspace::UserWriter::Commit()
+  {
+    ISerializable::SetKeyValueStore(that_.id_.GetSettingsKey(userId_), *userSettings_);
+  }
+
+
+  AnnotationsWorkspace::UserWriter::UserWriter(AnnotationsWorkspace& that,
+                                               const UserId& userId) :
+    lock_(that.mutex_),
+    that_(that),
+    userId_(userId)
+  {
+    if (that.persistentInfo_->AddActiveUser(userId_))
+    {
+      // Only update the key-value store if this is the first time we meet this user
+      ISerializable::SetKeyValueStore(that.id_.GetInfoKey(), *that.persistentInfo_);
+    }
+
+    Content::iterator found = that.content_.find(userId_);
+
+    if (found == that.content_.end())
+    {
+      std::unique_ptr<UserAnnotationsSettings> layers(new UserAnnotationsSettings);
+      userSettings_ = layers.get();
+      that.content_[userId_] = layers.release();
+      Commit();
+    }
+    else
+    {
+      assert(found->second != NULL);
+      userSettings_ = found->second;
+    }
+  }
+
+
+  void AnnotationsWorkspace::UserWriter::CreateUserLayer(Json::Value& answer)
+  {
+    assert(userSettings_ != NULL);
+
+    const std::string layerId = userSettings_->CreateUserLayer();
+    Commit();
+
+    UserLayer& layer = userSettings_->GetUserLayer(layerId);
+    layer.Serialize(answer);
+  }
+
+
+  void AnnotationsWorkspace::UserWriter::UpdateUserLayer(const UserLayer& updated)
+  {
+    assert(userSettings_ != NULL);
+
+    UserLayer& layer = userSettings_->GetUserLayer(updated.GetId());
+    layer.Assign(updated);
+    Commit();
+  }
+
+
+  void AnnotationsWorkspace::UserWriter::DeleteUserLayer(const std::string& layerId)
+  {
+    assert(userSettings_ != NULL);
+    userSettings_->GetUserLayers().DeleteLayer(layerId);
+    Commit();
+  }
+
+
+  void AnnotationsWorkspace::UserWriter::ImportLayer(const UserId& author,
+                                                     const std::string& layerId)
+  {
+    assert(userSettings_ != NULL);
+
+    Content::const_iterator found = that_.content_.find(author);
+    if (found == that_.content_.end())
+    {
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_UnknownResource);
+    }
+
+    assert(found->second != NULL);
+    const UserAnnotationsSettings& authorData = *found->second;
+
+    UserLayer& layer = authorData.GetUserLayer(layerId);
+    if (!layer.IsSharedWith(userId_))
+    {
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_ForbiddenAccess);
+    }
+
+    userSettings_->ImportLayer(author, layer);
+    Commit();
+  }
+
+
+  void AnnotationsWorkspace::UserWriter::RemoveImportedLayer(const std::string& layerId)
+  {
+    assert(userSettings_ != NULL);
+    userSettings_->GetImportedLayers().DeleteLayer(layerId);
+    Commit();
+  }
+
+
+  void AnnotationsWorkspace::UserWriter::UpdateImportedLayer(const ImportedLayer& updated)
+  {
+    assert(userSettings_ != NULL);
+
+    ImportedLayer& layer = userSettings_->GetImportedLayer(updated.GetId());
+    layer.Assign(updated);
+    Commit();
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/ViewerPlugin/Annotations/AnnotationsWorkspace.h	Wed Sep 02 16:30:04 2026 +0200
@@ -0,0 +1,133 @@
+/**
+ * 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) 2024-2026 Orthanc Team SRL, Belgium
+ * Copyright (C) 2021-2026 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
+ *
+ * This program is free software: you can redistribute it and/or
+ * modify it under the terms of the GNU Affero General Public License
+ * as published by the Free Software Foundation, either version 3 of
+ * the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Affero General Public License for more details.
+ * 
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ **/
+
+
+#pragma once
+
+#include "AnnotationsWorkspaceId.h"
+#include "ProjectInformation.h"
+#include "UserAnnotationsSettings.h"
+
+#include <IDynamicObject.h>
+#include <MultiThreading/ReaderWriterLock.h>
+
+
+namespace OrthancWSI
+{
+  class AnnotationsWorkspace : public Orthanc::IDynamicObject
+  {
+  private:
+    class PersistentInfo;
+
+    void Load(const UserId& user);
+
+    typedef std::map<UserId, UserAnnotationsSettings*>   Content;
+
+    Orthanc::ReaderWriterLock        mutex_;
+    AnnotationsWorkspaceId           id_;
+    std::unique_ptr<PersistentInfo>  persistentInfo_;
+    Content                          content_;
+    ProjectInformation               projectInformation_;
+
+  public:
+    AnnotationsWorkspace(const AnnotationsWorkspaceId& id);
+
+    ~AnnotationsWorkspace();
+
+    const AnnotationsWorkspaceId& GetId() const
+    {
+      return id_;
+    }
+
+    std::string GetProjectName()
+    {
+      return projectInformation_.GetName();
+    }
+
+    std::string GetProjectDescription()
+    {
+      return projectInformation_.GetDescription();
+    }
+
+    void SearchActiveUsers(std::set<UserId>& target,
+                           const std::string& query);
+
+    void ListUsersSharingLayerWith(std::set<UserId>& target,
+                                   const UserId& user);
+
+
+    class UserReader : public boost::noncopyable
+    {
+    private:
+      Orthanc::ReaderWriterLock::ReadLock lock_;
+      AnnotationsWorkspace&               that_;
+      UserId                              userId_;
+      const UserAnnotationsSettings*      userSettings_;
+
+    public:
+      UserReader(AnnotationsWorkspace& that,
+                 const UserId& userId);
+
+      bool IsValid() const
+      {
+        return userSettings_ != NULL;
+      }
+
+      void ListLayers(Json::Value& serialized) const;
+
+      void ListLayersSharedWith(Json::Value& target,
+                                const UserId& user) const;
+
+      void ListImportedLayers(std::set<UserId>& authors,
+                              std::set<std::string>& layerIds) const;
+    };
+
+
+    class UserWriter : public boost::noncopyable
+    {
+    private:
+      Orthanc::ReaderWriterLock::WriteLock  lock_;
+      AnnotationsWorkspace&                 that_;
+      UserId                                userId_;
+      UserAnnotationsSettings*              userSettings_;
+
+      void Commit();
+
+    public:
+      UserWriter(AnnotationsWorkspace& that,
+                 const UserId& userId);
+
+      void CreateUserLayer(Json::Value& answer);
+
+      void UpdateUserLayer(const UserLayer& updated);
+
+      void DeleteUserLayer(const std::string& layerId);
+
+      void ImportLayer(const UserId& author,
+                       const std::string& layerId);
+
+      void RemoveImportedLayer(const std::string& layerId);
+
+      void UpdateImportedLayer(const ImportedLayer& updated);
+    };
+  };
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/ViewerPlugin/Annotations/CachedAnnotationsWorkspace.cpp	Wed Sep 02 16:30:04 2026 +0200
@@ -0,0 +1,65 @@
+/**
+ * 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) 2024-2026 Orthanc Team SRL, Belgium
+ * Copyright (C) 2021-2026 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
+ *
+ * This program is free software: you can redistribute it and/or
+ * modify it under the terms of the GNU Affero General Public License
+ * as published by the Free Software Foundation, either version 3 of
+ * the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Affero General Public License for more details.
+ * 
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ **/
+
+
+#include "../../Framework/PrecompiledHeadersWSI.h"
+#include "CachedAnnotationsWorkspace.h"
+
+#include "../ViewerConfiguration.h"
+
+#include <Cache/SharedObjectCache.h>
+
+
+static Orthanc::SharedObjectCache& GetCache()
+{
+  static boost::mutex  mutex;
+  static std::unique_ptr<Orthanc::SharedObjectCache>  cache;
+
+  {
+    boost::mutex::scoped_lock lock(mutex);
+
+    if (cache.get() == NULL)
+    {
+      const unsigned int cacheSize = OrthancWSI::ViewerConfiguration::GetInstance().GetAnnotationsCacheSize();
+      cache.reset(new Orthanc::SharedObjectCache(cacheSize));
+    }
+
+    return *cache;
+  }
+}
+
+
+namespace OrthancWSI
+{
+  CachedAnnotationsWorkspace::CachedAnnotationsWorkspace(const AnnotationsWorkspaceId& id)
+  {
+    const std::string key = id.GetInfoKey();
+
+    cached_ = GetCache().GetCachedValue(key);
+
+    if (cached_.get() == NULL)
+    {
+      cached_.reset(new AnnotationsWorkspace(id));
+      GetCache().Store(key, cached_, 1);
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/ViewerPlugin/Annotations/CachedAnnotationsWorkspace.h	Wed Sep 02 16:30:04 2026 +0200
@@ -0,0 +1,46 @@
+/**
+ * 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) 2024-2026 Orthanc Team SRL, Belgium
+ * Copyright (C) 2021-2026 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
+ *
+ * This program is free software: you can redistribute it and/or
+ * modify it under the terms of the GNU Affero General Public License
+ * as published by the Free Software Foundation, either version 3 of
+ * the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Affero General Public License for more details.
+ * 
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ **/
+
+
+#pragma once
+
+#include "AnnotationsWorkspace.h"
+
+#include <boost/shared_ptr.hpp>
+
+
+namespace OrthancWSI
+{
+  class CachedAnnotationsWorkspace : public boost::noncopyable
+  {
+  private:
+    boost::shared_ptr<Orthanc::IDynamicObject>  cached_;
+
+  public:
+    CachedAnnotationsWorkspace(const AnnotationsWorkspaceId& id);
+
+    AnnotationsWorkspace& GetContent() const
+    {
+      return dynamic_cast<AnnotationsWorkspace&>(*cached_);
+    }
+  };
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/ViewerPlugin/Annotations/UserFeatures.cpp	Wed Sep 02 16:30:04 2026 +0200
@@ -0,0 +1,147 @@
+/**
+ * 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) 2024-2026 Orthanc Team SRL, Belgium
+ * Copyright (C) 2021-2026 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
+ *
+ * This program is free software: you can redistribute it and/or
+ * modify it under the terms of the GNU Affero General Public License
+ * as published by the Free Software Foundation, either version 3 of
+ * the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Affero General Public License for more details.
+ * 
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ **/
+
+
+#include "../../Framework/PrecompiledHeadersWSI.h"
+#include "UserFeatures.h"
+
+#include "../ViewerToolbox.h"
+
+#include <Compression/GzipCompressor.h>
+#include <OrthancException.h>
+#include <SerializationToolbox.h>
+#include <Toolbox.h>
+
+#include <boost/lexical_cast.hpp>
+
+
+static const char* const KEY_FEATURES = "features";
+static const char* const KEY_LAYER_ID = "layer-id";
+static const char* const KEY_TYPE = "type";
+static const char* const KEY_VERSION = "version";
+
+
+namespace OrthancWSI
+{
+  void UserFeatures::Load()
+  {
+    content_ = Json::arrayValue;
+
+    std::string compressed;
+    if (ViewerToolbox::LookupKeyValueStore(compressed, key_))
+    {
+      std::string uncompressed;
+      Orthanc::GzipCompressor compressor;
+      Orthanc::IBufferCompressor::Uncompress(uncompressed, compressor, compressed);
+
+      Json::Value unserialized;
+
+      if (!Orthanc::Toolbox::ReadJson(unserialized, uncompressed) ||
+          !unserialized.isObject() ||
+          !unserialized.isMember(KEY_FEATURES) ||
+          !unserialized[KEY_FEATURES].isArray())
+      {
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
+      }
+
+      const unsigned int version = Orthanc::SerializationToolbox::ReadUnsignedInteger(unserialized, KEY_VERSION);
+
+      if (version == ORTHANC_WSI_ANNOTATIONS_VERSION)
+      {
+        content_ = unserialized[KEY_FEATURES];
+      }
+      else
+      {
+        switch (version)
+        {
+          // Implement version conversion here
+
+          default:
+            throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented, "Cannot load annotations from version: " +
+                                            boost::lexical_cast<std::string>(version));
+        }
+      }
+    }
+  }
+
+
+  void UserFeatures::Save() const
+  {
+    assert(content_.isArray());
+
+    Json::Value unserialized;
+    unserialized[KEY_VERSION] = static_cast<unsigned int>(ORTHANC_WSI_ANNOTATIONS_VERSION);
+    unserialized[KEY_FEATURES] = content_;
+
+    std::string serialized;
+    Orthanc::Toolbox::WriteFastJson(serialized, unserialized);
+
+    std::string compressed;
+    Orthanc::GzipCompressor compressor;
+    Orthanc::IBufferCompressor::Compress(compressed, compressor, serialized);
+
+    ViewerToolbox::SetKeyValueStore(key_, compressed);
+  }
+
+
+  UserFeatures::UserFeatures(const std::string& key) :
+    key_(key)
+  {
+    Load();
+  }
+
+
+  void UserFeatures::GetContent(Json::Value& target)
+  {
+    Orthanc::ReaderWriterLock::ReadLock lock(mutex_);
+
+    assert(content_.isArray());
+    target = content_;
+  }
+
+
+  void UserFeatures::SetContent(const Json::Value& content)
+  {
+    if (!content.isArray())
+    {
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat);
+    }
+
+    for (Json::Value::ArrayIndex i = 0; i < content.size(); i++)
+    {
+      if (!content[i].isObject() ||
+          !content[i].isMember(KEY_LAYER_ID) ||
+          !content[i].isMember(KEY_TYPE) ||
+          !content[i][KEY_LAYER_ID].isString() ||
+          !content[i][KEY_TYPE].isString())
+      {
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat);
+      }
+    }
+
+    {
+      Orthanc::ReaderWriterLock::WriteLock lock(mutex_);
+      content_ = content;
+      Save();
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/ViewerPlugin/Annotations/UserFeatures.h	Wed Sep 02 16:30:04 2026 +0200
@@ -0,0 +1,53 @@
+/**
+ * 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) 2024-2026 Orthanc Team SRL, Belgium
+ * Copyright (C) 2021-2026 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
+ *
+ * This program is free software: you can redistribute it and/or
+ * modify it under the terms of the GNU Affero General Public License
+ * as published by the Free Software Foundation, either version 3 of
+ * the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Affero General Public License for more details.
+ * 
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ **/
+
+
+#pragma once
+
+#include <IDynamicObject.h>
+#include <MultiThreading/ReaderWriterLock.h>
+
+#include <json/value.h>
+#include <string>
+
+
+namespace OrthancWSI
+{
+  class UserFeatures : public Orthanc::IDynamicObject
+  {
+  private:
+    Orthanc::ReaderWriterLock  mutex_;
+    std::string                key_;
+    Json::Value                content_;
+
+    void Load();
+
+    void Save() const;
+
+  public:
+    UserFeatures(const std::string& key);
+
+    void GetContent(Json::Value& target);
+
+    void SetContent(const Json::Value& content);
+  };
+}
--- a/ViewerPlugin/CMakeLists.txt	Wed Sep 02 16:09:00 2026 +0200
+++ b/ViewerPlugin/CMakeLists.txt	Wed Sep 02 16:30:04 2026 +0200
@@ -188,12 +188,14 @@
   Annotations/AnnotationsRestApi.cpp
   Annotations/AnnotationsWorkspace.cpp
   Annotations/AnnotationsWorkspaceId.cpp
+  Annotations/CachedAnnotationsWorkspace.cpp
   Annotations/IAuthenticatedUser.cpp
   Annotations/ISerializable.cpp
   Annotations/ImportedLayer.cpp
   Annotations/LayersCollection.cpp
   Annotations/ProjectInformation.cpp
   Annotations/UserAnnotationsSettings.cpp
+  Annotations/UserFeatures.cpp
   Annotations/UserId.cpp
   Annotations/UserLayer.cpp
   DicomPyramidCache.cpp