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/OCollective.scala b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala index 9a598726..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._ @@ -62,6 +61,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 +148,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) @@ -162,7 +168,23 @@ object OCollective { None, LearnClassifierArgs(coll) ) - _ <- uts.updateOneTask(AccountId(coll, LearnClassifierArgs.taskName), ut) + _ <- uts.updateOneTask(UserTaskScope(coll), ut) + _ <- 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(UserTaskScope(coll), ut) _ <- joex.notifyAllNodes } yield () @@ -176,7 +198,23 @@ 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 + } 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(UserTaskScope(collective)) job <- ut.toJob _ <- queue.insert(job) _ <- joex.notifyAllNodes 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..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], @@ -144,6 +146,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 @@ -580,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], @@ -612,6 +627,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/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/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala b/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala index 34e015b1..2ae4cd14 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 @@ -36,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 @@ -53,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) ) } @@ -83,7 +86,12 @@ object OSimpleSearch { batch: Batch, useFTS: Boolean, resolveDetails: Boolean, - maxNoteLen: Int + maxNoteLen: Int, + searchMode: SearchMode + ) + final case class StatsSettings( + useFTS: Boolean, + searchMode: SearchMode ) sealed trait Items { @@ -214,7 +222,11 @@ 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 = + 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) { @@ -267,18 +279,24 @@ object OSimpleSearch { } def searchSummary( - useFTS: Boolean - )(q: Query, fulltextQuery: Option[String]): F[SearchSummary] = + settings: StatsSettings + )(q: Query, fulltextQuery: Option[String]): F[SearchSummary] = { + val validItemQuery = + 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 useFTS => + case Some(ftq) if settings.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/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 new file mode 100644 index 00000000..00946fdd --- /dev/null +++ b/modules/common/src/main/scala/docspell/common/EmptyTrashArgs.scala @@ -0,0 +1,43 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.common + +import docspell.common.syntax.all._ + +import com.github.eikek.calev.CalEvent +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") + + val defaultSchedule = CalEvent.unsafe("*-*-1/7 03:00:00") + + 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/common/src/main/scala/docspell/common/ItemState.scala b/modules/common/src/main/scala/docspell/common/ItemState.scala index 70ed7e04..e59d5049 100644 --- a/modules/common/src/main/scala/docspell/common/ItemState.scala +++ b/modules/common/src/main/scala/docspell/common/ItemState.scala @@ -28,11 +28,13 @@ 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 def created: ItemState = Created def confirmed: ItemState = Confirmed + def deleted: ItemState = Deleted def fromString(str: String): Either[String, ItemState] = str.toLowerCase match { @@ -40,6 +42,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/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/joex/src/main/scala/docspell/joex/JoexAppImpl.scala b/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala index 1d7f3419..22825325 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 @@ -33,7 +34,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 @@ -76,11 +77,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 { @@ -94,16 +107,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 +220,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..bbc1e4e2 --- /dev/null +++ b/modules/joex/src/main/scala/docspell/joex/emptytrash/EmptyTrashTask.scala @@ -0,0 +1,84 @@ +/* + * 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, RPeriodicTask} +import docspell.store.usertask.{UserTask, UserTaskScope} + +import com.github.eikek.calev.CalEvent + +object EmptyTrashTask { + type Args = EmptyTrashArgs + + def onCancel[F[_]]: Task[F, Args, Unit] = + Task.log(_.warn("Cancelling empty-trash task")) + + 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(UserTaskScope(collective)) + ) + + 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/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/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 e0d683bc..e02354d7 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" @@ -1478,6 +1499,7 @@ paths: - $ref: "#/components/parameters/limit" - $ref: "#/components/parameters/offset" - $ref: "#/components/parameters/withDetails" + - $ref: "#/components/parameters/searchMode" responses: 200: description: Ok @@ -1576,6 +1598,7 @@ paths: - authTokenHeader: [] parameters: - $ref: "#/components/parameters/q" + - $ref: "#/components/parameters/searchMode" responses: 200: description: Ok @@ -1607,7 +1630,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: @@ -1619,6 +1644,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" @@ -2305,6 +2350,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" @@ -4112,6 +4180,16 @@ components: withDetails: type: boolean default: false + searchMode: + type: string + format: searchmode + enum: + - normal + - trashed + default: normal + description: | + Specify whether the search query should apply to + soft-deleted items or not. query: type: string description: | @@ -4569,6 +4647,7 @@ components: required: - incomingCount - outgoingCount + - deletedCount - itemSize - tagCloud properties: @@ -4578,6 +4657,9 @@ components: outgoingCount: type: integer format: int32 + deletedCount: + type: integer + format: int32 itemSize: type: integer format: int64 @@ -5185,6 +5267,7 @@ components: - language - integrationEnabled - classifier + - emptyTrashSchedule properties: language: type: string @@ -5194,6 +5277,9 @@ components: description: | Whether the collective has the integration endpoint enabled. + emptyTrashSchedule: + type: string + format: calevent classifier: $ref: "#/components/schemas/ClassifierSetting" @@ -5834,6 +5920,13 @@ components: description: Whether to return details to each item. schema: type: boolean + searchMode: + name: searchMode + in: query + description: Whether to search in soft-deleted items only. + schema: + type: string + format: searchmode name: name: name in: path 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/http4s/QueryParam.scala b/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala index 74c2b418..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,6 +41,7 @@ object QueryParam { object Limit extends OptionalQueryParamDecoderMatcher[Int]("limit") object Offset extends OptionalQueryParamDecoderMatcher[Int]("offset") object WithDetails extends OptionalQueryParamDecoderMatcher[Boolean]("withDetails") + object SearchKind extends OptionalQueryParamDecoderMatcher[SearchMode]("searchMode") object WithFallback extends OptionalQueryParamDecoderMatcher[Boolean]("withFallback") } 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..abae60c8 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/CollectiveRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/CollectiveRoutes.scala @@ -12,7 +12,7 @@ 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._ @@ -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(EmptyTrashArgs.defaultSchedule), 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/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala index bbda6df0..771734ec 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." @@ -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 f2bec1fc..9453eb69 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.SearchKind(searchMode) => 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, + 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) => + case GET -> Root / "searchStats" :? QP.Query(q) :? QP.SearchKind(searchMode) => 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, + searchMode = searchMode.getOrElse(SearchMode.Normal) + ) + 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, + searchMode = userQuery.searchMode.getOrElse(SearchMode.Normal) ) 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, + searchMode = userQuery.searchMode.getOrElse(SearchMode.Normal) ) + resp <- searchItemStats(backend, dsl)(settings, fixQuery, itemQuery) } yield resp case req @ POST -> Root / "searchIndex" => @@ -144,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) @@ -393,7 +405,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,13 +452,18 @@ object ItemRoutes { } } - def searchItemStats[F[_]: Sync]( + 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/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/resources/db/migration/h2/V1.25.0__add_empty_trash.sql b/modules/store/src/main/resources/db/migration/h2/V1.25.0__add_empty_trash.sql new file mode 100644 index 00000000..45650e03 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/h2/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/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.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/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.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/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'; 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/store/src/main/scala/docspell/store/queries/QCollective.scala b/modules/store/src/main/scala/docspell/store/queries/QCollective.scala index ede76360..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] ) @@ -73,12 +74,21 @@ 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 q2 = Select( + count(i.id).s, + from(i), + i.cid === coll && i.state === ItemState.Deleted ).build.query[Int].unique val fileSize = sql""" @@ -102,19 +112,20 @@ 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]] = { 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] 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/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..f08079e5 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/REmptyTrashSetting.scala @@ -0,0 +1,86 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.store.records + +import cats.data.NonEmptyList +import cats.implicits._ +import fs2.Stream + +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 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) + +} 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..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._ @@ -152,7 +153,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 @@ -336,6 +351,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) @@ -360,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/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 914e1357..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]] @@ -83,20 +85,20 @@ 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! */ - 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)) }) } diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index abba1ebc..1f26e605 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 @@ -128,6 +130,7 @@ module Api exposing , setTagsMultiple , setUnconfirmed , startClassifier + , startEmptyTrash , startOnceNotifyDueItems , startOnceScanMailbox , startReIndex @@ -994,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 @@ -1676,6 +1692,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 +2003,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/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/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..d3574835 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm @@ -1604,6 +1604,9 @@ update key flags inav settings msg model = , cmd ) + RestoreItem -> + resultModelCmd ( model, Api.restoreItem flags model.item.id SaveResp ) + --- 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/Comp/SearchMenu.elm b/modules/webapp/src/main/elm/Comp/SearchMenu.elm index 627d4b64..a4a00daf 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.trashcan + , titleRight = [] + , info = Nothing + , body = + [ MB.viewItem <| + MB.Checkbox + { id = "trashed" + , value = model.searchMode == Data.SearchMode.Trashed + , label = texts.trashcan + , tagger = \_ -> ToggleSearchMode + } + ] + } ] diff --git a/modules/webapp/src/main/elm/Data/ItemQuery.elm b/modules/webapp/src/main/elm/Data/ItemQuery.elm index 72ab54ca..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,12 +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 = 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/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/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" + } 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/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/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 diff --git a/modules/webapp/src/main/elm/Page/Home/Data.elm b/modules/webapp/src/main/elm/Page/Home/Data.elm index 8560ca9d..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 = @@ -239,7 +243,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..7c68f082 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 (..) @@ -360,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 @@ -468,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 -> @@ -548,7 +594,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 +763,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 +774,7 @@ loadChangedItems flags ids = Set.toList ids searchInit = - Q.request (Just <| Q.ItemIdIn idList) + Q.request smode (Just <| Q.ItemIdIn idList) search = { searchInit 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 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"