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 package docspell.backend.ops
import cats.data.NonEmptyList
import cats.data.OptionT import cats.data.OptionT
import cats.effect.{Effect, Resource} import cats.effect.{Effect, Resource}
import cats.implicits._ import cats.implicits._
@ -13,21 +14,38 @@ import docspell.store.queue.JobQueue
import docspell.store.records._ import docspell.store.records._
import docspell.store.{AddResult, Store} import docspell.store.{AddResult, Store}
import doobie._
import doobie.implicits._ import doobie.implicits._
import org.log4s.getLogger import org.log4s.getLogger
trait OItem[F[_]] { trait OItem[F[_]] {
/** Sets the given tags (removing all existing ones). */ /** 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. */ /** Create a new tag and add it to the item. */
def addNewTag(item: Ident, tag: RTag): F[AddResult] 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 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. */ /** 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 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 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] def setItemDate(item: Ident, date: Option[Timestamp], collective: Ident): F[AddResult]
@ -130,21 +155,30 @@ object OItem {
item: Ident, item: Ident,
tags: List[String], tags: List[String],
collective: Ident collective: Ident
): F[UpdateResult] =
linkTagsMultipleItems(NonEmptyList.of(item), tags, collective)
def linkTagsMultipleItems(
items: NonEmptyList[Ident],
tags: List[String],
collective: Ident
): F[UpdateResult] = ): F[UpdateResult] =
tags.distinct match { tags.distinct match {
case Nil => UpdateResult.success.pure[F] case Nil => UpdateResult.success.pure[F]
case kws => case ws =>
val db = store.transact {
(for { (for {
_ <- OptionT(RItem.checkByIdAndCollective(item, collective)) itemIds <- OptionT
given <- OptionT.liftF(RTag.findAllByNameOrId(kws, collective)) .liftF(RItem.filterItems(items, collective))
exist <- OptionT.liftF(RTagItem.findAllIn(item, given.map(_.tagId))) .filter(_.nonEmpty)
given <- OptionT.liftF(RTag.findAllByNameOrId(ws, collective))
_ <- OptionT.liftF( _ <- 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) } yield UpdateResult.success).getOrElse(UpdateResult.notFound)
}
store.transact(db)
} }
def toggleTags( def toggleTags(
@ -169,20 +203,23 @@ object OItem {
store.transact(db) store.transact(db)
} }
def setTags(item: Ident, tagIds: List[Ident], collective: Ident): F[AddResult] = { def setTags(
val db = for { item: Ident,
cid <- RItem.getCollective(item) tagIds: List[Ident],
nd <- collective: Ident
if (cid.contains(collective)) RTagItem.deleteItemTags(item) ): F[UpdateResult] =
else 0.pure[ConnectionIO] setTagsMultipleItems(NonEmptyList.of(item), tagIds, collective)
ni <-
if (tagIds.nonEmpty && cid.contains(collective))
RTagItem.insertItemTags(item, tagIds)
else 0.pure[ConnectionIO]
} yield nd + ni
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] = def addNewTag(item: Ident, tag: RTag): F[AddResult] =
(for { (for {
@ -192,7 +229,7 @@ object OItem {
_ <- addres match { _ <- addres match {
case AddResult.Success => case AddResult.Success =>
OptionT.liftF( OptionT.liftF(
store.transact(RTagItem.insertItemTags(item, List(tag.tagId))) store.transact(RTagItem.setAllTags(item, List(tag.tagId)))
) )
case AddResult.EntityExists(_) => case AddResult.EntityExists(_) =>
OptionT.pure[F](0) OptionT.pure[F](0)
@ -371,9 +408,13 @@ object OItem {
onSuccessIgnoreError(fts.updateItemName(logger, item, collective, name)) 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 store
.transact(RItem.updateStateForCollective(item, state, collective)) .transact(RItem.updateStateForCollective(items, state, collective))
.attempt .attempt
.map(AddResult.fromUpdate) .map(AddResult.fromUpdate)

View File

@ -1927,7 +1927,10 @@ paths:
- Item (Multi Edit) - Item (Multi Edit)
summary: Add tags to multiple items summary: Add tags to multiple items
description: | 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: security:
- authTokenHeader: [] - authTokenHeader: []
requestBody: requestBody:
@ -1948,7 +1951,7 @@ paths:
summary: Sets tags to multiple items summary: Sets tags to multiple items
description: | description: |
Sets the given tags to all given items. If the tag list is 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: security:
- authTokenHeader: [] - authTokenHeader: []
requestBody: requestBody:

View File

@ -73,6 +73,7 @@ object RestServer {
"collective" -> CollectiveRoutes(restApp.backend, token), "collective" -> CollectiveRoutes(restApp.backend, token),
"queue" -> JobQueueRoutes(restApp.backend, token), "queue" -> JobQueueRoutes(restApp.backend, token),
"item" -> ItemRoutes(cfg, restApp.backend, token), "item" -> ItemRoutes(cfg, restApp.backend, token),
"items" -> ItemMultiRoutes(restApp.backend, token),
"attachment" -> AttachmentRoutes(restApp.backend, token), "attachment" -> AttachmentRoutes(restApp.backend, token),
"upload" -> UploadRoutes.secured(restApp.backend, cfg, token), "upload" -> UploadRoutes.secured(restApp.backend, cfg, token),
"checkfile" -> CheckFileRoutes.secured(restApp.backend, 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")" f ++ fr"IN (" ++ commas(values) ++ fr")"
def isIn[A: Put](values: NonEmptyList[A]): Fragment = 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 = def isLowerIn[A: Put](values: NonEmptyList[A]): Fragment =
fr"lower(" ++ f ++ fr") IN (" ++ commas(values.map(a => sql"$a").toList) ++ fr")" fr"lower(" ++ f ++ fr") IN (" ++ commas(values.map(a => sql"$a").toList) ++ fr")"

View File

@ -132,7 +132,7 @@ object RItem {
} yield n } yield n
def updateStateForCollective( def updateStateForCollective(
itemId: Ident, itemIds: NonEmptyList[Ident],
itemState: ItemState, itemState: ItemState,
coll: Ident coll: Ident
): ConnectionIO[Int] = ): ConnectionIO[Int] =
@ -140,7 +140,7 @@ object RItem {
t <- currentTime t <- currentTime
n <- updateRow( n <- updateRow(
table, table,
and(id.is(itemId), cid.is(coll)), and(id.isIn(itemIds), cid.is(coll)),
commas(state.setTo(itemState), updated.setTo(t)) commas(state.setTo(itemState), updated.setTo(t))
).update.run ).update.run
} yield n } yield n
@ -324,4 +324,10 @@ object RItem {
val empty: Option[Ident] = None val empty: Option[Ident] = None
updateRow(table, folder.is(folderId), folder.setTo(empty)).update.run 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] = def deleteItemTags(item: Ident): ConnectionIO[Int] =
deleteFrom(table, itemId.is(item)).update.run 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] = def deleteTag(tid: Ident): ConnectionIO[Int] =
deleteFrom(table, tagId.is(tid)).update.run 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]] = def findByItem(item: Ident): ConnectionIO[Vector[RTagItem]] =
selectSimple(all, table, itemId.is(item)).query[RTagItem].to[Vector] 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}") entities.map(v => fr"${v.tagItemId},${v.itemId},${v.tagId}")
).update.run ).update.run
} yield n } 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 , addCorrPerson
, addMember , addMember
, addTag , addTag
, addTagsMultiple
, cancelJob , cancelJob
, changeFolderName , changeFolderName
, changePassword , changePassword
@ -88,6 +89,7 @@ module Api exposing
, setItemNotes , setItemNotes
, setJobPrio , setJobPrio
, setTags , setTags
, setTagsMultiple
, setUnconfirmed , setUnconfirmed
, startClassifier , startClassifier
, startOnceNotifyDueItems , startOnceNotifyDueItems
@ -130,6 +132,7 @@ import Api.Model.ItemLightList exposing (ItemLightList)
import Api.Model.ItemProposals exposing (ItemProposals) import Api.Model.ItemProposals exposing (ItemProposals)
import Api.Model.ItemSearch exposing (ItemSearch) import Api.Model.ItemSearch exposing (ItemSearch)
import Api.Model.ItemUploadMeta exposing (ItemUploadMeta) import Api.Model.ItemUploadMeta exposing (ItemUploadMeta)
import Api.Model.ItemsAndRefs exposing (ItemsAndRefs)
import Api.Model.JobPriority exposing (JobPriority) import Api.Model.JobPriority exposing (JobPriority)
import Api.Model.JobQueueState exposing (JobQueueState) import Api.Model.JobQueueState exposing (JobQueueState)
import Api.Model.MoveAttachment exposing (MoveAttachment) 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 --- Item

View File

@ -40,5 +40,12 @@ multiUpdate flags ids change receive =
Set.toList ids Set.toList ids
in in
case change of case change of
TagChange tags ->
let
data =
ItemsAndRefs items (List.map .id tags.items)
in
Api.setTagsMultiple flags data receive
_ -> _ ->
Cmd.none Cmd.none

View File

@ -435,7 +435,10 @@ update mId key flags settings msg model =
res.change res.change
MultiUpdateResp MultiUpdateResp
in in
( { model | viewMode = SelectView svm_ }, Cmd.batch [ cmd_, upCmd ], sub_ ) ( { model | viewMode = SelectView svm_ }
, Cmd.batch [ cmd_, upCmd ]
, sub_
)
_ -> _ ->
noSub ( model, Cmd.none ) noSub ( model, Cmd.none )