diff --git a/build.sbt b/build.sbt index 3d1b1272..6e5bb349 100644 --- a/build.sbt +++ b/build.sbt @@ -242,6 +242,10 @@ val openapiScalaSettings = Seq( field => field .copy(typeDef = TypeDef("SearchMode", Imports("docspell.common.SearchMode"))) + case "duration" => + field => + field + .copy(typeDef = TypeDef("Duration", Imports("docspell.common.Duration"))) })) ) 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 3bd8436f..76be8a0d 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OCollective.scala @@ -61,7 +61,7 @@ trait OCollective[F[_]] { def startLearnClassifier(collective: Ident): F[Unit] - def startEmptyTrash(collective: Ident): F[Unit] + def startEmptyTrash(args: EmptyTrashArgs): F[Unit] /** Submits a task that (re)generates the preview images for all * attachments of the given collective. @@ -88,6 +88,8 @@ object OCollective { val Settings = RCollective.Settings type Classifier = RClassifierSetting.Classifier val Classifier = RClassifierSetting.Classifier + type EmptyTrash = REmptyTrashSetting.EmptyTrash + val EmptyTrash = REmptyTrashSetting.EmptyTrash sealed trait PassResetResult object PassResetResult { @@ -160,51 +162,47 @@ object OCollective { id <- Ident.randomId[F] on = sett.classifier.map(_.enabled).getOrElse(false) timer = sett.classifier.map(_.schedule).getOrElse(CalEvent.unsafe("")) + args = LearnClassifierArgs(coll) ut = UserTask( id, LearnClassifierArgs.taskName, on, timer, None, - LearnClassifierArgs(coll) + args ) - _ <- uts.updateOneTask(UserTaskScope(coll), ut) + _ <- uts.updateOneTask(UserTaskScope(coll), args.makeSubject.some, 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) + settings = sett.emptyTrash.getOrElse(EmptyTrash.default) + args = EmptyTrashArgs(coll, settings.minAge) + ut = UserTask(id, EmptyTrashArgs.taskName, true, settings.schedule, None, args) + _ <- uts.updateOneTask(UserTaskScope(coll), args.makeSubject.some, ut) _ <- joex.notifyAllNodes } yield () def startLearnClassifier(collective: Ident): F[Unit] = for { id <- Ident.randomId[F] + args = LearnClassifierArgs(collective) ut <- UserTask( id, LearnClassifierArgs.taskName, true, CalEvent(WeekdayComponent.All, DateEvent.All, TimeEvent.All), None, - LearnClassifierArgs(collective) - ).encode.toPeriodicTask(UserTaskScope(collective)) + args + ).encode.toPeriodicTask(UserTaskScope(collective), args.makeSubject.some) job <- ut.toJob _ <- queue.insert(job) _ <- joex.notifyAllNodes } yield () - def startEmptyTrash(collective: Ident): F[Unit] = + def startEmptyTrash(args: EmptyTrashArgs): F[Unit] = for { id <- Ident.randomId[F] ut <- UserTask( @@ -213,8 +211,8 @@ object OCollective { true, CalEvent(WeekdayComponent.All, DateEvent.All, TimeEvent.All), None, - EmptyTrashArgs(collective) - ).encode.toPeriodicTask(UserTaskScope(collective)) + args + ).encode.toPeriodicTask(UserTaskScope(args.collective), args.makeSubject.some) job <- ut.toJob _ <- queue.insert(job) _ <- joex.notifyAllNodes 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 cb469880..d7902d36 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala @@ -23,7 +23,7 @@ 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 findDeleted(collective: Ident, maxUpdate: Timestamp, limit: Int): F[Vector[RItem]] def findItems(maxNoteLen: Int)(q: Query, batch: Batch): F[Vector[ListItem]] @@ -147,9 +147,13 @@ object OItemSearch { .toVector } - def findDeleted(collective: Ident, limit: Int): F[Vector[RItem]] = + def findDeleted( + collective: Ident, + maxUpdate: Timestamp, + limit: Int + ): F[Vector[RItem]] = store - .transact(RItem.findDeleted(collective, limit)) + .transact(RItem.findDeleted(collective, maxUpdate, limit)) .take(limit.toLong) .compile .toVector 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 25db06ae..c170b97b 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OUserTask.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OUserTask.scala @@ -33,6 +33,7 @@ trait OUserTask[F[_]] { */ def submitScanMailbox( scope: UserTaskScope, + subject: Option[String], task: UserTask[ScanMailboxArgs] ): F[Unit] @@ -51,6 +52,7 @@ trait OUserTask[F[_]] { */ def submitNotifyDueItems( scope: UserTaskScope, + subject: Option[String], task: UserTask[NotifyDueItemsArgs] ): F[Unit] @@ -61,8 +63,8 @@ trait OUserTask[F[_]] { * executor's queue. It will not update the corresponding periodic * task. */ - def executeNow[A](scope: UserTaskScope, task: UserTask[A])(implicit - E: Encoder[A] + def executeNow[A](scope: UserTaskScope, subject: Option[String], task: UserTask[A])( + implicit E: Encoder[A] ): F[Unit] } @@ -75,11 +77,11 @@ object OUserTask { ): Resource[F, OUserTask[F]] = Resource.pure[F, OUserTask[F]](new OUserTask[F] { - def executeNow[A](scope: UserTaskScope, task: UserTask[A])(implicit - E: Encoder[A] + def executeNow[A](scope: UserTaskScope, subject: Option[String], task: UserTask[A])( + implicit E: Encoder[A] ): F[Unit] = for { - ptask <- task.encode.toPeriodicTask(scope) + ptask <- task.encode.toPeriodicTask(scope, subject) job <- ptask.toJob _ <- queue.insert(job) _ <- joex.notifyAllNodes @@ -103,10 +105,11 @@ object OUserTask { def submitScanMailbox( scope: UserTaskScope, + subject: Option[String], task: UserTask[ScanMailboxArgs] ): F[Unit] = for { - _ <- store.updateTask[ScanMailboxArgs](scope, task) + _ <- store.updateTask[ScanMailboxArgs](scope, subject, task) _ <- joex.notifyAllNodes } yield () @@ -124,10 +127,11 @@ object OUserTask { def submitNotifyDueItems( scope: UserTaskScope, + subject: Option[String], task: UserTask[NotifyDueItemsArgs] ): F[Unit] = for { - _ <- store.updateTask[NotifyDueItemsArgs](scope, task) + _ <- store.updateTask[NotifyDueItemsArgs](scope, subject, task) _ <- joex.notifyAllNodes } yield () }) diff --git a/modules/common/src/main/scala/docspell/common/EmptyTrashArgs.scala b/modules/common/src/main/scala/docspell/common/EmptyTrashArgs.scala index 00946fdd..838bf912 100644 --- a/modules/common/src/main/scala/docspell/common/EmptyTrashArgs.scala +++ b/modules/common/src/main/scala/docspell/common/EmptyTrashArgs.scala @@ -18,11 +18,12 @@ import io.circe.generic.semiauto._ * items. These are items with state `ItemState.Deleted`. */ case class EmptyTrashArgs( - collective: Ident + collective: Ident, + minAge: Duration ) { def makeSubject: String = - "Empty trash" + s"Empty Trash: Remove older than ${minAge.toJava}" } diff --git a/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala b/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala index 22825325..37113d5b 100644 --- a/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala +++ b/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala @@ -87,9 +87,11 @@ final class JoexAppImpl[F[_]: Async]( private def scheduleEmptyTrashTasks: F[Unit] = store .transact( - REmptyTrashSetting.findForAllCollectives(EmptyTrashArgs.defaultSchedule, 50) + REmptyTrashSetting.findForAllCollectives(OCollective.EmptyTrash.default, 50) + ) + .evalMap(es => + EmptyTrashTask.periodicTask(EmptyTrashArgs(es.cid, es.minAge), es.schedule) ) - .evalMap(es => EmptyTrashTask.periodicTask(es.cid, es.schedule)) .evalMap(pstore.insert) .compile .drain diff --git a/modules/joex/src/main/scala/docspell/joex/emptytrash/EmptyTrashTask.scala b/modules/joex/src/main/scala/docspell/joex/emptytrash/EmptyTrashTask.scala index bbc1e4e2..b7bf76b7 100644 --- a/modules/joex/src/main/scala/docspell/joex/emptytrash/EmptyTrashTask.scala +++ b/modules/joex/src/main/scala/docspell/joex/emptytrash/EmptyTrashTask.scala @@ -26,7 +26,7 @@ object EmptyTrashTask { private val pageSize = 20 - def periodicTask[F[_]: Sync](collective: Ident, ce: CalEvent): F[RPeriodicTask] = + def periodicTask[F[_]: Sync](args: EmptyTrashArgs, ce: CalEvent): F[RPeriodicTask] = Ident .randomId[F] .flatMap(id => @@ -36,8 +36,8 @@ object EmptyTrashTask { true, ce, None, - EmptyTrashArgs(collective) - ).encode.toPeriodicTask(UserTaskScope(collective)) + args + ).encode.toPeriodicTask(UserTaskScope(args.collective), args.makeSubject.some) ) def apply[F[_]: Async]( @@ -45,23 +45,27 @@ object EmptyTrashTask { 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) + now <- Timestamp.current[F] + maxDate = now.minus(ctx.args.minAge) + _ <- ctx.logger.info( + s"Starting removing all soft-deleted items older than ${maxDate.asString}" + ) + nDeleted <- deleteAll(ctx.args, maxDate, itemOps, itemSearchOps, ctx) _ <- ctx.logger.info(s"Finished deleting ${nDeleted} items") } yield () } private def deleteAll[F[_]: Async]( - collective: Ident, + args: Args, + maxUpdate: Timestamp, itemOps: OItem[F], itemSearchOps: OItemSearch[F], ctx: Context[F, _] ): F[Int] = Stream - .eval(itemSearchOps.findDeleted(collective, pageSize)) - .evalMap(deleteChunk(collective, itemOps, ctx)) + .eval(itemSearchOps.findDeleted(args.collective, maxUpdate, pageSize)) + .evalMap(deleteChunk(args.collective, itemOps, ctx)) .repeat .takeWhile(_ > 0) .compile diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index e02354d7..a46a60f9 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -1149,6 +1149,11 @@ paths: The request is empty, settings are used from the collective. security: - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/EmptyTrashSetting" responses: 200: description: Ok @@ -5267,7 +5272,7 @@ components: - language - integrationEnabled - classifier - - emptyTrashSchedule + - emptyTrash properties: language: type: string @@ -5277,11 +5282,24 @@ components: description: | Whether the collective has the integration endpoint enabled. - emptyTrashSchedule: - type: string - format: calevent classifier: $ref: "#/components/schemas/ClassifierSetting" + emptyTrash: + $ref: "#/components/schemas/EmptyTrashSetting" + + EmptyTrashSetting: + description: | + Settings for clearing the trash of items. + required: + - schedule + - minAge + properties: + schedule: + type: string + format: calevent + minAge: + type: integer + format: duration ClassifierSetting: description: | 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 abae60c8..dd2aac74 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,8 @@ import cats.implicits._ import docspell.backend.BackendApp import docspell.backend.auth.AuthToken import docspell.backend.ops.OCollective -import docspell.common.{EmptyTrashArgs, ListType} +import docspell.common.EmptyTrashArgs +import docspell.common.ListType import docspell.restapi.model._ import docspell.restserver.conv.Conversions import docspell.restserver.http4s._ @@ -56,7 +57,12 @@ object CollectiveRoutes { settings.classifier.listType ) ), - Some(settings.emptyTrashSchedule) + Some( + OCollective.EmptyTrash( + settings.emptyTrash.schedule, + settings.emptyTrash.minAge + ) + ) ) res <- backend.collective @@ -67,11 +73,11 @@ object CollectiveRoutes { case GET -> Root / "settings" => for { settDb <- backend.collective.findSettings(user.account.collective) + trash = settDb.flatMap(_.emptyTrash).getOrElse(OCollective.EmptyTrash.default) sett = settDb.map(c => CollectiveSettings( c.language, c.integrationEnabled, - c.emptyTrash.getOrElse(EmptyTrashArgs.defaultSchedule), ClassifierSetting( c.classifier.map(_.itemCount).getOrElse(0), c.classifier @@ -79,6 +85,10 @@ object CollectiveRoutes { .getOrElse(CalEvent.unsafe("*-1/3-01 01:00:00")), c.classifier.map(_.categories).getOrElse(Nil), c.classifier.map(_.listType).getOrElse(ListType.whitelist) + ), + EmptyTrashSetting( + trash.schedule, + trash.minAge ) ) ) @@ -103,9 +113,12 @@ object CollectiveRoutes { resp <- Ok(BasicResult(true, "Task submitted")) } yield resp - case POST -> Root / "emptytrash" / "startonce" => + case req @ POST -> Root / "emptytrash" / "startonce" => for { - _ <- backend.collective.startEmptyTrash(user.account.collective) + data <- req.as[EmptyTrashSetting] + _ <- backend.collective.startEmptyTrash( + EmptyTrashArgs(user.account.collective, data.minAge) + ) resp <- Ok(BasicResult(true, "Task submitted")) } yield resp 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 22376107..f51f3ce8 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/NotifyDueItemsRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/NotifyDueItemsRoutes.scala @@ -49,7 +49,7 @@ object NotifyDueItemsRoutes { newId <- Ident.randomId[F] task <- makeTask(newId, getBaseUrl(cfg, req), user.account, data) res <- - ut.executeNow(UserTaskScope(user.account), task) + ut.executeNow(UserTaskScope(user.account), None, task) .attempt .map(Conversions.basicResult(_, "Submitted successfully.")) resp <- Ok(res) @@ -69,7 +69,7 @@ object NotifyDueItemsRoutes { for { task <- makeTask(data.id, getBaseUrl(cfg, req), user.account, data) res <- - ut.submitNotifyDueItems(UserTaskScope(user.account), task) + ut.submitNotifyDueItems(UserTaskScope(user.account), None, task) .attempt .map(Conversions.basicResult(_, "Saved successfully")) resp <- Ok(res) @@ -87,7 +87,7 @@ object NotifyDueItemsRoutes { newId <- Ident.randomId[F] task <- makeTask(newId, getBaseUrl(cfg, req), user.account, data) res <- - ut.submitNotifyDueItems(UserTaskScope(user.account), task) + ut.submitNotifyDueItems(UserTaskScope(user.account), None, task) .attempt .map(Conversions.basicResult(_, "Saved successfully.")) resp <- Ok(res) 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 6ba7496e..c01503a4 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ScanMailboxRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ScanMailboxRoutes.scala @@ -46,7 +46,7 @@ object ScanMailboxRoutes { newId <- Ident.randomId[F] task <- makeTask(newId, user.account, data) res <- - ut.executeNow(UserTaskScope(user.account), task) + ut.executeNow(UserTaskScope(user.account), None, task) .attempt .map(Conversions.basicResult(_, "Submitted successfully.")) resp <- Ok(res) @@ -66,7 +66,7 @@ object ScanMailboxRoutes { for { task <- makeTask(data.id, user.account, data) res <- - ut.submitScanMailbox(UserTaskScope(user.account), task) + ut.submitScanMailbox(UserTaskScope(user.account), None, task) .attempt .map(Conversions.basicResult(_, "Saved successfully.")) resp <- Ok(res) @@ -84,7 +84,7 @@ object ScanMailboxRoutes { newId <- Ident.randomId[F] task <- makeTask(newId, user.account, data) res <- - ut.submitScanMailbox(UserTaskScope(user.account), task) + ut.submitScanMailbox(UserTaskScope(user.account), None, task) .attempt .map(Conversions.basicResult(_, "Saved successfully.")) resp <- Ok(res) diff --git a/modules/store/src/main/resources/db/migration/h2/V1.25.2__add_trash_min_age.sql b/modules/store/src/main/resources/db/migration/h2/V1.25.2__add_trash_min_age.sql new file mode 100644 index 00000000..c669b5ff --- /dev/null +++ b/modules/store/src/main/resources/db/migration/h2/V1.25.2__add_trash_min_age.sql @@ -0,0 +1,8 @@ +ALTER TABLE "empty_trash_setting" +ADD COLUMN "min_age" bigint; + +UPDATE "empty_trash_setting" +SET "min_age" = 604800000; + +ALTER TABLE "empty_trash_setting" +ALTER COLUMN "min_age" SET NOT NULL; diff --git a/modules/store/src/main/resources/db/migration/mariadb/V1.25.2__add_trash_min_age.sql b/modules/store/src/main/resources/db/migration/mariadb/V1.25.2__add_trash_min_age.sql new file mode 100644 index 00000000..3942aa6b --- /dev/null +++ b/modules/store/src/main/resources/db/migration/mariadb/V1.25.2__add_trash_min_age.sql @@ -0,0 +1,8 @@ +ALTER TABLE `empty_trash_setting` +ADD COLUMN (`min_age` bigint); + +UPDATE `empty_trash_setting` +SET `min_age` = 604800000; + +ALTER TABLE `empty_trash_setting` +MODIFY `min_age` bigint NOT NULL; diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.25.2__add_trash_min_age.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.25.2__add_trash_min_age.sql new file mode 100644 index 00000000..c669b5ff --- /dev/null +++ b/modules/store/src/main/resources/db/migration/postgresql/V1.25.2__add_trash_min_age.sql @@ -0,0 +1,8 @@ +ALTER TABLE "empty_trash_setting" +ADD COLUMN "min_age" bigint; + +UPDATE "empty_trash_setting" +SET "min_age" = 604800000; + +ALTER TABLE "empty_trash_setting" +ALTER COLUMN "min_age" SET NOT NULL; diff --git a/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala b/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala index a1ae5da3..09c01f38 100644 --- a/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala +++ b/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala @@ -34,6 +34,9 @@ trait DoobieMeta extends EmilDoobieMeta { e.apply(a).noSpaces ) + implicit val metaDuration: Meta[Duration] = + Meta[Long].imap(Duration.millis)(_.millis) + implicit val metaCollectiveState: Meta[CollectiveState] = Meta[String].imap(CollectiveState.unsafe)(CollectiveState.asString) 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 81236544..13219648 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QUserTask.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QUserTask.scala @@ -54,15 +54,23 @@ object QUserTask { ) ).query[RPeriodicTask].option.map(_.map(makeUserTask)) - def insert(scope: UserTaskScope, task: UserTask[String]): ConnectionIO[Int] = + def insert( + scope: UserTaskScope, + subject: Option[String], + task: UserTask[String] + ): ConnectionIO[Int] = for { - r <- task.toPeriodicTask[ConnectionIO](scope) + r <- task.toPeriodicTask[ConnectionIO](scope, subject) n <- RPeriodicTask.insert(r) } yield n - def update(scope: UserTaskScope, task: UserTask[String]): ConnectionIO[Int] = + def update( + scope: UserTaskScope, + subject: Option[String], + task: UserTask[String] + ): ConnectionIO[Int] = for { - r <- task.toPeriodicTask[ConnectionIO](scope) + r <- task.toPeriodicTask[ConnectionIO](scope, subject) 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 c3326b3d..43b7e382 100644 --- a/modules/store/src/main/scala/docspell/store/records/RCollective.scala +++ b/modules/store/src/main/scala/docspell/store/records/RCollective.scala @@ -13,7 +13,6 @@ import docspell.common._ import docspell.store.qb.DSL._ import docspell.store.qb._ -import com.github.eikek.calev._ import doobie._ import doobie.implicits._ @@ -75,16 +74,15 @@ object RCollective { ) ) now <- Timestamp.current[ConnectionIO] - cls = settings.classifier.map(_.toRecord(cid, now)) - n2 <- cls match { - case Some(cr) => - RClassifierSetting.update(cr) + n2 <- settings.classifier match { + case Some(cls) => + RClassifierSetting.update(cls.toRecord(cid, now)) case None => RClassifierSetting.delete(cid) } n3 <- settings.emptyTrash match { - case Some(trashSchedule) => - REmptyTrashSetting.update(REmptyTrashSetting(cid, trashSchedule, now)) + case Some(trash) => + REmptyTrashSetting.update(trash.toRecord(cid, now)) case None => REmptyTrashSetting.delete(cid) } @@ -114,7 +112,8 @@ object RCollective { cs.itemCount.s, cs.categories.s, cs.listType.s, - es.schedule.s + es.schedule.s, + es.minAge.s ), from(c).leftJoin(cs, cs.cid === c.id).leftJoin(es, es.cid === c.id), c.id === coll @@ -168,7 +167,7 @@ object RCollective { language: Language, integrationEnabled: Boolean, classifier: Option[RClassifierSetting.Classifier], - emptyTrash: Option[CalEvent] + emptyTrash: Option[REmptyTrashSetting.EmptyTrash] ) } diff --git a/modules/store/src/main/scala/docspell/store/records/REmptyTrashSetting.scala b/modules/store/src/main/scala/docspell/store/records/REmptyTrashSetting.scala index f08079e5..10c1e837 100644 --- a/modules/store/src/main/scala/docspell/store/records/REmptyTrashSetting.scala +++ b/modules/store/src/main/scala/docspell/store/records/REmptyTrashSetting.scala @@ -21,6 +21,7 @@ import doobie.implicits._ final case class REmptyTrashSetting( cid: Ident, schedule: CalEvent, + minAge: Duration, created: Timestamp ) @@ -31,8 +32,9 @@ object REmptyTrashSetting { val cid = Column[Ident]("cid", this) val schedule = Column[CalEvent]("schedule", this) + val minAge = Column[Duration]("min_age", this) val created = Column[Timestamp]("created", this) - val all = NonEmptyList.of[Column[_]](cid, schedule, created) + val all = NonEmptyList.of[Column[_]](cid, schedule, minAge, created) } val T = Table(None) @@ -43,7 +45,7 @@ object REmptyTrashSetting { DML.insert( T, T.all, - fr"${v.cid},${v.schedule},${v.created}" + fr"${v.cid},${v.schedule},${v.minAge},${v.created}" ) def update(v: REmptyTrashSetting): ConnectionIO[Int] = @@ -52,7 +54,8 @@ object REmptyTrashSetting { T, T.cid === v.cid, DML.set( - T.schedule.setTo(v.schedule) + T.schedule.setTo(v.schedule), + T.minAge.setTo(v.minAge) ) ) n2 <- if (n1 <= 0) insert(v) else 0.pure[ConnectionIO] @@ -64,7 +67,7 @@ object REmptyTrashSetting { } def findForAllCollectives( - default: CalEvent, + default: EmptyTrash, chunkSize: Int ): Stream[ConnectionIO, REmptyTrashSetting] = { val c = RCollective.as("c") @@ -72,7 +75,8 @@ object REmptyTrashSetting { val sql = run( select( c.id.s, - coalesce(e.schedule.s, const(default)).s, + coalesce(e.schedule.s, const(default.schedule)).s, + coalesce(e.minAge.s, const(default.minAge)).s, coalesce(e.created.s, c.created.s).s ), from(c).leftJoin(e, e.cid === c.id) @@ -83,4 +87,13 @@ object REmptyTrashSetting { def delete(coll: Ident): ConnectionIO[Int] = DML.delete(T, T.cid === coll) + final case class EmptyTrash(schedule: CalEvent, minAge: Duration) { + def toRecord(coll: Ident, created: Timestamp): REmptyTrashSetting = + REmptyTrashSetting(coll, schedule, minAge, created) + } + object EmptyTrash { + val default = EmptyTrash(EmptyTrashArgs.defaultSchedule, Duration.days(7)) + def fromRecord(r: REmptyTrashSetting): EmptyTrash = + EmptyTrash(r.schedule, r.minAge) + } } 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 0fcdc7e9..22f3e60c 100644 --- a/modules/store/src/main/scala/docspell/store/records/RItem.scala +++ b/modules/store/src/main/scala/docspell/store/records/RItem.scala @@ -389,8 +389,16 @@ 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) + def findDeleted( + collective: Ident, + maxUpdated: Timestamp, + chunkSize: Int + ): Stream[ConnectionIO, RItem] = + run( + select(T.all), + from(T), + T.cid === collective && T.state === ItemState.deleted && T.updated < maxUpdated + ) .query[RItem] .streamWithChunkSize(chunkSize) 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 42255a07..27be5eab 100644 --- a/modules/store/src/main/scala/docspell/store/usertask/UserTask.scala +++ b/modules/store/src/main/scala/docspell/store/usertask/UserTask.scala @@ -43,7 +43,8 @@ object UserTask { .map(a => ut.copy(args = a)) def toPeriodicTask[F[_]: Sync]( - scope: UserTaskScope + scope: UserTaskScope, + subject: Option[String] ): F[RPeriodicTask] = RPeriodicTask .create[F]( @@ -51,7 +52,7 @@ object UserTask { scope, ut.name, ut.args, - s"${scope.fold(_.user.id, _.id)}: ${ut.name.id}", + subject.getOrElse(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/UserTaskStore.scala b/modules/store/src/main/scala/docspell/store/usertask/UserTaskStore.scala index 7c084f00..1bb53794 100644 --- a/modules/store/src/main/scala/docspell/store/usertask/UserTaskStore.scala +++ b/modules/store/src/main/scala/docspell/store/usertask/UserTaskStore.scala @@ -61,7 +61,9 @@ trait UserTaskStore[F[_]] { * exists, a new one is created. Otherwise the existing task is * updated. */ - def updateTask[A](scope: UserTaskScope, ut: UserTask[A])(implicit E: Encoder[A]): F[Int] + def updateTask[A](scope: UserTaskScope, subject: Option[String], ut: UserTask[A])( + implicit E: Encoder[A] + ): F[Int] /** Delete the task with the given id of the given user. */ @@ -92,8 +94,8 @@ trait UserTaskStore[F[_]] { * the user `account`, they will all be removed and the given task * inserted! */ - def updateOneTask[A](scope: UserTaskScope, ut: UserTask[A])(implicit - E: Encoder[A] + def updateOneTask[A](scope: UserTaskScope, subject: Option[String], ut: UserTask[A])( + implicit E: Encoder[A] ): F[UserTask[String]] /** Delete all tasks of the given user that have name `name'. @@ -123,16 +125,16 @@ object UserTaskStore { case Left(err) => Stream.raiseError[F](new Exception(err)) }) - def updateTask[A](scope: UserTaskScope, ut: UserTask[A])(implicit - E: Encoder[A] + def updateTask[A](scope: UserTaskScope, subject: Option[String], ut: UserTask[A])( + implicit E: Encoder[A] ): F[Int] = { val exists = QUserTask.exists(ut.id) - val insert = QUserTask.insert(scope, ut.encode) + val insert = QUserTask.insert(scope, subject, ut.encode) store.add(insert, exists).flatMap { case AddResult.Success => 1.pure[F] case AddResult.EntityExists(_) => - store.transact(QUserTask.update(scope, ut.encode)) + store.transact(QUserTask.update(scope, subject, ut.encode)) case AddResult.Failure(ex) => Async[F].raiseError(ex) } @@ -166,21 +168,25 @@ object UserTaskStore { case Left(err) => Async[F].raiseError(new Exception(err)) }) - def updateOneTask[A](scope: UserTaskScope, ut: UserTask[A])(implicit + def updateOneTask[A]( + scope: UserTaskScope, + subject: Option[String], + ut: UserTask[A] + )(implicit E: Encoder[A] ): F[UserTask[String]] = getByNameRaw(scope, ut.name).compile.toList.flatMap { case a :: rest => val task = ut.copy(id = a.id).encode for { - _ <- store.transact(QUserTask.update(scope, task)) + _ <- store.transact(QUserTask.update(scope, subject, task)) _ <- store.transact( rest.traverse(t => QUserTask.delete(scope.toAccountId, t.id)) ) } yield task case Nil => val task = ut.encode - store.transact(QUserTask.insert(scope, task)).map(_ => task) + store.transact(QUserTask.insert(scope, subject, task)).map(_ => task) } def deleteAll(scope: UserTaskScope, name: Ident): F[Int] = diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index 1f26e605..1e167e5b 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -158,6 +158,7 @@ import Api.Model.CustomFieldValue exposing (CustomFieldValue) import Api.Model.DirectionValue exposing (DirectionValue) import Api.Model.EmailSettings exposing (EmailSettings) import Api.Model.EmailSettingsList exposing (EmailSettingsList) +import Api.Model.EmptyTrashSetting exposing (EmptyTrashSetting) import Api.Model.Equipment exposing (Equipment) import Api.Model.EquipmentList exposing (EquipmentList) import Api.Model.FolderDetail exposing (FolderDetail) @@ -999,13 +1000,14 @@ startClassifier flags receive = startEmptyTrash : Flags + -> EmptyTrashSetting -> (Result Http.Error BasicResult -> msg) -> Cmd msg -startEmptyTrash flags receive = +startEmptyTrash flags setting receive = Http2.authPost { url = flags.config.baseUrl ++ "/api/v1/sec/collective/emptytrash/startonce" , account = getAccount flags - , body = Http.emptyBody + , body = Http.jsonBody (Api.Model.EmptyTrashSetting.encode setting) , expect = Http.expectJson receive Api.Model.BasicResult.decoder } diff --git a/modules/webapp/src/main/elm/Comp/CollectiveSettingsForm.elm b/modules/webapp/src/main/elm/Comp/CollectiveSettingsForm.elm index 91da0d84..3c8a80b2 100644 --- a/modules/webapp/src/main/elm/Comp/CollectiveSettingsForm.elm +++ b/modules/webapp/src/main/elm/Comp/CollectiveSettingsForm.elm @@ -22,7 +22,6 @@ 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) @@ -54,11 +53,14 @@ type ClassifierResult | ClassifierResultSubmitError String | ClassifierResultOk + type EmptyTrashResult = EmptyTrashResultInitial | EmptyTrashResultHttpError Http.Error | EmptyTrashResultSubmitError String | EmptyTrashResultOk + | EmptyTrashResultInvalidForm + type FulltextReindexResult = FulltextReindexInitial @@ -79,7 +81,7 @@ init flags settings = Comp.ClassifierSettingsForm.init flags settings.classifier ( em, ec ) = - Comp.EmptyTrashForm.init flags settings.emptyTrashSchedule + Comp.EmptyTrashForm.init flags settings.emptyTrash in ( { langModel = Comp.Dropdown.makeSingleList @@ -101,24 +103,21 @@ init flags settings = getSettings : Model -> Maybe CollectiveSettings getSettings model = - Maybe.map + Maybe.map2 (\cls -> - { language = - Comp.Dropdown.getSelected model.langModel - |> List.head - |> Maybe.map Data.Language.toIso3 - |> 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 - model.classifierModel + \trash -> + { language = + Comp.Dropdown.getSelected model.langModel + |> List.head + |> Maybe.map Data.Language.toIso3 + |> Maybe.withDefault model.initSettings.language + , integrationEnabled = model.intEnabled + , classifier = cls + , emptyTrash = trash + } ) + (Comp.ClassifierSettingsForm.getSettings model.classifierModel) + (Comp.EmptyTrashForm.getSettings model.emptyTrashModel) type Msg @@ -233,8 +232,20 @@ update flags msg model = ( model, Api.startClassifier flags StartClassifierResp, Nothing ) StartEmptyTrashTask -> - ( model, Api.startEmptyTrash flags StartEmptyTrashResp, Nothing ) + case getSettings model of + Just settings -> + ( model + , Api.startEmptyTrash flags + settings.emptyTrash + StartEmptyTrashResp + , Nothing + ) + Nothing -> + ( { model | startEmptyTrashResult = EmptyTrashResultInvalidForm } + , Cmd.none + , Nothing + ) StartClassifierResp (Ok br) -> ( { model @@ -275,6 +286,7 @@ update flags msg model = ) + --- View2 @@ -471,6 +483,7 @@ renderClassifierResultMessage texts result = , ( S.successMessage, isSuccess ) , ( "hidden", result == ClassifierResultInitial ) ] + , class "ml-2" ] [ case result of ClassifierResultInitial -> @@ -505,6 +518,7 @@ renderFulltextReindexResultMessage texts result = FulltextReindexSubmitError m -> text m + renderEmptyTrashResultMessage : Texts -> EmptyTrashResult -> Html msg renderEmptyTrashResultMessage texts result = let @@ -525,6 +539,7 @@ renderEmptyTrashResultMessage texts result = , ( S.successMessage, isSuccess ) , ( "hidden", result == EmptyTrashResultInitial ) ] + , class "ml-2" ] [ case result of EmptyTrashResultInitial -> @@ -538,4 +553,7 @@ renderEmptyTrashResultMessage texts result = EmptyTrashResultSubmitError m -> text m + + EmptyTrashResultInvalidForm -> + text texts.emptyTrashStartInvalidForm ] diff --git a/modules/webapp/src/main/elm/Comp/EmptyTrashForm.elm b/modules/webapp/src/main/elm/Comp/EmptyTrashForm.elm index 86cfd1ab..5116f5cb 100644 --- a/modules/webapp/src/main/elm/Comp/EmptyTrashForm.elm +++ b/modules/webapp/src/main/elm/Comp/EmptyTrashForm.elm @@ -14,40 +14,36 @@ module Comp.EmptyTrashForm exposing , view ) -import Api +import Api.Model.EmptyTrashSetting exposing (EmptyTrashSetting) 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 + , minAgeModel : Comp.IntField.Model + , minAgeDays : Maybe Int } type Msg = ScheduleMsg Comp.CalEventInput.Msg + | MinAgeMsg Comp.IntField.Msg -init : Flags -> String -> ( Model, Cmd Msg ) -init flags schedule = +init : Flags -> EmptyTrashSetting -> ( Model, Cmd Msg ) +init flags settings = let newSchedule = - Data.CalEvent.fromEvent schedule + Data.CalEvent.fromEvent settings.schedule |> Maybe.withDefault Data.CalEvent.everyMonth ( cem, cec ) = @@ -55,14 +51,34 @@ init flags schedule = in ( { scheduleModel = cem , schedule = Just newSchedule + , minAgeModel = Comp.IntField.init (Just 0) Nothing False + , minAgeDays = Just <| millisToDays settings.minAge } , Cmd.map ScheduleMsg cec ) -getSettings : Model -> Maybe CalEvent +millisToDays : Int -> Int +millisToDays millis = + round <| toFloat millis / 1000 / 60 / 60 / 24 + + +daysToMillis : Int -> Int +daysToMillis days = + days * 24 * 60 * 60 * 1000 + + +getSettings : Model -> Maybe EmptyTrashSetting getSettings model = - model.schedule + Maybe.map2 + (\sch -> + \age -> + { schedule = Data.CalEvent.makeEvent sch + , minAge = daysToMillis age + } + ) + model.schedule + model.minAgeDays update : Flags -> Msg -> Model -> ( Model, Cmd Msg ) @@ -84,6 +100,18 @@ update flags msg model = , Cmd.map ScheduleMsg cc ) + MinAgeMsg lmsg -> + let + ( mm, newAge ) = + Comp.IntField.update lmsg model.minAgeModel + in + ( { model + | minAgeModel = mm + , minAgeDays = newAge + } + , Cmd.none + ) + --- View2 @@ -103,4 +131,16 @@ view texts _ model = model.scheduleModel ) ] + , div [ class "mb-4" ] + [ let + settings : Comp.IntField.ViewSettings + settings = + { number = model.minAgeDays + , label = texts.minAge + , classes = "" + , info = texts.minAgeInfo + } + in + Html.map MinAgeMsg (Comp.IntField.view settings model.minAgeModel) + ] ] diff --git a/modules/webapp/src/main/elm/Comp/IntField.elm b/modules/webapp/src/main/elm/Comp/IntField.elm index 783cdf9a..1012cdb6 100644 --- a/modules/webapp/src/main/elm/Comp/IntField.elm +++ b/modules/webapp/src/main/elm/Comp/IntField.elm @@ -47,13 +47,13 @@ init min max opt = tooLow : Model -> Int -> Bool tooLow model n = Maybe.map ((<) n) model.min - |> Maybe.withDefault (not model.optional) + |> Maybe.withDefault False tooHigh : Model -> Int -> Bool tooHigh model n = Maybe.map ((>) n) model.max - |> Maybe.withDefault (not model.optional) + |> Maybe.withDefault False update : Msg -> Model -> ( Model, Maybe Int ) diff --git a/modules/webapp/src/main/elm/Messages/Comp/CollectiveSettingsForm.elm b/modules/webapp/src/main/elm/Messages/Comp/CollectiveSettingsForm.elm index f86f1d94..773bac94 100644 --- a/modules/webapp/src/main/elm/Messages/Comp/CollectiveSettingsForm.elm +++ b/modules/webapp/src/main/elm/Messages/Comp/CollectiveSettingsForm.elm @@ -40,6 +40,7 @@ type alias Texts = , languageLabel : Language -> String , classifierTaskStarted : String , emptyTrashTaskStarted : String + , emptyTrashStartInvalidForm : String , fulltextReindexSubmitted : String , fulltextReindexOkMissing : String , emptyTrash : String @@ -71,6 +72,7 @@ gb = , languageLabel = Messages.Data.Language.gb , classifierTaskStarted = "Classifier task started." , emptyTrashTaskStarted = "Empty trash task started." + , emptyTrashStartInvalidForm = "The empty-trash form contains errors." , fulltextReindexSubmitted = "Fulltext Re-Index started." , fulltextReindexOkMissing = "Please type OK in the field if you really want to start re-indexing your data." @@ -103,6 +105,7 @@ de = , languageLabel = Messages.Data.Language.de , classifierTaskStarted = "Kategorisierung gestartet." , emptyTrashTaskStarted = "Papierkorb löschen gestartet." + , emptyTrashStartInvalidForm = "Das Papierkorb-Löschen Formular ist fehlerhaft!" , fulltextReindexSubmitted = "Volltext Neu-Indexierung gestartet." , fulltextReindexOkMissing = "Bitte tippe OK in das Feld ein, wenn Du wirklich den Index neu erzeugen möchtest." diff --git a/modules/webapp/src/main/elm/Messages/Comp/EmptyTrashForm.elm b/modules/webapp/src/main/elm/Messages/Comp/EmptyTrashForm.elm index 872da608..a8d94456 100644 --- a/modules/webapp/src/main/elm/Messages/Comp/EmptyTrashForm.elm +++ b/modules/webapp/src/main/elm/Messages/Comp/EmptyTrashForm.elm @@ -19,6 +19,8 @@ type alias Texts = { basics : Messages.Basics.Texts , calEventInput : Messages.Comp.CalEventInput.Texts , schedule : String + , minAge : String + , minAgeInfo : String } @@ -27,6 +29,8 @@ gb = { basics = Messages.Basics.gb , calEventInput = Messages.Comp.CalEventInput.gb , schedule = "Schedule" + , minAge = "Minimum Age (Days)" + , minAgeInfo = "The minimum age in days of an items to be removed. The last-update time is used." } @@ -35,4 +39,6 @@ de = { basics = Messages.Basics.de , calEventInput = Messages.Comp.CalEventInput.de , schedule = "Zeitplan" + , minAge = "Mindestalter (Tage)" + , minAgeInfo = "Das Mindestalter (in Tagen) der Dokumente, die gelöscht werden. Es wird das Datum der letzten Veränderung verwendet." }