Editing tags for multiple items

This commit is contained in:
Eike Kettner 2020-10-26 11:54:04 +01:00
parent 5735a47199
commit 7ad37c8d26
10 changed files with 340 additions and 43 deletions

View File

@ -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)

View File

@ -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:

View File

@ -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),

View File

@ -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)
)
}
}

View File

@ -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")"

View File

@ -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]
}

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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 )