From 48d13a35fc479373f18b1b9fa12befe1d64b477e Mon Sep 17 00:00:00 2001 From: eikek Date: Sat, 14 Aug 2021 14:09:07 +0200 Subject: [PATCH 01/15] Fix search summary to restrict on valid items --- .../scala/docspell/backend/ops/OSimpleSearch.scala | 13 +++++++++---- .../scala/docspell/store/queries/QCollective.scala | 12 ++++++++---- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala b/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala index 34e015b1..9050bb06 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala @@ -19,7 +19,10 @@ import docspell.store.queries.SearchSummary import org.log4s.getLogger -/** A "porcelain" api on top of OFulltext and OItemSearch. */ +/** A "porcelain" api on top of OFulltext and OItemSearch. This takes + * care of restricting the items to a subset, e.g. only items that + * have a "valid" state. + */ trait OSimpleSearch[F[_]] { /** Search for items using the given query and optional fulltext @@ -268,17 +271,19 @@ object OSimpleSearch { def searchSummary( useFTS: Boolean - )(q: Query, fulltextQuery: Option[String]): F[SearchSummary] = + )(q: Query, fulltextQuery: Option[String]): F[SearchSummary] = { + val validItemQuery = q.withFix(_.andQuery(ItemQuery.Expr.ValidItemStates)) fulltextQuery match { case Some(ftq) if useFTS => if (q.isEmpty) fts.findIndexOnlySummary(q.fix.account, OFulltext.FtsInput(ftq)) else fts - .findItemsSummary(q, OFulltext.FtsInput(ftq)) + .findItemsSummary(validItemQuery, OFulltext.FtsInput(ftq)) case _ => - is.findItemsSummary(q) + is.findItemsSummary(validItemQuery) } + } } } diff --git a/modules/store/src/main/scala/docspell/store/queries/QCollective.scala b/modules/store/src/main/scala/docspell/store/queries/QCollective.scala index ede76360..daeb380f 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QCollective.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QCollective.scala @@ -73,12 +73,16 @@ object QCollective { val q0 = Select( count(i.id).s, from(i), - i.cid === coll && i.incoming === Direction.incoming + i.cid === coll && i.incoming === Direction.incoming && i.state.in( + ItemState.validStates + ) ).build.query[Int].unique val q1 = Select( count(i.id).s, from(i), - i.cid === coll && i.incoming === Direction.outgoing + i.cid === coll && i.incoming === Direction.outgoing && i.state.in( + ItemState.validStates + ) ).build.query[Int].unique val fileSize = sql""" @@ -113,8 +117,8 @@ object QCollective { val sql = Select( select(t.all).append(count(ti.itemId).s), - from(ti).innerJoin(t, ti.tagId === t.tid), - t.cid === coll + from(ti).innerJoin(t, ti.tagId === t.tid).innerJoin(i, i.id === ti.itemId), + t.cid === coll && i.state.in(ItemState.validStates) ).groupBy(t.name, t.tid, t.category) sql.build.query[TagCount].to[List] From cb777e30c02f4915eda192383ce9a47a88a15637 Mon Sep 17 00:00:00 2001 From: eikek Date: Sat, 14 Aug 2021 14:18:03 +0200 Subject: [PATCH 02/15] Delete items by introducing a deleted state When deleting items via the http api, they are not deleted anymore but a new status "Deleted" is set. The collective insights contains now a count separately for deleted items. --- .../main/scala/docspell/backend/ops/OItem.scala | 5 +++++ .../main/scala/docspell/common/ItemState.scala | 2 ++ .../src/main/resources/docspell-openapi.yml | 4 ++++ .../docspell/restserver/conv/Conversions.scala | 1 + .../restserver/routes/ItemMultiRoutes.scala | 2 +- .../docspell/restserver/routes/ItemRoutes.scala | 4 ++-- .../docspell/store/queries/QCollective.scala | 17 ++++++++++++----- .../scala/docspell/store/records/RItem.scala | 14 ++++++++++++++ modules/webapp/src/main/elm/Messages/Basics.elm | 3 +++ .../main/elm/Page/CollectiveSettings/View2.elm | 1 + 10 files changed, 45 insertions(+), 8 deletions(-) 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 d9826904..e586bf34 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala @@ -144,6 +144,8 @@ trait OItem[F[_]] { def deleteAttachment(id: Ident, collective: Ident): F[Int] + def setDeletedState(items: NonEmptyList[Ident], collective: Ident): F[Int] + def deleteAttachmentMultiple( attachments: NonEmptyList[Ident], collective: Ident @@ -612,6 +614,9 @@ object OItem { n = results.sum } yield n + def setDeletedState(items: NonEmptyList[Ident], collective: Ident): F[Int] = + store.transact(RItem.setState(items, collective, ItemState.Deleted)) + def getProposals(item: Ident, collective: Ident): F[MetaProposalList] = store.transact(QAttachment.getMetaProposals(item, collective)) diff --git a/modules/common/src/main/scala/docspell/common/ItemState.scala b/modules/common/src/main/scala/docspell/common/ItemState.scala index 70ed7e04..05149ad7 100644 --- a/modules/common/src/main/scala/docspell/common/ItemState.scala +++ b/modules/common/src/main/scala/docspell/common/ItemState.scala @@ -28,6 +28,7 @@ object ItemState { case object Processing extends ItemState case object Created extends ItemState case object Confirmed extends ItemState + case object Deleted extends ItemState def premature: ItemState = Premature def processing: ItemState = Processing @@ -40,6 +41,7 @@ object ItemState { case "processing" => Right(Processing) case "created" => Right(Created) case "confirmed" => Right(Confirmed) + case "deleted" => Right(Deleted) case _ => Left(s"Invalid item state: $str") } diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index e0d683bc..f2d8d1d9 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -4569,6 +4569,7 @@ components: required: - incomingCount - outgoingCount + - deletedCount - itemSize - tagCloud properties: @@ -4578,6 +4579,9 @@ components: outgoingCount: type: integer format: int32 + deletedCount: + type: integer + format: int32 itemSize: type: integer format: int64 diff --git a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala index 2506a5ff..fb9323b1 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala @@ -63,6 +63,7 @@ trait Conversions { ItemInsights( d.incoming, d.outgoing, + d.deleted, d.bytes, mkTagCloud(d.tags) ) diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala index bbda6df0..2ffadde9 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala @@ -179,7 +179,7 @@ object ItemMultiRoutes extends MultiIdSupport { for { json <- req.as[IdList] items <- readIds[F](json.ids) - n <- backend.item.deleteItemMultiple(items, user.account.collective) + n <- backend.item.setDeletedState(items, user.account.collective) res = BasicResult( n > 0, if (n > 0) "Item(s) deleted" else "Item deletion failed." 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 f2bec1fc..803fc975 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala @@ -393,7 +393,7 @@ object ItemRoutes { case DELETE -> Root / Ident(id) => for { - n <- backend.item.deleteItem(id, user.account.collective) + n <- backend.item.setDeletedState(NonEmptyList.of(id), user.account.collective) res = BasicResult(n > 0, if (n > 0) "Item deleted" else "Item deletion failed.") resp <- Ok(res) } yield resp @@ -440,7 +440,7 @@ object ItemRoutes { } } - def searchItemStats[F[_]: Sync]( + private def searchItemStats[F[_]: Sync]( backend: BackendApp[F], dsl: Http4sDsl[F] )(ftsEnabled: Boolean, fixQuery: Query.Fix, itemQuery: ItemQueryString) = { diff --git a/modules/store/src/main/scala/docspell/store/queries/QCollective.scala b/modules/store/src/main/scala/docspell/store/queries/QCollective.scala index daeb380f..e00d27f4 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QCollective.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QCollective.scala @@ -65,6 +65,7 @@ object QCollective { case class InsightData( incoming: Int, outgoing: Int, + deleted: Int, bytes: Long, tags: List[TagCount] ) @@ -84,6 +85,11 @@ object QCollective { ItemState.validStates ) ).build.query[Int].unique + val q2 = Select( + count(i.id).s, + from(i), + i.cid === coll && i.state === ItemState.Deleted + ).build.query[Int].unique val fileSize = sql""" select sum(length) from ( @@ -106,11 +112,12 @@ object QCollective { ) as t""".query[Option[Long]].unique for { - n0 <- q0 - n1 <- q1 - n2 <- fileSize - n3 <- tagCloud(coll) - } yield InsightData(n0, n1, n2.getOrElse(0L), n3) + incoming <- q0 + outgoing <- q1 + size <- fileSize + tags <- tagCloud(coll) + deleted <- q2 + } yield InsightData(incoming, outgoing, deleted, size.getOrElse(0L), tags) } def tagCloud(coll: Ident): ConnectionIO[List[TagCount]] = { 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 1dd977cc..017e1992 100644 --- a/modules/store/src/main/scala/docspell/store/records/RItem.scala +++ b/modules/store/src/main/scala/docspell/store/records/RItem.scala @@ -336,6 +336,20 @@ object RItem { def deleteByIdAndCollective(itemId: Ident, coll: Ident): ConnectionIO[Int] = DML.delete(T, T.id === itemId && T.cid === coll) + def setState( + itemIds: NonEmptyList[Ident], + coll: Ident, + state: ItemState + ): ConnectionIO[Int] = + for { + t <- currentTime + n <- DML.update( + T, + T.id.in(itemIds) && T.cid === coll, + DML.set(T.state.setTo(state), T.updated.setTo(t)) + ) + } yield n + def existsById(itemId: Ident): ConnectionIO[Boolean] = Select(count(T.id).s, from(T), T.id === itemId).build.query[Int].unique.map(_ > 0) diff --git a/modules/webapp/src/main/elm/Messages/Basics.elm b/modules/webapp/src/main/elm/Messages/Basics.elm index 4c1b4126..0edba00b 100644 --- a/modules/webapp/src/main/elm/Messages/Basics.elm +++ b/modules/webapp/src/main/elm/Messages/Basics.elm @@ -15,6 +15,7 @@ module Messages.Basics exposing type alias Texts = { incoming : String , outgoing : String + , deleted : String , tags : String , items : String , submit : String @@ -51,6 +52,7 @@ gb : Texts gb = { incoming = "Incoming" , outgoing = "Outgoing" + , deleted = "Deleted" , tags = "Tags" , items = "Items" , submit = "Submit" @@ -92,6 +94,7 @@ de : Texts de = { incoming = "Eingehend" , outgoing = "Ausgehend" + , deleted = "Gelöscht" , tags = "Tags" , items = "Dokumente" , submit = "Speichern" diff --git a/modules/webapp/src/main/elm/Page/CollectiveSettings/View2.elm b/modules/webapp/src/main/elm/Page/CollectiveSettings/View2.elm index b190ec82..84fc7692 100644 --- a/modules/webapp/src/main/elm/Page/CollectiveSettings/View2.elm +++ b/modules/webapp/src/main/elm/Page/CollectiveSettings/View2.elm @@ -171,6 +171,7 @@ viewInsights texts flags model = [ stats (String.fromInt (model.insights.incomingCount + model.insights.outgoingCount)) texts.basics.items , stats (String.fromInt model.insights.incomingCount) texts.basics.incoming , stats (String.fromInt model.insights.outgoingCount) texts.basics.outgoing + , stats (String.fromInt model.insights.deletedCount) texts.basics.deleted ] ] , div From a7b74bd5aef65ba5bb517cba50b80ed99e924d8c Mon Sep 17 00:00:00 2001 From: eikek Date: Sat, 14 Aug 2021 14:53:05 +0200 Subject: [PATCH 03/15] Allow to search in soft-deleted items A new query/request parameter can be used to apply a search to only soft-deleted items. The query expression `Trashed` has been introduced which selects only items with state `Deleted`. This is another option an analog to `ValidItemStates` (both cannot be used together as they would select no items). This new query node is not added to the parser, because users may not use it in their own queries - it must be part of the "fixed" query so the application can control in which subset to search (it would otherwise be possible to select any items). --- .../docspell/backend/ops/OSimpleSearch.scala | 25 +++++++++----- .../main/scala/docspell/query/ItemQuery.scala | 3 +- .../docspell/query/internal/ExprUtil.scala | 3 +- .../src/main/resources/docspell-openapi.yml | 14 ++++++++ .../restserver/http4s/QueryParam.scala | 1 + .../restserver/routes/ItemRoutes.scala | 33 ++++++++++++------- .../qb/generator/ItemQueryGenerator.scala | 3 ++ .../webapp/src/main/elm/Data/ItemQuery.elm | 1 + 8 files changed, 62 insertions(+), 21 deletions(-) diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala b/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala index 9050bb06..aa2ecf00 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala @@ -39,7 +39,7 @@ trait OSimpleSearch[F[_]] { * and not the results. */ def searchSummary( - useFTS: Boolean + settings: StatsSettings )(q: Query, fulltextQuery: Option[String]): F[SearchSummary] /** Calls `search` by parsing the given query string into a query that @@ -56,12 +56,12 @@ trait OSimpleSearch[F[_]] { * results. */ final def searchSummaryByString( - useFTS: Boolean + settings: StatsSettings )(fix: Query.Fix, q: ItemQueryString)(implicit F: Applicative[F] ): F[StringSearchResult[SearchSummary]] = OSimpleSearch.applySearch[F, SearchSummary](fix, q)((iq, fts) => - searchSummary(useFTS)(iq, fts) + searchSummary(settings)(iq, fts) ) } @@ -86,7 +86,12 @@ object OSimpleSearch { batch: Batch, useFTS: Boolean, resolveDetails: Boolean, - maxNoteLen: Int + maxNoteLen: Int, + deleted: Boolean + ) + final case class StatsSettings( + useFTS: Boolean, + deleted: Boolean ) sealed trait Items { @@ -217,7 +222,9 @@ object OSimpleSearch { // 1. fulltext only if fulltextQuery.isDefined && q.isEmpty && useFTS // 2. sql+fulltext if fulltextQuery.isDefined && q.nonEmpty && useFTS // 3. sql-only else (if fulltextQuery.isEmpty || !useFTS) - val validItemQuery = q.withFix(_.andQuery(ItemQuery.Expr.ValidItemStates)) + val validItemQuery = + if (settings.deleted) q.withFix(_.andQuery(ItemQuery.Expr.Trashed)) + else q.withFix(_.andQuery(ItemQuery.Expr.ValidItemStates)) fulltextQuery match { case Some(ftq) if settings.useFTS => if (q.isEmpty) { @@ -270,11 +277,13 @@ object OSimpleSearch { } def searchSummary( - useFTS: Boolean + settings: StatsSettings )(q: Query, fulltextQuery: Option[String]): F[SearchSummary] = { - val validItemQuery = q.withFix(_.andQuery(ItemQuery.Expr.ValidItemStates)) + val validItemQuery = + if (settings.deleted) q.withFix(_.andQuery(ItemQuery.Expr.Trashed)) + else q.withFix(_.andQuery(ItemQuery.Expr.ValidItemStates)) fulltextQuery match { - case Some(ftq) if useFTS => + case Some(ftq) if settings.useFTS => if (q.isEmpty) fts.findIndexOnlySummary(q.fix.account, OFulltext.FtsInput(ftq)) else diff --git a/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala b/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala index 082b6f44..b2a78aa3 100644 --- a/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala +++ b/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala @@ -125,7 +125,8 @@ object ItemQuery { final case class ChecksumMatch(checksum: String) extends Expr final case class AttachId(id: String) extends Expr - case object ValidItemStates extends Expr + final case object ValidItemStates extends Expr + final case object Trashed extends Expr // things that can be expressed with terms above sealed trait MacroExpr extends Expr { diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala b/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala index 9c25edcf..8b15df94 100644 --- a/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala +++ b/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala @@ -75,9 +75,10 @@ object ExprUtil { expr case AttachId(_) => expr - case ValidItemStates => expr + case Trashed => + expr } private def spliceAnd(nodes: Nel[Expr]): Nel[Expr] = diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index f2d8d1d9..a45d266f 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -1478,6 +1478,7 @@ paths: - $ref: "#/components/parameters/limit" - $ref: "#/components/parameters/offset" - $ref: "#/components/parameters/withDetails" + - $ref: "#/components/parameters/deleted" responses: 200: description: Ok @@ -1576,6 +1577,7 @@ paths: - authTokenHeader: [] parameters: - $ref: "#/components/parameters/q" + - $ref: "#/components/parameters/deleted" responses: 200: description: Ok @@ -4112,6 +4114,12 @@ components: withDetails: type: boolean default: false + deleted: + type: boolean + default: false + description: | + If this is true, the search performed only for + soft-deleted items. query: type: string description: | @@ -5838,6 +5846,12 @@ components: description: Whether to return details to each item. schema: type: boolean + deleted: + name: deleted + in: query + description: Whether to search in soft-deleted items only. + schema: + type: boolean name: name: name in: path diff --git a/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala b/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala index 74c2b418..da9afe80 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala @@ -35,6 +35,7 @@ object QueryParam { object Limit extends OptionalQueryParamDecoderMatcher[Int]("limit") object Offset extends OptionalQueryParamDecoderMatcher[Int]("offset") object WithDetails extends OptionalQueryParamDecoderMatcher[Boolean]("withDetails") + object Deleted extends OptionalQueryParamDecoderMatcher[Boolean]("deleted") object WithFallback extends OptionalQueryParamDecoderMatcher[Boolean]("withFallback") } 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 803fc975..02030d7d 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala @@ -49,7 +49,7 @@ object ItemRoutes { HttpRoutes.of { case GET -> Root / "search" :? QP.Query(q) :? QP.Limit(limit) :? QP.Offset( offset - ) :? QP.WithDetails(detailFlag) => + ) :? QP.WithDetails(detailFlag) :? QP.Deleted(deletedFlag) => val batch = Batch(offset.getOrElse(0), limit.getOrElse(cfg.maxItemPageSize)) .restrictLimitTo(cfg.maxItemPageSize) val itemQuery = ItemQueryString(q) @@ -57,15 +57,20 @@ object ItemRoutes { batch, cfg.fullTextSearch.enabled, detailFlag.getOrElse(false), - cfg.maxNoteLength + cfg.maxNoteLength, + deletedFlag.getOrElse(false) ) val fixQuery = Query.Fix(user.account, None, None) searchItems(backend, dsl)(settings, fixQuery, itemQuery) - case GET -> Root / "searchStats" :? QP.Query(q) => + case GET -> Root / "searchStats" :? QP.Query(q) :? QP.Deleted(deletedFlag) => val itemQuery = ItemQueryString(q) val fixQuery = Query.Fix(user.account, None, None) - searchItemStats(backend, dsl)(cfg.fullTextSearch.enabled, fixQuery, itemQuery) + val settings = OSimpleSearch.StatsSettings( + useFTS = cfg.fullTextSearch.enabled, + deleted = deletedFlag.getOrElse(false) + ) + searchItemStats(backend, dsl)(settings, fixQuery, itemQuery) case req @ POST -> Root / "search" => for { @@ -81,7 +86,8 @@ object ItemRoutes { batch, cfg.fullTextSearch.enabled, userQuery.withDetails.getOrElse(false), - cfg.maxNoteLength + cfg.maxNoteLength, + deleted = userQuery.deleted.getOrElse(false) ) fixQuery = Query.Fix(user.account, None, None) resp <- searchItems(backend, dsl)(settings, fixQuery, itemQuery) @@ -92,11 +98,11 @@ object ItemRoutes { userQuery <- req.as[ItemQuery] itemQuery = ItemQueryString(userQuery.query) fixQuery = Query.Fix(user.account, None, None) - resp <- searchItemStats(backend, dsl)( - cfg.fullTextSearch.enabled, - fixQuery, - itemQuery + settings = OSimpleSearch.StatsSettings( + useFTS = cfg.fullTextSearch.enabled, + deleted = userQuery.deleted.getOrElse(false) ) + resp <- searchItemStats(backend, dsl)(settings, fixQuery, itemQuery) } yield resp case req @ POST -> Root / "searchIndex" => @@ -443,10 +449,15 @@ object ItemRoutes { private def searchItemStats[F[_]: Sync]( backend: BackendApp[F], dsl: Http4sDsl[F] - )(ftsEnabled: Boolean, fixQuery: Query.Fix, itemQuery: ItemQueryString) = { + )( + settings: OSimpleSearch.StatsSettings, + fixQuery: Query.Fix, + itemQuery: ItemQueryString + ) = { import dsl._ + backend.simpleSearch - .searchSummaryByString(ftsEnabled)(fixQuery, itemQuery) + .searchSummaryByString(settings)(fixQuery, itemQuery) .flatMap { case StringSearchResult.Success(summary) => Ok(Conversions.mkSearchStats(summary)) diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala index 24fed950..10bf7dec 100644 --- a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala +++ b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala @@ -126,6 +126,9 @@ object ItemQueryGenerator { case Expr.ValidItemStates => tables.item.state.in(ItemState.validStates) + case Expr.Trashed => + tables.item.state === ItemState.Deleted + case Expr.TagIdsMatch(op, tags) => val ids = tags.toList.flatMap(s => Ident.fromString(s).toOption) Nel diff --git a/modules/webapp/src/main/elm/Data/ItemQuery.elm b/modules/webapp/src/main/elm/Data/ItemQuery.elm index 72ab54ca..02ceb825 100644 --- a/modules/webapp/src/main/elm/Data/ItemQuery.elm +++ b/modules/webapp/src/main/elm/Data/ItemQuery.elm @@ -79,6 +79,7 @@ request mq = , limit = Nothing , withDetails = Just True , query = renderMaybe mq + , deleted = Just False } From edb344314fde8199948b8e26e0f01913a40cb677 Mon Sep 17 00:00:00 2001 From: eikek Date: Sat, 14 Aug 2021 15:08:29 +0200 Subject: [PATCH 04/15] Use an enum instead of a boolean to differentiate search It's not very likely to have more modes of search besides normal and trashed, but got surprised in that way quite often and it's nicer this way anyways. --- build.sbt | 4 ++ .../docspell/backend/ops/OSimpleSearch.scala | 16 ++++--- .../scala/docspell/common/SearchMode.scala | 43 +++++++++++++++++++ .../src/main/resources/docspell-openapi.yml | 23 ++++++---- .../restserver/http4s/QueryParam.scala | 8 +++- .../restserver/routes/ItemRoutes.scala | 12 +++--- .../webapp/src/main/elm/Data/ItemQuery.elm | 2 +- 7 files changed, 85 insertions(+), 23 deletions(-) create mode 100644 modules/common/src/main/scala/docspell/common/SearchMode.scala diff --git a/build.sbt b/build.sbt index 3eea7c5e..3d1b1272 100644 --- a/build.sbt +++ b/build.sbt @@ -238,6 +238,10 @@ val openapiScalaSettings = Seq( field.copy(typeDef = TypeDef("EquipmentUse", Imports("docspell.common.EquipmentUse")) ) + case "searchmode" => + field => + field + .copy(typeDef = TypeDef("SearchMode", Imports("docspell.common.SearchMode"))) })) ) diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala b/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala index aa2ecf00..2ae4cd14 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala @@ -87,11 +87,11 @@ object OSimpleSearch { useFTS: Boolean, resolveDetails: Boolean, maxNoteLen: Int, - deleted: Boolean + searchMode: SearchMode ) final case class StatsSettings( useFTS: Boolean, - deleted: Boolean + searchMode: SearchMode ) sealed trait Items { @@ -223,8 +223,10 @@ object OSimpleSearch { // 2. sql+fulltext if fulltextQuery.isDefined && q.nonEmpty && useFTS // 3. sql-only else (if fulltextQuery.isEmpty || !useFTS) val validItemQuery = - if (settings.deleted) q.withFix(_.andQuery(ItemQuery.Expr.Trashed)) - else q.withFix(_.andQuery(ItemQuery.Expr.ValidItemStates)) + settings.searchMode match { + case SearchMode.Trashed => q.withFix(_.andQuery(ItemQuery.Expr.Trashed)) + case SearchMode.Normal => q.withFix(_.andQuery(ItemQuery.Expr.ValidItemStates)) + } fulltextQuery match { case Some(ftq) if settings.useFTS => if (q.isEmpty) { @@ -280,8 +282,10 @@ object OSimpleSearch { settings: StatsSettings )(q: Query, fulltextQuery: Option[String]): F[SearchSummary] = { val validItemQuery = - if (settings.deleted) q.withFix(_.andQuery(ItemQuery.Expr.Trashed)) - else q.withFix(_.andQuery(ItemQuery.Expr.ValidItemStates)) + settings.searchMode match { + case SearchMode.Trashed => q.withFix(_.andQuery(ItemQuery.Expr.Trashed)) + case SearchMode.Normal => q.withFix(_.andQuery(ItemQuery.Expr.ValidItemStates)) + } fulltextQuery match { case Some(ftq) if settings.useFTS => if (q.isEmpty) diff --git a/modules/common/src/main/scala/docspell/common/SearchMode.scala b/modules/common/src/main/scala/docspell/common/SearchMode.scala new file mode 100644 index 00000000..451f5de9 --- /dev/null +++ b/modules/common/src/main/scala/docspell/common/SearchMode.scala @@ -0,0 +1,43 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.common + +import cats.data.NonEmptyList + +import io.circe.Decoder +import io.circe.Encoder + +sealed trait SearchMode { self: Product => + + final def name: String = + productPrefix.toLowerCase + +} + +object SearchMode { + + final case object Normal extends SearchMode + final case object Trashed extends SearchMode + + def fromString(str: String): Either[String, SearchMode] = + str.toLowerCase match { + case "normal" => Right(Normal) + case "trashed" => Right(Trashed) + case _ => Left(s"Invalid search mode: $str") + } + + val all: NonEmptyList[SearchMode] = + NonEmptyList.of(Normal, Trashed) + + def unsafe(str: String): SearchMode = + fromString(str).fold(sys.error, identity) + + implicit val jsonDecoder: Decoder[SearchMode] = + Decoder.decodeString.emap(fromString) + implicit val jsonEncoder: Encoder[SearchMode] = + Encoder.encodeString.contramap(_.name) +} diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index a45d266f..48a65da4 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -1478,7 +1478,7 @@ paths: - $ref: "#/components/parameters/limit" - $ref: "#/components/parameters/offset" - $ref: "#/components/parameters/withDetails" - - $ref: "#/components/parameters/deleted" + - $ref: "#/components/parameters/searchMode" responses: 200: description: Ok @@ -4114,12 +4114,16 @@ components: withDetails: type: boolean default: false - deleted: - type: boolean - default: false + searchMode: + type: string + format: searchmode + enum: + - normal + - trashed + default: normal description: | - If this is true, the search performed only for - soft-deleted items. + Specify whether the search query should apply to + soft-deleted items or not. query: type: string description: | @@ -5846,12 +5850,13 @@ components: description: Whether to return details to each item. schema: type: boolean - deleted: - name: deleted + searchMode: + name: searchMode in: query description: Whether to search in soft-deleted items only. schema: - type: boolean + type: string + format: searchmode name: name: name in: path diff --git a/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala b/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala index da9afe80..23619cd3 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala @@ -7,6 +7,7 @@ package docspell.restserver.http4s import docspell.common.ContactKind +import docspell.common.SearchMode import org.http4s.ParseFailure import org.http4s.QueryParamDecoder @@ -23,6 +24,11 @@ object QueryParam { implicit val queryStringDecoder: QueryParamDecoder[QueryString] = QueryParamDecoder[String].map(s => QueryString(s.trim.toLowerCase)) + implicit val searchModeDecoder: QueryParamDecoder[SearchMode] = + QueryParamDecoder[String].emap(str => + SearchMode.fromString(str).left.map(s => ParseFailure(str, s)) + ) + object FullOpt extends OptionalQueryParamDecoderMatcher[Boolean]("full") object OwningOpt extends OptionalQueryParamDecoderMatcher[Boolean]("owning") @@ -35,7 +41,7 @@ object QueryParam { object Limit extends OptionalQueryParamDecoderMatcher[Int]("limit") object Offset extends OptionalQueryParamDecoderMatcher[Int]("offset") object WithDetails extends OptionalQueryParamDecoderMatcher[Boolean]("withDetails") - object Deleted extends OptionalQueryParamDecoderMatcher[Boolean]("deleted") + object SearchKind extends OptionalQueryParamDecoderMatcher[SearchMode]("searchMode") object WithFallback extends OptionalQueryParamDecoderMatcher[Boolean]("withFallback") } 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 02030d7d..71bdbfb7 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala @@ -49,7 +49,7 @@ object ItemRoutes { HttpRoutes.of { case GET -> Root / "search" :? QP.Query(q) :? QP.Limit(limit) :? QP.Offset( offset - ) :? QP.WithDetails(detailFlag) :? QP.Deleted(deletedFlag) => + ) :? QP.WithDetails(detailFlag) :? QP.SearchKind(searchMode) => val batch = Batch(offset.getOrElse(0), limit.getOrElse(cfg.maxItemPageSize)) .restrictLimitTo(cfg.maxItemPageSize) val itemQuery = ItemQueryString(q) @@ -58,17 +58,17 @@ object ItemRoutes { cfg.fullTextSearch.enabled, detailFlag.getOrElse(false), cfg.maxNoteLength, - deletedFlag.getOrElse(false) + searchMode.getOrElse(SearchMode.Normal) ) val fixQuery = Query.Fix(user.account, None, None) searchItems(backend, dsl)(settings, fixQuery, itemQuery) - case GET -> Root / "searchStats" :? QP.Query(q) :? QP.Deleted(deletedFlag) => + case GET -> Root / "searchStats" :? QP.Query(q) :? QP.SearchKind(searchMode) => val itemQuery = ItemQueryString(q) val fixQuery = Query.Fix(user.account, None, None) val settings = OSimpleSearch.StatsSettings( useFTS = cfg.fullTextSearch.enabled, - deleted = deletedFlag.getOrElse(false) + searchMode = searchMode.getOrElse(SearchMode.Normal) ) searchItemStats(backend, dsl)(settings, fixQuery, itemQuery) @@ -87,7 +87,7 @@ object ItemRoutes { cfg.fullTextSearch.enabled, userQuery.withDetails.getOrElse(false), cfg.maxNoteLength, - deleted = userQuery.deleted.getOrElse(false) + searchMode = userQuery.searchMode.getOrElse(SearchMode.Normal) ) fixQuery = Query.Fix(user.account, None, None) resp <- searchItems(backend, dsl)(settings, fixQuery, itemQuery) @@ -100,7 +100,7 @@ object ItemRoutes { fixQuery = Query.Fix(user.account, None, None) settings = OSimpleSearch.StatsSettings( useFTS = cfg.fullTextSearch.enabled, - deleted = userQuery.deleted.getOrElse(false) + searchMode = userQuery.searchMode.getOrElse(SearchMode.Normal) ) resp <- searchItemStats(backend, dsl)(settings, fixQuery, itemQuery) } yield resp diff --git a/modules/webapp/src/main/elm/Data/ItemQuery.elm b/modules/webapp/src/main/elm/Data/ItemQuery.elm index 02ceb825..46d1e4be 100644 --- a/modules/webapp/src/main/elm/Data/ItemQuery.elm +++ b/modules/webapp/src/main/elm/Data/ItemQuery.elm @@ -79,7 +79,7 @@ request mq = , limit = Nothing , withDetails = Just True , query = renderMaybe mq - , deleted = Just False + , searchMode = Nothing } From 3f1ff5c1aca819a020a631c0d2cbe31f031bc3cf Mon Sep 17 00:00:00 2001 From: eikek Date: Sat, 14 Aug 2021 15:35:17 +0200 Subject: [PATCH 05/15] Allow to search in deleted items --- .../webapp/src/main/elm/Comp/SearchMenu.elm | 48 +++++++++++++++++++ .../webapp/src/main/elm/Data/ItemQuery.elm | 7 +-- .../webapp/src/main/elm/Data/SearchMode.elm | 40 ++++++++++++++++ .../webapp/src/main/elm/Page/Home/Data.elm | 2 +- .../webapp/src/main/elm/Page/Home/Update.elm | 9 ++-- 5 files changed, 98 insertions(+), 8 deletions(-) create mode 100644 modules/webapp/src/main/elm/Data/SearchMode.elm diff --git a/modules/webapp/src/main/elm/Comp/SearchMenu.elm b/modules/webapp/src/main/elm/Comp/SearchMenu.elm index 627d4b64..1374f0b9 100644 --- a/modules/webapp/src/main/elm/Comp/SearchMenu.elm +++ b/modules/webapp/src/main/elm/Comp/SearchMenu.elm @@ -45,6 +45,7 @@ import Data.Fields import Data.Flags exposing (Flags) import Data.ItemQuery as Q exposing (ItemQuery) import Data.PersonUse +import Data.SearchMode exposing (SearchMode) import Data.UiSettings exposing (UiSettings) import DatePicker exposing (DatePicker) import Html exposing (..) @@ -89,6 +90,7 @@ type alias Model = , customValues : CustomFieldValueCollect , sourceModel : Maybe String , openTabs : Set String + , searchMode : SearchMode } @@ -133,6 +135,7 @@ init flags = , customValues = Data.CustomFieldChange.emptyCollect , sourceModel = Nothing , openTabs = Set.fromList [ "Tags", "Inbox" ] + , searchMode = Data.SearchMode.Normal } @@ -323,6 +326,7 @@ resetModel model = model.customFieldModel , customValues = Data.CustomFieldChange.emptyCollect , sourceModel = Nothing + , searchMode = Data.SearchMode.Normal } @@ -343,6 +347,7 @@ type Msg | FromDueDateMsg Comp.DatePicker.Msg | UntilDueDateMsg Comp.DatePicker.Msg | ToggleInbox + | ToggleSearchMode | GetOrgResp (Result Http.Error ReferenceList) | GetEquipResp (Result Http.Error EquipmentList) | GetPersonResp (Result Http.Error PersonList) @@ -683,6 +688,24 @@ updateDrop ddm flags settings msg model = , dragDrop = DD.DragDropData ddm Nothing } + ToggleSearchMode -> + let + current = + model.searchMode + + next = + if current == Data.SearchMode.Normal then + Data.SearchMode.Trashed + + else + Data.SearchMode.Normal + in + { model = { model | searchMode = next } + , cmd = Cmd.none + , stateChange = True + , dragDrop = DD.DragDropData ddm Nothing + } + FromDateMsg m -> let ( dp, event ) = @@ -962,6 +985,7 @@ type SearchTab | TabDueDate | TabSource | TabDirection + | TabTrashed allTabs : List SearchTab @@ -977,6 +1001,7 @@ allTabs = , TabDueDate , TabSource , TabDirection + , TabTrashed ] @@ -1016,6 +1041,9 @@ tabName tab = TabDirection -> "direction" + TabTrashed -> + "trashed" + findTab : Comp.Tabs.Tab msg -> Maybe SearchTab findTab tab = @@ -1053,6 +1081,9 @@ findTab tab = "direction" -> Just TabDirection + "trashed" -> + Just TabTrashed + _ -> Nothing @@ -1099,6 +1130,9 @@ searchTabState settings model tab = Just TabInbox -> False + Just TabTrashed -> + False + Nothing -> False @@ -1447,4 +1481,18 @@ searchTabs texts ddd flags settings model = ) ] } + , { name = tabName TabTrashed + , title = texts.basics.deleted + , titleRight = [] + , info = Nothing + , body = + [ MB.viewItem <| + MB.Checkbox + { id = "trashed" + , value = model.searchMode == Data.SearchMode.Trashed + , label = texts.basics.deleted + , tagger = \_ -> ToggleSearchMode + } + ] + } ] diff --git a/modules/webapp/src/main/elm/Data/ItemQuery.elm b/modules/webapp/src/main/elm/Data/ItemQuery.elm index 46d1e4be..7a846444 100644 --- a/modules/webapp/src/main/elm/Data/ItemQuery.elm +++ b/modules/webapp/src/main/elm/Data/ItemQuery.elm @@ -21,6 +21,7 @@ module Data.ItemQuery exposing import Api.Model.CustomFieldValue exposing (CustomFieldValue) import Api.Model.ItemQuery as RQ import Data.Direction exposing (Direction) +import Data.SearchMode exposing (SearchMode) type TagMatch @@ -73,13 +74,13 @@ and list = Just (And es) -request : Maybe ItemQuery -> RQ.ItemQuery -request mq = +request : SearchMode -> Maybe ItemQuery -> RQ.ItemQuery +request smode mq = { offset = Nothing , limit = Nothing , withDetails = Just True , query = renderMaybe mq - , searchMode = Nothing + , searchMode = Data.SearchMode.asString smode |> Just } diff --git a/modules/webapp/src/main/elm/Data/SearchMode.elm b/modules/webapp/src/main/elm/Data/SearchMode.elm new file mode 100644 index 00000000..9d9493d5 --- /dev/null +++ b/modules/webapp/src/main/elm/Data/SearchMode.elm @@ -0,0 +1,40 @@ +{- + Copyright 2020 Docspell Contributors + + SPDX-License-Identifier: GPL-3.0-or-later +-} + + +module Data.SearchMode exposing + ( SearchMode(..) + , asString + , fromString + ) + + +type SearchMode + = Normal + | Trashed + + +fromString : String -> Maybe SearchMode +fromString str = + case String.toLower str of + "normal" -> + Just Normal + + "trashed" -> + Just Trashed + + _ -> + Nothing + + +asString : SearchMode -> String +asString smode = + case smode of + Normal -> + "normal" + + Trashed -> + "trashed" diff --git a/modules/webapp/src/main/elm/Page/Home/Data.elm b/modules/webapp/src/main/elm/Page/Home/Data.elm index 8560ca9d..46318790 100644 --- a/modules/webapp/src/main/elm/Page/Home/Data.elm +++ b/modules/webapp/src/main/elm/Page/Home/Data.elm @@ -239,7 +239,7 @@ doSearchDefaultCmd : SearchParam -> Model -> Cmd Msg doSearchDefaultCmd param model = let smask = - Q.request <| + Q.request model.searchMenuModel.searchMode <| Q.and [ Comp.SearchMenu.getItemQuery model.searchMenuModel , Maybe.map Q.Fragment model.powerSearchInput.input diff --git a/modules/webapp/src/main/elm/Page/Home/Update.elm b/modules/webapp/src/main/elm/Page/Home/Update.elm index 55a45ce7..9ddfb8b7 100644 --- a/modules/webapp/src/main/elm/Page/Home/Update.elm +++ b/modules/webapp/src/main/elm/Page/Home/Update.elm @@ -23,6 +23,7 @@ import Data.Flags exposing (Flags) import Data.ItemQuery as Q import Data.ItemSelection import Data.Items +import Data.SearchMode exposing (SearchMode) import Data.UiSettings exposing (UiSettings) import Page exposing (Page(..)) import Page.Home.Data exposing (..) @@ -548,7 +549,7 @@ update mId key flags settings msg model = case model.viewMode of SelectView svm -> -- replace changed items in the view - noSub ( nm, loadChangedItems flags svm.ids ) + noSub ( nm, loadChangedItems flags model.searchMenuModel.searchMode svm.ids ) _ -> noSub ( nm, Cmd.none ) @@ -717,8 +718,8 @@ replaceItems model newItems = { model | itemListModel = newList } -loadChangedItems : Flags -> Set String -> Cmd Msg -loadChangedItems flags ids = +loadChangedItems : Flags -> SearchMode -> Set String -> Cmd Msg +loadChangedItems flags smode ids = if Set.isEmpty ids then Cmd.none @@ -728,7 +729,7 @@ loadChangedItems flags ids = Set.toList ids searchInit = - Q.request (Just <| Q.ItemIdIn idList) + Q.request smode (Just <| Q.ItemIdIn idList) search = { searchInit From eede20b014493283817aa2bbc7dbfd179f95a4f9 Mon Sep 17 00:00:00 2001 From: eikek Date: Sat, 14 Aug 2021 16:06:55 +0200 Subject: [PATCH 06/15] Display deleted items in the webui The card renders a trash can in the top right corner and the detail page shows a label and also this trash can. --- modules/webapp/src/main/elm/Comp/ItemCard.elm | 29 +++++++++--- .../elm/Comp/ItemDetail/ItemInfoHeader.elm | 45 +++++++++++++++---- .../src/main/elm/Comp/ItemDetail/Model.elm | 1 + .../src/main/elm/Comp/ItemDetail/Update.elm | 7 +++ .../src/main/elm/Comp/ItemDetail/View2.elm | 30 +++++++++---- .../src/main/elm/Messages/Comp/ItemDetail.elm | 3 ++ modules/webapp/src/main/elm/Styles.elm | 5 +++ 7 files changed, 96 insertions(+), 24 deletions(-) diff --git a/modules/webapp/src/main/elm/Comp/ItemCard.elm b/modules/webapp/src/main/elm/Comp/ItemCard.elm index 23c54701..d3c7e6de 100644 --- a/modules/webapp/src/main/elm/Comp/ItemCard.elm +++ b/modules/webapp/src/main/elm/Comp/ItemCard.elm @@ -149,13 +149,19 @@ update ddm msg model = view2 : Texts -> ViewConfig -> UiSettings -> Model -> ItemLight -> Html Msg view2 texts cfg settings model item = let - isConfirmed = - item.state /= "created" + isCreated = + item.state == "created" + + isDeleted = + item.state == "deleted" cardColor = - if not isConfirmed then + if isCreated then "text-blue-500 dark:text-lightblue-500" + else if isDeleted then + "text-red-600 dark:text-orange-600" + else "" @@ -207,7 +213,7 @@ view2 texts cfg settings model item = [ previewImage2 settings cardAction model item ] ) - ++ [ mainContent2 texts cardAction cardColor isConfirmed settings cfg item + ++ [ mainContent2 texts cardAction cardColor isCreated isDeleted settings cfg item , metaDataContent2 texts settings item , notesContent2 settings item , fulltextResultsContent2 item @@ -293,11 +299,12 @@ mainContent2 : -> List (Attribute Msg) -> String -> Bool + -> Bool -> UiSettings -> ViewConfig -> ItemLight -> Html Msg -mainContent2 texts cardAction cardColor isConfirmed settings _ item = +mainContent2 texts _ cardColor isCreated isDeleted settings _ item = let dirIcon = i @@ -353,12 +360,22 @@ mainContent2 texts cardAction cardColor isConfirmed settings _ item = [ classList [ ( "absolute right-1 top-1 text-4xl", True ) , ( cardColor, True ) - , ( "hidden", isConfirmed ) + , ( "hidden", not isCreated ) ] , title texts.new ] [ i [ class "ml-2 fa fa-exclamation-circle" ] [] ] + , div + [ classList + [ ( "absolute right-1 top-1 text-4xl", True ) + , ( cardColor, True ) + , ( "hidden", not isDeleted ) + ] + , title texts.basics.deleted + ] + [ i [ class "ml-2 fa fa-trash-alt" ] [] + ] , div [ classList [ ( "opacity-75", True ) diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/ItemInfoHeader.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/ItemInfoHeader.elm index 568dcac3..a5616eb4 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/ItemInfoHeader.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/ItemInfoHeader.elm @@ -118,30 +118,57 @@ view texts settings model = ] , True ) + + isDeleted = + model.item.state == "deleted" + + isCreated = + model.item.state == "created" in div [ class "flex flex-col pb-2" ] [ div [ class "flex flex-row items-center text-2xl" ] - [ i - [ classList - [ ( "hidden", Data.UiSettings.fieldHidden settings Data.Fields.Direction ) + [ if isDeleted then + div + [ classList + [ ( "text-red-500 dark:text-orange-600 text-4xl", True ) + , ( "hidden", not isDeleted ) + ] + , title texts.basics.deleted ] - , class (Data.Direction.iconFromString2 model.item.direction) - , class "mr-2" - , title model.item.direction - ] - [] + [ i [ class "mr-2 fa fa-trash-alt" ] [] + ] + + else + i + [ classList + [ ( "hidden", Data.UiSettings.fieldHidden settings Data.Fields.Direction ) + ] + , class (Data.Direction.iconFromString2 model.item.direction) + , class "mr-2" + , title model.item.direction + ] + [] , div [ class "flex-grow ml-1 flex flex-col" ] [ div [ class "flex flex-row items-center font-semibold" ] [ text model.item.name , div [ classList - [ ( "hidden", model.item.state /= "created" ) + [ ( "hidden", not isCreated ) ] , class "ml-3 text-base label bg-blue-500 dark:bg-lightblue-500 text-white rounded-lg" ] [ text texts.new , i [ class "fa fa-exclamation ml-2" ] [] ] + , div + [ classList + [ ( "hidden", not isDeleted ) + ] + , class "ml-3 text-base label bg-red-500 dark:bg-orange-500 text-white rounded-lg" + ] + [ text texts.basics.deleted + , i [ class "fa fa-exclamation ml-2" ] [] + ] ] ] ] diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/Model.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/Model.elm index 65246daf..de3affe7 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/Model.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/Model.elm @@ -339,6 +339,7 @@ type Msg | RequestReprocessItem | ReprocessItemConfirmed | ToggleSelectView + | RestoreItem type SaveNameState diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm index 5697907b..b0905241 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm @@ -1604,6 +1604,13 @@ update key flags inav settings msg model = , cmd ) + RestoreItem -> + let + _ = + Debug.todo "implement" + in + resultModelCmd ( model, Cmd.none ) + --- Helper diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/View2.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/View2.elm index 0770eb41..0b4f7559 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/View2.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/View2.elm @@ -188,15 +188,27 @@ menuBar texts inav settings model = ] [ i [ class "fa fa-redo" ] [] ] - , MB.CustomElement <| - a - [ class S.deleteButton - , href "#" - , onClick RequestDelete - , title texts.deleteThisItem - ] - [ i [ class "fa fa-trash" ] [] - ] + , if model.item.state == "deleted" then + MB.CustomElement <| + a + [ class S.undeleteButton + , href "#" + , onClick RestoreItem + , title texts.undeleteThisItem + ] + [ i [ class "fa fa-trash-restore" ] [] + ] + + else + MB.CustomElement <| + a + [ class S.deleteButton + , href "#" + , onClick RequestDelete + , title texts.deleteThisItem + ] + [ i [ class "fa fa-trash" ] [] + ] ] , rootClasses = "mb-2" } diff --git a/modules/webapp/src/main/elm/Messages/Comp/ItemDetail.elm b/modules/webapp/src/main/elm/Messages/Comp/ItemDetail.elm index 26905f5d..dda86e66 100644 --- a/modules/webapp/src/main/elm/Messages/Comp/ItemDetail.elm +++ b/modules/webapp/src/main/elm/Messages/Comp/ItemDetail.elm @@ -46,6 +46,7 @@ type alias Texts = , unconfirmItemMetadata : String , reprocessItem : String , deleteThisItem : String + , undeleteThisItem : String , sentEmails : String , sendThisItemViaEmail : String , itemId : String @@ -79,6 +80,7 @@ gb = , unconfirmItemMetadata = "Un-confirm item metadata" , reprocessItem = "Reprocess this item" , deleteThisItem = "Delete this item" + , undeleteThisItem = "Restore this item" , sentEmails = "Sent E-Mails" , sendThisItemViaEmail = "Send this item via E-Mail" , itemId = "Item ID" @@ -112,6 +114,7 @@ de = , unconfirmItemMetadata = "Widerrufe Bestätigung" , reprocessItem = "Das Dokument erneut verarbeiten" , deleteThisItem = "Das Dokument löschen" + , undeleteThisItem = "Das Dokument wiederherstellen" , sentEmails = "Versendete E-Mails" , sendThisItemViaEmail = "Sende dieses Dokument via E-Mail" , itemId = "Dokument-ID" diff --git a/modules/webapp/src/main/elm/Styles.elm b/modules/webapp/src/main/elm/Styles.elm index f08a3d15..e92b29fe 100644 --- a/modules/webapp/src/main/elm/Styles.elm +++ b/modules/webapp/src/main/elm/Styles.elm @@ -200,6 +200,11 @@ deleteButton = " rounded my-auto whitespace-nowrap border border-red-500 dark:border-lightred-500 text-red-500 dark:text-orange-500 text-center px-4 py-2 shadow-none focus:outline-none focus:ring focus:ring-opacity-75 hover:bg-red-600 hover:text-white dark:hover:text-white dark:hover:bg-orange-500 dark:hover:text-bluegray-900 " +undeleteButton : String +undeleteButton = + " rounded my-auto whitespace-nowrap border border-green-500 dark:border-lightgreen-500 text-green-500 dark:text-lightgreen-500 text-center px-4 py-2 shadow-none focus:outline-none focus:ring focus:ring-opacity-75 hover:bg-green-600 hover:text-white dark:hover:text-white dark:hover:bg-lightgreen-500 dark:hover:text-bluegray-900 " + + deleteLabel : String deleteLabel = "label my-auto whitespace-nowrap border border-red-500 dark:border-lightred-500 text-red-500 dark:text-orange-500 text-center focus:outline-none focus:ring focus:ring-opacity-75 hover:bg-red-600 hover:text-white dark:hover:text-white dark:hover:bg-orange-500 dark:hover:text-bluegray-900" From f999662905c337d1edc1eece77b107453bc6d4dc Mon Sep 17 00:00:00 2001 From: eikek Date: Sat, 14 Aug 2021 16:45:51 +0200 Subject: [PATCH 07/15] Add routes to restore deleted items --- .../scala/docspell/backend/ops/OItem.scala | 13 +++++ .../scala/docspell/common/ItemState.scala | 1 + .../src/main/resources/docspell-openapi.yml | 47 ++++++++++++++++++- .../restserver/routes/ItemMultiRoutes.scala | 8 ++++ .../restserver/routes/ItemRoutes.scala | 6 +++ .../scala/docspell/store/records/RItem.scala | 16 ++++++- 6 files changed, 89 insertions(+), 2 deletions(-) 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 e586bf34..cc2ebfed 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala @@ -124,6 +124,8 @@ trait OItem[F[_]] { collective: Ident ): F[AddResult] + def restore(items: NonEmptyList[Ident], collective: Ident): F[UpdateResult] + def setItemDate( item: NonEmptyList[Ident], date: Option[Timestamp], @@ -582,6 +584,17 @@ object OItem { .attempt .map(AddResult.fromUpdate) + def restore( + items: NonEmptyList[Ident], + collective: Ident + ): F[UpdateResult] = + UpdateResult.fromUpdate( + store + .transact( + RItem.restoreStateForCollective(items, ItemState.Created, collective) + ) + ) + def setItemDate( items: NonEmptyList[Ident], date: Option[Timestamp], diff --git a/modules/common/src/main/scala/docspell/common/ItemState.scala b/modules/common/src/main/scala/docspell/common/ItemState.scala index 05149ad7..e59d5049 100644 --- a/modules/common/src/main/scala/docspell/common/ItemState.scala +++ b/modules/common/src/main/scala/docspell/common/ItemState.scala @@ -34,6 +34,7 @@ object ItemState { def processing: ItemState = Processing def created: ItemState = Created def confirmed: ItemState = Confirmed + def deleted: ItemState = Deleted def fromString(str: String): Either[String, ItemState] = str.toLowerCase match { diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 48a65da4..2256ae44 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -1609,7 +1609,9 @@ paths: tags: [ Item ] summary: Delete an item. description: | - Delete an item and all its data permanently. + Delete an item and all its data. This is a "soft delete", the + item is still in the database and can be undeleted. A periodic + job will eventually remove this item from the database. security: - authTokenHeader: [] parameters: @@ -1621,6 +1623,26 @@ paths: application/json: schema: $ref: "#/components/schemas/BasicResult" + /sec/item/{id}/restore: + post: + operationId: "sec-item-restore-by-id" + tags: [ Item ] + summary: Restore a deleted item. + description: | + A deleted item can be restored as long it is still in the + database. This action sets the item state to `created`. + security: + - authTokenHeader: [] + parameters: + - $ref: "#/components/parameters/id" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + /sec/item/{id}/tags: put: operationId: "sec-item-get-tags" @@ -2307,6 +2329,29 @@ paths: schema: $ref: "#/components/schemas/BasicResult" + /sec/items/restoreAll: + post: + operationId: "sec-items-restore-all" + tags: + - Item (Multi Edit) + summary: Restore multiple items. + description: | + Given a list of item ids, restores all of them. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/IdList" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + /sec/items/tags: post: operationId: "sec-items-add-all-tags" diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala index 2ffadde9..771734ec 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala @@ -187,6 +187,14 @@ object ItemMultiRoutes extends MultiIdSupport { resp <- Ok(res) } yield resp + case req @ POST -> Root / "restoreAll" => + for { + json <- req.as[IdList] + items <- readIds[F](json.ids) + res <- backend.item.restore(items, user.account.collective) + resp <- Ok(Conversions.basicResult(res, "Item(s) deleted")) + } yield resp + case req @ PUT -> Root / "customfield" => for { json <- req.as[ItemsAndFieldValue] 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 71bdbfb7..9453eb69 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala @@ -150,6 +150,12 @@ object ItemRoutes { resp <- Ok(Conversions.basicResult(res, "Item back to created.")) } yield resp + case POST -> Root / Ident(id) / "restore" => + for { + res <- backend.item.restore(NonEmptyList.of(id), user.account.collective) + resp <- Ok(Conversions.basicResult(res, "Item restored.")) + } yield resp + case req @ PUT -> Root / Ident(id) / "tags" => for { tags <- req.as[StringList].map(_.items) 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 017e1992..78eb5416 100644 --- a/modules/store/src/main/scala/docspell/store/records/RItem.scala +++ b/modules/store/src/main/scala/docspell/store/records/RItem.scala @@ -152,7 +152,21 @@ object RItem { t <- currentTime n <- DML.update( T, - T.id.in(itemIds) && T.cid === coll, + T.id.in(itemIds) && T.cid === coll && T.state.in(ItemState.validStates), + DML.set(T.state.setTo(itemState), T.updated.setTo(t)) + ) + } yield n + + def restoreStateForCollective( + itemIds: NonEmptyList[Ident], + itemState: ItemState, + coll: Ident + ): ConnectionIO[Int] = + for { + t <- currentTime + n <- DML.update( + T, + T.id.in(itemIds) && T.cid === coll && T.state === ItemState.deleted, DML.set(T.state.setTo(itemState), T.updated.setTo(t)) ) } yield n From 828e5cf703d4194da9930650a8b6766d1b3081ac Mon Sep 17 00:00:00 2001 From: eikek Date: Sat, 14 Aug 2021 16:46:04 +0200 Subject: [PATCH 08/15] Allow to restore deleted items in webui --- modules/webapp/src/main/elm/Api.elm | 26 +++++++++++ .../src/main/elm/Comp/ItemDetail/Update.elm | 6 +-- .../webapp/src/main/elm/Comp/SearchMenu.elm | 4 +- .../src/main/elm/Messages/Comp/SearchMenu.elm | 3 ++ .../src/main/elm/Messages/Page/Home.elm | 6 +++ .../webapp/src/main/elm/Page/Home/Data.elm | 4 ++ .../webapp/src/main/elm/Page/Home/Update.elm | 45 +++++++++++++++++++ .../webapp/src/main/elm/Page/Home/View2.elm | 18 ++++++++ 8 files changed, 105 insertions(+), 7 deletions(-) diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index abba1ebc..1ea82fbe 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -99,6 +99,8 @@ module Api exposing , removeTagsMultiple , reprocessItem , reprocessMultiple + , restoreAllItems + , restoreItem , saveClientSettings , sendMail , setAttachmentName @@ -1676,6 +1678,20 @@ deleteAllItems flags ids receive = } +restoreAllItems : + Flags + -> Set String + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +restoreAllItems flags ids receive = + Http2.authPost + { url = flags.config.baseUrl ++ "/api/v1/sec/items/restoreAll" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.IdList.encode (IdList (Set.toList ids))) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + --- Item @@ -1973,6 +1989,16 @@ setUnconfirmed flags item receive = } +restoreItem : Flags -> String -> (Result Http.Error BasicResult -> msg) -> Cmd msg +restoreItem flags item receive = + Http2.authPost + { url = flags.config.baseUrl ++ "/api/v1/sec/item/" ++ item ++ "/restore" + , account = getAccount flags + , body = Http.emptyBody + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + deleteItem : Flags -> String -> (Result Http.Error BasicResult -> msg) -> Cmd msg deleteItem flags item receive = Http2.authDelete diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm index b0905241..d3574835 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm @@ -1605,11 +1605,7 @@ update key flags inav settings msg model = ) RestoreItem -> - let - _ = - Debug.todo "implement" - in - resultModelCmd ( model, Cmd.none ) + resultModelCmd ( model, Api.restoreItem flags model.item.id SaveResp ) diff --git a/modules/webapp/src/main/elm/Comp/SearchMenu.elm b/modules/webapp/src/main/elm/Comp/SearchMenu.elm index 1374f0b9..a4a00daf 100644 --- a/modules/webapp/src/main/elm/Comp/SearchMenu.elm +++ b/modules/webapp/src/main/elm/Comp/SearchMenu.elm @@ -1482,7 +1482,7 @@ searchTabs texts ddd flags settings model = ] } , { name = tabName TabTrashed - , title = texts.basics.deleted + , title = texts.trashcan , titleRight = [] , info = Nothing , body = @@ -1490,7 +1490,7 @@ searchTabs texts ddd flags settings model = MB.Checkbox { id = "trashed" , value = model.searchMode == Data.SearchMode.Trashed - , label = texts.basics.deleted + , label = texts.trashcan , tagger = \_ -> ToggleSearchMode } ] diff --git a/modules/webapp/src/main/elm/Messages/Comp/SearchMenu.elm b/modules/webapp/src/main/elm/Messages/Comp/SearchMenu.elm index 27be0496..02f13091 100644 --- a/modules/webapp/src/main/elm/Messages/Comp/SearchMenu.elm +++ b/modules/webapp/src/main/elm/Messages/Comp/SearchMenu.elm @@ -46,6 +46,7 @@ type alias Texts = , sourceTab : String , searchInItemSource : String , direction : Direction -> String + , trashcan : String } @@ -77,6 +78,7 @@ gb = , sourceTab = "Source" , searchInItemSource = "Search in item source…" , direction = Messages.Data.Direction.gb + , trashcan = "Trash" } @@ -108,4 +110,5 @@ de = , sourceTab = "Quelle" , searchInItemSource = "Suche in Dokumentquelle…" , direction = Messages.Data.Direction.de + , trashcan = "Papierkorb" } diff --git a/modules/webapp/src/main/elm/Messages/Page/Home.elm b/modules/webapp/src/main/elm/Messages/Page/Home.elm index 27c875d0..5aa8ecbf 100644 --- a/modules/webapp/src/main/elm/Messages/Page/Home.elm +++ b/modules/webapp/src/main/elm/Messages/Page/Home.elm @@ -30,9 +30,11 @@ type alias Texts = , powerSearchPlaceholder : String , reallyReprocessQuestion : String , reallyDeleteQuestion : String + , reallyRestoreQuestion : String , editSelectedItems : Int -> String , reprocessSelectedItems : Int -> String , deleteSelectedItems : Int -> String + , undeleteSelectedItems : Int -> String , selectAllVisible : String , selectNone : String , resetSearchForm : String @@ -54,9 +56,11 @@ gb = , powerSearchPlaceholder = "Search query …" , reallyReprocessQuestion = "Really reprocess all selected items? Metadata of unconfirmed items may change." , reallyDeleteQuestion = "Really delete all selected items?" + , reallyRestoreQuestion = "Really restore all selected items?" , editSelectedItems = \n -> "Edit " ++ String.fromInt n ++ " selected items" , reprocessSelectedItems = \n -> "Reprocess " ++ String.fromInt n ++ " selected items" , deleteSelectedItems = \n -> "Delete " ++ String.fromInt n ++ " selected items" + , undeleteSelectedItems = \n -> "Restore " ++ String.fromInt n ++ " selected items" , selectAllVisible = "Select all visible" , selectNone = "Select none" , resetSearchForm = "Reset search form" @@ -78,9 +82,11 @@ de = , powerSearchPlaceholder = "Suchanfrage…" , reallyReprocessQuestion = "Wirklich die gewählten Dokumente neu verarbeiten? Die Metadaten von nicht bestätigten Dokumenten können sich dabei ändern." , reallyDeleteQuestion = "Wirklich alle gewählten Dokumente löschen?" + , reallyRestoreQuestion = "Wirklich alle gewählten Dokumente wiederherstellen?" , editSelectedItems = \n -> "Ändere " ++ String.fromInt n ++ " gewählte Dokumente" , reprocessSelectedItems = \n -> "Erneute Verarbeitung von " ++ String.fromInt n ++ " gewählten Dokumenten" , deleteSelectedItems = \n -> "Lösche " ++ String.fromInt n ++ " gewählte Dokumente" + , undeleteSelectedItems = \n -> "Stelle " ++ String.fromInt n ++ " gewählte Dokumente wieder her" , selectAllVisible = "Wähle alle Dokumente in der Liste" , selectNone = "Wähle alle Dokumente ab" , resetSearchForm = "Suchformular zurücksetzen" diff --git a/modules/webapp/src/main/elm/Page/Home/Data.elm b/modules/webapp/src/main/elm/Page/Home/Data.elm index 46318790..8d8455f9 100644 --- a/modules/webapp/src/main/elm/Page/Home/Data.elm +++ b/modules/webapp/src/main/elm/Page/Home/Data.elm @@ -68,6 +68,7 @@ type alias Model = type ConfirmModalValue = ConfirmReprocessItems | ConfirmDelete + | ConfirmRestore type alias SelectViewModel = @@ -185,7 +186,9 @@ type Msg | SelectAllItems | SelectNoItems | RequestDeleteSelected + | RequestRestoreSelected | DeleteSelectedConfirmed + | RestoreSelectedConfirmed | CloseConfirmModal | EditSelectedItems | EditMenuMsg Comp.ItemDetail.MultiEditMenu.Msg @@ -214,6 +217,7 @@ type SelectActionMode | DeleteSelected | EditSelected | ReprocessSelected + | RestoreSelected type alias SearchParam = diff --git a/modules/webapp/src/main/elm/Page/Home/Update.elm b/modules/webapp/src/main/elm/Page/Home/Update.elm index 9ddfb8b7..7c68f082 100644 --- a/modules/webapp/src/main/elm/Page/Home/Update.elm +++ b/modules/webapp/src/main/elm/Page/Home/Update.elm @@ -361,6 +361,28 @@ update mId key flags settings msg model = _ -> noSub ( model, Cmd.none ) + RestoreSelectedConfirmed -> + case model.viewMode of + SelectView svm -> + let + cmd = + Api.restoreAllItems flags svm.ids DeleteAllResp + in + noSub + ( { model + | viewMode = + SelectView + { svm + | confirmModal = Nothing + , action = RestoreSelected + } + } + , cmd + ) + + _ -> + noSub ( model, Cmd.none ) + DeleteAllResp (Ok res) -> if res.success then @@ -469,6 +491,29 @@ update mId key flags settings msg model = _ -> noSub ( model, Cmd.none ) + RequestRestoreSelected -> + case model.viewMode of + SelectView svm -> + if svm.ids == Set.empty then + noSub ( model, Cmd.none ) + + else + let + model_ = + { model + | viewMode = + SelectView + { svm + | action = RestoreSelected + , confirmModal = Just ConfirmRestore + } + } + in + noSub ( model_, Cmd.none ) + + _ -> + noSub ( model, Cmd.none ) + EditSelectedItems -> case model.viewMode of SelectView svm -> diff --git a/modules/webapp/src/main/elm/Page/Home/View2.elm b/modules/webapp/src/main/elm/Page/Home/View2.elm index ad464746..dbbff10f 100644 --- a/modules/webapp/src/main/elm/Page/Home/View2.elm +++ b/modules/webapp/src/main/elm/Page/Home/View2.elm @@ -78,6 +78,14 @@ confirmModal texts model = texts.basics.yes texts.basics.no texts.reallyDeleteQuestion + ConfirmRestore -> + Comp.ConfirmModal.defaultSettings + RestoreSelectedConfirmed + CloseConfirmModal + texts.basics.yes + texts.basics.no + texts.reallyRestoreQuestion + in case model.viewMode of SelectView svm -> @@ -264,6 +272,16 @@ editMenuBar texts model svm = , ( "bg-gray-200 dark:bg-bluegray-600", svm.action == DeleteSelected ) ] } + , MB.CustomButton + { tagger = RequestRestoreSelected + , label = "" + , icon = Just "fa fa-trash-restore" + , title = texts.undeleteSelectedItems selectCount + , inputClass = + [ ( btnStyle, True ) + , ( "bg-gray-200 dark:bg-bluegray-600", svm.action == RestoreSelected ) + ] + } ] , end = [ MB.CustomButton From 4901276c66a1688a19b6aa39e49a57c486e830c9 Mon Sep 17 00:00:00 2001 From: eikek Date: Sat, 14 Aug 2021 18:06:48 +0200 Subject: [PATCH 09/15] Change "empty trash" settings for a collective and submit the job --- .../docspell/backend/ops/OCollective.scala | 43 +++++- .../docspell/common/EmptyTrashArgs.scala | 40 ++++++ .../src/main/resources/docspell-openapi.yml | 25 ++++ .../restserver/routes/CollectiveRoutes.scala | 10 +- .../migration/h2/V1.25__add_empty_trash.sql | 6 + .../mariadb/V1.25.0__add_empty_trash.sql | 6 + .../postgresql/V1.25.0__add_empty_trash.sql | 6 + .../docspell/store/records/RCollective.scala | 24 ++-- .../store/records/REmptyTrashSetting.scala | 68 ++++++++++ modules/webapp/src/main/elm/Api.elm | 14 ++ .../main/elm/Comp/CollectiveSettingsForm.elm | 128 ++++++++++++++++-- .../src/main/elm/Comp/EmptyTrashForm.elm | 106 +++++++++++++++ .../Messages/Comp/CollectiveSettingsForm.elm | 10 ++ .../main/elm/Messages/Comp/EmptyTrashForm.elm | 38 ++++++ 14 files changed, 505 insertions(+), 19 deletions(-) create mode 100644 modules/common/src/main/scala/docspell/common/EmptyTrashArgs.scala create mode 100644 modules/store/src/main/resources/db/migration/h2/V1.25__add_empty_trash.sql create mode 100644 modules/store/src/main/resources/db/migration/mariadb/V1.25.0__add_empty_trash.sql create mode 100644 modules/store/src/main/resources/db/migration/postgresql/V1.25.0__add_empty_trash.sql create mode 100644 modules/store/src/main/scala/docspell/store/records/REmptyTrashSetting.scala create mode 100644 modules/webapp/src/main/elm/Comp/EmptyTrashForm.elm create mode 100644 modules/webapp/src/main/elm/Messages/Comp/EmptyTrashForm.elm diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala index 9a598726..14377f35 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala @@ -62,6 +62,8 @@ trait OCollective[F[_]] { def startLearnClassifier(collective: Ident): F[Unit] + def startEmptyTrash(collective: Ident): F[Unit] + /** Submits a task that (re)generates the preview images for all * attachments of the given collective. */ @@ -147,9 +149,14 @@ object OCollective { .transact(RCollective.updateSettings(collective, sett)) .attempt .map(AddResult.fromUpdate) - .flatMap(res => updateLearnClassifierTask(collective, sett) *> res.pure[F]) + .flatMap(res => + updateLearnClassifierTask(collective, sett) *> updateEmptyTrashTask( + collective, + sett + ) *> res.pure[F] + ) - def updateLearnClassifierTask(coll: Ident, sett: Settings) = + private def updateLearnClassifierTask(coll: Ident, sett: Settings): F[Unit] = for { id <- Ident.randomId[F] on = sett.classifier.map(_.enabled).getOrElse(false) @@ -166,6 +173,22 @@ object OCollective { _ <- joex.notifyAllNodes } yield () + private def updateEmptyTrashTask(coll: Ident, sett: Settings): F[Unit] = + for { + id <- Ident.randomId[F] + timer = sett.emptyTrash.getOrElse(CalEvent.unsafe("")) + ut = UserTask( + id, + EmptyTrashArgs.taskName, + true, + timer, + None, + EmptyTrashArgs(coll) + ) + _ <- uts.updateOneTask(AccountId(coll, EmptyTrashArgs.taskName), ut) + _ <- joex.notifyAllNodes + } yield () + def startLearnClassifier(collective: Ident): F[Unit] = for { id <- Ident.randomId[F] @@ -182,6 +205,22 @@ object OCollective { _ <- joex.notifyAllNodes } yield () + def startEmptyTrash(collective: Ident): F[Unit] = + for { + id <- Ident.randomId[F] + ut <- UserTask( + id, + EmptyTrashArgs.taskName, + true, + CalEvent(WeekdayComponent.All, DateEvent.All, TimeEvent.All), + None, + EmptyTrashArgs(collective) + ).encode.toPeriodicTask(AccountId(collective, EmptyTrashArgs.taskName)) + job <- ut.toJob + _ <- queue.insert(job) + _ <- joex.notifyAllNodes + } yield () + def findSettings(collective: Ident): F[Option[OCollective.Settings]] = store.transact(RCollective.getSettings(collective)) diff --git a/modules/common/src/main/scala/docspell/common/EmptyTrashArgs.scala b/modules/common/src/main/scala/docspell/common/EmptyTrashArgs.scala new file mode 100644 index 00000000..2c85fddf --- /dev/null +++ b/modules/common/src/main/scala/docspell/common/EmptyTrashArgs.scala @@ -0,0 +1,40 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.common + +import docspell.common.syntax.all._ + +import io.circe._ +import io.circe.generic.semiauto._ + +/** Arguments to the empty-trash task. + * + * This task is run periodically to really delete all soft-deleted + * items. These are items with state `ItemState.Deleted`. + */ +case class EmptyTrashArgs( + collective: Ident +) { + + def makeSubject: String = + "Empty trash " + +} + +object EmptyTrashArgs { + + val taskName = Ident.unsafe("empty-trash") + + implicit val jsonEncoder: Encoder[EmptyTrashArgs] = + deriveEncoder[EmptyTrashArgs] + implicit val jsonDecoder: Decoder[EmptyTrashArgs] = + deriveDecoder[EmptyTrashArgs] + + def parse(str: String): Either[Throwable, EmptyTrashArgs] = + str.parseJsonAs[EmptyTrashArgs] + +} diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 2256ae44..7c43a369 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -1136,6 +1136,27 @@ paths: schema: $ref: "#/components/schemas/BasicResult" + /sec/collective/emptytrash/startonce: + post: + operationId: "sec-collective-emptytrash-start-now" + tags: [ Collective ] + summary: Starts the empty trash task + description: | + Submits a task to remove all items from the database that have + been "soft-deleted". This task is also run periodically and + can be triggered here to be immediatly submitted. + + The request is empty, settings are used from the collective. + security: + - authTokenHeader: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + /sec/user: get: operationId: "sec-user-get-all" @@ -5246,6 +5267,7 @@ components: - language - integrationEnabled - classifier + - emptyTrashSchedule properties: language: type: string @@ -5255,6 +5277,9 @@ components: description: | Whether the collective has the integration endpoint enabled. + emptyTrashSchedule: + type: string + format: calevent classifier: $ref: "#/components/schemas/ClassifierSetting" diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/CollectiveRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/CollectiveRoutes.scala index 3cbb27cd..bc7c3ef0 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/CollectiveRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/CollectiveRoutes.scala @@ -55,7 +55,8 @@ object CollectiveRoutes { settings.classifier.categoryList, settings.classifier.listType ) - ) + ), + Some(settings.emptyTrashSchedule) ) res <- backend.collective @@ -70,6 +71,7 @@ object CollectiveRoutes { CollectiveSettings( c.language, c.integrationEnabled, + c.emptyTrash.getOrElse(CalEvent.unsafe("*-*-1/7 03:00:00")), ClassifierSetting( c.classifier.map(_.itemCount).getOrElse(0), c.classifier @@ -101,6 +103,12 @@ object CollectiveRoutes { resp <- Ok(BasicResult(true, "Task submitted")) } yield resp + case POST -> Root / "emptytrash" / "startonce" => + for { + _ <- backend.collective.startEmptyTrash(user.account.collective) + resp <- Ok(BasicResult(true, "Task submitted")) + } yield resp + case GET -> Root => for { collDb <- backend.collective.find(user.account.collective) diff --git a/modules/store/src/main/resources/db/migration/h2/V1.25__add_empty_trash.sql b/modules/store/src/main/resources/db/migration/h2/V1.25__add_empty_trash.sql new file mode 100644 index 00000000..45650e03 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/h2/V1.25__add_empty_trash.sql @@ -0,0 +1,6 @@ +CREATE TABLE "empty_trash_setting" ( + "cid" varchar(254) not null primary key, + "schedule" varchar(254) not null, + "created" timestamp not null, + foreign key ("cid") references "collective"("cid") +); diff --git a/modules/store/src/main/resources/db/migration/mariadb/V1.25.0__add_empty_trash.sql b/modules/store/src/main/resources/db/migration/mariadb/V1.25.0__add_empty_trash.sql new file mode 100644 index 00000000..d845e617 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/mariadb/V1.25.0__add_empty_trash.sql @@ -0,0 +1,6 @@ +CREATE TABLE `empty_trash_setting` ( + `cid` varchar(254) not null primary key, + `schedule` varchar(254) not null, + `created` timestamp not null, + foreign key (`cid`) references `collective`(`cid`) +); diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.25.0__add_empty_trash.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.25.0__add_empty_trash.sql new file mode 100644 index 00000000..45650e03 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/postgresql/V1.25.0__add_empty_trash.sql @@ -0,0 +1,6 @@ +CREATE TABLE "empty_trash_setting" ( + "cid" varchar(254) not null primary key, + "schedule" varchar(254) not null, + "created" timestamp not null, + foreign key ("cid") references "collective"("cid") +); diff --git a/modules/store/src/main/scala/docspell/store/records/RCollective.scala b/modules/store/src/main/scala/docspell/store/records/RCollective.scala index bac88ccd..c3326b3d 100644 --- a/modules/store/src/main/scala/docspell/store/records/RCollective.scala +++ b/modules/store/src/main/scala/docspell/store/records/RCollective.scala @@ -13,6 +13,7 @@ import docspell.common._ import docspell.store.qb.DSL._ import docspell.store.qb._ +import com.github.eikek.calev._ import doobie._ import doobie.implicits._ @@ -73,17 +74,21 @@ object RCollective { T.integration.setTo(settings.integrationEnabled) ) ) - cls <- - Timestamp - .current[ConnectionIO] - .map(now => settings.classifier.map(_.toRecord(cid, now))) + now <- Timestamp.current[ConnectionIO] + cls = settings.classifier.map(_.toRecord(cid, now)) n2 <- cls match { case Some(cr) => RClassifierSetting.update(cr) case None => RClassifierSetting.delete(cid) } - } yield n1 + n2 + n3 <- settings.emptyTrash match { + case Some(trashSchedule) => + REmptyTrashSetting.update(REmptyTrashSetting(cid, trashSchedule, now)) + case None => + REmptyTrashSetting.delete(cid) + } + } yield n1 + n2 + n3 // this hides categories that have been deleted in the meantime // they are finally removed from the json array once the learn classifier task is run @@ -99,6 +104,7 @@ object RCollective { import RClassifierSetting.stringListMeta val c = RCollective.as("c") val cs = RClassifierSetting.as("cs") + val es = REmptyTrashSetting.as("es") Select( select( @@ -107,9 +113,10 @@ object RCollective { cs.schedule.s, cs.itemCount.s, cs.categories.s, - cs.listType.s + cs.listType.s, + es.schedule.s ), - from(c).leftJoin(cs, cs.cid === c.id), + from(c).leftJoin(cs, cs.cid === c.id).leftJoin(es, es.cid === c.id), c.id === coll ).build.query[Settings].option } @@ -160,7 +167,8 @@ object RCollective { case class Settings( language: Language, integrationEnabled: Boolean, - classifier: Option[RClassifierSetting.Classifier] + classifier: Option[RClassifierSetting.Classifier], + emptyTrash: Option[CalEvent] ) } diff --git a/modules/store/src/main/scala/docspell/store/records/REmptyTrashSetting.scala b/modules/store/src/main/scala/docspell/store/records/REmptyTrashSetting.scala new file mode 100644 index 00000000..f4df6900 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/REmptyTrashSetting.scala @@ -0,0 +1,68 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.store.records + +import cats.data.NonEmptyList +import cats.implicits._ + +import docspell.common._ +import docspell.store.qb.DSL._ +import docspell.store.qb._ + +import com.github.eikek.calev._ +import doobie._ +import doobie.implicits._ + +final case class REmptyTrashSetting( + cid: Ident, + schedule: CalEvent, + created: Timestamp +) + +object REmptyTrashSetting { + + final case class Table(alias: Option[String]) extends TableDef { + val tableName = "empty_trash_setting" + + val cid = Column[Ident]("cid", this) + val schedule = Column[CalEvent]("schedule", this) + val created = Column[Timestamp]("created", this) + val all = NonEmptyList.of[Column[_]](cid, schedule, created) + } + + val T = Table(None) + def as(alias: String): Table = + Table(Some(alias)) + + def insert(v: REmptyTrashSetting): ConnectionIO[Int] = + DML.insert( + T, + T.all, + fr"${v.cid},${v.schedule},${v.created}" + ) + + def update(v: REmptyTrashSetting): ConnectionIO[Int] = + for { + n1 <- DML.update( + T, + T.cid === v.cid, + DML.set( + T.schedule.setTo(v.schedule) + ) + ) + n2 <- if (n1 <= 0) insert(v) else 0.pure[ConnectionIO] + } yield n1 + n2 + + def findById(id: Ident): ConnectionIO[Option[REmptyTrashSetting]] = { + val sql = run(select(T.all), from(T), T.cid === id) + sql.query[REmptyTrashSetting].option + } + + def delete(coll: Ident): ConnectionIO[Int] = + DML.delete(T, T.cid === coll) + +} diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index 1ea82fbe..1f26e605 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -130,6 +130,7 @@ module Api exposing , setTagsMultiple , setUnconfirmed , startClassifier + , startEmptyTrash , startOnceNotifyDueItems , startOnceScanMailbox , startReIndex @@ -996,6 +997,19 @@ startClassifier flags receive = } +startEmptyTrash : + Flags + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +startEmptyTrash flags receive = + Http2.authPost + { url = flags.config.baseUrl ++ "/api/v1/sec/collective/emptytrash/startonce" + , account = getAccount flags + , body = Http.emptyBody + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + getTagCloud : Flags -> (Result Http.Error TagCloud -> msg) -> Cmd msg getTagCloud flags receive = Http2.authGet diff --git a/modules/webapp/src/main/elm/Comp/CollectiveSettingsForm.elm b/modules/webapp/src/main/elm/Comp/CollectiveSettingsForm.elm index 4d24d817..91da0d84 100644 --- a/modules/webapp/src/main/elm/Comp/CollectiveSettingsForm.elm +++ b/modules/webapp/src/main/elm/Comp/CollectiveSettingsForm.elm @@ -20,7 +20,9 @@ import Api.Model.CollectiveSettings exposing (CollectiveSettings) import Comp.Basic as B import Comp.ClassifierSettingsForm import Comp.Dropdown +import Comp.EmptyTrashForm import Comp.MenuBar as MB +import Data.CalEvent import Data.DropdownStyle as DS import Data.Flags exposing (Flags) import Data.Language exposing (Language) @@ -41,6 +43,8 @@ type alias Model = , fullTextReIndexResult : FulltextReindexResult , classifierModel : Comp.ClassifierSettingsForm.Model , startClassifierResult : ClassifierResult + , emptyTrashModel : Comp.EmptyTrashForm.Model + , startEmptyTrashResult : EmptyTrashResult } @@ -50,6 +54,11 @@ type ClassifierResult | ClassifierResultSubmitError String | ClassifierResultOk +type EmptyTrashResult + = EmptyTrashResultInitial + | EmptyTrashResultHttpError Http.Error + | EmptyTrashResultSubmitError String + | EmptyTrashResultOk type FulltextReindexResult = FulltextReindexInitial @@ -68,6 +77,9 @@ init flags settings = ( cm, cc ) = Comp.ClassifierSettingsForm.init flags settings.classifier + + ( em, ec ) = + Comp.EmptyTrashForm.init flags settings.emptyTrashSchedule in ( { langModel = Comp.Dropdown.makeSingleList @@ -80,8 +92,10 @@ init flags settings = , fullTextReIndexResult = FulltextReindexInitial , classifierModel = cm , startClassifierResult = ClassifierResultInitial + , emptyTrashModel = em + , startEmptyTrashResult = EmptyTrashResultInitial } - , Cmd.map ClassifierSettingMsg cc + , Cmd.batch [ Cmd.map ClassifierSettingMsg cc, Cmd.map EmptyTrashMsg ec ] ) @@ -96,6 +110,10 @@ getSettings model = |> Maybe.withDefault model.initSettings.language , integrationEnabled = model.intEnabled , classifier = cls + , emptyTrashSchedule = + Comp.EmptyTrashForm.getSettings model.emptyTrashModel + |> Maybe.withDefault Data.CalEvent.everyMonth + |> Data.CalEvent.makeEvent } ) (Comp.ClassifierSettingsForm.getSettings @@ -110,9 +128,12 @@ type Msg | TriggerReIndex | TriggerReIndexResult (Result Http.Error BasicResult) | ClassifierSettingMsg Comp.ClassifierSettingsForm.Msg + | EmptyTrashMsg Comp.EmptyTrashForm.Msg | SaveSettings | StartClassifierTask + | StartEmptyTrashTask | StartClassifierResp (Result Http.Error BasicResult) + | StartEmptyTrashResp (Result Http.Error BasicResult) update : Flags -> Msg -> Model -> ( Model, Cmd Msg, Maybe CollectiveSettings ) @@ -188,6 +209,18 @@ update flags msg model = , Nothing ) + EmptyTrashMsg lmsg -> + let + ( cm, cc ) = + Comp.EmptyTrashForm.update flags lmsg model.emptyTrashModel + in + ( { model + | emptyTrashModel = cm + } + , Cmd.map EmptyTrashMsg cc + , Nothing + ) + SaveSettings -> case getSettings model of Just s -> @@ -199,6 +232,10 @@ update flags msg model = StartClassifierTask -> ( model, Api.startClassifier flags StartClassifierResp, Nothing ) + StartEmptyTrashTask -> + ( model, Api.startEmptyTrash flags StartEmptyTrashResp, Nothing ) + + StartClassifierResp (Ok br) -> ( { model | startClassifierResult = @@ -218,6 +255,24 @@ update flags msg model = , Nothing ) + StartEmptyTrashResp (Ok br) -> + ( { model + | startEmptyTrashResult = + if br.success then + EmptyTrashResultOk + + else + EmptyTrashResultSubmitError br.message + } + , Cmd.none + , Nothing + ) + + StartEmptyTrashResp (Err err) -> + ( { model | startEmptyTrashResult = EmptyTrashResultHttpError err } + , Cmd.none + , Nothing + ) --- View2 @@ -257,7 +312,7 @@ view2 flags texts settings model = , end = [] , rootClasses = "mb-4" } - , h3 [ class S.header3 ] + , h2 [ class S.header2 ] [ text texts.documentLanguage ] , div [ class "mb-4" ] @@ -279,8 +334,8 @@ view2 flags texts settings model = [ ( "hidden", not flags.config.integrationEnabled ) ] ] - [ h3 - [ class S.header3 + [ h2 + [ class S.header2 ] [ text texts.integrationEndpoint ] @@ -311,8 +366,8 @@ view2 flags texts settings model = [ ( "hidden", not flags.config.fullTextSearchEnabled ) ] ] - [ h3 - [ class S.header3 ] + [ h2 + [ class S.header2 ] [ text texts.fulltextSearch ] , div [ class "mb-4" ] @@ -348,8 +403,8 @@ view2 flags texts settings model = [ ( " hidden", not flags.config.showClassificationSettings ) ] ] - [ h3 - [ class S.header3 ] + [ h2 + [ class S.header2 ] [ text texts.autoTagging ] , div @@ -371,6 +426,28 @@ view2 flags texts settings model = ] ] ] + , div [] + [ h2 [ class S.header2 ] + [ text texts.emptyTrash + ] + , div [ class "mb-4" ] + [ Html.map EmptyTrashMsg + (Comp.EmptyTrashForm.view texts.emptyTrashForm + settings + model.emptyTrashModel + ) + , div [ class "flex flex-row justify-end" ] + [ B.secondaryBasicButton + { handler = onClick StartEmptyTrashTask + , icon = "fa fa-play" + , label = texts.startNow + , disabled = model.emptyTrashModel.schedule == Nothing + , attrs = [ href "#" ] + } + , renderEmptyTrashResultMessage texts model.startEmptyTrashResult + ] + ] + ] ] @@ -427,3 +504,38 @@ renderFulltextReindexResultMessage texts result = FulltextReindexSubmitError m -> text m + +renderEmptyTrashResultMessage : Texts -> EmptyTrashResult -> Html msg +renderEmptyTrashResultMessage texts result = + let + isSuccess = + case result of + EmptyTrashResultOk -> + True + + _ -> + False + + isError = + not isSuccess + in + div + [ classList + [ ( S.errorMessage, isError ) + , ( S.successMessage, isSuccess ) + , ( "hidden", result == EmptyTrashResultInitial ) + ] + ] + [ case result of + EmptyTrashResultInitial -> + text "" + + EmptyTrashResultOk -> + text texts.emptyTrashTaskStarted + + EmptyTrashResultHttpError err -> + text (texts.httpError err) + + EmptyTrashResultSubmitError m -> + text m + ] diff --git a/modules/webapp/src/main/elm/Comp/EmptyTrashForm.elm b/modules/webapp/src/main/elm/Comp/EmptyTrashForm.elm new file mode 100644 index 00000000..86cfd1ab --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/EmptyTrashForm.elm @@ -0,0 +1,106 @@ +{- + Copyright 2020 Docspell Contributors + + SPDX-License-Identifier: GPL-3.0-or-later +-} + + +module Comp.EmptyTrashForm exposing + ( Model + , Msg + , getSettings + , init + , update + , view + ) + +import Api +import Comp.CalEventInput +import Comp.Dropdown +import Comp.FixedDropdown +import Comp.IntField +import Data.CalEvent exposing (CalEvent) +import Data.DropdownStyle as DS +import Data.Flags exposing (Flags) +import Data.ListType exposing (ListType) +import Data.UiSettings exposing (UiSettings) +import Html exposing (..) +import Html.Attributes exposing (..) +import Http +import Markdown +import Messages.Comp.EmptyTrashForm exposing (Texts) +import Styles as S +import Util.Tag + + +type alias Model = + { scheduleModel : Comp.CalEventInput.Model + , schedule : Maybe CalEvent + } + + +type Msg + = ScheduleMsg Comp.CalEventInput.Msg + + +init : Flags -> String -> ( Model, Cmd Msg ) +init flags schedule = + let + newSchedule = + Data.CalEvent.fromEvent schedule + |> Maybe.withDefault Data.CalEvent.everyMonth + + ( cem, cec ) = + Comp.CalEventInput.init flags newSchedule + in + ( { scheduleModel = cem + , schedule = Just newSchedule + } + , Cmd.map ScheduleMsg cec + ) + + +getSettings : Model -> Maybe CalEvent +getSettings model = + model.schedule + + +update : Flags -> Msg -> Model -> ( Model, Cmd Msg ) +update flags msg model = + case msg of + ScheduleMsg lmsg -> + let + ( cm, cc, ce ) = + Comp.CalEventInput.update + flags + model.schedule + lmsg + model.scheduleModel + in + ( { model + | scheduleModel = cm + , schedule = ce + } + , Cmd.map ScheduleMsg cc + ) + + + +--- View2 + + +view : Texts -> UiSettings -> Model -> Html Msg +view texts _ model = + div [] + [ div [ class "mb-4" ] + [ label [ class S.inputLabel ] + [ text texts.schedule ] + , Html.map ScheduleMsg + (Comp.CalEventInput.view2 + texts.calEventInput + "" + model.schedule + model.scheduleModel + ) + ] + ] diff --git a/modules/webapp/src/main/elm/Messages/Comp/CollectiveSettingsForm.elm b/modules/webapp/src/main/elm/Messages/Comp/CollectiveSettingsForm.elm index d4bd8274..f86f1d94 100644 --- a/modules/webapp/src/main/elm/Messages/Comp/CollectiveSettingsForm.elm +++ b/modules/webapp/src/main/elm/Messages/Comp/CollectiveSettingsForm.elm @@ -15,6 +15,7 @@ import Data.Language exposing (Language) import Http import Messages.Basics import Messages.Comp.ClassifierSettingsForm +import Messages.Comp.EmptyTrashForm import Messages.Comp.HttpError import Messages.Data.Language @@ -22,6 +23,7 @@ import Messages.Data.Language type alias Texts = { basics : Messages.Basics.Texts , classifierSettingsForm : Messages.Comp.ClassifierSettingsForm.Texts + , emptyTrashForm : Messages.Comp.EmptyTrashForm.Texts , httpError : Http.Error -> String , save : String , saveSettings : String @@ -37,8 +39,10 @@ type alias Texts = , startNow : String , languageLabel : Language -> String , classifierTaskStarted : String + , emptyTrashTaskStarted : String , fulltextReindexSubmitted : String , fulltextReindexOkMissing : String + , emptyTrash : String } @@ -46,6 +50,7 @@ gb : Texts gb = { basics = Messages.Basics.gb , classifierSettingsForm = Messages.Comp.ClassifierSettingsForm.gb + , emptyTrashForm = Messages.Comp.EmptyTrashForm.gb , httpError = Messages.Comp.HttpError.gb , save = "Save" , saveSettings = "Save Settings" @@ -65,9 +70,11 @@ gb = , startNow = "Start now" , languageLabel = Messages.Data.Language.gb , classifierTaskStarted = "Classifier task started." + , emptyTrashTaskStarted = "Empty trash task started." , fulltextReindexSubmitted = "Fulltext Re-Index started." , fulltextReindexOkMissing = "Please type OK in the field if you really want to start re-indexing your data." + , emptyTrash = "Empty Trash" } @@ -75,6 +82,7 @@ de : Texts de = { basics = Messages.Basics.de , classifierSettingsForm = Messages.Comp.ClassifierSettingsForm.de + , emptyTrashForm = Messages.Comp.EmptyTrashForm.de , httpError = Messages.Comp.HttpError.de , save = "Speichern" , saveSettings = "Einstellungen speichern" @@ -94,7 +102,9 @@ de = , startNow = "Jetzt starten" , languageLabel = Messages.Data.Language.de , classifierTaskStarted = "Kategorisierung gestartet." + , emptyTrashTaskStarted = "Papierkorb löschen gestartet." , fulltextReindexSubmitted = "Volltext Neu-Indexierung gestartet." , fulltextReindexOkMissing = "Bitte tippe OK in das Feld ein, wenn Du wirklich den Index neu erzeugen möchtest." + , emptyTrash = "Papierkorb löschen" } diff --git a/modules/webapp/src/main/elm/Messages/Comp/EmptyTrashForm.elm b/modules/webapp/src/main/elm/Messages/Comp/EmptyTrashForm.elm new file mode 100644 index 00000000..872da608 --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Comp/EmptyTrashForm.elm @@ -0,0 +1,38 @@ +{- + Copyright 2020 Docspell Contributors + + SPDX-License-Identifier: GPL-3.0-or-later +-} + + +module Messages.Comp.EmptyTrashForm exposing + ( Texts + , de + , gb + ) + +import Messages.Basics +import Messages.Comp.CalEventInput + + +type alias Texts = + { basics : Messages.Basics.Texts + , calEventInput : Messages.Comp.CalEventInput.Texts + , schedule : String + } + + +gb : Texts +gb = + { basics = Messages.Basics.gb + , calEventInput = Messages.Comp.CalEventInput.gb + , schedule = "Schedule" + } + + +de : Texts +de = + { basics = Messages.Basics.de + , calEventInput = Messages.Comp.CalEventInput.de + , schedule = "Zeitplan" + } From 50706c3d6dfbca45c5d94b239ce71088adf2ec55 Mon Sep 17 00:00:00 2001 From: eikek Date: Sat, 14 Aug 2021 19:31:36 +0200 Subject: [PATCH 10/15] Add a task implementation to delete items --- .../docspell/backend/ops/OItemSearch.scala | 9 +++ .../scala/docspell/joex/JoexAppImpl.scala | 29 +++++--- .../joex/emptytrash/EmptyTrashTask.scala | 68 +++++++++++++++++++ .../scala/docspell/store/records/RItem.scala | 6 ++ .../store/usertask/UserTaskStore.scala | 4 +- 5 files changed, 104 insertions(+), 12 deletions(-) create mode 100644 modules/joex/src/main/scala/docspell/joex/emptytrash/EmptyTrashTask.scala diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala b/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala index 3abc6771..cb469880 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala @@ -23,6 +23,8 @@ import doobie.implicits._ trait OItemSearch[F[_]] { def findItem(id: Ident, collective: Ident): F[Option[ItemData]] + def findDeleted(collective: Ident, limit: Int): F[Vector[RItem]] + def findItems(maxNoteLen: Int)(q: Query, batch: Batch): F[Vector[ListItem]] /** Same as `findItems` but does more queries per item to find all tags. */ @@ -145,6 +147,13 @@ object OItemSearch { .toVector } + def findDeleted(collective: Ident, limit: Int): F[Vector[RItem]] = + store + .transact(RItem.findDeleted(collective, limit)) + .take(limit.toLong) + .compile + .toVector + def findItemsWithTags( maxNoteLen: Int )(q: Query, batch: Batch): F[Vector[ListItemWithTags]] = diff --git a/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala b/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala index 1d7f3419..cd2a114c 100644 --- a/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala +++ b/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala @@ -18,6 +18,7 @@ import docspell.common._ import docspell.ftsclient.FtsClient import docspell.ftssolr.SolrFtsClient import docspell.joex.analysis.RegexNerFile +import docspell.joex.emptytrash._ import docspell.joex.fts.{MigrationTask, ReIndexTask} import docspell.joex.hk._ import docspell.joex.learn.LearnClassifierTask @@ -94,16 +95,17 @@ object JoexAppImpl { for { httpClient <- BlazeClientBuilder[F](clientEC).resource client = JoexClient(httpClient) - store <- Store.create(cfg.jdbc, connectEC) - queue <- JobQueue(store) - pstore <- PeriodicTaskStore.create(store) - nodeOps <- ONode(store) - joex <- OJoex(client, store) - upload <- OUpload(store, queue, cfg.files, joex) - fts <- createFtsClient(cfg)(httpClient) - itemOps <- OItem(store, fts, queue, joex) - analyser <- TextAnalyser.create[F](cfg.textAnalysis.textAnalysisConfig) - regexNer <- RegexNerFile(cfg.textAnalysis.regexNerFileConfig, store) + store <- Store.create(cfg.jdbc, connectEC) + queue <- JobQueue(store) + pstore <- PeriodicTaskStore.create(store) + nodeOps <- ONode(store) + joex <- OJoex(client, store) + upload <- OUpload(store, queue, cfg.files, joex) + fts <- createFtsClient(cfg)(httpClient) + itemOps <- OItem(store, fts, queue, joex) + itemSearchOps <- OItemSearch(store) + analyser <- TextAnalyser.create[F](cfg.textAnalysis.textAnalysisConfig) + regexNer <- RegexNerFile(cfg.textAnalysis.regexNerFileConfig, store) javaEmil = JavaMailEmil(Settings.defaultSettings.copy(debug = cfg.mailDebug)) sch <- SchedulerBuilder(cfg.scheduler, store) @@ -206,6 +208,13 @@ object JoexAppImpl { AllPageCountTask.onCancel[F] ) ) + .withTask( + JobTask.json( + EmptyTrashArgs.taskName, + EmptyTrashTask[F](itemOps, itemSearchOps), + EmptyTrashTask.onCancel[F] + ) + ) .resource psch <- PeriodicScheduler.create( cfg.periodicScheduler, diff --git a/modules/joex/src/main/scala/docspell/joex/emptytrash/EmptyTrashTask.scala b/modules/joex/src/main/scala/docspell/joex/emptytrash/EmptyTrashTask.scala new file mode 100644 index 00000000..12173cb2 --- /dev/null +++ b/modules/joex/src/main/scala/docspell/joex/emptytrash/EmptyTrashTask.scala @@ -0,0 +1,68 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.joex.emptytrash + +import cats.effect._ +import cats.implicits._ +import fs2.Stream + +import docspell.backend.ops.{OItem, OItemSearch} +import docspell.common._ +import docspell.joex.scheduler._ +import docspell.store.records.RItem + +object EmptyTrashTask { + + type Args = EmptyTrashArgs + + def onCancel[F[_]]: Task[F, Args, Unit] = + Task.log(_.warn("Cancelling empty-trash task")) + + private val pageSize = 20 + + def apply[F[_]: Async]( + itemOps: OItem[F], + itemSearchOps: OItemSearch[F] + ): Task[F, Args, Unit] = + Task { ctx => + val collId = ctx.args.collective + for { + _ <- ctx.logger.info(s"Starting removing all soft-deleted items") + nDeleted <- deleteAll(collId, itemOps, itemSearchOps, ctx) + _ <- ctx.logger.info(s"Finished deleting ${nDeleted} items") + } yield () + } + + private def deleteAll[F[_]: Async]( + collective: Ident, + itemOps: OItem[F], + itemSearchOps: OItemSearch[F], + ctx: Context[F, _] + ): F[Int] = + Stream + .eval(itemSearchOps.findDeleted(collective, pageSize)) + .evalMap(deleteChunk(collective, itemOps, ctx)) + .repeat + .takeWhile(_ > 0) + .compile + .foldMonoid + + private def deleteChunk[F[_]: Async]( + collective: Ident, + itemOps: OItem[F], + ctx: Context[F, _] + )(chunk: Vector[RItem]): F[Int] = + if (chunk.isEmpty) { + 0.pure[F] + } else { + ctx.logger.info(s"Deleting next ${chunk.size} items …") *> + chunk.traverse(i => + ctx.logger.debug(s"Delete item ${i.id.id} / ${i.name} now") *> + itemOps.deleteItem(i.id, collective) + ) *> chunk.size.pure[F] + } +} 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 78eb5416..0fcdc7e9 100644 --- a/modules/store/src/main/scala/docspell/store/records/RItem.scala +++ b/modules/store/src/main/scala/docspell/store/records/RItem.scala @@ -9,6 +9,7 @@ package docspell.store.records import cats.data.NonEmptyList import cats.effect.Sync import cats.implicits._ +import fs2.Stream import docspell.common._ import docspell.store.qb.DSL._ @@ -388,6 +389,11 @@ object RItem { def findById(itemId: Ident): ConnectionIO[Option[RItem]] = run(select(T.all), from(T), T.id === itemId).query[RItem].option + def findDeleted(collective: Ident, chunkSize: Int): Stream[ConnectionIO, RItem] = + run(select(T.all), from(T), T.cid === collective && T.state === ItemState.deleted) + .query[RItem] + .streamWithChunkSize(chunkSize) + def checkByIdAndCollective(itemId: Ident, coll: Ident): ConnectionIO[Option[Ident]] = Select(T.id.s, from(T), T.id === itemId && T.cid === coll).build.query[Ident].option diff --git a/modules/store/src/main/scala/docspell/store/usertask/UserTaskStore.scala b/modules/store/src/main/scala/docspell/store/usertask/UserTaskStore.scala index 914e1357..6caab8f0 100644 --- a/modules/store/src/main/scala/docspell/store/usertask/UserTaskStore.scala +++ b/modules/store/src/main/scala/docspell/store/usertask/UserTaskStore.scala @@ -83,10 +83,10 @@ trait UserTaskStore[F[_]] { * * Unlike `updateTask`, this ensures that there is at most one task * of some name in the db. Multiple same tasks (task with same - * name) may not be allowed to run, dependening on what they do. + * name) may not be allowed to run, depending on what they do. * This is not ensured by the database, though. * - * If there are currently mutliple tasks with same name as `ut` for + * If there are currently multiple tasks with same name as `ut` for * the user `account`, they will all be removed and the given task * inserted! */ From 27fd7a586725afcb4ff1da8d07790a44807042ed Mon Sep 17 00:00:00 2001 From: eikek Date: Sat, 14 Aug 2021 20:10:42 +0200 Subject: [PATCH 11/15] Make sure the empty-trash task is started for all collectives --- .../docspell/backend/ops/OCollective.scala | 4 ++-- .../docspell/common/EmptyTrashArgs.scala | 6 ++++-- .../scala/docspell/joex/JoexAppImpl.scala | 17 +++++++++++++---- .../joex/emptytrash/EmptyTrashTask.scala | 19 ++++++++++++++++--- .../restserver/routes/CollectiveRoutes.scala | 6 ++---- .../store/records/REmptyTrashSetting.scala | 18 ++++++++++++++++++ 6 files changed, 55 insertions(+), 15 deletions(-) diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala index 14377f35..db217e0c 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala @@ -185,7 +185,7 @@ object OCollective { None, EmptyTrashArgs(coll) ) - _ <- uts.updateOneTask(AccountId(coll, EmptyTrashArgs.taskName), ut) + _ <- uts.updateOneTask(AccountId(coll, coll), ut) _ <- joex.notifyAllNodes } yield () @@ -215,7 +215,7 @@ object OCollective { CalEvent(WeekdayComponent.All, DateEvent.All, TimeEvent.All), None, EmptyTrashArgs(collective) - ).encode.toPeriodicTask(AccountId(collective, EmptyTrashArgs.taskName)) + ).encode.toPeriodicTask(AccountId(collective, collective)) job <- ut.toJob _ <- queue.insert(job) _ <- joex.notifyAllNodes diff --git a/modules/common/src/main/scala/docspell/common/EmptyTrashArgs.scala b/modules/common/src/main/scala/docspell/common/EmptyTrashArgs.scala index 2c85fddf..9ce432bf 100644 --- a/modules/common/src/main/scala/docspell/common/EmptyTrashArgs.scala +++ b/modules/common/src/main/scala/docspell/common/EmptyTrashArgs.scala @@ -6,8 +6,8 @@ package docspell.common +import com.github.eikek.calev.CalEvent import docspell.common.syntax.all._ - import io.circe._ import io.circe.generic.semiauto._ @@ -21,7 +21,7 @@ case class EmptyTrashArgs( ) { def makeSubject: String = - "Empty trash " + "Empty trash" } @@ -29,6 +29,8 @@ object EmptyTrashArgs { val taskName = Ident.unsafe("empty-trash") + val defaultSchedule = CalEvent.unsafe("*-*-1/7 03:00:00") + implicit val jsonEncoder: Encoder[EmptyTrashArgs] = deriveEncoder[EmptyTrashArgs] implicit val jsonDecoder: Decoder[EmptyTrashArgs] = diff --git a/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala b/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala index cd2a114c..ad7ec625 100644 --- a/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala +++ b/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala @@ -7,11 +7,9 @@ package docspell.joex import scala.concurrent.ExecutionContext - import cats.effect._ import cats.implicits._ import fs2.concurrent.SignallingRef - import docspell.analysis.TextAnalyser import docspell.backend.ops._ import docspell.common._ @@ -34,8 +32,7 @@ import docspell.joex.scheduler._ import docspell.joexapi.client.JoexClient import docspell.store.Store import docspell.store.queue._ -import docspell.store.records.RJobLog - +import docspell.store.records.{REmptyTrashSetting, RJobLog} import emil.javamail._ import org.http4s.blaze.client.BlazeClientBuilder import org.http4s.client.Client @@ -77,11 +74,23 @@ final class JoexAppImpl[F[_]: Async]( HouseKeepingTask .periodicTask[F](cfg.houseKeeping.schedule) .flatMap(pstore.insert) *> + scheduleEmptyTrashTasks *> MigrationTask.job.flatMap(queue.insertIfNew) *> AllPreviewsTask .job(MakePreviewArgs.StoreMode.WhenMissing, None) .flatMap(queue.insertIfNew) *> AllPageCountTask.job.flatMap(queue.insertIfNew) + + private def scheduleEmptyTrashTasks: F[Unit] = + store + .transact( + REmptyTrashSetting.findForAllCollectives(EmptyTrashArgs.defaultSchedule, 50) + ) + .evalMap(es => EmptyTrashTask.periodicTask(es.cid, es.schedule)) + .evalMap(pstore.insert) + .compile + .drain + } object JoexAppImpl { diff --git a/modules/joex/src/main/scala/docspell/joex/emptytrash/EmptyTrashTask.scala b/modules/joex/src/main/scala/docspell/joex/emptytrash/EmptyTrashTask.scala index 12173cb2..fda6fb98 100644 --- a/modules/joex/src/main/scala/docspell/joex/emptytrash/EmptyTrashTask.scala +++ b/modules/joex/src/main/scala/docspell/joex/emptytrash/EmptyTrashTask.scala @@ -8,15 +8,15 @@ package docspell.joex.emptytrash import cats.effect._ import cats.implicits._ +import com.github.eikek.calev.CalEvent import fs2.Stream - import docspell.backend.ops.{OItem, OItemSearch} import docspell.common._ import docspell.joex.scheduler._ -import docspell.store.records.RItem +import docspell.store.records.{RItem, RPeriodicTask} +import docspell.store.usertask.UserTask object EmptyTrashTask { - type Args = EmptyTrashArgs def onCancel[F[_]]: Task[F, Args, Unit] = @@ -24,6 +24,19 @@ object EmptyTrashTask { private val pageSize = 20 + def periodicTask[F[_]: Sync](collective: Ident, ce: CalEvent): F[RPeriodicTask] = { + Ident.randomId[F].flatMap( id => + UserTask( + id, + EmptyTrashArgs.taskName, + true, + ce, + None, + EmptyTrashArgs(collective) + ).encode.toPeriodicTask(AccountId(collective, collective))) + } + + def apply[F[_]: Async]( itemOps: OItem[F], itemSearchOps: OItemSearch[F] diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/CollectiveRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/CollectiveRoutes.scala index bc7c3ef0..71747f5c 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/CollectiveRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/CollectiveRoutes.scala @@ -8,15 +8,13 @@ package docspell.restserver.routes import cats.effect._ import cats.implicits._ - import docspell.backend.BackendApp import docspell.backend.auth.AuthToken import docspell.backend.ops.OCollective -import docspell.common.ListType +import docspell.common.{EmptyTrashArgs, ListType} import docspell.restapi.model._ import docspell.restserver.conv.Conversions import docspell.restserver.http4s._ - import com.github.eikek.calev.CalEvent import org.http4s.HttpRoutes import org.http4s.circe.CirceEntityDecoder._ @@ -71,7 +69,7 @@ object CollectiveRoutes { CollectiveSettings( c.language, c.integrationEnabled, - c.emptyTrash.getOrElse(CalEvent.unsafe("*-*-1/7 03:00:00")), + c.emptyTrash.getOrElse(EmptyTrashArgs.defaultSchedule), ClassifierSetting( c.classifier.map(_.itemCount).getOrElse(0), c.classifier diff --git a/modules/store/src/main/scala/docspell/store/records/REmptyTrashSetting.scala b/modules/store/src/main/scala/docspell/store/records/REmptyTrashSetting.scala index f4df6900..f08079e5 100644 --- a/modules/store/src/main/scala/docspell/store/records/REmptyTrashSetting.scala +++ b/modules/store/src/main/scala/docspell/store/records/REmptyTrashSetting.scala @@ -8,6 +8,7 @@ package docspell.store.records import cats.data.NonEmptyList import cats.implicits._ +import fs2.Stream import docspell.common._ import docspell.store.qb.DSL._ @@ -62,6 +63,23 @@ object REmptyTrashSetting { sql.query[REmptyTrashSetting].option } + def findForAllCollectives( + default: CalEvent, + chunkSize: Int + ): Stream[ConnectionIO, REmptyTrashSetting] = { + val c = RCollective.as("c") + val e = REmptyTrashSetting.as("e") + val sql = run( + select( + c.id.s, + coalesce(e.schedule.s, const(default)).s, + coalesce(e.created.s, c.created.s).s + ), + from(c).leftJoin(e, e.cid === c.id) + ) + sql.query[REmptyTrashSetting].streamWithChunkSize(chunkSize) + } + def delete(coll: Ident): ConnectionIO[Int] = DML.delete(T, T.cid === coll) From 548dfb9a572b681d7b442e39f24b18a24ae36b9c Mon Sep 17 00:00:00 2001 From: eikek Date: Sat, 14 Aug 2021 20:10:57 +0200 Subject: [PATCH 12/15] Fix openapi.yml where undefined query parameter --- modules/restapi/src/main/resources/docspell-openapi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 7c43a369..e02354d7 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -1598,7 +1598,7 @@ paths: - authTokenHeader: [] parameters: - $ref: "#/components/parameters/q" - - $ref: "#/components/parameters/deleted" + - $ref: "#/components/parameters/searchMode" responses: 200: description: Ok From 31d885ed7927a52c1333f06e9bfaa30e225a018d Mon Sep 17 00:00:00 2001 From: eikek Date: Sat, 14 Aug 2021 21:10:09 +0200 Subject: [PATCH 13/15] Refactor user tasks to support collective and user scopes Before, there were periodic tasks run per collective and not user by making sure that submitter + group are the same value. This is now encoded in `UserTaskScope` so it is now obvious and errors can be reduced when using this. --- .../docspell/backend/ops/OCollective.scala | 11 ++- .../docspell/backend/ops/OUserTask.scala | 52 +++++++------ .../docspell/common/EmptyTrashArgs.scala | 3 +- .../scala/docspell/joex/JoexAppImpl.scala | 3 + .../joex/emptytrash/EmptyTrashTask.scala | 31 ++++---- .../docspell/joex/hk/HouseKeepingTask.scala | 4 +- .../restserver/routes/CollectiveRoutes.scala | 2 + .../routes/NotifyDueItemsRoutes.scala | 12 +-- .../restserver/routes/ScanMailboxRoutes.scala | 12 +-- .../docspell/store/queries/QUserTask.scala | 10 +-- .../store/records/RPeriodicTask.scala | 14 ++-- .../docspell/store/usertask/UserTask.scala | 7 +- .../store/usertask/UserTaskScope.scala | 52 +++++++++++++ .../store/usertask/UserTaskStore.scala | 78 ++++++++++--------- 14 files changed, 177 insertions(+), 114 deletions(-) create mode 100644 modules/store/src/main/scala/docspell/store/usertask/UserTaskScope.scala diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala index db217e0c..3bd8436f 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala @@ -18,8 +18,7 @@ import docspell.store.UpdateResult import docspell.store.queries.QCollective import docspell.store.queue.JobQueue import docspell.store.records._ -import docspell.store.usertask.UserTask -import docspell.store.usertask.UserTaskStore +import docspell.store.usertask.{UserTask, UserTaskScope, UserTaskStore} import docspell.store.{AddResult, Store} import com.github.eikek.calev._ @@ -169,7 +168,7 @@ object OCollective { None, LearnClassifierArgs(coll) ) - _ <- uts.updateOneTask(AccountId(coll, LearnClassifierArgs.taskName), ut) + _ <- uts.updateOneTask(UserTaskScope(coll), ut) _ <- joex.notifyAllNodes } yield () @@ -185,7 +184,7 @@ object OCollective { None, EmptyTrashArgs(coll) ) - _ <- uts.updateOneTask(AccountId(coll, coll), ut) + _ <- uts.updateOneTask(UserTaskScope(coll), ut) _ <- joex.notifyAllNodes } yield () @@ -199,7 +198,7 @@ object OCollective { CalEvent(WeekdayComponent.All, DateEvent.All, TimeEvent.All), None, LearnClassifierArgs(collective) - ).encode.toPeriodicTask(AccountId(collective, LearnClassifierArgs.taskName)) + ).encode.toPeriodicTask(UserTaskScope(collective)) job <- ut.toJob _ <- queue.insert(job) _ <- joex.notifyAllNodes @@ -215,7 +214,7 @@ object OCollective { CalEvent(WeekdayComponent.All, DateEvent.All, TimeEvent.All), None, EmptyTrashArgs(collective) - ).encode.toPeriodicTask(AccountId(collective, collective)) + ).encode.toPeriodicTask(UserTaskScope(collective)) job <- ut.toJob _ <- queue.insert(job) _ <- joex.notifyAllNodes diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OUserTask.scala b/modules/backend/src/main/scala/docspell/backend/ops/OUserTask.scala index c7583c04..25db06ae 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OUserTask.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OUserTask.scala @@ -21,47 +21,47 @@ trait OUserTask[F[_]] { /** Return the settings for all scan-mailbox tasks of the current user. */ - def getScanMailbox(account: AccountId): Stream[F, UserTask[ScanMailboxArgs]] + def getScanMailbox(scope: UserTaskScope): Stream[F, UserTask[ScanMailboxArgs]] /** Find a scan-mailbox task by the given id. */ def findScanMailbox( id: Ident, - account: AccountId + scope: UserTaskScope ): OptionT[F, UserTask[ScanMailboxArgs]] /** Updates the scan-mailbox tasks and notifies the joex nodes. */ def submitScanMailbox( - account: AccountId, + scope: UserTaskScope, task: UserTask[ScanMailboxArgs] ): F[Unit] /** Return the settings for all the notify-due-items task of the * current user. */ - def getNotifyDueItems(account: AccountId): Stream[F, UserTask[NotifyDueItemsArgs]] + def getNotifyDueItems(scope: UserTaskScope): Stream[F, UserTask[NotifyDueItemsArgs]] /** Find a notify-due-items task by the given id. */ def findNotifyDueItems( id: Ident, - account: AccountId + scope: UserTaskScope ): OptionT[F, UserTask[NotifyDueItemsArgs]] /** Updates the notify-due-items tasks and notifies the joex nodes. */ def submitNotifyDueItems( - account: AccountId, + scope: UserTaskScope, task: UserTask[NotifyDueItemsArgs] ): F[Unit] /** Removes a user task with the given id. */ - def deleteTask(account: AccountId, id: Ident): F[Unit] + def deleteTask(scope: UserTaskScope, id: Ident): F[Unit] /** Discards the schedule and immediately submits the task to the job * executor's queue. It will not update the corresponding periodic * task. */ - def executeNow[A](account: AccountId, task: UserTask[A])(implicit + def executeNow[A](scope: UserTaskScope, task: UserTask[A])(implicit E: Encoder[A] ): F[Unit] } @@ -75,57 +75,59 @@ object OUserTask { ): Resource[F, OUserTask[F]] = Resource.pure[F, OUserTask[F]](new OUserTask[F] { - def executeNow[A](account: AccountId, task: UserTask[A])(implicit + def executeNow[A](scope: UserTaskScope, task: UserTask[A])(implicit E: Encoder[A] ): F[Unit] = for { - ptask <- task.encode.toPeriodicTask(account) + ptask <- task.encode.toPeriodicTask(scope) job <- ptask.toJob _ <- queue.insert(job) _ <- joex.notifyAllNodes } yield () - def getScanMailbox(account: AccountId): Stream[F, UserTask[ScanMailboxArgs]] = + def getScanMailbox(scope: UserTaskScope): Stream[F, UserTask[ScanMailboxArgs]] = store - .getByName[ScanMailboxArgs](account, ScanMailboxArgs.taskName) + .getByName[ScanMailboxArgs](scope, ScanMailboxArgs.taskName) def findScanMailbox( id: Ident, - account: AccountId + scope: UserTaskScope ): OptionT[F, UserTask[ScanMailboxArgs]] = - OptionT(getScanMailbox(account).find(_.id == id).compile.last) + OptionT(getScanMailbox(scope).find(_.id == id).compile.last) - def deleteTask(account: AccountId, id: Ident): F[Unit] = + def deleteTask(scope: UserTaskScope, id: Ident): F[Unit] = (for { - _ <- store.getByIdRaw(account, id) - _ <- OptionT.liftF(store.deleteTask(account, id)) + _ <- store.getByIdRaw(scope, id) + _ <- OptionT.liftF(store.deleteTask(scope, id)) } yield ()).getOrElse(()) def submitScanMailbox( - account: AccountId, + scope: UserTaskScope, task: UserTask[ScanMailboxArgs] ): F[Unit] = for { - _ <- store.updateTask[ScanMailboxArgs](account, task) + _ <- store.updateTask[ScanMailboxArgs](scope, task) _ <- joex.notifyAllNodes } yield () - def getNotifyDueItems(account: AccountId): Stream[F, UserTask[NotifyDueItemsArgs]] = + def getNotifyDueItems( + scope: UserTaskScope + ): Stream[F, UserTask[NotifyDueItemsArgs]] = store - .getByName[NotifyDueItemsArgs](account, NotifyDueItemsArgs.taskName) + .getByName[NotifyDueItemsArgs](scope, NotifyDueItemsArgs.taskName) def findNotifyDueItems( id: Ident, - account: AccountId + scope: UserTaskScope ): OptionT[F, UserTask[NotifyDueItemsArgs]] = - OptionT(getNotifyDueItems(account).find(_.id == id).compile.last) + OptionT(getNotifyDueItems(scope).find(_.id == id).compile.last) def submitNotifyDueItems( - account: AccountId, + scope: UserTaskScope, task: UserTask[NotifyDueItemsArgs] ): F[Unit] = for { - _ <- store.updateTask[NotifyDueItemsArgs](account, task) + _ <- store.updateTask[NotifyDueItemsArgs](scope, task) _ <- joex.notifyAllNodes } yield () }) diff --git a/modules/common/src/main/scala/docspell/common/EmptyTrashArgs.scala b/modules/common/src/main/scala/docspell/common/EmptyTrashArgs.scala index 9ce432bf..00946fdd 100644 --- a/modules/common/src/main/scala/docspell/common/EmptyTrashArgs.scala +++ b/modules/common/src/main/scala/docspell/common/EmptyTrashArgs.scala @@ -6,8 +6,9 @@ package docspell.common -import com.github.eikek.calev.CalEvent import docspell.common.syntax.all._ + +import com.github.eikek.calev.CalEvent import io.circe._ import io.circe.generic.semiauto._ diff --git a/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala b/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala index ad7ec625..22825325 100644 --- a/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala +++ b/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala @@ -7,9 +7,11 @@ package docspell.joex import scala.concurrent.ExecutionContext + import cats.effect._ import cats.implicits._ import fs2.concurrent.SignallingRef + import docspell.analysis.TextAnalyser import docspell.backend.ops._ import docspell.common._ @@ -33,6 +35,7 @@ import docspell.joexapi.client.JoexClient import docspell.store.Store import docspell.store.queue._ import docspell.store.records.{REmptyTrashSetting, RJobLog} + import emil.javamail._ import org.http4s.blaze.client.BlazeClientBuilder import org.http4s.client.Client diff --git a/modules/joex/src/main/scala/docspell/joex/emptytrash/EmptyTrashTask.scala b/modules/joex/src/main/scala/docspell/joex/emptytrash/EmptyTrashTask.scala index fda6fb98..bbc1e4e2 100644 --- a/modules/joex/src/main/scala/docspell/joex/emptytrash/EmptyTrashTask.scala +++ b/modules/joex/src/main/scala/docspell/joex/emptytrash/EmptyTrashTask.scala @@ -8,13 +8,15 @@ package docspell.joex.emptytrash import cats.effect._ import cats.implicits._ -import com.github.eikek.calev.CalEvent import fs2.Stream + import docspell.backend.ops.{OItem, OItemSearch} import docspell.common._ import docspell.joex.scheduler._ import docspell.store.records.{RItem, RPeriodicTask} -import docspell.store.usertask.UserTask +import docspell.store.usertask.{UserTask, UserTaskScope} + +import com.github.eikek.calev.CalEvent object EmptyTrashTask { type Args = EmptyTrashArgs @@ -24,18 +26,19 @@ object EmptyTrashTask { private val pageSize = 20 - def periodicTask[F[_]: Sync](collective: Ident, ce: CalEvent): F[RPeriodicTask] = { - Ident.randomId[F].flatMap( id => - UserTask( - id, - EmptyTrashArgs.taskName, - true, - ce, - None, - EmptyTrashArgs(collective) - ).encode.toPeriodicTask(AccountId(collective, collective))) - } - + def periodicTask[F[_]: Sync](collective: Ident, ce: CalEvent): F[RPeriodicTask] = + Ident + .randomId[F] + .flatMap(id => + UserTask( + id, + EmptyTrashArgs.taskName, + true, + ce, + None, + EmptyTrashArgs(collective) + ).encode.toPeriodicTask(UserTaskScope(collective)) + ) def apply[F[_]: Async]( itemOps: OItem[F], diff --git a/modules/joex/src/main/scala/docspell/joex/hk/HouseKeepingTask.scala b/modules/joex/src/main/scala/docspell/joex/hk/HouseKeepingTask.scala index 9a29077a..2ba2dbc4 100644 --- a/modules/joex/src/main/scala/docspell/joex/hk/HouseKeepingTask.scala +++ b/modules/joex/src/main/scala/docspell/joex/hk/HouseKeepingTask.scala @@ -13,6 +13,7 @@ import docspell.common._ import docspell.joex.Config import docspell.joex.scheduler.Task import docspell.store.records._ +import docspell.store.usertask.UserTaskScope import com.github.eikek.calev._ @@ -36,11 +37,10 @@ object HouseKeepingTask { RPeriodicTask .createJson( true, + UserTaskScope(DocspellSystem.taskGroup), taskName, - DocspellSystem.taskGroup, (), "Docspell house-keeping", - DocspellSystem.taskGroup, Priority.Low, ce, None diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/CollectiveRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/CollectiveRoutes.scala index 71747f5c..abae60c8 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/CollectiveRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/CollectiveRoutes.scala @@ -8,6 +8,7 @@ package docspell.restserver.routes import cats.effect._ import cats.implicits._ + import docspell.backend.BackendApp import docspell.backend.auth.AuthToken import docspell.backend.ops.OCollective @@ -15,6 +16,7 @@ import docspell.common.{EmptyTrashArgs, ListType} import docspell.restapi.model._ import docspell.restserver.conv.Conversions import docspell.restserver.http4s._ + import com.github.eikek.calev.CalEvent import org.http4s.HttpRoutes import org.http4s.circe.CirceEntityDecoder._ diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/NotifyDueItemsRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/NotifyDueItemsRoutes.scala index b69c52b2..22376107 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/NotifyDueItemsRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/NotifyDueItemsRoutes.scala @@ -38,7 +38,7 @@ object NotifyDueItemsRoutes { HttpRoutes.of { case GET -> Root / Ident(id) => (for { - task <- ut.findNotifyDueItems(id, user.account) + task <- ut.findNotifyDueItems(id, UserTaskScope(user.account)) res <- OptionT.liftF(taskToSettings(user.account, backend, task)) resp <- OptionT.liftF(Ok(res)) } yield resp).getOrElseF(NotFound()) @@ -49,7 +49,7 @@ object NotifyDueItemsRoutes { newId <- Ident.randomId[F] task <- makeTask(newId, getBaseUrl(cfg, req), user.account, data) res <- - ut.executeNow(user.account, task) + ut.executeNow(UserTaskScope(user.account), task) .attempt .map(Conversions.basicResult(_, "Submitted successfully.")) resp <- Ok(res) @@ -58,7 +58,7 @@ object NotifyDueItemsRoutes { case DELETE -> Root / Ident(id) => for { res <- - ut.deleteTask(user.account, id) + ut.deleteTask(UserTaskScope(user.account), id) .attempt .map(Conversions.basicResult(_, "Deleted successfully")) resp <- Ok(res) @@ -69,7 +69,7 @@ object NotifyDueItemsRoutes { for { task <- makeTask(data.id, getBaseUrl(cfg, req), user.account, data) res <- - ut.submitNotifyDueItems(user.account, task) + ut.submitNotifyDueItems(UserTaskScope(user.account), task) .attempt .map(Conversions.basicResult(_, "Saved successfully")) resp <- Ok(res) @@ -87,14 +87,14 @@ object NotifyDueItemsRoutes { newId <- Ident.randomId[F] task <- makeTask(newId, getBaseUrl(cfg, req), user.account, data) res <- - ut.submitNotifyDueItems(user.account, task) + ut.submitNotifyDueItems(UserTaskScope(user.account), task) .attempt .map(Conversions.basicResult(_, "Saved successfully.")) resp <- Ok(res) } yield resp case GET -> Root => - ut.getNotifyDueItems(user.account) + ut.getNotifyDueItems(UserTaskScope(user.account)) .evalMap(task => taskToSettings(user.account, backend, task)) .compile .toVector diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ScanMailboxRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ScanMailboxRoutes.scala index e2595542..6ba7496e 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ScanMailboxRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ScanMailboxRoutes.scala @@ -35,7 +35,7 @@ object ScanMailboxRoutes { HttpRoutes.of { case GET -> Root / Ident(id) => (for { - task <- ut.findScanMailbox(id, user.account) + task <- ut.findScanMailbox(id, UserTaskScope(user.account)) res <- OptionT.liftF(taskToSettings(user.account, backend, task)) resp <- OptionT.liftF(Ok(res)) } yield resp).getOrElseF(NotFound()) @@ -46,7 +46,7 @@ object ScanMailboxRoutes { newId <- Ident.randomId[F] task <- makeTask(newId, user.account, data) res <- - ut.executeNow(user.account, task) + ut.executeNow(UserTaskScope(user.account), task) .attempt .map(Conversions.basicResult(_, "Submitted successfully.")) resp <- Ok(res) @@ -55,7 +55,7 @@ object ScanMailboxRoutes { case DELETE -> Root / Ident(id) => for { res <- - ut.deleteTask(user.account, id) + ut.deleteTask(UserTaskScope(user.account), id) .attempt .map(Conversions.basicResult(_, "Deleted successfully.")) resp <- Ok(res) @@ -66,7 +66,7 @@ object ScanMailboxRoutes { for { task <- makeTask(data.id, user.account, data) res <- - ut.submitScanMailbox(user.account, task) + ut.submitScanMailbox(UserTaskScope(user.account), task) .attempt .map(Conversions.basicResult(_, "Saved successfully.")) resp <- Ok(res) @@ -84,14 +84,14 @@ object ScanMailboxRoutes { newId <- Ident.randomId[F] task <- makeTask(newId, user.account, data) res <- - ut.submitScanMailbox(user.account, task) + ut.submitScanMailbox(UserTaskScope(user.account), task) .attempt .map(Conversions.basicResult(_, "Saved successfully.")) resp <- Ok(res) } yield resp case GET -> Root => - ut.getScanMailbox(user.account) + ut.getScanMailbox(UserTaskScope(user.account)) .evalMap(task => taskToSettings(user.account, backend, task)) .compile .toVector diff --git a/modules/store/src/main/scala/docspell/store/queries/QUserTask.scala b/modules/store/src/main/scala/docspell/store/queries/QUserTask.scala index 3705416e..81236544 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QUserTask.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QUserTask.scala @@ -12,7 +12,7 @@ import docspell.common._ import docspell.store.qb.DSL._ import docspell.store.qb._ import docspell.store.records._ -import docspell.store.usertask.UserTask +import docspell.store.usertask.{UserTask, UserTaskScope} import doobie._ @@ -54,15 +54,15 @@ object QUserTask { ) ).query[RPeriodicTask].option.map(_.map(makeUserTask)) - def insert(account: AccountId, task: UserTask[String]): ConnectionIO[Int] = + def insert(scope: UserTaskScope, task: UserTask[String]): ConnectionIO[Int] = for { - r <- task.toPeriodicTask[ConnectionIO](account) + r <- task.toPeriodicTask[ConnectionIO](scope) n <- RPeriodicTask.insert(r) } yield n - def update(account: AccountId, task: UserTask[String]): ConnectionIO[Int] = + def update(scope: UserTaskScope, task: UserTask[String]): ConnectionIO[Int] = for { - r <- task.toPeriodicTask[ConnectionIO](account) + r <- task.toPeriodicTask[ConnectionIO](scope) n <- RPeriodicTask.update(r) } yield n diff --git a/modules/store/src/main/scala/docspell/store/records/RPeriodicTask.scala b/modules/store/src/main/scala/docspell/store/records/RPeriodicTask.scala index 4eb48e90..e4a3b8b0 100644 --- a/modules/store/src/main/scala/docspell/store/records/RPeriodicTask.scala +++ b/modules/store/src/main/scala/docspell/store/records/RPeriodicTask.scala @@ -13,6 +13,7 @@ import cats.implicits._ import docspell.common._ import docspell.store.qb.DSL._ import docspell.store.qb._ +import docspell.store.usertask.UserTaskScope import com.github.eikek.calev.CalEvent import doobie._ @@ -67,11 +68,10 @@ object RPeriodicTask { def create[F[_]: Sync]( enabled: Boolean, + scope: UserTaskScope, task: Ident, - group: Ident, args: String, subject: String, - submitter: Ident, priority: Priority, timer: CalEvent, summary: Option[String] @@ -86,10 +86,10 @@ object RPeriodicTask { id, enabled, task, - group, + scope.collective, args, subject, - submitter, + scope.fold(_.user, identity), priority, None, None, @@ -107,22 +107,20 @@ object RPeriodicTask { def createJson[F[_]: Sync, A]( enabled: Boolean, + scope: UserTaskScope, task: Ident, - group: Ident, args: A, subject: String, - submitter: Ident, priority: Priority, timer: CalEvent, summary: Option[String] )(implicit E: Encoder[A]): F[RPeriodicTask] = create[F]( enabled, + scope, task, - group, E(args).noSpaces, subject, - submitter, priority, timer, summary diff --git a/modules/store/src/main/scala/docspell/store/usertask/UserTask.scala b/modules/store/src/main/scala/docspell/store/usertask/UserTask.scala index 722374a0..42255a07 100644 --- a/modules/store/src/main/scala/docspell/store/usertask/UserTask.scala +++ b/modules/store/src/main/scala/docspell/store/usertask/UserTask.scala @@ -43,16 +43,15 @@ object UserTask { .map(a => ut.copy(args = a)) def toPeriodicTask[F[_]: Sync]( - account: AccountId + scope: UserTaskScope ): F[RPeriodicTask] = RPeriodicTask .create[F]( ut.enabled, + scope, ut.name, - account.collective, ut.args, - s"${account.user.id}: ${ut.name.id}", - account.user, + s"${scope.fold(_.user.id, _.id)}: ${ut.name.id}", Priority.Low, ut.timer, ut.summary diff --git a/modules/store/src/main/scala/docspell/store/usertask/UserTaskScope.scala b/modules/store/src/main/scala/docspell/store/usertask/UserTaskScope.scala new file mode 100644 index 00000000..464e07d1 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/usertask/UserTaskScope.scala @@ -0,0 +1,52 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.store.usertask + +import docspell.common._ + +sealed trait UserTaskScope { self: Product => + + def name: String = + productPrefix.toLowerCase + + def collective: Ident + + def fold[A](fa: AccountId => A, fb: Ident => A): A + + /** Maps to the account or uses the collective for both parts if the + * scope is collective wide. + */ + private[usertask] def toAccountId: AccountId = + AccountId(collective, fold(_.user, identity)) +} + +object UserTaskScope { + + final case class Account(account: AccountId) extends UserTaskScope { + val collective = account.collective + + def fold[A](fa: AccountId => A, fb: Ident => A): A = + fa(account) + } + + final case class Collective(collective: Ident) extends UserTaskScope { + def fold[A](fa: AccountId => A, fb: Ident => A): A = + fb(collective) + } + + def collective(id: Ident): UserTaskScope = + Collective(id) + + def account(accountId: AccountId): UserTaskScope = + Account(accountId) + + def apply(accountId: AccountId): UserTaskScope = + UserTaskScope.account(accountId) + + def apply(collective: Ident): UserTaskScope = + UserTaskScope.collective(collective) +} diff --git a/modules/store/src/main/scala/docspell/store/usertask/UserTaskStore.scala b/modules/store/src/main/scala/docspell/store/usertask/UserTaskStore.scala index 6caab8f0..7c084f00 100644 --- a/modules/store/src/main/scala/docspell/store/usertask/UserTaskStore.scala +++ b/modules/store/src/main/scala/docspell/store/usertask/UserTaskStore.scala @@ -22,13 +22,15 @@ import io.circe._ * once. * * This class defines methods at a higher level, dealing with - * `UserTask` and `AccountId` instead of directly using + * `UserTask` and `UserTaskScope` instead of directly using * `RPeriodicTask`. A user task is associated to a specific user (not - * just the collective). + * just the collective). But it can be associated to the whole + * collective by using the collective as submitter, too. This is + * abstracted in `UserTaskScope`. * * implNote: The mapping is as follows: The collective is the task * group. The submitter property contains the username. Once a task - * is saved to the database, it can only be refernced uniquely by its + * is saved to the database, it can only be referenced uniquely by its * id. A user may submit multiple same tasks (with different * properties). */ @@ -36,22 +38,22 @@ trait UserTaskStore[F[_]] { /** Return all tasks of the given user. */ - def getAll(account: AccountId): Stream[F, UserTask[String]] + def getAll(scope: UserTaskScope): Stream[F, UserTask[String]] /** Return all tasks of the given name and user. The task's arguments * are returned as stored in the database. */ - def getByNameRaw(account: AccountId, name: Ident): Stream[F, UserTask[String]] + def getByNameRaw(scope: UserTaskScope, name: Ident): Stream[F, UserTask[String]] /** Return all tasks of the given name and user. The task's arguments * are decoded using the given json decoder. */ - def getByName[A](account: AccountId, name: Ident)(implicit + def getByName[A](scope: UserTaskScope, name: Ident)(implicit D: Decoder[A] ): Stream[F, UserTask[A]] /** Return a user-task with the given id. */ - def getByIdRaw(account: AccountId, id: Ident): OptionT[F, UserTask[String]] + def getByIdRaw(scope: UserTaskScope, id: Ident): OptionT[F, UserTask[String]] /** Updates or inserts the given task. * @@ -59,23 +61,23 @@ trait UserTaskStore[F[_]] { * exists, a new one is created. Otherwise the existing task is * updated. */ - def updateTask[A](account: AccountId, ut: UserTask[A])(implicit E: Encoder[A]): F[Int] + def updateTask[A](scope: UserTaskScope, ut: UserTask[A])(implicit E: Encoder[A]): F[Int] /** Delete the task with the given id of the given user. */ - def deleteTask(account: AccountId, id: Ident): F[Int] + def deleteTask(scope: UserTaskScope, id: Ident): F[Int] /** Return the task of the given user and name. If multiple exists, an * error is returned. The task's arguments are returned as stored * in the database. */ - def getOneByNameRaw(account: AccountId, name: Ident): OptionT[F, UserTask[String]] + def getOneByNameRaw(scope: UserTaskScope, name: Ident): OptionT[F, UserTask[String]] /** Return the task of the given user and name. If multiple exists, an * error is returned. The task's arguments are decoded using the * given json decoder. */ - def getOneByName[A](account: AccountId, name: Ident)(implicit + def getOneByName[A](scope: UserTaskScope, name: Ident)(implicit D: Decoder[A] ): OptionT[F, UserTask[A]] @@ -90,13 +92,13 @@ trait UserTaskStore[F[_]] { * the user `account`, they will all be removed and the given task * inserted! */ - def updateOneTask[A](account: AccountId, ut: UserTask[A])(implicit + def updateOneTask[A](scope: UserTaskScope, ut: UserTask[A])(implicit E: Encoder[A] ): F[UserTask[String]] /** Delete all tasks of the given user that have name `name'. */ - def deleteAll(account: AccountId, name: Ident): F[Int] + def deleteAll(scope: UserTaskScope, name: Ident): F[Int] } object UserTaskStore { @@ -104,47 +106,47 @@ object UserTaskStore { def apply[F[_]: Async](store: Store[F]): Resource[F, UserTaskStore[F]] = Resource.pure[F, UserTaskStore[F]](new UserTaskStore[F] { - def getAll(account: AccountId): Stream[F, UserTask[String]] = - store.transact(QUserTask.findAll(account)) + def getAll(scope: UserTaskScope): Stream[F, UserTask[String]] = + store.transact(QUserTask.findAll(scope.toAccountId)) - def getByNameRaw(account: AccountId, name: Ident): Stream[F, UserTask[String]] = - store.transact(QUserTask.findByName(account, name)) + def getByNameRaw(scope: UserTaskScope, name: Ident): Stream[F, UserTask[String]] = + store.transact(QUserTask.findByName(scope.toAccountId, name)) - def getByIdRaw(account: AccountId, id: Ident): OptionT[F, UserTask[String]] = - OptionT(store.transact(QUserTask.findById(account, id))) + def getByIdRaw(scope: UserTaskScope, id: Ident): OptionT[F, UserTask[String]] = + OptionT(store.transact(QUserTask.findById(scope.toAccountId, id))) - def getByName[A](account: AccountId, name: Ident)(implicit + def getByName[A](scope: UserTaskScope, name: Ident)(implicit D: Decoder[A] ): Stream[F, UserTask[A]] = - getByNameRaw(account, name).flatMap(_.decode match { + getByNameRaw(scope, name).flatMap(_.decode match { case Right(ua) => Stream.emit(ua) case Left(err) => Stream.raiseError[F](new Exception(err)) }) - def updateTask[A](account: AccountId, ut: UserTask[A])(implicit + def updateTask[A](scope: UserTaskScope, ut: UserTask[A])(implicit E: Encoder[A] ): F[Int] = { val exists = QUserTask.exists(ut.id) - val insert = QUserTask.insert(account, ut.encode) + val insert = QUserTask.insert(scope, ut.encode) store.add(insert, exists).flatMap { case AddResult.Success => 1.pure[F] case AddResult.EntityExists(_) => - store.transact(QUserTask.update(account, ut.encode)) + store.transact(QUserTask.update(scope, ut.encode)) case AddResult.Failure(ex) => Async[F].raiseError(ex) } } - def deleteTask(account: AccountId, id: Ident): F[Int] = - store.transact(QUserTask.delete(account, id)) + def deleteTask(scope: UserTaskScope, id: Ident): F[Int] = + store.transact(QUserTask.delete(scope.toAccountId, id)) def getOneByNameRaw( - account: AccountId, + scope: UserTaskScope, name: Ident ): OptionT[F, UserTask[String]] = OptionT( - getByNameRaw(account, name) + getByNameRaw(scope, name) .take(2) .compile .toList @@ -155,32 +157,34 @@ object UserTaskStore { } ) - def getOneByName[A](account: AccountId, name: Ident)(implicit + def getOneByName[A](scope: UserTaskScope, name: Ident)(implicit D: Decoder[A] ): OptionT[F, UserTask[A]] = - getOneByNameRaw(account, name) + getOneByNameRaw(scope, name) .semiflatMap(_.decode match { case Right(ua) => ua.pure[F] case Left(err) => Async[F].raiseError(new Exception(err)) }) - def updateOneTask[A](account: AccountId, ut: UserTask[A])(implicit + def updateOneTask[A](scope: UserTaskScope, ut: UserTask[A])(implicit E: Encoder[A] ): F[UserTask[String]] = - getByNameRaw(account, ut.name).compile.toList.flatMap { + getByNameRaw(scope, ut.name).compile.toList.flatMap { case a :: rest => val task = ut.copy(id = a.id).encode for { - _ <- store.transact(QUserTask.update(account, task)) - _ <- store.transact(rest.traverse(t => QUserTask.delete(account, t.id))) + _ <- store.transact(QUserTask.update(scope, task)) + _ <- store.transact( + rest.traverse(t => QUserTask.delete(scope.toAccountId, t.id)) + ) } yield task case Nil => val task = ut.encode - store.transact(QUserTask.insert(account, task)).map(_ => task) + store.transact(QUserTask.insert(scope, task)).map(_ => task) } - def deleteAll(account: AccountId, name: Ident): F[Int] = - store.transact(QUserTask.deleteAll(account, name)) + def deleteAll(scope: UserTaskScope, name: Ident): F[Int] = + store.transact(QUserTask.deleteAll(scope.toAccountId, name)) }) } From 4562c7715205d3ba1744bef5df63594fd9baf9b2 Mon Sep 17 00:00:00 2001 From: eikek Date: Sat, 14 Aug 2021 21:23:38 +0200 Subject: [PATCH 14/15] Fix migration changeset name for h2 --- .../{V1.25__add_empty_trash.sql => V1.25.0__add_empty_trash.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename modules/store/src/main/resources/db/migration/h2/{V1.25__add_empty_trash.sql => V1.25.0__add_empty_trash.sql} (100%) diff --git a/modules/store/src/main/resources/db/migration/h2/V1.25__add_empty_trash.sql b/modules/store/src/main/resources/db/migration/h2/V1.25.0__add_empty_trash.sql similarity index 100% rename from modules/store/src/main/resources/db/migration/h2/V1.25__add_empty_trash.sql rename to modules/store/src/main/resources/db/migration/h2/V1.25.0__add_empty_trash.sql From ef31c90e34e88bec6859d2f6ecc676dff4a2a624 Mon Sep 17 00:00:00 2001 From: eikek Date: Sat, 14 Aug 2021 21:29:57 +0200 Subject: [PATCH 15/15] Fix submitter column of a collective-scoped periodic job --- .../db/migration/h2/V1.25.1__fix_periodic_submitter_value.sql | 3 +++ .../mariadb/V1.25.1__fix_periodic_submitter_value.sql | 3 +++ .../postgresql/V1.25.1__fix_periodic_submitter_value.sql | 3 +++ 3 files changed, 9 insertions(+) create mode 100644 modules/store/src/main/resources/db/migration/h2/V1.25.1__fix_periodic_submitter_value.sql create mode 100644 modules/store/src/main/resources/db/migration/mariadb/V1.25.1__fix_periodic_submitter_value.sql create mode 100644 modules/store/src/main/resources/db/migration/postgresql/V1.25.1__fix_periodic_submitter_value.sql diff --git a/modules/store/src/main/resources/db/migration/h2/V1.25.1__fix_periodic_submitter_value.sql b/modules/store/src/main/resources/db/migration/h2/V1.25.1__fix_periodic_submitter_value.sql new file mode 100644 index 00000000..4410510f --- /dev/null +++ b/modules/store/src/main/resources/db/migration/h2/V1.25.1__fix_periodic_submitter_value.sql @@ -0,0 +1,3 @@ +UPDATE "periodic_task" +SET submitter = group_ +WHERE submitter = 'learn-classifier'; diff --git a/modules/store/src/main/resources/db/migration/mariadb/V1.25.1__fix_periodic_submitter_value.sql b/modules/store/src/main/resources/db/migration/mariadb/V1.25.1__fix_periodic_submitter_value.sql new file mode 100644 index 00000000..f5e8916a --- /dev/null +++ b/modules/store/src/main/resources/db/migration/mariadb/V1.25.1__fix_periodic_submitter_value.sql @@ -0,0 +1,3 @@ +UPDATE `periodic_task` +SET submitter = group_ +WHERE submitter = 'learn-classifier'; diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.25.1__fix_periodic_submitter_value.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.25.1__fix_periodic_submitter_value.sql new file mode 100644 index 00000000..4410510f --- /dev/null +++ b/modules/store/src/main/resources/db/migration/postgresql/V1.25.1__fix_periodic_submitter_value.sql @@ -0,0 +1,3 @@ +UPDATE "periodic_task" +SET submitter = group_ +WHERE submitter = 'learn-classifier';