From 06ad9ac46c18f71652c6a492405c99ff2e2d5b9d Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Sat, 8 Aug 2020 15:03:28 +0200
Subject: [PATCH] Add routes to conveniently set/toggle tags

---
 .../scala/docspell/backend/ops/OItem.scala    | 25 ++++++++
 .../src/main/resources/docspell-openapi.yml   | 63 +++++++++++++++++++
 .../restserver/routes/ItemRoutes.scala        | 14 +++++
 .../docspell/store/records/RTagItem.scala     |  8 +++
 4 files changed, 110 insertions(+)

diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala
index 133991ae..4919fdfe 100644
--- a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala
+++ b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala
@@ -26,6 +26,9 @@ trait OItem[F[_]] {
   /** Apply all tags to the given item. Tags must exist, but can be IDs or names. */
   def linkTags(item: Ident, tags: List[String], collective: Ident): F[UpdateResult]
 
+  /** Toggles tags of the given item. Tags must exist, but can be IDs or names. */
+  def toggleTags(item: Ident, tags: List[String], collective: Ident): F[UpdateResult]
+
   def setDirection(item: Ident, direction: Direction, collective: Ident): F[AddResult]
 
   def setFolder(item: Ident, folder: Option[Ident], collective: Ident): F[AddResult]
@@ -115,6 +118,28 @@ object OItem {
               store.transact(db)
           }
 
+        def toggleTags(
+            item: Ident,
+            tags: List[String],
+            collective: Ident
+        ): F[UpdateResult] =
+          tags.distinct match {
+            case Nil => UpdateResult.success.pure[F]
+            case kws =>
+              val db =
+                (for {
+                  _     <- OptionT(RItem.checkByIdAndCollective(item, collective))
+                  given <- OptionT.liftF(RTag.findAllByNameOrId(kws, collective))
+                  exist <- OptionT.liftF(RTagItem.findAllIn(item, given.map(_.tagId)))
+                  remove = given.map(_.tagId).toSet.intersect(exist.map(_.tagId).toSet)
+                  toadd  = given.map(_.tagId).diff(exist.map(_.tagId))
+                  _ <- OptionT.liftF(RTagItem.setAllTags(item, toadd))
+                  _ <- OptionT.liftF(RTagItem.removeAllTags(item, remove.toSeq))
+                } yield UpdateResult.success).getOrElse(UpdateResult.notFound)
+
+              store.transact(db)
+          }
+
         def setTags(item: Ident, tagIds: List[Ident], collective: Ident): F[AddResult] = {
           val db = for {
             cid <- RItem.getCollective(item)
diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml
index b14b28d4..7833b28e 100644
--- a/modules/restapi/src/main/resources/docspell-openapi.yml
+++ b/modules/restapi/src/main/resources/docspell-openapi.yml
@@ -1377,6 +1377,59 @@ paths:
             application/json:
               schema:
                 $ref: "#/components/schemas/BasicResult"
+
+  /sec/item/{id}/taglink:
+    post:
+      tags: [Item]
+      summary: Link existing tags to an item.
+      description: |
+        Sets all given tags to the item. The tags must exist,
+        otherwise they are ignored. The tags may be specified as names
+        or ids.
+      security:
+        - authTokenHeader: []
+      parameters:
+        - $ref: "#/components/parameters/id"
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: "#/components/schemas/StringList"
+      responses:
+        200:
+          description: Ok
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/BasicResult"
+
+  /sec/item/{id}/tagtoggle:
+    post:
+      tags: [Item]
+      summary: Toggles existing tags to an item.
+      description: |
+        Toggles all given tags of the item. The tags must exist,
+        otherwise they are ignored. The tags may be specified as names
+        or ids. Tags are either removed or linked from/to the item,
+        depending on whether the item currently is tagged with the
+        corresponding tag.
+      security:
+        - authTokenHeader: []
+      parameters:
+        - $ref: "#/components/parameters/id"
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: "#/components/schemas/StringList"
+      responses:
+        200:
+          description: Ok
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/BasicResult"
+
   /sec/item/{id}/direction:
     put:
       tags: [ Item ]
@@ -2551,6 +2604,16 @@ paths:
 
 components:
   schemas:
+    StringList:
+      description: |
+        A simple list of strings.
+      required:
+        - items
+      properties:
+        items:
+          type: array
+          items:
+            type: string
     FolderList:
       description: |
         A list of folders with their member counts.
diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala
index d94ef314..8f51d79a 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala
@@ -142,6 +142,20 @@ object ItemRoutes {
           resp <- Ok(Conversions.basicResult(res, "Tag added."))
         } yield resp
 
+      case req @ PUT -> Root / Ident(id) / "taglink" =>
+        for {
+          tags <- req.as[StringList]
+          res  <- backend.item.linkTags(id, tags.items, user.account.collective)
+          resp <- Ok(Conversions.basicResult(res, "Tags linked"))
+        } yield resp
+
+      case req @ POST -> Root / Ident(id) / "tagtoggle" =>
+        for {
+          tags <- req.as[StringList]
+          res  <- backend.item.toggleTags(id, tags.items, user.account.collective)
+          resp <- Ok(Conversions.basicResult(res, "Tags linked"))
+        } yield resp
+
       case req @ PUT -> Root / Ident(id) / "direction" =>
         for {
           dir  <- req.as[DirectionValue]
diff --git a/modules/store/src/main/scala/docspell/store/records/RTagItem.scala b/modules/store/src/main/scala/docspell/store/records/RTagItem.scala
index 35050225..706e64b4 100644
--- a/modules/store/src/main/scala/docspell/store/records/RTagItem.scala
+++ b/modules/store/src/main/scala/docspell/store/records/RTagItem.scala
@@ -55,6 +55,14 @@ object RTagItem {
         Vector.empty.pure[ConnectionIO]
     }
 
+  def removeAllTags(item: Ident, tags: Seq[Ident]): ConnectionIO[Int] =
+    NonEmptyList.fromList(tags.toList) match {
+      case None =>
+        0.pure[ConnectionIO]
+      case Some(nel) =>
+        deleteFrom(table, and(itemId.is(item), tagId.isIn(nel))).update.run
+    }
+
   def setAllTags(item: Ident, tags: Seq[Ident]): ConnectionIO[Int] =
     if (tags.isEmpty) 0.pure[ConnectionIO]
     else