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