changeset 508:971c7c1ca617 annotations

cont reorganization
author Sebastien Jodogne <s.jodogne@gmail.com>
date Wed, 19 Aug 2026 15:25:12 +0200
parents 142b9ddc9d28
children e26bf161c039
files ViewerPlugin/Annotations/AnnotationsRestApi.cpp ViewerPlugin/Annotations/AnnotationsRestApi.h ViewerPlugin/CMakeLists.txt ViewerPlugin/Plugin.cpp ViewerPlugin/ViewerToolbox.cpp ViewerPlugin/ViewerToolbox.h
diffstat 6 files changed, 1488 insertions(+), 1345 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/ViewerPlugin/Annotations/AnnotationsRestApi.cpp	Wed Aug 19 15:25:12 2026 +0200
@@ -0,0 +1,1302 @@
+/**
+ * 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 "AnnotationsRestApi.h"
+
+#include "../ViewerToolbox.h"
+#include "IAuthenticatedUser.h"
+
+#include <Cache/SharedObjectCache.h>
+#include <Compression/GzipCompressor.h>
+#include <Logging.h>
+#include <MultiThreading/ReaderWriterLock.h>
+#include <SerializationToolbox.h>
+
+#include "../../Resources/Orthanc/Plugins/OrthancPluginCppWrapper.h"
+
+
+namespace OrthancWSI
+{
+  class AnnotationsId
+  {
+  private:
+    std::string            projectId_;
+    Orthanc::ResourceType  level_;
+    std::string            resourceId_;
+    unsigned int           frameNumber_;
+
+  public:
+    AnnotationsId(const std::string& projectId,
+                  Orthanc::ResourceType level,
+                  const std::string& resourceId,
+                  unsigned int frameNumber) :
+      projectId_(projectId),
+      level_(level),
+      resourceId_(resourceId),
+      frameNumber_(frameNumber)
+    {
+      if (level_ != Orthanc::ResourceType_Series &&
+          level_ != Orthanc::ResourceType_Instance)
+      {
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_ParameterOutOfRange);
+      }
+
+      if (projectId_.find('|') != std::string::npos ||
+          resourceId_.find('|') != std::string::npos)
+      {
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_ParameterOutOfRange);
+      }
+    }
+
+    const std::string& GetProjectId() const
+    {
+      return projectId_;
+    }
+
+    Orthanc::ResourceType GetLevel() const
+    {
+      return level_;
+    }
+
+    const std::string& GetResourceId() const
+    {
+      return resourceId_;
+    }
+
+    std::string GetKey() const
+    {
+      switch (level_)
+      {
+        case Orthanc::ResourceType_Series:
+          return projectId_ + "|series|" + resourceId_;
+
+        case Orthanc::ResourceType_Instance:
+          return projectId_ + "|instance|" + boost::lexical_cast<std::string>(frameNumber_) + "|" + resourceId_;
+
+        default:
+          throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
+      }
+    }
+  };
+
+
+  static const char* const KEY_AUTHOR = "author";
+  static const char* const KEY_COLOR = "color";
+  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_NAME = "name";
+  static const char* const KEY_PUBLIC = "public";
+  static const char* const KEY_SHARED_WITH = "shared_with";
+  static const char* const KEY_VISIBLE = "visible";
+
+  static const char* const KEY_VALUE_STORE = "wsi";
+
+
+/**
+
+   Content of a layer in the DB:
+
+   - visible             (editable)
+   - color               (editable)
+   - author type and ID  (read-only)
+   - layer ID            (read-only)
+   - name of the layer   (editable)
+   - shared_with         (editable, can be disabled for learners by the instructors)
+   - public              (editable, can be disabled for learners by the instructors)
+
+   Content of an imported shared layer in the DB:
+
+   - visible             (editable, set to "true" on import)
+   - color               (editable, can be different from original layer)
+   - author type and ID  (read-only)
+   - layer ID            (read-only)
+   - name of the layer   (read-only)
+
+   Finding shared layers:
+
+   map<UserId, std::list<UserLayer>>
+
+   map<UserId, std::list<std::pair<UserId, const UserLayer*>>>
+
+   API must return: color, author, layer ID, and name (visible is set to true once loaded)
+
+**/
+
+
+  class ISerializable : public boost::noncopyable
+  {
+  public:
+    virtual ~ISerializable()
+    {
+    }
+
+    virtual void Serialize(Json::Value& target) const = 0;
+
+    static void Serialize(std::string& target,
+                          const ISerializable& obj)
+    {
+      Json::Value value;
+      obj.Serialize(value);
+      Orthanc::Toolbox::WriteFastJson(target, value);
+    }
+  };
+
+
+  class ILayer : public ISerializable
+  {
+  public:
+    virtual std::string GetId() const = 0;
+  };
+
+
+  class LayersCollection : public ISerializable
+  {
+  private:
+    typedef std::list<ILayer*>                        Content;
+    typedef std::map<std::string, Content::iterator>  Index;
+
+    Content  content_;
+    Index    index_;
+
+  public:
+    ~LayersCollection()
+    {
+      for (Content::iterator it = content_.begin(); it != content_.end(); ++it)
+      {
+        assert(*it != NULL);
+        delete *it;
+      }
+    }
+
+    size_t GetSize() const
+    {
+      assert(content_.size() == index_.size());
+      return content_.size();
+    }
+
+    void AddLayer(ILayer* layer /* takes ownership */)
+    {
+      std::unique_ptr<ILayer> protection(layer);
+
+      if (layer == NULL)
+      {
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_NullPointer);
+      }
+
+      const std::string id = protection->GetId();
+
+      if (index_.find(id) != index_.end())
+      {
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_BadSequenceOfCalls, "Duplicate layer ID");
+      }
+      else
+      {
+        content_.push_back(protection.release());
+
+        Content::iterator it = content_.end();
+        --it;  // Points to the element we just inserted
+        index_[id] = it;
+      }
+    }
+
+    bool HasLayer(const std::string& id) const
+    {
+      return (index_.find(id) != index_.end());
+    }
+
+    ILayer& GetLayer(const std::string& id) const
+    {
+      Index::const_iterator found = index_.find(id);
+
+      if (found == index_.end())
+      {
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_UnknownResource);
+      }
+      else
+      {
+        assert(*(found->second) != NULL);
+        return **(found->second);
+      }
+    }
+
+    void DeleteLayer(const std::string& id)
+    {
+      Index::iterator found = index_.find(id);
+
+      if (found == index_.end())
+      {
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_UnknownResource);
+      }
+      else
+      {
+        assert(*(found->second) != NULL);
+        delete *(found->second);
+        content_.erase(found->second);
+        index_.erase(found);
+      }
+    }
+
+    virtual void Serialize(Json::Value& target) const ORTHANC_OVERRIDE
+    {
+      target = Json::arrayValue;
+
+      for (Content::const_iterator it = content_.begin(); it != content_.end(); ++it)
+      {
+        assert(*it != NULL);
+
+        Json::Value item;
+        (*it)->Serialize(item);
+        target.append(item);
+      }
+    }
+  };
+
+
+  static void SetKeyValueStore(const std::string& key,
+                               const std::string& value)
+  {
+#if ORTHANC_PLUGINS_VERSION_IS_ABOVE(1, 12, 8)
+    OrthancPlugins::KeyValueStore store(KEY_VALUE_STORE);
+    store.Store(key, value);
+#endif
+  }
+
+
+  static void SetKeyValueStore(const std::string& key,
+                               const Json::Value& value)
+  {
+    std::string s;
+    Orthanc::Toolbox::WriteFastJson(s, value);
+    SetKeyValueStore(key, s);
+  }
+
+
+  static void SetKeyValueStore(const std::string& key,
+                               const ISerializable& value)
+  {
+    std::string s;
+    ISerializable::Serialize(s, value);
+    SetKeyValueStore(key, s);
+  }
+
+
+  static bool LookupKeyValueStore(std::string& value,
+                                  const std::string& key)
+  {
+#if ORTHANC_PLUGINS_VERSION_IS_ABOVE(1, 12, 8)
+    OrthancPlugins::KeyValueStore store(KEY_VALUE_STORE);
+    return store.GetValue(value, key);
+#else
+    return false;
+#endif
+  }
+
+
+  static bool LookupKeyValueStore(Json::Value& value,
+                                  const std::string& key)
+  {
+    std::string s;
+    if (LookupKeyValueStore(s, key))
+    {
+      if (Orthanc::Toolbox::ReadJson(value, s))
+      {
+        return true;
+      }
+      else
+      {
+        LOG(WARNING) << "Discarding incorrect JSON in the key-value store: " << key;
+        return false;
+      }
+    }
+    else
+    {
+      return false;
+    }
+  }
+
+
+  static std::string GetInfoKey(const AnnotationsId& annotations)
+  {
+    return annotations.GetKey() + "|info";
+  }
+
+
+  static std::string GetLayersKey(const AnnotationsId& annotations,
+                                  const UserId& user)
+  {
+    return annotations.GetKey() + "|layers|" + user.GetKey();
+  }
+
+
+  static std::string GetFeaturesKey(const AnnotationsId& annotations,
+                                    const UserId& user)
+  {
+    return annotations.GetKey() + "|features|" + user.GetKey();
+  }
+
+
+
+  static const char* const KEY_ACTIVE_USERS = "active-users";
+  static const char* const KEY_PROJECT_NAME = "project-name";
+  static const char* const KEY_PROJECT_DESCRIPTION = "project-description";
+  static const char* const KEY_USER_LAYERS = "user-layers";
+  static const char* const KEY_SHARED_LAYERS = "shared-layers";
+
+
+  class UserLayer : public ILayer
+  {
+  private:
+    bool              isVisible_;
+    RGBColor          color_;
+    std::string       id_;
+    std::string       name_;
+    std::set<UserId>  sharedWith_;
+    bool              isPublic_;
+
+  public:
+    UserLayer(const RGBColor& color,
+              const std::string& name) :
+      isVisible_(true),
+      color_(color),
+      id_(Orthanc::Toolbox::GenerateUuid()),
+      name_(name),
+      isPublic_(false)
+    {
+    }
+
+    UserLayer(const Json::Value& source) :
+      color_(ViewerToolbox::ParseColor(Orthanc::SerializationToolbox::ReadString(source, KEY_COLOR)))
+    {
+      isVisible_ = Orthanc::SerializationToolbox::ReadBoolean(source, KEY_VISIBLE);
+      id_ = Orthanc::SerializationToolbox::ReadString(source, KEY_ID);
+      name_ = Orthanc::SerializationToolbox::ReadString(source, KEY_NAME);
+
+      if (source.isMember(KEY_PUBLIC))
+      {
+        isPublic_ = Orthanc::SerializationToolbox::ReadBoolean(source, KEY_PUBLIC);
+      }
+      else
+      {
+        isPublic_ = false;
+      }
+
+      if (source.isMember(KEY_SHARED_WITH))
+      {
+        const Json::Value& sharedWith = source[KEY_SHARED_WITH];
+
+        if (!sharedWith.isArray())
+        {
+          throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol);
+        }
+
+        for (Json::Value::ArrayIndex i = 0; i < sharedWith.size(); i++)
+        {
+          sharedWith_.insert(UserId(sharedWith[i]));
+        }
+      }
+    }
+
+    virtual std::string GetId() const ORTHANC_OVERRIDE
+    {
+      return id_;
+    }
+
+    bool IsVisible() const
+    {
+      return isVisible_;
+    }
+
+    const RGBColor& GetColor() const
+    {
+      return color_;
+    }
+
+    const std::string& GetName() const
+    {
+      return name_;
+    }
+
+    bool IsSharedWith(const UserId& user) const
+    {
+      return (isPublic_ ||
+              sharedWith_.find(user) != sharedWith_.end());
+    }
+
+    void Assign(const UserLayer& other)
+    {
+      isVisible_ = other.isVisible_;
+      color_ = other.color_;
+      name_ = other.name_;
+      sharedWith_ = other.sharedWith_;
+      isPublic_ = other.isPublic_;
+    }
+
+    virtual void Serialize(Json::Value& target) const ORTHANC_OVERRIDE
+    {
+      Json::Value sharedWith = Json::arrayValue;
+      for (std::set<UserId>::const_iterator it = sharedWith_.begin(); it != sharedWith_.end(); ++it)
+      {
+        Json::Value item;
+        it->Serialize(item);
+        sharedWith.append(item);
+      }
+
+      target = Json::objectValue;
+      target[KEY_VISIBLE] = isVisible_;
+      target[KEY_COLOR] = ViewerToolbox::SerializeColor(color_);
+      target[KEY_ID] = id_;
+      target[KEY_NAME] = name_;
+      target[KEY_PUBLIC] = isPublic_;
+      target[KEY_SHARED_WITH] = sharedWith;
+    }
+  };
+
+
+  class SharedLayer : public ILayer
+  {
+  private:
+    bool         isVisible_;
+    RGBColor     color_;
+    UserId       author_;
+    std::string  id_;
+    std::string  name_;
+
+  public:
+    SharedLayer(const UserId& author,
+                const UserLayer& layer) :
+      isVisible_(true),
+      color_(layer.GetColor()),
+      author_(author),
+      id_(layer.GetId()),
+      name_(layer.GetName())
+    {
+    }
+
+    SharedLayer(const Json::Value& source) :
+      color_(0, 0, 0)
+    {
+      if (!source.isMember(KEY_AUTHOR))
+      {
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol);
+      }
+
+      isVisible_ = Orthanc::SerializationToolbox::ReadBoolean(source, KEY_VISIBLE);
+      author_ = UserId(source[KEY_AUTHOR]);
+      id_ = Orthanc::SerializationToolbox::ReadString(source, KEY_ID);
+      name_ = Orthanc::SerializationToolbox::ReadString(source, KEY_NAME);
+
+      std::string color = Orthanc::SerializationToolbox::ReadString(source, KEY_COLOR);
+      color_ = ViewerToolbox::ParseColor(color);
+    }
+
+    virtual std::string GetId() const ORTHANC_OVERRIDE
+    {
+      return id_;
+    }
+
+    bool IsVisible() const
+    {
+      return isVisible_;
+    }
+
+    const RGBColor& GetColor() const
+    {
+      return color_;
+    }
+
+    const UserId& GetAuthor() const
+    {
+      return author_;
+    }
+
+    const std::string& GetName() const
+    {
+      return name_;
+    }
+
+    virtual void Serialize(Json::Value& target) const ORTHANC_OVERRIDE
+    {
+      target = Json::objectValue;
+      target[KEY_VISIBLE] = isVisible_;
+      target[KEY_COLOR] = ViewerToolbox::SerializeColor(color_);
+      target[KEY_ID] = id_;
+      target[KEY_NAME] = name_;
+
+      author_.Serialize(target[KEY_AUTHOR]);
+    }
+  };
+
+
+  class UserData : public ISerializable
+  {
+  private:
+    LayersCollection    userLayers_;
+
+  public:
+    UserData()
+    {
+    }
+
+    UserData(const Json::Value& source)
+    {
+      if (!source.isArray())
+      {
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat);
+      }
+      else
+      {
+        for (Json::Value::ArrayIndex i = 0; i < source.size(); i++)
+        {
+          userLayers_.AddLayer(new UserLayer(source[i]));
+        }
+      }
+    }
+
+    std::string AddUserLayer(UserLayer* layer)
+    {
+      std::unique_ptr<UserLayer> protection(layer);
+
+      if (layer == NULL)
+      {
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_NullPointer);
+      }
+
+      const std::string id = protection->GetId();
+
+      userLayers_.AddLayer(protection.release());
+
+      return id;
+    }
+
+    UserLayer& GetUserLayer(const std::string& layerId) const
+    {
+      return dynamic_cast<UserLayer&>(userLayers_.GetLayer(layerId));
+    }
+
+    std::string CreateUserLayer()
+    {
+      static const uint8_t PALETTE[] = {
+        0xe6, 0x39, 0x46,  // red: #e63946
+        0x2a, 0x9d, 0x8f,
+        0xe9, 0xc4, 0x6a,
+        0x26, 0x46, 0x53,
+        0xf4, 0xa2, 0x61
+      };
+
+      static const size_t PALETTE_SIZE = sizeof(PALETTE) / (3 * sizeof(uint8_t));
+
+      size_t item = userLayers_.GetSize() % PALETTE_SIZE;
+
+      RGBColor color(PALETTE[3 * item],
+                     PALETTE[3 * item + 1],
+                     PALETTE[3 * item + 2]);
+
+      std::string name;
+      if (userLayers_.GetSize() == 0)
+      {
+        name = "Default";
+      }
+      else
+      {
+        name = "Layer " + boost::lexical_cast<std::string>(userLayers_.GetSize() + 1);
+      }
+
+      return AddUserLayer(new UserLayer(color, name));
+    }
+
+    void DeleteUserLayer(const std::string& layerId)
+    {
+      userLayers_.DeleteLayer(layerId);
+    }
+
+    virtual void Serialize(Json::Value& target) const ORTHANC_OVERRIDE
+    {
+      userLayers_.Serialize(target);
+    }
+  };
+
+
+  class AnnotationsInfo : public ISerializable
+  {
+  private:
+    std::string        projectName_;
+    std::string        projectDescription_;
+    std::set<UserId>   activeUsers_;
+
+  public:
+    AnnotationsInfo()
+    {
+    }
+
+    AnnotationsInfo(const Json::Value& source)
+    {
+      projectName_ = Orthanc::SerializationToolbox::ReadString(source, KEY_PROJECT_NAME);
+      projectDescription_ = Orthanc::SerializationToolbox::ReadString(source, KEY_PROJECT_DESCRIPTION);
+
+      const Json::Value& users = source[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]));
+      }
+    }
+
+    const std::string& GetProjectName() const
+    {
+      return projectName_;
+    }
+
+    void SetProjectName(const std::string& name)
+    {
+      projectName_ = name;
+    }
+
+    const std::string& GetProjectDescription() const
+    {
+      return projectDescription_;
+    }
+
+    void SetProjectDescription(const std::string& description)
+    {
+      projectDescription_ = description;
+    }
+
+    // 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& target) 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);
+      }
+
+      target = Json::objectValue;
+      target[KEY_PROJECT_NAME] = projectName_;
+      target[KEY_PROJECT_DESCRIPTION] = projectDescription_;
+      target[KEY_ACTIVE_USERS] = users;
+    }
+  };
+
+
+  class CachedAnnotations : public Orthanc::IDynamicObject
+  {
+  private:
+    void LoadUserData(const UserId& user)
+    {
+      const std::string key = id_.GetKey() + "|layers|" + user.GetKey();
+
+      Json::Value layers;
+      if (LookupKeyValueStore(layers, key))
+      {
+        std::unique_ptr<UserData> item(new UserData(layers));
+
+        if (content_.find(user) == content_.end())  // Should never be false
+        {
+          content_[user] = item.release();
+        }
+      }
+    }
+
+    typedef std::map<UserId, UserData*>   Content;
+
+    Orthanc::ReaderWriterLock         mutex_;
+    std::unique_ptr<AnnotationsInfo>  info_;
+    AnnotationsId                     id_;
+    Content                           content_;
+
+  public:
+    CachedAnnotations(const AnnotationsId& id) :
+      id_(id)
+    {
+      const std::string key = GetInfoKey(id);
+
+      Json::Value info;
+
+      if (LookupKeyValueStore(info, key))
+      {
+        info_.reset(new AnnotationsInfo(info));
+
+        for (std::set<UserId>::const_iterator it = info_->GetActiveUsers().begin();
+             it != info_->GetActiveUsers().end(); ++it)
+        {
+          LoadUserData(*it);
+        }
+      }
+      else
+      {
+        info_.reset(new AnnotationsInfo);
+
+        if (OrthancPlugins::RestApiGet(info, "/education/api-plugins/project?id=" + id.GetProjectId(), true))
+        {
+          // The "orthanc-education" plugin is available
+          info_->SetProjectName(Orthanc::SerializationToolbox::ReadString(info, "name"));
+          info_->SetProjectDescription(Orthanc::SerializationToolbox::ReadString(info, "description"));
+        }
+
+        info_->Serialize(info);
+        SetKeyValueStore(key, info);
+      }
+    }
+
+    ~CachedAnnotations()
+    {
+      for (Content::iterator it = content_.begin(); it != content_.end(); ++it)
+      {
+        assert(it->second != NULL);
+        delete it->second;
+      }
+    }
+
+    class UserReader : public boost::noncopyable
+    {
+    private:
+      Orthanc::ReaderWriterLock::ReadLock lock_;
+      const AnnotationsInfo&              info_;
+      const UserData*                     userData_;
+
+    public:
+      UserReader(CachedAnnotations& that,
+                 const IAuthenticatedUser& user) :
+        lock_(that.mutex_),
+        info_(*that.info_)
+      {
+        Content::const_iterator found = that.content_.find(user.GetAnnotatingId());
+
+        if (found == that.content_.end())
+        {
+          userData_ = NULL;
+        }
+        else
+        {
+          assert(found->second != NULL);
+          userData_ = found->second;
+        }
+      }
+
+      bool IsValid() const
+      {
+        return userData_ != NULL;
+      }
+
+      const AnnotationsInfo& GetAnnotationsInfo() const
+      {
+        return info_;
+      }
+
+      void ListLayers(Json::Value& target) const
+      {
+        target = Json::objectValue;
+
+        if (IsValid())
+        {
+          userData_->Serialize(target[KEY_USER_LAYERS]);
+          target[KEY_SHARED_LAYERS] = Json::arrayValue;  // TODO
+        }
+        else
+        {
+          target[KEY_USER_LAYERS] = Json::arrayValue;
+          target[KEY_SHARED_LAYERS] = Json::arrayValue;
+        }
+      }
+    };
+
+
+    class UserWriter : public boost::noncopyable
+    {
+    private:
+      Orthanc::ReaderWriterLock::WriteLock  lock_;
+      CachedAnnotations&                    that_;
+      UserId                                userId_;
+      UserData*                             userData_;
+
+      void Commit()
+      {
+        SetKeyValueStore(GetLayersKey(that_.id_, userId_), *userData_);
+      }
+
+    public:
+      UserWriter(CachedAnnotations& that,
+                 const IAuthenticatedUser& user) :
+        lock_(that.mutex_),
+        that_(that),
+        userId_(user.GetAnnotatingId())
+      {
+        const UserId id = user.GetAnnotatingId();
+
+        if (that.info_->AddActiveUser(userId_))
+        {
+          // Only update the key-value store if this is the first time we meet this user
+          SetKeyValueStore(GetInfoKey(that.id_), *that.info_);
+        }
+
+        Content::iterator found = that.content_.find(userId_);
+
+        if (found == that.content_.end())
+        {
+          std::unique_ptr<UserData> layers(new UserData);
+          userData_ = layers.get();
+          that.content_[userId_] = layers.release();
+          Commit();
+        }
+        else
+        {
+          assert(found->second != NULL);
+          userData_ = found->second;
+        }
+      }
+
+      void CreateUserLayer(Json::Value& answer)
+      {
+        assert(userData_ != NULL);
+
+        const std::string layerId = userData_->CreateUserLayer();
+        Commit();
+
+        UserLayer& layer = userData_->GetUserLayer(layerId);
+        layer.Serialize(answer);
+      }
+
+      void UpdateUserLayer(const UserLayer& updated)
+      {
+        assert(userData_ != NULL);
+
+        UserLayer& layer = userData_->GetUserLayer(updated.GetId());
+        layer.Assign(updated);
+        Commit();
+      }
+
+      void DeleteUserLayer(const std::string& layerId)
+      {
+        assert(userData_ != NULL);
+        userData_->DeleteUserLayer(layerId);
+        Commit();
+      }
+    };
+
+
+#if 0
+    void GetSharedLayers(Json::Value& layers,
+                         const IAuthenticatedUser& user)
+    {
+      Orthanc::ReaderWriterLock::ReadLock lock(mutex_);
+
+      layers = Json::arrayValue;   // TODO
+    }
+
+    bool HasAccessToLayer(const IAuthenticatedUser& user,
+                          const UserId& author,
+                          const std::string& layerId)
+    {
+      Orthanc::ReaderWriterLock::ReadLock lock(mutex_);
+
+      return false;  // TODO
+    }
+
+    void CreateUserLayer(const IAuthenticatedUser& user)
+    {
+      // TODO
+    }
+
+    void AnswerLayers(OrthancPluginRestOutput* output,
+                      const IAuthenticatedUser& user)
+    {
+
+      ViewerToolbox::AnswerJson(output, answer);
+    }
+#endif
+  };
+
+
+  class AnnotationsCommandContext : public boost::noncopyable
+  {
+  private:
+    std::unique_ptr<IAuthenticatedUser>         user_;
+    Json::Value                                 body_;
+    std::unique_ptr<AnnotationsId>              annotationsId_;
+    boost::shared_ptr<Orthanc::IDynamicObject>  cachedAnnotations_;
+
+  public:
+    AnnotationsCommandContext(const OrthancPluginHttpRequest* request)
+    {
+      if (request->method != OrthancPluginHttpMethod_Post)
+      {
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_BadSequenceOfCalls);
+      }
+
+      user_.reset(IAuthenticatedUser::FromHttpRequest(request));
+
+      if (!Orthanc::Toolbox::ReadJson(body_, request->body, request->bodySize) ||
+          !body_.isObject())
+      {
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol);
+      }
+
+      const std::string projectId = Orthanc::SerializationToolbox::ReadString(body_, "project", "" /* default project */);
+      const std::string level = Orthanc::SerializationToolbox::ReadString(body_, "level");
+      const std::string resourceId = Orthanc::SerializationToolbox::ReadString(body_, "resource");
+      unsigned int frameNumber = Orthanc::SerializationToolbox::ReadUnsignedInteger(body_, "frame", 0 /* default frame */);
+
+      annotationsId_.reset(new AnnotationsId(projectId, Orthanc::StringToResourceType(level.c_str()), resourceId, frameNumber));
+
+      IAuthenticatedUser::ProjectRole role = user_->GetRoleInProject(annotationsId_->GetProjectId());
+
+      if (role != IAuthenticatedUser::ProjectRole_Instructor &&
+          role != IAuthenticatedUser::ProjectRole_Learner)
+      {
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_ForbiddenAccess, "User \"" + user_->Format() +
+                                        "\" is not instructor or learner of project \"" + annotationsId_->GetProjectId() + "\"");
+      }
+
+      const std::string cacheKey = annotationsId_->GetKey();
+
+      static Orthanc::SharedObjectCache annotationsCache_(100);  // TODO - PARAMETER
+
+      cachedAnnotations_ = annotationsCache_.GetCachedValue(cacheKey);
+
+      if (cachedAnnotations_.get() == NULL)
+      {
+        cachedAnnotations_.reset(new CachedAnnotations(*annotationsId_));
+        annotationsCache_.Store(cacheKey, cachedAnnotations_, 1);
+      }
+    }
+
+    const IAuthenticatedUser& GetUser() const
+    {
+      assert(user_.get() != NULL);
+      return *user_;
+    }
+
+    const AnnotationsId& GetAnnotationsId() const
+    {
+      assert(annotationsId_.get() != NULL);
+      return *annotationsId_;
+    }
+
+    std::string GetFeaturesKey() const
+    {
+      return ::OrthancWSI::GetFeaturesKey(GetAnnotationsId(), GetUser().GetAnnotatingId());
+    }
+
+    std::string GetBodyString(const char* field) const
+    {
+      return Orthanc::SerializationToolbox::ReadString(body_, field);
+    }
+
+    const Json::Value& GetBodyField(const char* field) const
+    {
+      if (!body_.isMember(field))
+      {
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol);
+      }
+      else
+      {
+        return body_[field];
+      }
+    }
+
+    CachedAnnotations& GetCachedAnnotations()
+    {
+      return dynamic_cast<CachedAnnotations&>(*cachedAnnotations_);
+    }
+  };
+
+
+  static bool ProtectPostRequest(OrthancPluginRestOutput* output,
+                                 const OrthancPluginHttpRequest* request)
+  {
+    if (request->method != OrthancPluginHttpMethod_Post)
+    {
+      OrthancPluginSendMethodNotAllowed(OrthancPlugins::GetGlobalContext(), output, "POST");
+      return false;
+    }
+    else
+    {
+      return true;
+    }
+  }
+
+
+
+
+  void GetAnnotationsInfo(OrthancPluginRestOutput* output,
+                          const char* url,
+                          const OrthancPluginHttpRequest* request)
+  {
+    if (ProtectPostRequest(output, request))
+    {
+      AnnotationsCommandContext context(request);
+
+      CachedAnnotations::UserReader reader(context.GetCachedAnnotations(), context.GetUser());
+
+      Json::Value answer;
+      answer["project-name"] = reader.GetAnnotationsInfo().GetProjectName();
+      answer["project-description"] = reader.GetAnnotationsInfo().GetProjectDescription();
+
+      const UserId user = context.GetUser().GetAnnotatingId();
+
+      if (user.GetType() == UserId::Type_Standard)
+      {
+        answer["user"] = user.GetName();
+      }
+      else
+      {
+        answer["user"] = "";
+      }
+
+#if ORTHANC_PLUGINS_VERSION_IS_ABOVE(1, 12, 8)
+      answer["persistent-annotations"] = true;
+#else
+      answer["persistent-annotations"] = false;
+#endif
+
+      ViewerToolbox::AnswerJson(output, answer);
+    }
+  }
+
+
+  void ListLayers(OrthancPluginRestOutput* output,
+                  const char* url,
+                  const OrthancPluginHttpRequest* request)
+  {
+    if (ProtectPostRequest(output, request))
+    {
+      AnnotationsCommandContext context(request);
+
+      CachedAnnotations::UserReader reader(context.GetCachedAnnotations(), context.GetUser());
+
+      Json::Value answer;
+      reader.ListLayers(answer);
+
+      ViewerToolbox::AnswerJson(output, answer);
+    }
+  }
+
+
+  void CreateUserLayer(OrthancPluginRestOutput* output,
+                       const char* url,
+                       const OrthancPluginHttpRequest* request)
+  {
+    if (ProtectPostRequest(output, request))
+    {
+      AnnotationsCommandContext context(request);
+
+      CachedAnnotations::UserWriter writer(context.GetCachedAnnotations(), context.GetUser());
+
+      Json::Value answer;
+      writer.CreateUserLayer(answer);
+
+      ViewerToolbox::AnswerJson(output, answer);
+    }
+  }
+
+
+  void SaveUserLayer(OrthancPluginRestOutput* output,
+                     const char* url,
+                     const OrthancPluginHttpRequest* request)
+  {
+    if (ProtectPostRequest(output, request))
+    {
+      AnnotationsCommandContext context(request);
+
+      UserLayer updated(context.GetBodyField("layer"));
+
+      {
+        CachedAnnotations::UserWriter writer(context.GetCachedAnnotations(), context.GetUser());
+        writer.UpdateUserLayer(updated);
+      }
+
+      ViewerToolbox::AnswerEmpty(output);
+    }
+  }
+
+
+  void DeleteUserLayer(OrthancPluginRestOutput* output,
+                       const char* url,
+                       const OrthancPluginHttpRequest* request)
+  {
+    if (ProtectPostRequest(output, request))
+    {
+      AnnotationsCommandContext context(request);
+
+      const std::string layerId = context.GetBodyString("layer-id");
+
+      {
+        CachedAnnotations::UserWriter writer(context.GetCachedAnnotations(), context.GetUser());
+        writer.DeleteUserLayer(layerId);
+      }
+
+      ViewerToolbox::AnswerEmpty(output);
+    }
+  }
+
+
+  void LoadUserFeatures(OrthancPluginRestOutput* output,
+                        const char* url,
+                        const OrthancPluginHttpRequest* request)
+  {
+    if (request->method != OrthancPluginHttpMethod_Post)
+    {
+      OrthancPluginSendMethodNotAllowed(OrthancPlugins::GetGlobalContext(), output, "POST");
+    }
+    else
+    {
+      AnnotationsCommandContext context(request);
+
+      Json::Value answer;
+      answer[KEY_FEATURES] = Json::arrayValue;
+
+#if ORTHANC_PLUGINS_VERSION_IS_ABOVE(1, 12, 8)
+      std::string compressed;
+      if (LookupKeyValueStore(compressed, context.GetFeaturesKey()))
+      {
+        std::string uncompressed;
+        Orthanc::GzipCompressor compressor;
+        Orthanc::IBufferCompressor::Uncompress(uncompressed, compressor, compressed);
+
+        if (!Orthanc::Toolbox::ReadJson(answer[KEY_FEATURES], uncompressed))
+        {
+          throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
+        }
+      }
+#else
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented, "Your Orthanc SDK is too old to load annotations");
+#endif
+
+      ViewerToolbox::AnswerJson(output, answer);
+    }
+  }
+
+
+  void SaveUserFeatures(OrthancPluginRestOutput* output,
+                        const char* url,
+                        const OrthancPluginHttpRequest* request)
+  {
+    if (request->method != OrthancPluginHttpMethod_Post)
+    {
+      OrthancPluginSendMethodNotAllowed(OrthancPlugins::GetGlobalContext(), output, "POST");
+    }
+    else
+    {
+      AnnotationsCommandContext context(request);
+
+      std::string features;
+      Orthanc::Toolbox::WriteFastJson(features, context.GetBodyField(KEY_FEATURES));
+
+      std::string compressed;
+      Orthanc::GzipCompressor compressor;
+      Orthanc::IBufferCompressor::Compress(compressed, compressor, features);
+
+#if ORTHANC_PLUGINS_VERSION_IS_ABOVE(1, 12, 8)
+      SetKeyValueStore(context.GetFeaturesKey(), compressed);
+      ViewerToolbox::AnswerEmpty(output);
+#else
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented, "Your Orthanc SDK is too old to save annotations");
+#endif
+    }
+  }
+
+
+  void ListSharedLayers(OrthancPluginRestOutput* output,
+                        const char* url,
+                        const OrthancPluginHttpRequest* request)
+  {
+    if (request->method != OrthancPluginHttpMethod_Post)
+    {
+      OrthancPluginSendMethodNotAllowed(OrthancPlugins::GetGlobalContext(), output, "POST");
+    }
+    else
+    {
+      std::unique_ptr<IAuthenticatedUser> user(IAuthenticatedUser::FromHttpRequest(request));
+
+      Json::Value body;
+      if (!Orthanc::Toolbox::ReadJson(body, request->body, request->bodySize) ||
+          !body.isObject())
+      {
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol);
+      }
+
+      Json::Value answer;
+
+      for (size_t i = 0; i < 10; i++)
+      {
+        const std::string userId = "User " + boost::lexical_cast<std::string>(i);
+
+        Json::Value layers = Json::arrayValue;
+        for (size_t j = 0; j < 5; j++) {
+          Json::Value layer;
+          layer["id"] = Orthanc::Toolbox::GenerateUuid();
+          layer["name"] = "Layer " + boost::lexical_cast<std::string>(j);
+          layers.append(layer);
+        }
+
+        Json::Value user;
+        user["layers"] = layers;
+        answer[userId] = user;
+      }
+
+      ViewerToolbox::AnswerJson(output, answer);
+    }
+  }
+}
+
+
+void RegisterAnnotationsRestApi()
+{
+  OrthancPlugins::RegisterRestCallback<OrthancWSI::GetAnnotationsInfo>("/wsi/api/annotations-info", true);
+  OrthancPlugins::RegisterRestCallback<OrthancWSI::CreateUserLayer>("/wsi/api/create-user-layer", true);
+  OrthancPlugins::RegisterRestCallback<OrthancWSI::DeleteUserLayer>("/wsi/api/delete-user-layer", true);
+  OrthancPlugins::RegisterRestCallback<OrthancWSI::ListLayers>("/wsi/api/list-layers", true);
+  OrthancPlugins::RegisterRestCallback<OrthancWSI::SaveUserLayer>("/wsi/api/save-user-layer", true);
+  OrthancPlugins::RegisterRestCallback<OrthancWSI::LoadUserFeatures>("/wsi/api/load-user-features", true);
+  OrthancPlugins::RegisterRestCallback<OrthancWSI::SaveUserFeatures>("/wsi/api/save-user-features", true);
+
+  // TODO
+  OrthancPlugins::RegisterRestCallback<OrthancWSI::ListSharedLayers>("/wsi/api/shared-layers", true);
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/ViewerPlugin/Annotations/AnnotationsRestApi.h	Wed Aug 19 15:25:12 2026 +0200
@@ -0,0 +1,26 @@
+/**
+ * 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
+
+void RegisterAnnotationsRestApi();
--- a/ViewerPlugin/CMakeLists.txt	Wed Aug 19 14:59:26 2026 +0200
+++ b/ViewerPlugin/CMakeLists.txt	Wed Aug 19 15:25:12 2026 +0200
@@ -186,6 +186,7 @@
 #####################################################################
 
 set(ORTHANC_WSI_SOURCES
+  Annotations/AnnotationsRestApi.cpp
   Annotations/IAuthenticatedUser.cpp
   Annotations/UserId.cpp
   DicomPyramidCache.cpp
@@ -194,6 +195,7 @@
   OrthancPyramidFrameFetcher.cpp
   Plugin.cpp
   RawTile.cpp
+  ViewerToolbox.cpp
 
   ${ORTHANC_WSI_DIR}/Framework/ColorSpaces.cpp
   ${ORTHANC_WSI_DIR}/Framework/DicomToolbox.cpp
--- a/ViewerPlugin/Plugin.cpp	Wed Aug 19 14:59:26 2026 +0200
+++ b/ViewerPlugin/Plugin.cpp	Wed Aug 19 15:25:12 2026 +0200
@@ -23,25 +23,16 @@
 
 #include "../Framework/PrecompiledHeadersWSI.h"
 
-#include "Annotations/IAuthenticatedUser.h"
+#include "../Framework/ImageToolbox.h"
+#include "Annotations/AnnotationsRestApi.h"
 #include "DicomPyramidCache.h"
 #include "IIIF.h"
+#include "OrthancPluginConnection.h"
 #include "OrthancPyramidFrameFetcher.h"
 #include "RawTile.h"
-
-#include "../Framework/ColorSpaces.h"
-#include "../Framework/ImageToolbox.h"
-#include "../Framework/Inputs/DecodedPyramidCache.h"
-#include "../Framework/Inputs/DecodedTiledPyramid.h"
-#include "../Framework/Inputs/OnTheFlyPyramid.h"
+#include "ViewerToolbox.h"
 
-#include <Compatibility.h>  // For std::unique_ptr
-#include <Compression/GzipCompressor.h>
-#include <Images/Image.h>
-#include <Images/ImageProcessing.h>
 #include <Logging.h>
-#include <OrthancException.h>
-#include <SerializationToolbox.h>
 #include <SystemToolbox.h>
 
 #include "../Resources/Orthanc/Plugins/OrthancPluginCppWrapper.h"
@@ -51,9 +42,6 @@
 #include <cassert>
 
 
-#include "OrthancPluginConnection.h"
-
-
 #define ORTHANC_PLUGIN_NAME "wsi"
 
 
@@ -110,22 +98,6 @@
 }
 
 
-static void AnswerJson(OrthancPluginRestOutput* output,
-                       const Json::Value& value)
-{
-  std::string s;
-  Orthanc::Toolbox::WriteFastJson(s, value);
-  OrthancPluginAnswerBuffer(OrthancPlugins::GetGlobalContext(), output, s.c_str(), s.size(), "application/json");
-}
-
-
-static void AnswerEmpty(OrthancPluginRestOutput* output)
-{
-  Json::Value answer = Json::objectValue;
-  AnswerJson(output, answer);
-}
-
-
 void ServePyramid(OrthancPluginRestOutput* output,
                   const char* url,
                   const OrthancPluginHttpRequest* request)
@@ -159,7 +131,7 @@
     }
   }
 
-  AnswerJson(output, answer);
+  OrthancWSI::ViewerToolbox::AnswerJson(output, answer);
 }
 
 
@@ -195,7 +167,7 @@
     }
   }
 
-  AnswerJson(output, answer);
+  OrthancWSI::ViewerToolbox::AnswerJson(output, answer);
 }
 
 
@@ -500,1304 +472,6 @@
 
 
 
-#include <Cache/SharedObjectCache.h>
-#include <MultiThreading/ReaderWriterLock.h>
-
-
-class AnnotationsId
-{
-private:
-  std::string            projectId_;
-  Orthanc::ResourceType  level_;
-  std::string            resourceId_;
-  unsigned int           frameNumber_;
-
-public:
-  AnnotationsId(const std::string& projectId,
-                Orthanc::ResourceType level,
-                const std::string& resourceId,
-                unsigned int frameNumber) :
-    projectId_(projectId),
-    level_(level),
-    resourceId_(resourceId),
-    frameNumber_(frameNumber)
-  {
-    if (level_ != Orthanc::ResourceType_Series &&
-        level_ != Orthanc::ResourceType_Instance)
-    {
-      throw Orthanc::OrthancException(Orthanc::ErrorCode_ParameterOutOfRange);
-    }
-
-    if (projectId_.find('|') != std::string::npos ||
-        resourceId_.find('|') != std::string::npos)
-    {
-      throw Orthanc::OrthancException(Orthanc::ErrorCode_ParameterOutOfRange);
-    }
-  }
-
-  const std::string& GetProjectId() const
-  {
-    return projectId_;
-  }
-
-  Orthanc::ResourceType GetLevel() const
-  {
-    return level_;
-  }
-
-  const std::string& GetResourceId() const
-  {
-    return resourceId_;
-  }
-
-  std::string GetKey() const
-  {
-    switch (level_)
-    {
-    case Orthanc::ResourceType_Series:
-      return projectId_ + "|series|" + resourceId_;
-
-    case Orthanc::ResourceType_Instance:
-      return projectId_ + "|instance|" + boost::lexical_cast<std::string>(frameNumber_) + "|" + resourceId_;
-
-    default:
-      throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
-    }
-  }
-};
-
-
-static const char* const KEY_AUTHOR = "author";
-static const char* const KEY_COLOR = "color";
-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_NAME = "name";
-static const char* const KEY_PUBLIC = "public";
-static const char* const KEY_SHARED_WITH = "shared_with";
-static const char* const KEY_VISIBLE = "visible";
-
-static const char* const KEY_VALUE_STORE = "wsi";
-
-
-/**
-
-   Content of a layer in the DB:
-
-   - visible             (editable)
-   - color               (editable)
-   - author type and ID  (read-only)
-   - layer ID            (read-only)
-   - name of the layer   (editable)
-   - shared_with         (editable, can be disabled for learners by the instructors)
-   - public              (editable, can be disabled for learners by the instructors)
-
-   Content of an imported shared layer in the DB:
-
-   - visible             (editable, set to "true" on import)
-   - color               (editable, can be different from original layer)
-   - author type and ID  (read-only)
-   - layer ID            (read-only)
-   - name of the layer   (read-only)
-
-   Finding shared layers:
-
-   map<UserId, std::list<UserLayer>>
-
-   map<UserId, std::list<std::pair<UserId, const UserLayer*>>>
-
-   API must return: color, author, layer ID, and name (visible is set to true once loaded)
-
- **/
-
-
-class ISerializable : public boost::noncopyable
-{
-public:
-  virtual ~ISerializable()
-  {
-  }
-
-  virtual void Serialize(Json::Value& target) const = 0;
-
-  static void Serialize(std::string& target,
-                        const ISerializable& obj)
-  {
-    Json::Value value;
-    obj.Serialize(value);
-    Orthanc::Toolbox::WriteFastJson(target, value);
-  }
-};
-
-
-class ILayer : public ISerializable
-{
-public:
-  virtual std::string GetId() const = 0;
-};
-
-
-class LayersCollection : public ISerializable
-{
-private:
-  typedef std::list<ILayer*>                        Content;
-  typedef std::map<std::string, Content::iterator>  Index;
-
-  Content  content_;
-  Index    index_;
-
-public:
-  ~LayersCollection()
-  {
-    for (Content::iterator it = content_.begin(); it != content_.end(); ++it)
-    {
-      assert(*it != NULL);
-      delete *it;
-    }
-  }
-
-  size_t GetSize() const
-  {
-    assert(content_.size() == index_.size());
-    return content_.size();
-  }
-
-  void AddLayer(ILayer* layer /* takes ownership */)
-  {
-    std::unique_ptr<ILayer> protection(layer);
-
-    if (layer == NULL)
-    {
-      throw Orthanc::OrthancException(Orthanc::ErrorCode_NullPointer);
-    }
-
-    const std::string id = protection->GetId();
-
-    if (index_.find(id) != index_.end())
-    {
-      throw Orthanc::OrthancException(Orthanc::ErrorCode_BadSequenceOfCalls, "Duplicate layer ID");
-    }
-    else
-    {
-      content_.push_back(protection.release());
-
-      Content::iterator it = content_.end();
-      --it;  // Points to the element we just inserted
-      index_[id] = it;
-    }
-  }
-
-  bool HasLayer(const std::string& id) const
-  {
-    return (index_.find(id) != index_.end());
-  }
-
-  ILayer& GetLayer(const std::string& id) const
-  {
-    Index::const_iterator found = index_.find(id);
-
-    if (found == index_.end())
-    {
-      throw Orthanc::OrthancException(Orthanc::ErrorCode_UnknownResource);
-    }
-    else
-    {
-      assert(*(found->second) != NULL);
-      return **(found->second);
-    }
-  }
-
-  void DeleteLayer(const std::string& id)
-  {
-    Index::iterator found = index_.find(id);
-
-    if (found == index_.end())
-    {
-      throw Orthanc::OrthancException(Orthanc::ErrorCode_UnknownResource);
-    }
-    else
-    {
-      assert(*(found->second) != NULL);
-      delete *(found->second);
-      content_.erase(found->second);
-      index_.erase(found);
-    }
-  }
-
-  virtual void Serialize(Json::Value& target) const ORTHANC_OVERRIDE
-  {
-    target = Json::arrayValue;
-
-    for (Content::const_iterator it = content_.begin(); it != content_.end(); ++it)
-    {
-      assert(*it != NULL);
-
-      Json::Value item;
-      (*it)->Serialize(item);
-      target.append(item);
-    }
-  }
-};
-
-
-static void SetKeyValueStore(const std::string& key,
-                             const std::string& value)
-{
-#if ORTHANC_PLUGINS_VERSION_IS_ABOVE(1, 12, 8)
-  OrthancPlugins::KeyValueStore store(KEY_VALUE_STORE);
-  store.Store(key, value);
-#endif
-}
-
-
-static void SetKeyValueStore(const std::string& key,
-                             const Json::Value& value)
-{
-  std::string s;
-  Orthanc::Toolbox::WriteFastJson(s, value);
-  SetKeyValueStore(key, s);
-}
-
-
-static void SetKeyValueStore(const std::string& key,
-                             const ISerializable& value)
-{
-  std::string s;
-  ISerializable::Serialize(s, value);
-  SetKeyValueStore(key, s);
-}
-
-
-static bool LookupKeyValueStore(std::string& value,
-                                const std::string& key)
-{
-#if ORTHANC_PLUGINS_VERSION_IS_ABOVE(1, 12, 8)
-  OrthancPlugins::KeyValueStore store(KEY_VALUE_STORE);
-  return store.GetValue(value, key);
-#else
-  return false;
-#endif
-}
-
-
-static bool LookupKeyValueStore(Json::Value& value,
-                                const std::string& key)
-{
-  std::string s;
-  if (LookupKeyValueStore(s, key))
-  {
-    if (Orthanc::Toolbox::ReadJson(value, s))
-    {
-      return true;
-    }
-    else
-    {
-      LOG(WARNING) << "Discarding incorrect JSON in the key-value store: " << key;
-      return false;
-    }
-  }
-  else
-  {
-    return false;
-  }
-}
-
-
-static std::string GetInfoKey(const AnnotationsId& annotations)
-{
-  return annotations.GetKey() + "|info";
-}
-
-
-static std::string GetLayersKey(const AnnotationsId& annotations,
-                                const UserId& user)
-{
-  return annotations.GetKey() + "|layers|" + user.GetKey();
-}
-
-
-static std::string GetFeaturesKey(const AnnotationsId& annotations,
-                                  const UserId& user)
-{
-  return annotations.GetKey() + "|features|" + user.GetKey();
-}
-
-
-
-static const char* const KEY_ACTIVE_USERS = "active-users";
-static const char* const KEY_PROJECT_NAME = "project-name";
-static const char* const KEY_PROJECT_DESCRIPTION = "project-description";
-static const char* const KEY_USER_LAYERS = "user-layers";
-static const char* const KEY_SHARED_LAYERS = "shared-layers";
-
-
-static unsigned int GetHex(char c)
-{
-  if (c >= '0' && c <= '9')
-  {
-    return c - '0';
-  }
-  else if (c >= 'a' && c <= 'f')
-  {
-    return c - 'a' + 10;
-  }
-  else if (c >= 'A' && c <= 'F')
-  {
-    return c - 'A' + 10;
-  }
-  else
-  {
-    throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol);
-  }
-}
-
-
-static OrthancWSI::RGBColor ParseColor(const std::string& color)
-{
-  if (color.size() != 7 ||
-      color[0] != '#')
-  {
-    throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol);
-  }
-  else
-  {
-    unsigned int r = GetHex(color[1]) * 16 + GetHex(color[2]);
-    unsigned int g = GetHex(color[3]) * 16 + GetHex(color[4]);
-    unsigned int b = GetHex(color[5]) * 16 + GetHex(color[6]);
-    return OrthancWSI::RGBColor(r, g, b);
-  }
-}
-
-
-static std::string SerializeColor(const OrthancWSI::RGBColor& color)
-{
-  char buf[16];
-  sprintf(buf, "#%02x%02x%02x", color.GetR(), color.GetG(), color.GetB());
-  return buf;
-}
-
-
-class UserLayer : public ILayer
-{
-private:
-  bool                   isVisible_;
-  OrthancWSI::RGBColor   color_;
-  std::string            id_;
-  std::string            name_;
-  std::set<UserId>       sharedWith_;
-  bool                   isPublic_;
-
-public:
-  UserLayer(const OrthancWSI::RGBColor& color,
-            const std::string& name) :
-    isVisible_(true),
-    color_(color),
-    id_(Orthanc::Toolbox::GenerateUuid()),
-    name_(name),
-    isPublic_(false)
-  {
-  }
-
-  UserLayer(const Json::Value& source) :
-    color_(ParseColor(Orthanc::SerializationToolbox::ReadString(source, KEY_COLOR)))
-  {
-    isVisible_ = Orthanc::SerializationToolbox::ReadBoolean(source, KEY_VISIBLE);
-    id_ = Orthanc::SerializationToolbox::ReadString(source, KEY_ID);
-    name_ = Orthanc::SerializationToolbox::ReadString(source, KEY_NAME);
-
-    if (source.isMember(KEY_PUBLIC))
-    {
-      isPublic_ = Orthanc::SerializationToolbox::ReadBoolean(source, KEY_PUBLIC);
-    }
-    else
-    {
-      isPublic_ = false;
-    }
-
-    if (source.isMember(KEY_SHARED_WITH))
-    {
-      const Json::Value& sharedWith = source[KEY_SHARED_WITH];
-
-      if (!sharedWith.isArray())
-      {
-        throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol);
-      }
-
-      for (Json::Value::ArrayIndex i = 0; i < sharedWith.size(); i++)
-      {
-        sharedWith_.insert(UserId(sharedWith[i]));
-      }
-    }
-  }
-
-  virtual std::string GetId() const ORTHANC_OVERRIDE
-  {
-    return id_;
-  }
-
-  bool IsVisible() const
-  {
-    return isVisible_;
-  }
-
-  const OrthancWSI::RGBColor& GetColor() const
-  {
-    return color_;
-  }
-
-  const std::string& GetName() const
-  {
-    return name_;
-  }
-
-  bool IsSharedWith(const UserId& user) const
-  {
-    return (isPublic_ ||
-            sharedWith_.find(user) != sharedWith_.end());
-  }
-
-  void Assign(const UserLayer& other)
-  {
-    isVisible_ = other.isVisible_;
-    color_ = other.color_;
-    name_ = other.name_;
-    sharedWith_ = other.sharedWith_;
-    isPublic_ = other.isPublic_;
-  }
-
-  virtual void Serialize(Json::Value& target) const ORTHANC_OVERRIDE
-  {
-    Json::Value sharedWith = Json::arrayValue;
-    for (std::set<UserId>::const_iterator it = sharedWith_.begin(); it != sharedWith_.end(); ++it)
-    {
-      Json::Value item;
-      it->Serialize(item);
-      sharedWith.append(item);
-    }
-
-    target = Json::objectValue;
-    target[KEY_VISIBLE] = isVisible_;
-    target[KEY_COLOR] = SerializeColor(color_);
-    target[KEY_ID] = id_;
-    target[KEY_NAME] = name_;
-    target[KEY_PUBLIC] = isPublic_;
-    target[KEY_SHARED_WITH] = sharedWith;
-  }
-};
-
-
-class SharedLayer : public ILayer
-{
-private:
-  bool                  isVisible_;
-  OrthancWSI::RGBColor  color_;
-  UserId                author_;
-  std::string           id_;
-  std::string           name_;
-
-public:
-  SharedLayer(const UserId& author,
-              const UserLayer& layer) :
-    isVisible_(true),
-    color_(layer.GetColor()),
-    author_(author),
-    id_(layer.GetId()),
-    name_(layer.GetName())
-  {
-  }
-
-  SharedLayer(const Json::Value& source) :
-    color_(0, 0, 0)
-  {
-    if (!source.isMember(KEY_AUTHOR))
-    {
-      throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol);
-    }
-
-    isVisible_ = Orthanc::SerializationToolbox::ReadBoolean(source, KEY_VISIBLE);
-    author_ = UserId(source[KEY_AUTHOR]);
-    id_ = Orthanc::SerializationToolbox::ReadString(source, KEY_ID);
-    name_ = Orthanc::SerializationToolbox::ReadString(source, KEY_NAME);
-
-    std::string color = Orthanc::SerializationToolbox::ReadString(source, KEY_COLOR);
-    color_ = ParseColor(color);
-  }
-
-  virtual std::string GetId() const ORTHANC_OVERRIDE
-  {
-    return id_;
-  }
-
-  bool IsVisible() const
-  {
-    return isVisible_;
-  }
-
-  const OrthancWSI::RGBColor& GetColor() const
-  {
-    return color_;
-  }
-
-  const UserId& GetAuthor() const
-  {
-    return author_;
-  }
-
-  const std::string& GetName() const
-  {
-    return name_;
-  }
-
-  virtual void Serialize(Json::Value& target) const ORTHANC_OVERRIDE
-  {
-    target = Json::objectValue;
-    target[KEY_VISIBLE] = isVisible_;
-    target[KEY_COLOR] = SerializeColor(color_);
-    target[KEY_ID] = id_;
-    target[KEY_NAME] = name_;
-
-    author_.Serialize(target[KEY_AUTHOR]);
-  }
-};
-
-
-class UserData : public ISerializable
-{
-private:
-  LayersCollection    userLayers_;
-
-public:
-  UserData()
-  {
-  }
-
-  UserData(const Json::Value& source)
-  {
-    if (!source.isArray())
-    {
-      throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat);
-    }
-    else
-    {
-      for (Json::Value::ArrayIndex i = 0; i < source.size(); i++)
-      {
-        userLayers_.AddLayer(new UserLayer(source[i]));
-      }
-    }
-  }
-
-  std::string AddUserLayer(UserLayer* layer)
-  {
-    std::unique_ptr<UserLayer> protection(layer);
-
-    if (layer == NULL)
-    {
-      throw Orthanc::OrthancException(Orthanc::ErrorCode_NullPointer);
-    }
-
-    const std::string id = protection->GetId();
-
-    userLayers_.AddLayer(protection.release());
-
-    return id;
-  }
-
-  UserLayer& GetUserLayer(const std::string& layerId) const
-  {
-    return dynamic_cast<UserLayer&>(userLayers_.GetLayer(layerId));
-  }
-
-  std::string CreateUserLayer()
-  {
-    static const uint8_t PALETTE[] = {
-      0xe6, 0x39, 0x46,  // red: #e63946
-      0x2a, 0x9d, 0x8f,
-      0xe9, 0xc4, 0x6a,
-      0x26, 0x46, 0x53,
-      0xf4, 0xa2, 0x61
-    };
-
-    static const size_t PALETTE_SIZE = sizeof(PALETTE) / (3 * sizeof(uint8_t));
-
-    size_t item = userLayers_.GetSize() % PALETTE_SIZE;
-
-    OrthancWSI::RGBColor color(PALETTE[3 * item],
-                               PALETTE[3 * item + 1],
-                               PALETTE[3 * item + 2]);
-
-    std::string name;
-    if (userLayers_.GetSize() == 0)
-    {
-      name = "Default";
-    }
-    else
-    {
-      name = "Layer " + boost::lexical_cast<std::string>(userLayers_.GetSize() + 1);
-    }
-
-    return AddUserLayer(new UserLayer(color, name));
-  }
-
-  void DeleteUserLayer(const std::string& layerId)
-  {
-    userLayers_.DeleteLayer(layerId);
-  }
-
-  virtual void Serialize(Json::Value& target) const ORTHANC_OVERRIDE
-  {
-    userLayers_.Serialize(target);
-  }
-};
-
-
-class AnnotationsInfo : public ISerializable
-{
-private:
-  std::string        projectName_;
-  std::string        projectDescription_;
-  std::set<UserId>   activeUsers_;
-
-public:
-  AnnotationsInfo()
-  {
-  }
-
-  AnnotationsInfo(const Json::Value& source)
-  {
-    projectName_ = Orthanc::SerializationToolbox::ReadString(source, KEY_PROJECT_NAME);
-    projectDescription_ = Orthanc::SerializationToolbox::ReadString(source, KEY_PROJECT_DESCRIPTION);
-
-    const Json::Value& users = source[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]));
-    }
-  }
-
-  const std::string& GetProjectName() const
-  {
-    return projectName_;
-  }
-
-  void SetProjectName(const std::string& name)
-  {
-    projectName_ = name;
-  }
-
-  const std::string& GetProjectDescription() const
-  {
-    return projectDescription_;
-  }
-
-  void SetProjectDescription(const std::string& description)
-  {
-    projectDescription_ = description;
-  }
-
-  // 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& target) 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);
-    }
-
-    target = Json::objectValue;
-    target[KEY_PROJECT_NAME] = projectName_;
-    target[KEY_PROJECT_DESCRIPTION] = projectDescription_;
-    target[KEY_ACTIVE_USERS] = users;
-  }
-};
-
-
-class CachedAnnotations : public Orthanc::IDynamicObject
-{
-private:
-  void LoadUserData(const UserId& user)
-  {
-    const std::string key = id_.GetKey() + "|layers|" + user.GetKey();
-
-    Json::Value layers;
-    if (LookupKeyValueStore(layers, key))
-    {
-      std::unique_ptr<UserData> item(new UserData(layers));
-
-      if (content_.find(user) == content_.end())  // Should never be false
-      {
-        content_[user] = item.release();
-      }
-    }
-  }
-
-  typedef std::map<UserId, UserData*>   Content;
-
-  Orthanc::ReaderWriterLock         mutex_;
-  std::unique_ptr<AnnotationsInfo>  info_;
-  AnnotationsId                     id_;
-  Content                           content_;
-
-public:
-  CachedAnnotations(const AnnotationsId& id) :
-    id_(id)
-  {
-    const std::string key = GetInfoKey(id);
-
-    Json::Value info;
-
-    if (LookupKeyValueStore(info, key))
-    {
-      info_.reset(new AnnotationsInfo(info));
-
-      for (std::set<UserId>::const_iterator it = info_->GetActiveUsers().begin();
-           it != info_->GetActiveUsers().end(); ++it)
-      {
-        LoadUserData(*it);
-      }
-    }
-    else
-    {
-      info_.reset(new AnnotationsInfo);
-
-      if (OrthancPlugins::RestApiGet(info, "/education/api-plugins/project?id=" + id.GetProjectId(), true))
-      {
-        // The "orthanc-education" plugin is available
-        info_->SetProjectName(Orthanc::SerializationToolbox::ReadString(info, "name"));
-        info_->SetProjectDescription(Orthanc::SerializationToolbox::ReadString(info, "description"));
-      }
-
-      info_->Serialize(info);
-      SetKeyValueStore(key, info);
-    }
-  }
-
-  ~CachedAnnotations()
-  {
-    for (Content::iterator it = content_.begin(); it != content_.end(); ++it)
-    {
-      assert(it->second != NULL);
-      delete it->second;
-    }
-  }
-
-  class UserReader : public boost::noncopyable
-  {
-  private:
-    Orthanc::ReaderWriterLock::ReadLock lock_;
-    const AnnotationsInfo&              info_;
-    const UserData*                     userData_;
-
-  public:
-    UserReader(CachedAnnotations& that,
-               const IAuthenticatedUser& user) :
-      lock_(that.mutex_),
-      info_(*that.info_)
-    {
-      Content::const_iterator found = that.content_.find(user.GetAnnotatingId());
-
-      if (found == that.content_.end())
-      {
-        userData_ = NULL;
-      }
-      else
-      {
-        assert(found->second != NULL);
-        userData_ = found->second;
-      }
-    }
-
-    bool IsValid() const
-    {
-      return userData_ != NULL;
-    }
-
-    const AnnotationsInfo& GetAnnotationsInfo() const
-    {
-      return info_;
-    }
-
-    void ListLayers(Json::Value& target) const
-    {
-      target = Json::objectValue;
-
-      if (IsValid())
-      {
-        userData_->Serialize(target[KEY_USER_LAYERS]);
-        target[KEY_SHARED_LAYERS] = Json::arrayValue;  // TODO
-      }
-      else
-      {
-        target[KEY_USER_LAYERS] = Json::arrayValue;
-        target[KEY_SHARED_LAYERS] = Json::arrayValue;
-      }
-    }
-  };
-
-
-  class UserWriter : public boost::noncopyable
-  {
-  private:
-    Orthanc::ReaderWriterLock::WriteLock  lock_;
-    CachedAnnotations&                    that_;
-    UserId                                userId_;
-    UserData*                             userData_;
-
-    void Commit()
-    {
-      SetKeyValueStore(GetLayersKey(that_.id_, userId_), *userData_);
-    }
-
-  public:
-    UserWriter(CachedAnnotations& that,
-               const IAuthenticatedUser& user) :
-      lock_(that.mutex_),
-      that_(that),
-      userId_(user.GetAnnotatingId())
-    {
-      const UserId id = user.GetAnnotatingId();
-
-      if (that.info_->AddActiveUser(userId_))
-      {
-        // Only update the key-value store if this is the first time we meet this user
-        SetKeyValueStore(GetInfoKey(that.id_), *that.info_);
-      }
-
-      Content::iterator found = that.content_.find(userId_);
-
-      if (found == that.content_.end())
-      {
-        std::unique_ptr<UserData> layers(new UserData);
-        userData_ = layers.get();
-        that.content_[userId_] = layers.release();
-        Commit();
-      }
-      else
-      {
-        assert(found->second != NULL);
-        userData_ = found->second;
-      }
-    }
-
-    void CreateUserLayer(Json::Value& answer)
-    {
-      assert(userData_ != NULL);
-
-      const std::string layerId = userData_->CreateUserLayer();
-      Commit();
-
-      UserLayer& layer = userData_->GetUserLayer(layerId);
-      layer.Serialize(answer);
-    }
-
-    void UpdateUserLayer(const UserLayer& updated)
-    {
-      assert(userData_ != NULL);
-
-      UserLayer& layer = userData_->GetUserLayer(updated.GetId());
-      layer.Assign(updated);
-      Commit();
-    }
-
-    void DeleteUserLayer(const std::string& layerId)
-    {
-      assert(userData_ != NULL);
-      userData_->DeleteUserLayer(layerId);
-      Commit();
-    }
-  };
-
-
-#if 0
-  void GetSharedLayers(Json::Value& layers,
-                       const IAuthenticatedUser& user)
-  {
-    Orthanc::ReaderWriterLock::ReadLock lock(mutex_);
-
-    layers = Json::arrayValue;   // TODO
-  }
-
-  bool HasAccessToLayer(const IAuthenticatedUser& user,
-                        const UserId& author,
-                        const std::string& layerId)
-  {
-    Orthanc::ReaderWriterLock::ReadLock lock(mutex_);
-
-    return false;  // TODO
-  }
-
-  void CreateUserLayer(const IAuthenticatedUser& user)
-  {
-    // TODO
-  }
-
-  void AnswerLayers(OrthancPluginRestOutput* output,
-                    const IAuthenticatedUser& user)
-  {
-
-    AnswerJson(output, answer);
-  }
-#endif
-};
-
-
-class AnnotationsCommandContext : public boost::noncopyable
-{
-private:
-  std::unique_ptr<IAuthenticatedUser>         user_;
-  Json::Value                                 body_;
-  std::unique_ptr<AnnotationsId>              annotationsId_;
-  boost::shared_ptr<Orthanc::IDynamicObject>  cachedAnnotations_;
-
-public:
-  AnnotationsCommandContext(const OrthancPluginHttpRequest* request)
-  {
-    if (request->method != OrthancPluginHttpMethod_Post)
-    {
-      throw Orthanc::OrthancException(Orthanc::ErrorCode_BadSequenceOfCalls);
-    }
-
-    user_.reset(IAuthenticatedUser::FromHttpRequest(request));
-
-    if (!Orthanc::Toolbox::ReadJson(body_, request->body, request->bodySize) ||
-        !body_.isObject())
-    {
-      throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol);
-    }
-
-    const std::string projectId = Orthanc::SerializationToolbox::ReadString(body_, "project", "" /* default project */);
-    const std::string level = Orthanc::SerializationToolbox::ReadString(body_, "level");
-    const std::string resourceId = Orthanc::SerializationToolbox::ReadString(body_, "resource");
-    unsigned int frameNumber = Orthanc::SerializationToolbox::ReadUnsignedInteger(body_, "frame", 0 /* default frame */);
-
-    annotationsId_.reset(new AnnotationsId(projectId, Orthanc::StringToResourceType(level.c_str()), resourceId, frameNumber));
-
-    IAuthenticatedUser::ProjectRole role = user_->GetRoleInProject(annotationsId_->GetProjectId());
-
-    if (role != IAuthenticatedUser::ProjectRole_Instructor &&
-        role != IAuthenticatedUser::ProjectRole_Learner)
-    {
-      throw Orthanc::OrthancException(Orthanc::ErrorCode_ForbiddenAccess, "User \"" + user_->Format() +
-                                      "\" is not instructor or learner of project \"" + annotationsId_->GetProjectId() + "\"");
-    }
-
-    const std::string cacheKey = annotationsId_->GetKey();
-
-    static Orthanc::SharedObjectCache annotationsCache_(100);  // TODO - PARAMETER
-
-    cachedAnnotations_ = annotationsCache_.GetCachedValue(cacheKey);
-
-    if (cachedAnnotations_.get() == NULL)
-    {
-      cachedAnnotations_.reset(new CachedAnnotations(*annotationsId_));
-      annotationsCache_.Store(cacheKey, cachedAnnotations_, 1);
-    }
-  }
-
-  const IAuthenticatedUser& GetUser() const
-  {
-    assert(user_.get() != NULL);
-    return *user_;
-  }
-
-  const AnnotationsId& GetAnnotationsId() const
-  {
-    assert(annotationsId_.get() != NULL);
-    return *annotationsId_;
-  }
-
-  std::string GetFeaturesKey() const
-  {
-    return ::GetFeaturesKey(GetAnnotationsId(), GetUser().GetAnnotatingId());
-  }
-
-  std::string GetBodyString(const char* field) const
-  {
-    return Orthanc::SerializationToolbox::ReadString(body_, field);
-  }
-
-  const Json::Value& GetBodyField(const char* field) const
-  {
-    if (!body_.isMember(field))
-    {
-      throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol);
-    }
-    else
-    {
-      return body_[field];
-    }
-  }
-
-  CachedAnnotations& GetCachedAnnotations()
-  {
-    return dynamic_cast<CachedAnnotations&>(*cachedAnnotations_);
-  }
-};
-
-
-static bool ProtectPostRequest(OrthancPluginRestOutput* output,
-                               const OrthancPluginHttpRequest* request)
-{
-  if (request->method != OrthancPluginHttpMethod_Post)
-  {
-    OrthancPluginSendMethodNotAllowed(OrthancPlugins::GetGlobalContext(), output, "POST");
-    return false;
-  }
-  else
-  {
-    return true;
-  }
-}
-
-
-
-
-void GetAnnotationsInfo(OrthancPluginRestOutput* output,
-                        const char* url,
-                        const OrthancPluginHttpRequest* request)
-{
-  if (ProtectPostRequest(output, request))
-  {
-    AnnotationsCommandContext context(request);
-
-    CachedAnnotations::UserReader reader(context.GetCachedAnnotations(), context.GetUser());
-
-    Json::Value answer;
-    answer["project-name"] = reader.GetAnnotationsInfo().GetProjectName();
-    answer["project-description"] = reader.GetAnnotationsInfo().GetProjectDescription();
-
-    const UserId user = context.GetUser().GetAnnotatingId();
-
-    if (user.GetType() == UserId::Type_Standard)
-    {
-      answer["user"] = user.GetName();
-    }
-    else
-    {
-      answer["user"] = "";
-    }
-
-#if ORTHANC_PLUGINS_VERSION_IS_ABOVE(1, 12, 8)
-    answer["persistent-annotations"] = true;
-#else
-    answer["persistent-annotations"] = false;
-#endif
-
-    AnswerJson(output, answer);
-  }
-}
-
-
-void ListLayers(OrthancPluginRestOutput* output,
-                const char* url,
-                const OrthancPluginHttpRequest* request)
-{
-  if (ProtectPostRequest(output, request))
-  {
-    AnnotationsCommandContext context(request);
-
-    CachedAnnotations::UserReader reader(context.GetCachedAnnotations(), context.GetUser());
-
-    Json::Value answer;
-    reader.ListLayers(answer);
-
-    AnswerJson(output, answer);
-  }
-}
-
-
-void CreateUserLayer(OrthancPluginRestOutput* output,
-                     const char* url,
-                     const OrthancPluginHttpRequest* request)
-{
-  if (ProtectPostRequest(output, request))
-  {
-    AnnotationsCommandContext context(request);
-
-    CachedAnnotations::UserWriter writer(context.GetCachedAnnotations(), context.GetUser());
-
-    Json::Value answer;
-    writer.CreateUserLayer(answer);
-
-    AnswerJson(output, answer);
-  }
-}
-
-
-void SaveUserLayer(OrthancPluginRestOutput* output,
-                   const char* url,
-                   const OrthancPluginHttpRequest* request)
-{
-  if (ProtectPostRequest(output, request))
-  {
-    AnnotationsCommandContext context(request);
-
-    UserLayer updated(context.GetBodyField("layer"));
-
-    {
-      CachedAnnotations::UserWriter writer(context.GetCachedAnnotations(), context.GetUser());
-      writer.UpdateUserLayer(updated);
-    }
-
-    AnswerEmpty(output);
-  }
-}
-
-
-void DeleteUserLayer(OrthancPluginRestOutput* output,
-                     const char* url,
-                     const OrthancPluginHttpRequest* request)
-{
-  if (ProtectPostRequest(output, request))
-  {
-    AnnotationsCommandContext context(request);
-
-    const std::string layerId = context.GetBodyString("layer-id");
-
-    {
-      CachedAnnotations::UserWriter writer(context.GetCachedAnnotations(), context.GetUser());
-      writer.DeleteUserLayer(layerId);
-    }
-
-    AnswerEmpty(output);
-  }
-}
-
-
-void LoadUserFeatures(OrthancPluginRestOutput* output,
-                      const char* url,
-                      const OrthancPluginHttpRequest* request)
-{
-  if (request->method != OrthancPluginHttpMethod_Post)
-  {
-    OrthancPluginSendMethodNotAllowed(OrthancPlugins::GetGlobalContext(), output, "POST");
-  }
-  else
-  {
-    AnnotationsCommandContext context(request);
-
-    Json::Value answer;
-    answer[KEY_FEATURES] = Json::arrayValue;
-
-#if ORTHANC_PLUGINS_VERSION_IS_ABOVE(1, 12, 8)
-    std::string compressed;
-    if (LookupKeyValueStore(compressed, context.GetFeaturesKey()))
-    {
-      std::string uncompressed;
-      Orthanc::GzipCompressor compressor;
-      Orthanc::IBufferCompressor::Uncompress(uncompressed, compressor, compressed);
-
-      if (!Orthanc::Toolbox::ReadJson(answer[KEY_FEATURES], uncompressed))
-      {
-        throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
-      }
-    }
-#else
-    throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented, "Your Orthanc SDK is too old to load annotations");
-#endif
-
-    AnswerJson(output, answer);
-  }
-}
-
-
-void SaveUserFeatures(OrthancPluginRestOutput* output,
-                      const char* url,
-                      const OrthancPluginHttpRequest* request)
-{
-  if (request->method != OrthancPluginHttpMethod_Post)
-  {
-    OrthancPluginSendMethodNotAllowed(OrthancPlugins::GetGlobalContext(), output, "POST");
-  }
-  else
-  {
-    AnnotationsCommandContext context(request);
-
-    std::string features;
-    Orthanc::Toolbox::WriteFastJson(features, context.GetBodyField(KEY_FEATURES));
-
-    std::string compressed;
-    Orthanc::GzipCompressor compressor;
-    Orthanc::IBufferCompressor::Compress(compressed, compressor, features);
-
-#if ORTHANC_PLUGINS_VERSION_IS_ABOVE(1, 12, 8)
-    SetKeyValueStore(context.GetFeaturesKey(), compressed);
-    AnswerEmpty(output);
-#else
-    throw Orthanc::OrthancException(Orthanc::ErrorCode_NotImplemented, "Your Orthanc SDK is too old to save annotations");
-#endif
-  }
-}
-
-
-void ListSharedLayers(OrthancPluginRestOutput* output,
-                      const char* url,
-                      const OrthancPluginHttpRequest* request)
-{
-  if (request->method != OrthancPluginHttpMethod_Post)
-  {
-    OrthancPluginSendMethodNotAllowed(OrthancPlugins::GetGlobalContext(), output, "POST");
-  }
-  else
-  {
-    std::unique_ptr<IAuthenticatedUser> user(IAuthenticatedUser::FromHttpRequest(request));
-
-    Json::Value body;
-    if (!Orthanc::Toolbox::ReadJson(body, request->body, request->bodySize) ||
-        !body.isObject())
-    {
-      throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol);
-    }
-
-    Json::Value answer;
-
-    for (size_t i = 0; i < 10; i++)
-    {
-      const std::string userId = "User " + boost::lexical_cast<std::string>(i);
-
-      Json::Value layers = Json::arrayValue;
-      for (size_t j = 0; j < 5; j++) {
-        Json::Value layer;
-        layer["id"] = Orthanc::Toolbox::GenerateUuid();
-        layer["name"] = "Layer " + boost::lexical_cast<std::string>(j);
-        layers.append(layer);
-      }
-
-      Json::Value user;
-      user["layers"] = layers;
-      answer[userId] = user;
-    }
-
-    AnswerJson(output, answer);
-  }
-}
-
-
 extern "C"
 {
   ORTHANC_PLUGINS_API int32_t OrthancPluginInitialize(OrthancPluginContext* context)
@@ -1913,11 +587,9 @@
     OrthancPlugins::RegisterRestCallback<ServeJavaScriptLibraries>("/wsi/libs/(.*)", true);
 
 #if ORTHANC_STANDALONE == 1
-    OrthancPlugins::RegisterRestCallback<ServeEmbeddedFile>("/wsi/app/(annotations.js)", true);
     OrthancPlugins::RegisterRestCallback<ServeEmbeddedFile>("/wsi/app/(viewer.html)", true);
     OrthancPlugins::RegisterRestCallback<ServeEmbeddedFile>("/wsi/app/(viewer.js)", true);
 #else
-    OrthancPlugins::RegisterRestCallback<ServeSourceFile>("/wsi/app/(annotations.js)", true);
     OrthancPlugins::RegisterRestCallback<ServeSourceFile>("/wsi/app/(viewer.html)", true);
     OrthancPlugins::RegisterRestCallback<ServeSourceFile>("/wsi/app/(viewer.js)", true);
 #endif
@@ -1927,18 +599,16 @@
     OrthancPlugins::RegisterRestCallback<ServeFramePyramid>("/wsi/frames-pyramids/([0-9a-f-]+)/([0-9-]+)", true);
     OrthancPlugins::RegisterRestCallback<ServeFrameTile>("/wsi/frames-tiles/([0-9a-f-]+)/([0-9-]+)/([0-9-]+)/([0-9-]+)/([0-9-]+)", true);
 
-    // NEW
-    OrthancPlugins::RegisterRestCallback<GetAnnotationsInfo>("/wsi/api/annotations-info", true);
-    OrthancPlugins::RegisterRestCallback<CreateUserLayer>("/wsi/api/create-user-layer", true);
-    OrthancPlugins::RegisterRestCallback<DeleteUserLayer>("/wsi/api/delete-user-layer", true);
-    OrthancPlugins::RegisterRestCallback<ListLayers>("/wsi/api/list-layers", true);
-    OrthancPlugins::RegisterRestCallback<SaveUserLayer>("/wsi/api/save-user-layer", true);
-    OrthancPlugins::RegisterRestCallback<LoadUserFeatures>("/wsi/api/load-user-features", true);
-    OrthancPlugins::RegisterRestCallback<SaveUserFeatures>("/wsi/api/save-user-features", true);
+    if (true)  // TODO - Protect by some configuration option
+    {
+      RegisterAnnotationsRestApi();
 
-    // TODO
-    OrthancPlugins::RegisterRestCallback<ListSharedLayers>("/wsi/api/shared-layers", true);
-
+#if ORTHANC_STANDALONE == 1
+      OrthancPlugins::RegisterRestCallback<ServeEmbeddedFile>("/wsi/app/(annotations.js)", true);
+#else
+      OrthancPlugins::RegisterRestCallback<ServeSourceFile>("/wsi/app/(annotations.js)", true);
+#endif
+    }
 
     OrthancPlugins::OrthancConfiguration mainConfiguration;
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/ViewerPlugin/ViewerToolbox.cpp	Wed Aug 19 15:25:12 2026 +0200
@@ -0,0 +1,98 @@
+/**
+ * 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 "ViewerToolbox.h"
+
+#include <OrthancException.h>
+#include <Toolbox.h>
+
+#include "../Resources/Orthanc/Plugins/OrthancPluginCppWrapper.h"
+
+
+static unsigned int GetHex(char c)
+{
+  if (c >= '0' && c <= '9')
+  {
+    return c - '0';
+  }
+  else if (c >= 'a' && c <= 'f')
+  {
+    return c - 'a' + 10;
+  }
+  else if (c >= 'A' && c <= 'F')
+  {
+    return c - 'A' + 10;
+  }
+  else
+  {
+    throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol);
+  }
+}
+
+
+namespace OrthancWSI
+{
+  namespace ViewerToolbox
+  {
+    void AnswerJson(OrthancPluginRestOutput* output,
+                    const Json::Value& value)
+    {
+      std::string s;
+      Orthanc::Toolbox::WriteFastJson(s, value);
+      OrthancPluginAnswerBuffer(OrthancPlugins::GetGlobalContext(), output, s.c_str(), s.size(), "application/json");
+    }
+
+
+    void AnswerEmpty(OrthancPluginRestOutput* output)
+    {
+      Json::Value answer = Json::objectValue;
+      AnswerJson(output, answer);
+    }
+
+
+    RGBColor ParseColor(const std::string& color)
+    {
+      if (color.size() != 7 ||
+          color[0] != '#')
+      {
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol);
+      }
+      else
+      {
+        unsigned int r = GetHex(color[1]) * 16 + GetHex(color[2]);
+        unsigned int g = GetHex(color[3]) * 16 + GetHex(color[4]);
+        unsigned int b = GetHex(color[5]) * 16 + GetHex(color[6]);
+        return RGBColor(r, g, b);
+      }
+    }
+
+
+    std::string SerializeColor(const RGBColor& color)
+    {
+      char buf[16];
+      sprintf(buf, "#%02x%02x%02x", color.GetR(), color.GetG(), color.GetB());
+      return buf;
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/ViewerPlugin/ViewerToolbox.h	Wed Aug 19 15:25:12 2026 +0200
@@ -0,0 +1,45 @@
+/**
+ * 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 "../Framework/ColorSpaces.h"
+
+#include <json/value.h>
+#include <orthanc/OrthancCPlugin.h>
+
+
+namespace OrthancWSI
+{
+  namespace ViewerToolbox
+  {
+    void AnswerJson(OrthancPluginRestOutput* output,
+                    const Json::Value& value);
+
+    void AnswerEmpty(OrthancPluginRestOutput* output);
+
+    RGBColor ParseColor(const std::string& color);
+
+    std::string SerializeColor(const RGBColor& color);
+  }
+}