mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-04-04 18:39:33 +00:00
Use a minimum age of items to remove
In order to keep deleted items for a while, the periodic task can now use a duration to only remove items with a certain age. This can be used to ensure that a deleted item stays at least X days before it will be removed from the database. Refs: #347
This commit is contained in:
parent
d136bb8166
commit
f4a2b86ea8
@ -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")))
|
||||
}))
|
||||
)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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 ()
|
||||
})
|
||||
|
@ -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}"
|
||||
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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: |
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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]
|
||||
)
|
||||
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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] =
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
]
|
||||
|
@ -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)
|
||||
]
|
||||
]
|
||||
|
@ -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 )
|
||||
|
@ -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."
|
||||
|
@ -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."
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user