From 7ad37c8d2653a320434cfd3b19f90a9219a687e5 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 26 Oct 2020 11:54:04 +0100 Subject: [PATCH] Editing tags for multiple items --- .../scala/docspell/backend/ops/OItem.scala | 97 ++++++--- .../src/main/resources/docspell-openapi.yml | 7 +- .../docspell/restserver/RestServer.scala | 1 + .../restserver/routes/ItemMultiRoutes.scala | 189 ++++++++++++++++++ .../scala/docspell/store/impl/Column.scala | 7 +- .../scala/docspell/store/records/RItem.scala | 10 +- .../docspell/store/records/RTagItem.scala | 25 ++- modules/webapp/src/main/elm/Api.elm | 35 ++++ .../main/elm/Comp/ItemDetail/FormChange.elm | 7 + .../webapp/src/main/elm/Page/Home/Update.elm | 5 +- 10 files changed, 340 insertions(+), 43 deletions(-) create mode 100644 modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala 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 da3efce2..0e855320 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala @@ -1,5 +1,6 @@ package docspell.backend.ops +import cats.data.NonEmptyList import cats.data.OptionT import cats.effect.{Effect, Resource} import cats.implicits._ @@ -13,21 +14,38 @@ import docspell.store.queue.JobQueue import docspell.store.records._ import docspell.store.{AddResult, Store} -import doobie._ import doobie.implicits._ import org.log4s.getLogger trait OItem[F[_]] { /** Sets the given tags (removing all existing ones). */ - def setTags(item: Ident, tagIds: List[Ident], collective: Ident): F[AddResult] + def setTags(item: Ident, tagIds: List[Ident], collective: Ident): F[UpdateResult] + + /** Sets tags for multiple items. The tags of the items will be + * replaced with the given ones. Same as `setTags` but for multiple + * items. + */ + def setTagsMultipleItems( + items: NonEmptyList[Ident], + tags: List[Ident], + collective: Ident + ): F[UpdateResult] /** Create a new tag and add it to the item. */ def addNewTag(item: Ident, tag: RTag): F[AddResult] - /** Apply all tags to the given item. Tags must exist, but can be IDs or names. */ + /** Apply all tags to the given item. Tags must exist, but can be IDs + * or names. Existing tags on the item are left unchanged. + */ def linkTags(item: Ident, tags: List[String], collective: Ident): F[UpdateResult] + def linkTagsMultipleItems( + items: NonEmptyList[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] @@ -55,7 +73,14 @@ trait OItem[F[_]] { def setName(item: Ident, name: String, collective: Ident): F[AddResult] - def setState(item: Ident, state: ItemState, collective: Ident): F[AddResult] + def setState(item: Ident, state: ItemState, collective: Ident): F[AddResult] = + setStates(NonEmptyList.of(item), state, collective) + + def setStates( + item: NonEmptyList[Ident], + state: ItemState, + collective: Ident + ): F[AddResult] def setItemDate(item: Ident, date: Option[Timestamp], collective: Ident): F[AddResult] @@ -130,21 +155,30 @@ object OItem { item: Ident, tags: List[String], collective: Ident + ): F[UpdateResult] = + linkTagsMultipleItems(NonEmptyList.of(item), tags, collective) + + def linkTagsMultipleItems( + items: NonEmptyList[Ident], + tags: List[String], + collective: Ident ): F[UpdateResult] = tags.distinct match { case Nil => UpdateResult.success.pure[F] - case kws => - val db = + case ws => + store.transact { (for { - _ <- OptionT(RItem.checkByIdAndCollective(item, collective)) - given <- OptionT.liftF(RTag.findAllByNameOrId(kws, collective)) - exist <- OptionT.liftF(RTagItem.findAllIn(item, given.map(_.tagId))) + itemIds <- OptionT + .liftF(RItem.filterItems(items, collective)) + .filter(_.nonEmpty) + given <- OptionT.liftF(RTag.findAllByNameOrId(ws, collective)) _ <- OptionT.liftF( - RTagItem.setAllTags(item, given.map(_.tagId).diff(exist.map(_.tagId))) + itemIds.traverse(item => + RTagItem.appendTags(item, given.map(_.tagId).toList) + ) ) } yield UpdateResult.success).getOrElse(UpdateResult.notFound) - - store.transact(db) + } } def toggleTags( @@ -169,20 +203,23 @@ object OItem { store.transact(db) } - def setTags(item: Ident, tagIds: List[Ident], collective: Ident): F[AddResult] = { - val db = for { - cid <- RItem.getCollective(item) - nd <- - if (cid.contains(collective)) RTagItem.deleteItemTags(item) - else 0.pure[ConnectionIO] - ni <- - if (tagIds.nonEmpty && cid.contains(collective)) - RTagItem.insertItemTags(item, tagIds) - else 0.pure[ConnectionIO] - } yield nd + ni + def setTags( + item: Ident, + tagIds: List[Ident], + collective: Ident + ): F[UpdateResult] = + setTagsMultipleItems(NonEmptyList.of(item), tagIds, collective) - store.transact(db).attempt.map(AddResult.fromUpdate) - } + def setTagsMultipleItems( + items: NonEmptyList[Ident], + tags: List[Ident], + collective: Ident + ): F[UpdateResult] = + UpdateResult.fromUpdate(store.transact(for { + k <- RTagItem.deleteItemTags(items, collective) + res <- items.traverse(i => RTagItem.setAllTags(i, tags)) + n = res.fold + } yield k + n)) def addNewTag(item: Ident, tag: RTag): F[AddResult] = (for { @@ -192,7 +229,7 @@ object OItem { _ <- addres match { case AddResult.Success => OptionT.liftF( - store.transact(RTagItem.insertItemTags(item, List(tag.tagId))) + store.transact(RTagItem.setAllTags(item, List(tag.tagId))) ) case AddResult.EntityExists(_) => OptionT.pure[F](0) @@ -371,9 +408,13 @@ object OItem { onSuccessIgnoreError(fts.updateItemName(logger, item, collective, name)) ) - def setState(item: Ident, state: ItemState, collective: Ident): F[AddResult] = + def setStates( + items: NonEmptyList[Ident], + state: ItemState, + collective: Ident + ): F[AddResult] = store - .transact(RItem.updateStateForCollective(item, state, collective)) + .transact(RItem.updateStateForCollective(items, state, collective)) .attempt .map(AddResult.fromUpdate) diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index dcc81815..9a051e7e 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -1927,7 +1927,10 @@ paths: - Item (Multi Edit) summary: Add tags to multiple items description: | - Add the given tags to all given items. + Add the given tags to all given items. The tags that are + currently attached to the items are not changed. If there are + new tags in the given list, then they are added. Otherwise, + the item is left unchanged. security: - authTokenHeader: [] requestBody: @@ -1948,7 +1951,7 @@ paths: summary: Sets tags to multiple items description: | Sets the given tags to all given items. If the tag list is - empty, then tags are removed from the items. + empty, then all tags are removed from the items. security: - authTokenHeader: [] requestBody: diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala index 6d856522..de4dfbfb 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala @@ -73,6 +73,7 @@ object RestServer { "collective" -> CollectiveRoutes(restApp.backend, token), "queue" -> JobQueueRoutes(restApp.backend, token), "item" -> ItemRoutes(cfg, restApp.backend, token), + "items" -> ItemMultiRoutes(restApp.backend, token), "attachment" -> AttachmentRoutes(restApp.backend, token), "upload" -> UploadRoutes.secured(restApp.backend, cfg, token), "checkfile" -> CheckFileRoutes.secured(restApp.backend, token), diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala new file mode 100644 index 00000000..e064c18b --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala @@ -0,0 +1,189 @@ +package docspell.restserver.routes + +import cats.ApplicativeError +import cats.MonadError +import cats.data.NonEmptyList +import cats.effect._ +import cats.implicits._ + +import docspell.backend.BackendApp +import docspell.backend.auth.AuthToken +import docspell.common.{Ident, ItemState} +import docspell.restapi.model._ +import docspell.restserver.conv.Conversions + +import io.circe.DecodingFailure +import org.http4s.HttpRoutes +import org.http4s.circe.CirceEntityDecoder._ +import org.http4s.circe.CirceEntityEncoder._ +import org.http4s.dsl.Http4sDsl + +object ItemMultiRoutes { +// private[this] val logger = getLogger + + def apply[F[_]: Effect]( + backend: BackendApp[F], + user: AuthToken + ): HttpRoutes[F] = { + val dsl = new Http4sDsl[F] {} + import dsl._ + + HttpRoutes.of { + case req @ PUT -> Root / "confirm" => + for { + json <- req.as[IdList] + data <- readIds[F](json.ids) + res <- backend.item.setStates( + data, + ItemState.Confirmed, + user.account.collective + ) + resp <- Ok(Conversions.basicResult(res, "Item data confirmed")) + } yield resp + + case req @ PUT -> Root / "unconfirm" => + for { + json <- req.as[IdList] + data <- readIds[F](json.ids) + res <- backend.item.setStates( + data, + ItemState.Created, + user.account.collective + ) + resp <- Ok(Conversions.basicResult(res, "Item back to created.")) + } yield resp + + case req @ PUT -> Root / "tags" => + for { + json <- req.as[ItemsAndRefs] + items <- readIds[F](json.items) + tags <- json.refs.traverse(readId[F]) + res <- backend.item.setTagsMultipleItems(items, tags, user.account.collective) + resp <- Ok(Conversions.basicResult(res, "Tags updated")) + } yield resp + + case req @ POST -> Root / "tags" => + for { + json <- req.as[ItemsAndRefs] + items <- readIds[F](json.items) + res <- backend.item.linkTagsMultipleItems( + items, + json.refs, + user.account.collective + ) + resp <- Ok(Conversions.basicResult(res, "Tags added.")) + } yield resp + + // case req @ PUT -> Root / "direction" => + // for { + // dir <- req.as[DirectionValue] + // res <- backend.item.setDirection(id, dir.direction, user.account.collective) + // resp <- Ok(Conversions.basicResult(res, "Direction updated")) + // } yield resp + + // case req @ PUT -> Root / "folder" => + // for { + // idref <- req.as[OptionalId] + // res <- backend.item.setFolder(id, idref.id, user.account.collective) + // resp <- Ok(Conversions.basicResult(res, "Folder updated")) + // } yield resp + + // case req @ PUT -> Root / "corrOrg" => + // for { + // idref <- req.as[OptionalId] + // res <- backend.item.setCorrOrg(id, idref.id, user.account.collective) + // resp <- Ok(Conversions.basicResult(res, "Correspondent organization updated")) + // } yield resp + + // case req @ PUT -> Root / "corrPerson" => + // for { + // idref <- req.as[OptionalId] + // res <- backend.item.setCorrPerson(id, idref.id, user.account.collective) + // resp <- Ok(Conversions.basicResult(res, "Correspondent person updated")) + // } yield resp + + // case req @ PUT -> Root / "concPerson" => + // for { + // idref <- req.as[OptionalId] + // res <- backend.item.setConcPerson(id, idref.id, user.account.collective) + // resp <- Ok(Conversions.basicResult(res, "Concerned person updated")) + // } yield resp + + // case req @ PUT -> Root / "concEquipment" => + // for { + // idref <- req.as[OptionalId] + // res <- backend.item.setConcEquip(id, idref.id, user.account.collective) + // resp <- Ok(Conversions.basicResult(res, "Concerned equipment updated")) + // } yield resp + + // case req @ PUT -> Root / "name" => + // for { + // text <- req.as[OptionalText] + // res <- backend.item.setName( + // id, + // text.text.notEmpty.getOrElse(""), + // user.account.collective + // ) + // resp <- Ok(Conversions.basicResult(res, "Name updated")) + // } yield resp + + // case req @ PUT -> Root / "duedate" => + // for { + // date <- req.as[OptionalDate] + // _ <- logger.fdebug(s"Setting item due date to ${date.date}") + // res <- backend.item.setItemDueDate(id, date.date, user.account.collective) + // resp <- Ok(Conversions.basicResult(res, "Item due date updated")) + // } yield resp + + // case req @ PUT -> Root / "date" => + // for { + // date <- req.as[OptionalDate] + // _ <- logger.fdebug(s"Setting item date to ${date.date}") + // res <- backend.item.setItemDate(id, date.date, user.account.collective) + // resp <- Ok(Conversions.basicResult(res, "Item date updated")) + // } yield resp + + // case req @ POST -> Root / "reprocess" => + // for { + // data <- req.as[IdList] + // ids = data.ids.flatMap(s => Ident.fromString(s).toOption) + // _ <- logger.fdebug(s"Re-process item ${id.id}") + // res <- backend.item.reprocess(id, ids, user.account, true) + // resp <- Ok(Conversions.basicResult(res, "Re-process task submitted.")) + // } yield resp + + // case POST -> Root / "deleteAll" => + // for { + // n <- backend.item.deleteItem(id, user.account.collective) + // res = BasicResult(n > 0, if (n > 0) "Item deleted" else "Item deletion failed.") + // resp <- Ok(res) + // } yield resp + } + } + + implicit final class OptionString(opt: Option[String]) { + def notEmpty: Option[String] = + opt.map(_.trim).filter(_.nonEmpty) + } + + private def readId[F[_]]( + id: String + )(implicit F: ApplicativeError[F, Throwable]): F[Ident] = + Ident + .fromString(id) + .fold( + err => F.raiseError(DecodingFailure(err, Nil)), + F.pure + ) + + private def readIds[F[_]](ids: List[String])(implicit + F: MonadError[F, Throwable] + ): F[NonEmptyList[Ident]] = + ids.traverse(readId[F]).map(NonEmptyList.fromList).flatMap { + case Some(nel) => nel.pure[F] + case None => + F.raiseError( + DecodingFailure("Empty list found, at least one element required", Nil) + ) + } +} diff --git a/modules/store/src/main/scala/docspell/store/impl/Column.scala b/modules/store/src/main/scala/docspell/store/impl/Column.scala index 87253163..4dec4d6c 100644 --- a/modules/store/src/main/scala/docspell/store/impl/Column.scala +++ b/modules/store/src/main/scala/docspell/store/impl/Column.scala @@ -57,7 +57,12 @@ case class Column(name: String, ns: String = "", alias: String = "") { f ++ fr"IN (" ++ commas(values) ++ fr")" def isIn[A: Put](values: NonEmptyList[A]): Fragment = - isIn(values.map(a => sql"$a").toList) + values.tail match { + case Nil => + is(values.head) + case _ => + isIn(values.map(a => sql"$a").toList) + } def isLowerIn[A: Put](values: NonEmptyList[A]): Fragment = fr"lower(" ++ f ++ fr") IN (" ++ commas(values.map(a => sql"$a").toList) ++ fr")" diff --git a/modules/store/src/main/scala/docspell/store/records/RItem.scala b/modules/store/src/main/scala/docspell/store/records/RItem.scala index a0025ddb..fddf12e3 100644 --- a/modules/store/src/main/scala/docspell/store/records/RItem.scala +++ b/modules/store/src/main/scala/docspell/store/records/RItem.scala @@ -132,7 +132,7 @@ object RItem { } yield n def updateStateForCollective( - itemId: Ident, + itemIds: NonEmptyList[Ident], itemState: ItemState, coll: Ident ): ConnectionIO[Int] = @@ -140,7 +140,7 @@ object RItem { t <- currentTime n <- updateRow( table, - and(id.is(itemId), cid.is(coll)), + and(id.isIn(itemIds), cid.is(coll)), commas(state.setTo(itemState), updated.setTo(t)) ).update.run } yield n @@ -324,4 +324,10 @@ object RItem { val empty: Option[Ident] = None updateRow(table, folder.is(folderId), folder.setTo(empty)).update.run } + + def filterItemsFragment(items: NonEmptyList[Ident], coll: Ident): Fragment = + selectSimple(Seq(id), table, and(cid.is(coll), id.isIn(items))) + + def filterItems(items: NonEmptyList[Ident], coll: Ident): ConnectionIO[Vector[Ident]] = + filterItemsFragment(items, coll).query[Ident].to[Vector] } 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 706e64b4..c9aad9db 100644 --- a/modules/store/src/main/scala/docspell/store/records/RTagItem.scala +++ b/modules/store/src/main/scala/docspell/store/records/RTagItem.scala @@ -30,18 +30,17 @@ object RTagItem { def deleteItemTags(item: Ident): ConnectionIO[Int] = deleteFrom(table, itemId.is(item)).update.run + def deleteItemTags(items: NonEmptyList[Ident], cid: Ident): ConnectionIO[Int] = { + val itemsFiltered = + RItem.filterItemsFragment(items, cid) + val sql = fr"DELETE FROM" ++ table ++ fr"WHERE" ++ itemId.isIn(itemsFiltered) + + sql.update.run + } + def deleteTag(tid: Ident): ConnectionIO[Int] = deleteFrom(table, tagId.is(tid)).update.run - def insertItemTags(item: Ident, tags: Seq[Ident]): ConnectionIO[Int] = - for { - tagValues <- tags.toList.traverse(id => - Ident.randomId[ConnectionIO].map(rid => RTagItem(rid, item, id)) - ) - tagFrag = tagValues.map(v => fr"${v.tagItemId},${v.itemId},${v.tagId}") - ins <- insertRows(table, all, tagFrag).update.run - } yield ins - def findByItem(item: Ident): ConnectionIO[Vector[RTagItem]] = selectSimple(all, table, itemId.is(item)).query[RTagItem].to[Vector] @@ -76,4 +75,12 @@ object RTagItem { entities.map(v => fr"${v.tagItemId},${v.itemId},${v.tagId}") ).update.run } yield n + + def appendTags(item: Ident, tags: List[Ident]): ConnectionIO[Int] = + for { + existing <- findByItem(item) + toadd = tags.toSet.diff(existing.map(_.tagId).toSet) + n <- setAllTags(item, toadd.toSeq) + } yield n + } diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index c90865ab..dc648f75 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -5,6 +5,7 @@ module Api exposing , addCorrPerson , addMember , addTag + , addTagsMultiple , cancelJob , changeFolderName , changePassword @@ -88,6 +89,7 @@ module Api exposing , setItemNotes , setJobPrio , setTags + , setTagsMultiple , setUnconfirmed , startClassifier , startOnceNotifyDueItems @@ -130,6 +132,7 @@ import Api.Model.ItemLightList exposing (ItemLightList) import Api.Model.ItemProposals exposing (ItemProposals) import Api.Model.ItemSearch exposing (ItemSearch) import Api.Model.ItemUploadMeta exposing (ItemUploadMeta) +import Api.Model.ItemsAndRefs exposing (ItemsAndRefs) import Api.Model.JobPriority exposing (JobPriority) import Api.Model.JobQueueState exposing (JobQueueState) import Api.Model.MoveAttachment exposing (MoveAttachment) @@ -1262,6 +1265,38 @@ getJobQueueStateTask flags = +--- Item (Mulit Edit) + + +setTagsMultiple : + Flags + -> ItemsAndRefs + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +setTagsMultiple flags data receive = + Http2.authPut + { url = flags.config.baseUrl ++ "/api/v1/sec/items/tags" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ItemsAndRefs.encode data) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +addTagsMultiple : + Flags + -> ItemsAndRefs + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +addTagsMultiple flags data receive = + Http2.authPost + { url = flags.config.baseUrl ++ "/api/v1/sec/items/tags" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ItemsAndRefs.encode data) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + + --- Item diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm index 4cb0c91a..4a483c44 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm @@ -40,5 +40,12 @@ multiUpdate flags ids change receive = Set.toList ids in case change of + TagChange tags -> + let + data = + ItemsAndRefs items (List.map .id tags.items) + in + Api.setTagsMultiple flags data receive + _ -> Cmd.none diff --git a/modules/webapp/src/main/elm/Page/Home/Update.elm b/modules/webapp/src/main/elm/Page/Home/Update.elm index 05a8259b..9d0884ea 100644 --- a/modules/webapp/src/main/elm/Page/Home/Update.elm +++ b/modules/webapp/src/main/elm/Page/Home/Update.elm @@ -435,7 +435,10 @@ update mId key flags settings msg model = res.change MultiUpdateResp in - ( { model | viewMode = SelectView svm_ }, Cmd.batch [ cmd_, upCmd ], sub_ ) + ( { model | viewMode = SelectView svm_ } + , Cmd.batch [ cmd_, upCmd ] + , sub_ + ) _ -> noSub ( model, Cmd.none )