Merge pull request #1006 from eikek/feature/347-delete-items

Feature/347 delete items
This commit is contained in:
mergify[bot] 2021-08-14 20:16:17 +00:00 committed by GitHub
commit fe7d64d989
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
60 changed files with 1385 additions and 199 deletions

View File

@ -238,6 +238,10 @@ val openapiScalaSettings = Seq(
field.copy(typeDef = field.copy(typeDef =
TypeDef("EquipmentUse", Imports("docspell.common.EquipmentUse")) TypeDef("EquipmentUse", Imports("docspell.common.EquipmentUse"))
) )
case "searchmode" =>
field =>
field
.copy(typeDef = TypeDef("SearchMode", Imports("docspell.common.SearchMode")))
})) }))
) )

View File

@ -18,8 +18,7 @@ import docspell.store.UpdateResult
import docspell.store.queries.QCollective import docspell.store.queries.QCollective
import docspell.store.queue.JobQueue import docspell.store.queue.JobQueue
import docspell.store.records._ import docspell.store.records._
import docspell.store.usertask.UserTask import docspell.store.usertask.{UserTask, UserTaskScope, UserTaskStore}
import docspell.store.usertask.UserTaskStore
import docspell.store.{AddResult, Store} import docspell.store.{AddResult, Store}
import com.github.eikek.calev._ import com.github.eikek.calev._
@ -62,6 +61,8 @@ trait OCollective[F[_]] {
def startLearnClassifier(collective: Ident): F[Unit] def startLearnClassifier(collective: Ident): F[Unit]
def startEmptyTrash(collective: Ident): F[Unit]
/** Submits a task that (re)generates the preview images for all /** Submits a task that (re)generates the preview images for all
* attachments of the given collective. * attachments of the given collective.
*/ */
@ -147,9 +148,14 @@ object OCollective {
.transact(RCollective.updateSettings(collective, sett)) .transact(RCollective.updateSettings(collective, sett))
.attempt .attempt
.map(AddResult.fromUpdate) .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 { for {
id <- Ident.randomId[F] id <- Ident.randomId[F]
on = sett.classifier.map(_.enabled).getOrElse(false) on = sett.classifier.map(_.enabled).getOrElse(false)
@ -162,7 +168,23 @@ object OCollective {
None, None,
LearnClassifierArgs(coll) 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 _ <- joex.notifyAllNodes
} yield () } yield ()
@ -176,7 +198,23 @@ object OCollective {
CalEvent(WeekdayComponent.All, DateEvent.All, TimeEvent.All), CalEvent(WeekdayComponent.All, DateEvent.All, TimeEvent.All),
None, None,
LearnClassifierArgs(collective) 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 job <- ut.toJob
_ <- queue.insert(job) _ <- queue.insert(job)
_ <- joex.notifyAllNodes _ <- joex.notifyAllNodes

View File

@ -124,6 +124,8 @@ trait OItem[F[_]] {
collective: Ident collective: Ident
): F[AddResult] ): F[AddResult]
def restore(items: NonEmptyList[Ident], collective: Ident): F[UpdateResult]
def setItemDate( def setItemDate(
item: NonEmptyList[Ident], item: NonEmptyList[Ident],
date: Option[Timestamp], date: Option[Timestamp],
@ -144,6 +146,8 @@ trait OItem[F[_]] {
def deleteAttachment(id: Ident, collective: Ident): F[Int] def deleteAttachment(id: Ident, collective: Ident): F[Int]
def setDeletedState(items: NonEmptyList[Ident], collective: Ident): F[Int]
def deleteAttachmentMultiple( def deleteAttachmentMultiple(
attachments: NonEmptyList[Ident], attachments: NonEmptyList[Ident],
collective: Ident collective: Ident
@ -580,6 +584,17 @@ object OItem {
.attempt .attempt
.map(AddResult.fromUpdate) .map(AddResult.fromUpdate)
def restore(
items: NonEmptyList[Ident],
collective: Ident
): F[UpdateResult] =
UpdateResult.fromUpdate(
store
.transact(
RItem.restoreStateForCollective(items, ItemState.Created, collective)
)
)
def setItemDate( def setItemDate(
items: NonEmptyList[Ident], items: NonEmptyList[Ident],
date: Option[Timestamp], date: Option[Timestamp],
@ -612,6 +627,9 @@ object OItem {
n = results.sum n = results.sum
} yield n } 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] = def getProposals(item: Ident, collective: Ident): F[MetaProposalList] =
store.transact(QAttachment.getMetaProposals(item, collective)) store.transact(QAttachment.getMetaProposals(item, collective))

View File

@ -23,6 +23,8 @@ import doobie.implicits._
trait OItemSearch[F[_]] { trait OItemSearch[F[_]] {
def findItem(id: Ident, collective: Ident): F[Option[ItemData]] 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]] def findItems(maxNoteLen: Int)(q: Query, batch: Batch): F[Vector[ListItem]]
/** Same as `findItems` but does more queries per item to find all tags. */ /** Same as `findItems` but does more queries per item to find all tags. */
@ -145,6 +147,13 @@ object OItemSearch {
.toVector .toVector
} }
def findDeleted(collective: Ident, limit: Int): F[Vector[RItem]] =
store
.transact(RItem.findDeleted(collective, limit))
.take(limit.toLong)
.compile
.toVector
def findItemsWithTags( def findItemsWithTags(
maxNoteLen: Int maxNoteLen: Int
)(q: Query, batch: Batch): F[Vector[ListItemWithTags]] = )(q: Query, batch: Batch): F[Vector[ListItemWithTags]] =

View File

@ -19,7 +19,10 @@ import docspell.store.queries.SearchSummary
import org.log4s.getLogger 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[_]] { trait OSimpleSearch[F[_]] {
/** Search for items using the given query and optional fulltext /** Search for items using the given query and optional fulltext
@ -36,7 +39,7 @@ trait OSimpleSearch[F[_]] {
* and not the results. * and not the results.
*/ */
def searchSummary( def searchSummary(
useFTS: Boolean settings: StatsSettings
)(q: Query, fulltextQuery: Option[String]): F[SearchSummary] )(q: Query, fulltextQuery: Option[String]): F[SearchSummary]
/** Calls `search` by parsing the given query string into a query that /** Calls `search` by parsing the given query string into a query that
@ -53,12 +56,12 @@ trait OSimpleSearch[F[_]] {
* results. * results.
*/ */
final def searchSummaryByString( final def searchSummaryByString(
useFTS: Boolean settings: StatsSettings
)(fix: Query.Fix, q: ItemQueryString)(implicit )(fix: Query.Fix, q: ItemQueryString)(implicit
F: Applicative[F] F: Applicative[F]
): F[StringSearchResult[SearchSummary]] = ): F[StringSearchResult[SearchSummary]] =
OSimpleSearch.applySearch[F, SearchSummary](fix, q)((iq, fts) => OSimpleSearch.applySearch[F, SearchSummary](fix, q)((iq, fts) =>
searchSummary(useFTS)(iq, fts) searchSummary(settings)(iq, fts)
) )
} }
@ -83,7 +86,12 @@ object OSimpleSearch {
batch: Batch, batch: Batch,
useFTS: Boolean, useFTS: Boolean,
resolveDetails: Boolean, resolveDetails: Boolean,
maxNoteLen: Int maxNoteLen: Int,
searchMode: SearchMode
)
final case class StatsSettings(
useFTS: Boolean,
searchMode: SearchMode
) )
sealed trait Items { sealed trait Items {
@ -214,7 +222,11 @@ object OSimpleSearch {
// 1. fulltext only if fulltextQuery.isDefined && q.isEmpty && useFTS // 1. fulltext only if fulltextQuery.isDefined && q.isEmpty && useFTS
// 2. sql+fulltext if fulltextQuery.isDefined && q.nonEmpty && useFTS // 2. sql+fulltext if fulltextQuery.isDefined && q.nonEmpty && useFTS
// 3. sql-only else (if fulltextQuery.isEmpty || !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 { fulltextQuery match {
case Some(ftq) if settings.useFTS => case Some(ftq) if settings.useFTS =>
if (q.isEmpty) { if (q.isEmpty) {
@ -267,18 +279,24 @@ object OSimpleSearch {
} }
def searchSummary( def searchSummary(
useFTS: Boolean settings: StatsSettings
)(q: Query, fulltextQuery: Option[String]): F[SearchSummary] = )(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 { fulltextQuery match {
case Some(ftq) if useFTS => case Some(ftq) if settings.useFTS =>
if (q.isEmpty) if (q.isEmpty)
fts.findIndexOnlySummary(q.fix.account, OFulltext.FtsInput(ftq)) fts.findIndexOnlySummary(q.fix.account, OFulltext.FtsInput(ftq))
else else
fts fts
.findItemsSummary(q, OFulltext.FtsInput(ftq)) .findItemsSummary(validItemQuery, OFulltext.FtsInput(ftq))
case _ => case _ =>
is.findItemsSummary(q) is.findItemsSummary(validItemQuery)
}
} }
} }
} }

View File

@ -21,47 +21,47 @@ trait OUserTask[F[_]] {
/** Return the settings for all scan-mailbox tasks of the current user. /** 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. */ /** Find a scan-mailbox task by the given id. */
def findScanMailbox( def findScanMailbox(
id: Ident, id: Ident,
account: AccountId scope: UserTaskScope
): OptionT[F, UserTask[ScanMailboxArgs]] ): OptionT[F, UserTask[ScanMailboxArgs]]
/** Updates the scan-mailbox tasks and notifies the joex nodes. /** Updates the scan-mailbox tasks and notifies the joex nodes.
*/ */
def submitScanMailbox( def submitScanMailbox(
account: AccountId, scope: UserTaskScope,
task: UserTask[ScanMailboxArgs] task: UserTask[ScanMailboxArgs]
): F[Unit] ): F[Unit]
/** Return the settings for all the notify-due-items task of the /** Return the settings for all the notify-due-items task of the
* current user. * 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. */ /** Find a notify-due-items task by the given id. */
def findNotifyDueItems( def findNotifyDueItems(
id: Ident, id: Ident,
account: AccountId scope: UserTaskScope
): OptionT[F, UserTask[NotifyDueItemsArgs]] ): OptionT[F, UserTask[NotifyDueItemsArgs]]
/** Updates the notify-due-items tasks and notifies the joex nodes. /** Updates the notify-due-items tasks and notifies the joex nodes.
*/ */
def submitNotifyDueItems( def submitNotifyDueItems(
account: AccountId, scope: UserTaskScope,
task: UserTask[NotifyDueItemsArgs] task: UserTask[NotifyDueItemsArgs]
): F[Unit] ): F[Unit]
/** Removes a user task with the given id. */ /** 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 /** Discards the schedule and immediately submits the task to the job
* executor's queue. It will not update the corresponding periodic * executor's queue. It will not update the corresponding periodic
* task. * task.
*/ */
def executeNow[A](account: AccountId, task: UserTask[A])(implicit def executeNow[A](scope: UserTaskScope, task: UserTask[A])(implicit
E: Encoder[A] E: Encoder[A]
): F[Unit] ): F[Unit]
} }
@ -75,57 +75,59 @@ object OUserTask {
): Resource[F, OUserTask[F]] = ): Resource[F, OUserTask[F]] =
Resource.pure[F, OUserTask[F]](new 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] E: Encoder[A]
): F[Unit] = ): F[Unit] =
for { for {
ptask <- task.encode.toPeriodicTask(account) ptask <- task.encode.toPeriodicTask(scope)
job <- ptask.toJob job <- ptask.toJob
_ <- queue.insert(job) _ <- queue.insert(job)
_ <- joex.notifyAllNodes _ <- joex.notifyAllNodes
} yield () } yield ()
def getScanMailbox(account: AccountId): Stream[F, UserTask[ScanMailboxArgs]] = def getScanMailbox(scope: UserTaskScope): Stream[F, UserTask[ScanMailboxArgs]] =
store store
.getByName[ScanMailboxArgs](account, ScanMailboxArgs.taskName) .getByName[ScanMailboxArgs](scope, ScanMailboxArgs.taskName)
def findScanMailbox( def findScanMailbox(
id: Ident, id: Ident,
account: AccountId scope: UserTaskScope
): OptionT[F, UserTask[ScanMailboxArgs]] = ): 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 { (for {
_ <- store.getByIdRaw(account, id) _ <- store.getByIdRaw(scope, id)
_ <- OptionT.liftF(store.deleteTask(account, id)) _ <- OptionT.liftF(store.deleteTask(scope, id))
} yield ()).getOrElse(()) } yield ()).getOrElse(())
def submitScanMailbox( def submitScanMailbox(
account: AccountId, scope: UserTaskScope,
task: UserTask[ScanMailboxArgs] task: UserTask[ScanMailboxArgs]
): F[Unit] = ): F[Unit] =
for { for {
_ <- store.updateTask[ScanMailboxArgs](account, task) _ <- store.updateTask[ScanMailboxArgs](scope, task)
_ <- joex.notifyAllNodes _ <- joex.notifyAllNodes
} yield () } yield ()
def getNotifyDueItems(account: AccountId): Stream[F, UserTask[NotifyDueItemsArgs]] = def getNotifyDueItems(
scope: UserTaskScope
): Stream[F, UserTask[NotifyDueItemsArgs]] =
store store
.getByName[NotifyDueItemsArgs](account, NotifyDueItemsArgs.taskName) .getByName[NotifyDueItemsArgs](scope, NotifyDueItemsArgs.taskName)
def findNotifyDueItems( def findNotifyDueItems(
id: Ident, id: Ident,
account: AccountId scope: UserTaskScope
): OptionT[F, UserTask[NotifyDueItemsArgs]] = ): OptionT[F, UserTask[NotifyDueItemsArgs]] =
OptionT(getNotifyDueItems(account).find(_.id == id).compile.last) OptionT(getNotifyDueItems(scope).find(_.id == id).compile.last)
def submitNotifyDueItems( def submitNotifyDueItems(
account: AccountId, scope: UserTaskScope,
task: UserTask[NotifyDueItemsArgs] task: UserTask[NotifyDueItemsArgs]
): F[Unit] = ): F[Unit] =
for { for {
_ <- store.updateTask[NotifyDueItemsArgs](account, task) _ <- store.updateTask[NotifyDueItemsArgs](scope, task)
_ <- joex.notifyAllNodes _ <- joex.notifyAllNodes
} yield () } yield ()
}) })

View File

@ -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]
}

View File

@ -28,11 +28,13 @@ object ItemState {
case object Processing extends ItemState case object Processing extends ItemState
case object Created extends ItemState case object Created extends ItemState
case object Confirmed extends ItemState case object Confirmed extends ItemState
case object Deleted extends ItemState
def premature: ItemState = Premature def premature: ItemState = Premature
def processing: ItemState = Processing def processing: ItemState = Processing
def created: ItemState = Created def created: ItemState = Created
def confirmed: ItemState = Confirmed def confirmed: ItemState = Confirmed
def deleted: ItemState = Deleted
def fromString(str: String): Either[String, ItemState] = def fromString(str: String): Either[String, ItemState] =
str.toLowerCase match { str.toLowerCase match {
@ -40,6 +42,7 @@ object ItemState {
case "processing" => Right(Processing) case "processing" => Right(Processing)
case "created" => Right(Created) case "created" => Right(Created)
case "confirmed" => Right(Confirmed) case "confirmed" => Right(Confirmed)
case "deleted" => Right(Deleted)
case _ => Left(s"Invalid item state: $str") case _ => Left(s"Invalid item state: $str")
} }

View File

@ -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)
}

View File

@ -18,6 +18,7 @@ import docspell.common._
import docspell.ftsclient.FtsClient import docspell.ftsclient.FtsClient
import docspell.ftssolr.SolrFtsClient import docspell.ftssolr.SolrFtsClient
import docspell.joex.analysis.RegexNerFile import docspell.joex.analysis.RegexNerFile
import docspell.joex.emptytrash._
import docspell.joex.fts.{MigrationTask, ReIndexTask} import docspell.joex.fts.{MigrationTask, ReIndexTask}
import docspell.joex.hk._ import docspell.joex.hk._
import docspell.joex.learn.LearnClassifierTask import docspell.joex.learn.LearnClassifierTask
@ -33,7 +34,7 @@ import docspell.joex.scheduler._
import docspell.joexapi.client.JoexClient import docspell.joexapi.client.JoexClient
import docspell.store.Store import docspell.store.Store
import docspell.store.queue._ import docspell.store.queue._
import docspell.store.records.RJobLog import docspell.store.records.{REmptyTrashSetting, RJobLog}
import emil.javamail._ import emil.javamail._
import org.http4s.blaze.client.BlazeClientBuilder import org.http4s.blaze.client.BlazeClientBuilder
@ -76,11 +77,23 @@ final class JoexAppImpl[F[_]: Async](
HouseKeepingTask HouseKeepingTask
.periodicTask[F](cfg.houseKeeping.schedule) .periodicTask[F](cfg.houseKeeping.schedule)
.flatMap(pstore.insert) *> .flatMap(pstore.insert) *>
scheduleEmptyTrashTasks *>
MigrationTask.job.flatMap(queue.insertIfNew) *> MigrationTask.job.flatMap(queue.insertIfNew) *>
AllPreviewsTask AllPreviewsTask
.job(MakePreviewArgs.StoreMode.WhenMissing, None) .job(MakePreviewArgs.StoreMode.WhenMissing, None)
.flatMap(queue.insertIfNew) *> .flatMap(queue.insertIfNew) *>
AllPageCountTask.job.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 { object JoexAppImpl {
@ -102,6 +115,7 @@ object JoexAppImpl {
upload <- OUpload(store, queue, cfg.files, joex) upload <- OUpload(store, queue, cfg.files, joex)
fts <- createFtsClient(cfg)(httpClient) fts <- createFtsClient(cfg)(httpClient)
itemOps <- OItem(store, fts, queue, joex) itemOps <- OItem(store, fts, queue, joex)
itemSearchOps <- OItemSearch(store)
analyser <- TextAnalyser.create[F](cfg.textAnalysis.textAnalysisConfig) analyser <- TextAnalyser.create[F](cfg.textAnalysis.textAnalysisConfig)
regexNer <- RegexNerFile(cfg.textAnalysis.regexNerFileConfig, store) regexNer <- RegexNerFile(cfg.textAnalysis.regexNerFileConfig, store)
javaEmil = javaEmil =
@ -206,6 +220,13 @@ object JoexAppImpl {
AllPageCountTask.onCancel[F] AllPageCountTask.onCancel[F]
) )
) )
.withTask(
JobTask.json(
EmptyTrashArgs.taskName,
EmptyTrashTask[F](itemOps, itemSearchOps),
EmptyTrashTask.onCancel[F]
)
)
.resource .resource
psch <- PeriodicScheduler.create( psch <- PeriodicScheduler.create(
cfg.periodicScheduler, cfg.periodicScheduler,

View File

@ -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]
}
}

View File

@ -13,6 +13,7 @@ import docspell.common._
import docspell.joex.Config import docspell.joex.Config
import docspell.joex.scheduler.Task import docspell.joex.scheduler.Task
import docspell.store.records._ import docspell.store.records._
import docspell.store.usertask.UserTaskScope
import com.github.eikek.calev._ import com.github.eikek.calev._
@ -36,11 +37,10 @@ object HouseKeepingTask {
RPeriodicTask RPeriodicTask
.createJson( .createJson(
true, true,
UserTaskScope(DocspellSystem.taskGroup),
taskName, taskName,
DocspellSystem.taskGroup,
(), (),
"Docspell house-keeping", "Docspell house-keeping",
DocspellSystem.taskGroup,
Priority.Low, Priority.Low,
ce, ce,
None None

View File

@ -125,7 +125,8 @@ object ItemQuery {
final case class ChecksumMatch(checksum: String) extends Expr final case class ChecksumMatch(checksum: String) extends Expr
final case class AttachId(id: 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 // things that can be expressed with terms above
sealed trait MacroExpr extends Expr { sealed trait MacroExpr extends Expr {

View File

@ -75,9 +75,10 @@ object ExprUtil {
expr expr
case AttachId(_) => case AttachId(_) =>
expr expr
case ValidItemStates => case ValidItemStates =>
expr expr
case Trashed =>
expr
} }
private def spliceAnd(nodes: Nel[Expr]): Nel[Expr] = private def spliceAnd(nodes: Nel[Expr]): Nel[Expr] =

View File

@ -1136,6 +1136,27 @@ paths:
schema: schema:
$ref: "#/components/schemas/BasicResult" $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: /sec/user:
get: get:
operationId: "sec-user-get-all" operationId: "sec-user-get-all"
@ -1478,6 +1499,7 @@ paths:
- $ref: "#/components/parameters/limit" - $ref: "#/components/parameters/limit"
- $ref: "#/components/parameters/offset" - $ref: "#/components/parameters/offset"
- $ref: "#/components/parameters/withDetails" - $ref: "#/components/parameters/withDetails"
- $ref: "#/components/parameters/searchMode"
responses: responses:
200: 200:
description: Ok description: Ok
@ -1576,6 +1598,7 @@ paths:
- authTokenHeader: [] - authTokenHeader: []
parameters: parameters:
- $ref: "#/components/parameters/q" - $ref: "#/components/parameters/q"
- $ref: "#/components/parameters/searchMode"
responses: responses:
200: 200:
description: Ok description: Ok
@ -1607,7 +1630,9 @@ paths:
tags: [ Item ] tags: [ Item ]
summary: Delete an item. summary: Delete an item.
description: | 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: security:
- authTokenHeader: [] - authTokenHeader: []
parameters: parameters:
@ -1619,6 +1644,26 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/BasicResult" $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: /sec/item/{id}/tags:
put: put:
operationId: "sec-item-get-tags" operationId: "sec-item-get-tags"
@ -2305,6 +2350,29 @@ paths:
schema: schema:
$ref: "#/components/schemas/BasicResult" $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: /sec/items/tags:
post: post:
operationId: "sec-items-add-all-tags" operationId: "sec-items-add-all-tags"
@ -4112,6 +4180,16 @@ components:
withDetails: withDetails:
type: boolean type: boolean
default: false 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: query:
type: string type: string
description: | description: |
@ -4569,6 +4647,7 @@ components:
required: required:
- incomingCount - incomingCount
- outgoingCount - outgoingCount
- deletedCount
- itemSize - itemSize
- tagCloud - tagCloud
properties: properties:
@ -4578,6 +4657,9 @@ components:
outgoingCount: outgoingCount:
type: integer type: integer
format: int32 format: int32
deletedCount:
type: integer
format: int32
itemSize: itemSize:
type: integer type: integer
format: int64 format: int64
@ -5185,6 +5267,7 @@ components:
- language - language
- integrationEnabled - integrationEnabled
- classifier - classifier
- emptyTrashSchedule
properties: properties:
language: language:
type: string type: string
@ -5194,6 +5277,9 @@ components:
description: | description: |
Whether the collective has the integration endpoint Whether the collective has the integration endpoint
enabled. enabled.
emptyTrashSchedule:
type: string
format: calevent
classifier: classifier:
$ref: "#/components/schemas/ClassifierSetting" $ref: "#/components/schemas/ClassifierSetting"
@ -5834,6 +5920,13 @@ components:
description: Whether to return details to each item. description: Whether to return details to each item.
schema: schema:
type: boolean type: boolean
searchMode:
name: searchMode
in: query
description: Whether to search in soft-deleted items only.
schema:
type: string
format: searchmode
name: name:
name: name name: name
in: path in: path

View File

@ -63,6 +63,7 @@ trait Conversions {
ItemInsights( ItemInsights(
d.incoming, d.incoming,
d.outgoing, d.outgoing,
d.deleted,
d.bytes, d.bytes,
mkTagCloud(d.tags) mkTagCloud(d.tags)
) )

View File

@ -7,6 +7,7 @@
package docspell.restserver.http4s package docspell.restserver.http4s
import docspell.common.ContactKind import docspell.common.ContactKind
import docspell.common.SearchMode
import org.http4s.ParseFailure import org.http4s.ParseFailure
import org.http4s.QueryParamDecoder import org.http4s.QueryParamDecoder
@ -23,6 +24,11 @@ object QueryParam {
implicit val queryStringDecoder: QueryParamDecoder[QueryString] = implicit val queryStringDecoder: QueryParamDecoder[QueryString] =
QueryParamDecoder[String].map(s => QueryString(s.trim.toLowerCase)) 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 FullOpt extends OptionalQueryParamDecoderMatcher[Boolean]("full")
object OwningOpt extends OptionalQueryParamDecoderMatcher[Boolean]("owning") object OwningOpt extends OptionalQueryParamDecoderMatcher[Boolean]("owning")
@ -35,6 +41,7 @@ object QueryParam {
object Limit extends OptionalQueryParamDecoderMatcher[Int]("limit") object Limit extends OptionalQueryParamDecoderMatcher[Int]("limit")
object Offset extends OptionalQueryParamDecoderMatcher[Int]("offset") object Offset extends OptionalQueryParamDecoderMatcher[Int]("offset")
object WithDetails extends OptionalQueryParamDecoderMatcher[Boolean]("withDetails") object WithDetails extends OptionalQueryParamDecoderMatcher[Boolean]("withDetails")
object SearchKind extends OptionalQueryParamDecoderMatcher[SearchMode]("searchMode")
object WithFallback extends OptionalQueryParamDecoderMatcher[Boolean]("withFallback") object WithFallback extends OptionalQueryParamDecoderMatcher[Boolean]("withFallback")
} }

View File

@ -12,7 +12,7 @@ import cats.implicits._
import docspell.backend.BackendApp import docspell.backend.BackendApp
import docspell.backend.auth.AuthToken import docspell.backend.auth.AuthToken
import docspell.backend.ops.OCollective import docspell.backend.ops.OCollective
import docspell.common.ListType import docspell.common.{EmptyTrashArgs, ListType}
import docspell.restapi.model._ import docspell.restapi.model._
import docspell.restserver.conv.Conversions import docspell.restserver.conv.Conversions
import docspell.restserver.http4s._ import docspell.restserver.http4s._
@ -55,7 +55,8 @@ object CollectiveRoutes {
settings.classifier.categoryList, settings.classifier.categoryList,
settings.classifier.listType settings.classifier.listType
) )
) ),
Some(settings.emptyTrashSchedule)
) )
res <- res <-
backend.collective backend.collective
@ -70,6 +71,7 @@ object CollectiveRoutes {
CollectiveSettings( CollectiveSettings(
c.language, c.language,
c.integrationEnabled, c.integrationEnabled,
c.emptyTrash.getOrElse(EmptyTrashArgs.defaultSchedule),
ClassifierSetting( ClassifierSetting(
c.classifier.map(_.itemCount).getOrElse(0), c.classifier.map(_.itemCount).getOrElse(0),
c.classifier c.classifier
@ -101,6 +103,12 @@ object CollectiveRoutes {
resp <- Ok(BasicResult(true, "Task submitted")) resp <- Ok(BasicResult(true, "Task submitted"))
} yield resp } 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 => case GET -> Root =>
for { for {
collDb <- backend.collective.find(user.account.collective) collDb <- backend.collective.find(user.account.collective)

View File

@ -179,7 +179,7 @@ object ItemMultiRoutes extends MultiIdSupport {
for { for {
json <- req.as[IdList] json <- req.as[IdList]
items <- readIds[F](json.ids) items <- readIds[F](json.ids)
n <- backend.item.deleteItemMultiple(items, user.account.collective) n <- backend.item.setDeletedState(items, user.account.collective)
res = BasicResult( res = BasicResult(
n > 0, n > 0,
if (n > 0) "Item(s) deleted" else "Item deletion failed." if (n > 0) "Item(s) deleted" else "Item deletion failed."
@ -187,6 +187,14 @@ object ItemMultiRoutes extends MultiIdSupport {
resp <- Ok(res) resp <- Ok(res)
} yield resp } 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" => case req @ PUT -> Root / "customfield" =>
for { for {
json <- req.as[ItemsAndFieldValue] json <- req.as[ItemsAndFieldValue]

View File

@ -49,7 +49,7 @@ object ItemRoutes {
HttpRoutes.of { HttpRoutes.of {
case GET -> Root / "search" :? QP.Query(q) :? QP.Limit(limit) :? QP.Offset( case GET -> Root / "search" :? QP.Query(q) :? QP.Limit(limit) :? QP.Offset(
offset offset
) :? QP.WithDetails(detailFlag) => ) :? QP.WithDetails(detailFlag) :? QP.SearchKind(searchMode) =>
val batch = Batch(offset.getOrElse(0), limit.getOrElse(cfg.maxItemPageSize)) val batch = Batch(offset.getOrElse(0), limit.getOrElse(cfg.maxItemPageSize))
.restrictLimitTo(cfg.maxItemPageSize) .restrictLimitTo(cfg.maxItemPageSize)
val itemQuery = ItemQueryString(q) val itemQuery = ItemQueryString(q)
@ -57,15 +57,20 @@ object ItemRoutes {
batch, batch,
cfg.fullTextSearch.enabled, cfg.fullTextSearch.enabled,
detailFlag.getOrElse(false), detailFlag.getOrElse(false),
cfg.maxNoteLength cfg.maxNoteLength,
searchMode.getOrElse(SearchMode.Normal)
) )
val fixQuery = Query.Fix(user.account, None, None) val fixQuery = Query.Fix(user.account, None, None)
searchItems(backend, dsl)(settings, fixQuery, itemQuery) 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 itemQuery = ItemQueryString(q)
val fixQuery = Query.Fix(user.account, None, None) 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" => case req @ POST -> Root / "search" =>
for { for {
@ -81,7 +86,8 @@ object ItemRoutes {
batch, batch,
cfg.fullTextSearch.enabled, cfg.fullTextSearch.enabled,
userQuery.withDetails.getOrElse(false), userQuery.withDetails.getOrElse(false),
cfg.maxNoteLength cfg.maxNoteLength,
searchMode = userQuery.searchMode.getOrElse(SearchMode.Normal)
) )
fixQuery = Query.Fix(user.account, None, None) fixQuery = Query.Fix(user.account, None, None)
resp <- searchItems(backend, dsl)(settings, fixQuery, itemQuery) resp <- searchItems(backend, dsl)(settings, fixQuery, itemQuery)
@ -92,11 +98,11 @@ object ItemRoutes {
userQuery <- req.as[ItemQuery] userQuery <- req.as[ItemQuery]
itemQuery = ItemQueryString(userQuery.query) itemQuery = ItemQueryString(userQuery.query)
fixQuery = Query.Fix(user.account, None, None) fixQuery = Query.Fix(user.account, None, None)
resp <- searchItemStats(backend, dsl)( settings = OSimpleSearch.StatsSettings(
cfg.fullTextSearch.enabled, useFTS = cfg.fullTextSearch.enabled,
fixQuery, searchMode = userQuery.searchMode.getOrElse(SearchMode.Normal)
itemQuery
) )
resp <- searchItemStats(backend, dsl)(settings, fixQuery, itemQuery)
} yield resp } yield resp
case req @ POST -> Root / "searchIndex" => case req @ POST -> Root / "searchIndex" =>
@ -144,6 +150,12 @@ object ItemRoutes {
resp <- Ok(Conversions.basicResult(res, "Item back to created.")) resp <- Ok(Conversions.basicResult(res, "Item back to created."))
} yield resp } 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" => case req @ PUT -> Root / Ident(id) / "tags" =>
for { for {
tags <- req.as[StringList].map(_.items) tags <- req.as[StringList].map(_.items)
@ -393,7 +405,7 @@ object ItemRoutes {
case DELETE -> Root / Ident(id) => case DELETE -> Root / Ident(id) =>
for { 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.") res = BasicResult(n > 0, if (n > 0) "Item deleted" else "Item deletion failed.")
resp <- Ok(res) resp <- Ok(res)
} yield resp } yield resp
@ -440,13 +452,18 @@ object ItemRoutes {
} }
} }
def searchItemStats[F[_]: Sync]( private def searchItemStats[F[_]: Sync](
backend: BackendApp[F], backend: BackendApp[F],
dsl: Http4sDsl[F] dsl: Http4sDsl[F]
)(ftsEnabled: Boolean, fixQuery: Query.Fix, itemQuery: ItemQueryString) = { )(
settings: OSimpleSearch.StatsSettings,
fixQuery: Query.Fix,
itemQuery: ItemQueryString
) = {
import dsl._ import dsl._
backend.simpleSearch backend.simpleSearch
.searchSummaryByString(ftsEnabled)(fixQuery, itemQuery) .searchSummaryByString(settings)(fixQuery, itemQuery)
.flatMap { .flatMap {
case StringSearchResult.Success(summary) => case StringSearchResult.Success(summary) =>
Ok(Conversions.mkSearchStats(summary)) Ok(Conversions.mkSearchStats(summary))

View File

@ -38,7 +38,7 @@ object NotifyDueItemsRoutes {
HttpRoutes.of { HttpRoutes.of {
case GET -> Root / Ident(id) => case GET -> Root / Ident(id) =>
(for { (for {
task <- ut.findNotifyDueItems(id, user.account) task <- ut.findNotifyDueItems(id, UserTaskScope(user.account))
res <- OptionT.liftF(taskToSettings(user.account, backend, task)) res <- OptionT.liftF(taskToSettings(user.account, backend, task))
resp <- OptionT.liftF(Ok(res)) resp <- OptionT.liftF(Ok(res))
} yield resp).getOrElseF(NotFound()) } yield resp).getOrElseF(NotFound())
@ -49,7 +49,7 @@ object NotifyDueItemsRoutes {
newId <- Ident.randomId[F] newId <- Ident.randomId[F]
task <- makeTask(newId, getBaseUrl(cfg, req), user.account, data) task <- makeTask(newId, getBaseUrl(cfg, req), user.account, data)
res <- res <-
ut.executeNow(user.account, task) ut.executeNow(UserTaskScope(user.account), task)
.attempt .attempt
.map(Conversions.basicResult(_, "Submitted successfully.")) .map(Conversions.basicResult(_, "Submitted successfully."))
resp <- Ok(res) resp <- Ok(res)
@ -58,7 +58,7 @@ object NotifyDueItemsRoutes {
case DELETE -> Root / Ident(id) => case DELETE -> Root / Ident(id) =>
for { for {
res <- res <-
ut.deleteTask(user.account, id) ut.deleteTask(UserTaskScope(user.account), id)
.attempt .attempt
.map(Conversions.basicResult(_, "Deleted successfully")) .map(Conversions.basicResult(_, "Deleted successfully"))
resp <- Ok(res) resp <- Ok(res)
@ -69,7 +69,7 @@ object NotifyDueItemsRoutes {
for { for {
task <- makeTask(data.id, getBaseUrl(cfg, req), user.account, data) task <- makeTask(data.id, getBaseUrl(cfg, req), user.account, data)
res <- res <-
ut.submitNotifyDueItems(user.account, task) ut.submitNotifyDueItems(UserTaskScope(user.account), task)
.attempt .attempt
.map(Conversions.basicResult(_, "Saved successfully")) .map(Conversions.basicResult(_, "Saved successfully"))
resp <- Ok(res) resp <- Ok(res)
@ -87,14 +87,14 @@ object NotifyDueItemsRoutes {
newId <- Ident.randomId[F] newId <- Ident.randomId[F]
task <- makeTask(newId, getBaseUrl(cfg, req), user.account, data) task <- makeTask(newId, getBaseUrl(cfg, req), user.account, data)
res <- res <-
ut.submitNotifyDueItems(user.account, task) ut.submitNotifyDueItems(UserTaskScope(user.account), task)
.attempt .attempt
.map(Conversions.basicResult(_, "Saved successfully.")) .map(Conversions.basicResult(_, "Saved successfully."))
resp <- Ok(res) resp <- Ok(res)
} yield resp } yield resp
case GET -> Root => case GET -> Root =>
ut.getNotifyDueItems(user.account) ut.getNotifyDueItems(UserTaskScope(user.account))
.evalMap(task => taskToSettings(user.account, backend, task)) .evalMap(task => taskToSettings(user.account, backend, task))
.compile .compile
.toVector .toVector

View File

@ -35,7 +35,7 @@ object ScanMailboxRoutes {
HttpRoutes.of { HttpRoutes.of {
case GET -> Root / Ident(id) => case GET -> Root / Ident(id) =>
(for { (for {
task <- ut.findScanMailbox(id, user.account) task <- ut.findScanMailbox(id, UserTaskScope(user.account))
res <- OptionT.liftF(taskToSettings(user.account, backend, task)) res <- OptionT.liftF(taskToSettings(user.account, backend, task))
resp <- OptionT.liftF(Ok(res)) resp <- OptionT.liftF(Ok(res))
} yield resp).getOrElseF(NotFound()) } yield resp).getOrElseF(NotFound())
@ -46,7 +46,7 @@ object ScanMailboxRoutes {
newId <- Ident.randomId[F] newId <- Ident.randomId[F]
task <- makeTask(newId, user.account, data) task <- makeTask(newId, user.account, data)
res <- res <-
ut.executeNow(user.account, task) ut.executeNow(UserTaskScope(user.account), task)
.attempt .attempt
.map(Conversions.basicResult(_, "Submitted successfully.")) .map(Conversions.basicResult(_, "Submitted successfully."))
resp <- Ok(res) resp <- Ok(res)
@ -55,7 +55,7 @@ object ScanMailboxRoutes {
case DELETE -> Root / Ident(id) => case DELETE -> Root / Ident(id) =>
for { for {
res <- res <-
ut.deleteTask(user.account, id) ut.deleteTask(UserTaskScope(user.account), id)
.attempt .attempt
.map(Conversions.basicResult(_, "Deleted successfully.")) .map(Conversions.basicResult(_, "Deleted successfully."))
resp <- Ok(res) resp <- Ok(res)
@ -66,7 +66,7 @@ object ScanMailboxRoutes {
for { for {
task <- makeTask(data.id, user.account, data) task <- makeTask(data.id, user.account, data)
res <- res <-
ut.submitScanMailbox(user.account, task) ut.submitScanMailbox(UserTaskScope(user.account), task)
.attempt .attempt
.map(Conversions.basicResult(_, "Saved successfully.")) .map(Conversions.basicResult(_, "Saved successfully."))
resp <- Ok(res) resp <- Ok(res)
@ -84,14 +84,14 @@ object ScanMailboxRoutes {
newId <- Ident.randomId[F] newId <- Ident.randomId[F]
task <- makeTask(newId, user.account, data) task <- makeTask(newId, user.account, data)
res <- res <-
ut.submitScanMailbox(user.account, task) ut.submitScanMailbox(UserTaskScope(user.account), task)
.attempt .attempt
.map(Conversions.basicResult(_, "Saved successfully.")) .map(Conversions.basicResult(_, "Saved successfully."))
resp <- Ok(res) resp <- Ok(res)
} yield resp } yield resp
case GET -> Root => case GET -> Root =>
ut.getScanMailbox(user.account) ut.getScanMailbox(UserTaskScope(user.account))
.evalMap(task => taskToSettings(user.account, backend, task)) .evalMap(task => taskToSettings(user.account, backend, task))
.compile .compile
.toVector .toVector

View File

@ -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")
);

View File

@ -0,0 +1,3 @@
UPDATE "periodic_task"
SET submitter = group_
WHERE submitter = 'learn-classifier';

View File

@ -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`)
);

View File

@ -0,0 +1,3 @@
UPDATE `periodic_task`
SET submitter = group_
WHERE submitter = 'learn-classifier';

View File

@ -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")
);

View File

@ -0,0 +1,3 @@
UPDATE "periodic_task"
SET submitter = group_
WHERE submitter = 'learn-classifier';

View File

@ -126,6 +126,9 @@ object ItemQueryGenerator {
case Expr.ValidItemStates => case Expr.ValidItemStates =>
tables.item.state.in(ItemState.validStates) tables.item.state.in(ItemState.validStates)
case Expr.Trashed =>
tables.item.state === ItemState.Deleted
case Expr.TagIdsMatch(op, tags) => case Expr.TagIdsMatch(op, tags) =>
val ids = tags.toList.flatMap(s => Ident.fromString(s).toOption) val ids = tags.toList.flatMap(s => Ident.fromString(s).toOption)
Nel Nel

View File

@ -65,6 +65,7 @@ object QCollective {
case class InsightData( case class InsightData(
incoming: Int, incoming: Int,
outgoing: Int, outgoing: Int,
deleted: Int,
bytes: Long, bytes: Long,
tags: List[TagCount] tags: List[TagCount]
) )
@ -73,12 +74,21 @@ object QCollective {
val q0 = Select( val q0 = Select(
count(i.id).s, count(i.id).s,
from(i), 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 ).build.query[Int].unique
val q1 = Select( val q1 = Select(
count(i.id).s, count(i.id).s,
from(i), 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 ).build.query[Int].unique
val fileSize = sql""" val fileSize = sql"""
@ -102,19 +112,20 @@ object QCollective {
) as t""".query[Option[Long]].unique ) as t""".query[Option[Long]].unique
for { for {
n0 <- q0 incoming <- q0
n1 <- q1 outgoing <- q1
n2 <- fileSize size <- fileSize
n3 <- tagCloud(coll) tags <- tagCloud(coll)
} yield InsightData(n0, n1, n2.getOrElse(0L), n3) deleted <- q2
} yield InsightData(incoming, outgoing, deleted, size.getOrElse(0L), tags)
} }
def tagCloud(coll: Ident): ConnectionIO[List[TagCount]] = { def tagCloud(coll: Ident): ConnectionIO[List[TagCount]] = {
val sql = val sql =
Select( Select(
select(t.all).append(count(ti.itemId).s), select(t.all).append(count(ti.itemId).s),
from(ti).innerJoin(t, ti.tagId === t.tid), from(ti).innerJoin(t, ti.tagId === t.tid).innerJoin(i, i.id === ti.itemId),
t.cid === coll t.cid === coll && i.state.in(ItemState.validStates)
).groupBy(t.name, t.tid, t.category) ).groupBy(t.name, t.tid, t.category)
sql.build.query[TagCount].to[List] sql.build.query[TagCount].to[List]

View File

@ -12,7 +12,7 @@ import docspell.common._
import docspell.store.qb.DSL._ import docspell.store.qb.DSL._
import docspell.store.qb._ import docspell.store.qb._
import docspell.store.records._ import docspell.store.records._
import docspell.store.usertask.UserTask import docspell.store.usertask.{UserTask, UserTaskScope}
import doobie._ import doobie._
@ -54,15 +54,15 @@ object QUserTask {
) )
).query[RPeriodicTask].option.map(_.map(makeUserTask)) ).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 { for {
r <- task.toPeriodicTask[ConnectionIO](account) r <- task.toPeriodicTask[ConnectionIO](scope)
n <- RPeriodicTask.insert(r) n <- RPeriodicTask.insert(r)
} yield n } yield n
def update(account: AccountId, task: UserTask[String]): ConnectionIO[Int] = def update(scope: UserTaskScope, task: UserTask[String]): ConnectionIO[Int] =
for { for {
r <- task.toPeriodicTask[ConnectionIO](account) r <- task.toPeriodicTask[ConnectionIO](scope)
n <- RPeriodicTask.update(r) n <- RPeriodicTask.update(r)
} yield n } yield n

View File

@ -13,6 +13,7 @@ import docspell.common._
import docspell.store.qb.DSL._ import docspell.store.qb.DSL._
import docspell.store.qb._ import docspell.store.qb._
import com.github.eikek.calev._
import doobie._ import doobie._
import doobie.implicits._ import doobie.implicits._
@ -73,17 +74,21 @@ object RCollective {
T.integration.setTo(settings.integrationEnabled) T.integration.setTo(settings.integrationEnabled)
) )
) )
cls <- now <- Timestamp.current[ConnectionIO]
Timestamp cls = settings.classifier.map(_.toRecord(cid, now))
.current[ConnectionIO]
.map(now => settings.classifier.map(_.toRecord(cid, now)))
n2 <- cls match { n2 <- cls match {
case Some(cr) => case Some(cr) =>
RClassifierSetting.update(cr) RClassifierSetting.update(cr)
case None => case None =>
RClassifierSetting.delete(cid) 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 // 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 // they are finally removed from the json array once the learn classifier task is run
@ -99,6 +104,7 @@ object RCollective {
import RClassifierSetting.stringListMeta import RClassifierSetting.stringListMeta
val c = RCollective.as("c") val c = RCollective.as("c")
val cs = RClassifierSetting.as("cs") val cs = RClassifierSetting.as("cs")
val es = REmptyTrashSetting.as("es")
Select( Select(
select( select(
@ -107,9 +113,10 @@ object RCollective {
cs.schedule.s, cs.schedule.s,
cs.itemCount.s, cs.itemCount.s,
cs.categories.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 c.id === coll
).build.query[Settings].option ).build.query[Settings].option
} }
@ -160,7 +167,8 @@ object RCollective {
case class Settings( case class Settings(
language: Language, language: Language,
integrationEnabled: Boolean, integrationEnabled: Boolean,
classifier: Option[RClassifierSetting.Classifier] classifier: Option[RClassifierSetting.Classifier],
emptyTrash: Option[CalEvent]
) )
} }

View File

@ -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)
}

View File

@ -9,6 +9,7 @@ package docspell.store.records
import cats.data.NonEmptyList import cats.data.NonEmptyList
import cats.effect.Sync import cats.effect.Sync
import cats.implicits._ import cats.implicits._
import fs2.Stream
import docspell.common._ import docspell.common._
import docspell.store.qb.DSL._ import docspell.store.qb.DSL._
@ -152,7 +153,21 @@ object RItem {
t <- currentTime t <- currentTime
n <- DML.update( n <- DML.update(
T, 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)) DML.set(T.state.setTo(itemState), T.updated.setTo(t))
) )
} yield n } yield n
@ -336,6 +351,20 @@ object RItem {
def deleteByIdAndCollective(itemId: Ident, coll: Ident): ConnectionIO[Int] = def deleteByIdAndCollective(itemId: Ident, coll: Ident): ConnectionIO[Int] =
DML.delete(T, T.id === itemId && T.cid === coll) 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] = def existsById(itemId: Ident): ConnectionIO[Boolean] =
Select(count(T.id).s, from(T), T.id === itemId).build.query[Int].unique.map(_ > 0) 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]] = def findById(itemId: Ident): ConnectionIO[Option[RItem]] =
run(select(T.all), from(T), T.id === itemId).query[RItem].option 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]] = 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 Select(T.id.s, from(T), T.id === itemId && T.cid === coll).build.query[Ident].option

View File

@ -13,6 +13,7 @@ import cats.implicits._
import docspell.common._ import docspell.common._
import docspell.store.qb.DSL._ import docspell.store.qb.DSL._
import docspell.store.qb._ import docspell.store.qb._
import docspell.store.usertask.UserTaskScope
import com.github.eikek.calev.CalEvent import com.github.eikek.calev.CalEvent
import doobie._ import doobie._
@ -67,11 +68,10 @@ object RPeriodicTask {
def create[F[_]: Sync]( def create[F[_]: Sync](
enabled: Boolean, enabled: Boolean,
scope: UserTaskScope,
task: Ident, task: Ident,
group: Ident,
args: String, args: String,
subject: String, subject: String,
submitter: Ident,
priority: Priority, priority: Priority,
timer: CalEvent, timer: CalEvent,
summary: Option[String] summary: Option[String]
@ -86,10 +86,10 @@ object RPeriodicTask {
id, id,
enabled, enabled,
task, task,
group, scope.collective,
args, args,
subject, subject,
submitter, scope.fold(_.user, identity),
priority, priority,
None, None,
None, None,
@ -107,22 +107,20 @@ object RPeriodicTask {
def createJson[F[_]: Sync, A]( def createJson[F[_]: Sync, A](
enabled: Boolean, enabled: Boolean,
scope: UserTaskScope,
task: Ident, task: Ident,
group: Ident,
args: A, args: A,
subject: String, subject: String,
submitter: Ident,
priority: Priority, priority: Priority,
timer: CalEvent, timer: CalEvent,
summary: Option[String] summary: Option[String]
)(implicit E: Encoder[A]): F[RPeriodicTask] = )(implicit E: Encoder[A]): F[RPeriodicTask] =
create[F]( create[F](
enabled, enabled,
scope,
task, task,
group,
E(args).noSpaces, E(args).noSpaces,
subject, subject,
submitter,
priority, priority,
timer, timer,
summary summary

View File

@ -43,16 +43,15 @@ object UserTask {
.map(a => ut.copy(args = a)) .map(a => ut.copy(args = a))
def toPeriodicTask[F[_]: Sync]( def toPeriodicTask[F[_]: Sync](
account: AccountId scope: UserTaskScope
): F[RPeriodicTask] = ): F[RPeriodicTask] =
RPeriodicTask RPeriodicTask
.create[F]( .create[F](
ut.enabled, ut.enabled,
scope,
ut.name, ut.name,
account.collective,
ut.args, ut.args,
s"${account.user.id}: ${ut.name.id}", s"${scope.fold(_.user.id, _.id)}: ${ut.name.id}",
account.user,
Priority.Low, Priority.Low,
ut.timer, ut.timer,
ut.summary ut.summary

View File

@ -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)
}

View File

@ -22,13 +22,15 @@ import io.circe._
* once. * once.
* *
* This class defines methods at a higher level, dealing with * 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 * `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 * implNote: The mapping is as follows: The collective is the task
* group. The submitter property contains the username. Once a 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 * id. A user may submit multiple same tasks (with different
* properties). * properties).
*/ */
@ -36,22 +38,22 @@ trait UserTaskStore[F[_]] {
/** Return all tasks of the given user. /** 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 /** Return all tasks of the given name and user. The task's arguments
* are returned as stored in the database. * 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 /** Return all tasks of the given name and user. The task's arguments
* are decoded using the given json decoder. * 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] D: Decoder[A]
): Stream[F, UserTask[A]] ): Stream[F, UserTask[A]]
/** Return a user-task with the given id. */ /** 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. /** Updates or inserts the given task.
* *
@ -59,23 +61,23 @@ trait UserTaskStore[F[_]] {
* exists, a new one is created. Otherwise the existing task is * exists, a new one is created. Otherwise the existing task is
* updated. * 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. /** 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 /** Return the task of the given user and name. If multiple exists, an
* error is returned. The task's arguments are returned as stored * error is returned. The task's arguments are returned as stored
* in the database. * 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 /** Return the task of the given user and name. If multiple exists, an
* error is returned. The task's arguments are decoded using the * error is returned. The task's arguments are decoded using the
* given json decoder. * given json decoder.
*/ */
def getOneByName[A](account: AccountId, name: Ident)(implicit def getOneByName[A](scope: UserTaskScope, name: Ident)(implicit
D: Decoder[A] D: Decoder[A]
): OptionT[F, UserTask[A]] ): OptionT[F, UserTask[A]]
@ -83,20 +85,20 @@ trait UserTaskStore[F[_]] {
* *
* Unlike `updateTask`, this ensures that there is at most one task * Unlike `updateTask`, this ensures that there is at most one task
* of some name in the db. Multiple same tasks (task with same * 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. * 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 * the user `account`, they will all be removed and the given task
* inserted! * inserted!
*/ */
def updateOneTask[A](account: AccountId, ut: UserTask[A])(implicit def updateOneTask[A](scope: UserTaskScope, ut: UserTask[A])(implicit
E: Encoder[A] E: Encoder[A]
): F[UserTask[String]] ): F[UserTask[String]]
/** Delete all tasks of the given user that have name `name'. /** 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 { object UserTaskStore {
@ -104,47 +106,47 @@ object UserTaskStore {
def apply[F[_]: Async](store: Store[F]): Resource[F, UserTaskStore[F]] = def apply[F[_]: Async](store: Store[F]): Resource[F, UserTaskStore[F]] =
Resource.pure[F, UserTaskStore[F]](new UserTaskStore[F] { Resource.pure[F, UserTaskStore[F]](new UserTaskStore[F] {
def getAll(account: AccountId): Stream[F, UserTask[String]] = def getAll(scope: UserTaskScope): Stream[F, UserTask[String]] =
store.transact(QUserTask.findAll(account)) store.transact(QUserTask.findAll(scope.toAccountId))
def getByNameRaw(account: AccountId, name: Ident): Stream[F, UserTask[String]] = def getByNameRaw(scope: UserTaskScope, name: Ident): Stream[F, UserTask[String]] =
store.transact(QUserTask.findByName(account, name)) store.transact(QUserTask.findByName(scope.toAccountId, name))
def getByIdRaw(account: AccountId, id: Ident): OptionT[F, UserTask[String]] = def getByIdRaw(scope: UserTaskScope, id: Ident): OptionT[F, UserTask[String]] =
OptionT(store.transact(QUserTask.findById(account, id))) 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] D: Decoder[A]
): Stream[F, UserTask[A]] = ): Stream[F, UserTask[A]] =
getByNameRaw(account, name).flatMap(_.decode match { getByNameRaw(scope, name).flatMap(_.decode match {
case Right(ua) => Stream.emit(ua) case Right(ua) => Stream.emit(ua)
case Left(err) => Stream.raiseError[F](new Exception(err)) 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] E: Encoder[A]
): F[Int] = { ): F[Int] = {
val exists = QUserTask.exists(ut.id) 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 { store.add(insert, exists).flatMap {
case AddResult.Success => case AddResult.Success =>
1.pure[F] 1.pure[F]
case AddResult.EntityExists(_) => case AddResult.EntityExists(_) =>
store.transact(QUserTask.update(account, ut.encode)) store.transact(QUserTask.update(scope, ut.encode))
case AddResult.Failure(ex) => case AddResult.Failure(ex) =>
Async[F].raiseError(ex) Async[F].raiseError(ex)
} }
} }
def deleteTask(account: AccountId, id: Ident): F[Int] = def deleteTask(scope: UserTaskScope, id: Ident): F[Int] =
store.transact(QUserTask.delete(account, id)) store.transact(QUserTask.delete(scope.toAccountId, id))
def getOneByNameRaw( def getOneByNameRaw(
account: AccountId, scope: UserTaskScope,
name: Ident name: Ident
): OptionT[F, UserTask[String]] = ): OptionT[F, UserTask[String]] =
OptionT( OptionT(
getByNameRaw(account, name) getByNameRaw(scope, name)
.take(2) .take(2)
.compile .compile
.toList .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] D: Decoder[A]
): OptionT[F, UserTask[A]] = ): OptionT[F, UserTask[A]] =
getOneByNameRaw(account, name) getOneByNameRaw(scope, name)
.semiflatMap(_.decode match { .semiflatMap(_.decode match {
case Right(ua) => ua.pure[F] case Right(ua) => ua.pure[F]
case Left(err) => Async[F].raiseError(new Exception(err)) 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] E: Encoder[A]
): F[UserTask[String]] = ): F[UserTask[String]] =
getByNameRaw(account, ut.name).compile.toList.flatMap { getByNameRaw(scope, ut.name).compile.toList.flatMap {
case a :: rest => case a :: rest =>
val task = ut.copy(id = a.id).encode val task = ut.copy(id = a.id).encode
for { for {
_ <- store.transact(QUserTask.update(account, task)) _ <- store.transact(QUserTask.update(scope, task))
_ <- store.transact(rest.traverse(t => QUserTask.delete(account, t.id))) _ <- store.transact(
rest.traverse(t => QUserTask.delete(scope.toAccountId, t.id))
)
} yield task } yield task
case Nil => case Nil =>
val task = ut.encode 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] = def deleteAll(scope: UserTaskScope, name: Ident): F[Int] =
store.transact(QUserTask.deleteAll(account, name)) store.transact(QUserTask.deleteAll(scope.toAccountId, name))
}) })
} }

View File

@ -99,6 +99,8 @@ module Api exposing
, removeTagsMultiple , removeTagsMultiple
, reprocessItem , reprocessItem
, reprocessMultiple , reprocessMultiple
, restoreAllItems
, restoreItem
, saveClientSettings , saveClientSettings
, sendMail , sendMail
, setAttachmentName , setAttachmentName
@ -128,6 +130,7 @@ module Api exposing
, setTagsMultiple , setTagsMultiple
, setUnconfirmed , setUnconfirmed
, startClassifier , startClassifier
, startEmptyTrash
, startOnceNotifyDueItems , startOnceNotifyDueItems
, startOnceScanMailbox , startOnceScanMailbox
, startReIndex , 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 -> (Result Http.Error TagCloud -> msg) -> Cmd msg
getTagCloud flags receive = getTagCloud flags receive =
Http2.authGet 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 --- 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 -> String -> (Result Http.Error BasicResult -> msg) -> Cmd msg
deleteItem flags item receive = deleteItem flags item receive =
Http2.authDelete Http2.authDelete

View File

@ -20,7 +20,9 @@ import Api.Model.CollectiveSettings exposing (CollectiveSettings)
import Comp.Basic as B import Comp.Basic as B
import Comp.ClassifierSettingsForm import Comp.ClassifierSettingsForm
import Comp.Dropdown import Comp.Dropdown
import Comp.EmptyTrashForm
import Comp.MenuBar as MB import Comp.MenuBar as MB
import Data.CalEvent
import Data.DropdownStyle as DS import Data.DropdownStyle as DS
import Data.Flags exposing (Flags) import Data.Flags exposing (Flags)
import Data.Language exposing (Language) import Data.Language exposing (Language)
@ -41,6 +43,8 @@ type alias Model =
, fullTextReIndexResult : FulltextReindexResult , fullTextReIndexResult : FulltextReindexResult
, classifierModel : Comp.ClassifierSettingsForm.Model , classifierModel : Comp.ClassifierSettingsForm.Model
, startClassifierResult : ClassifierResult , startClassifierResult : ClassifierResult
, emptyTrashModel : Comp.EmptyTrashForm.Model
, startEmptyTrashResult : EmptyTrashResult
} }
@ -50,6 +54,11 @@ type ClassifierResult
| ClassifierResultSubmitError String | ClassifierResultSubmitError String
| ClassifierResultOk | ClassifierResultOk
type EmptyTrashResult
= EmptyTrashResultInitial
| EmptyTrashResultHttpError Http.Error
| EmptyTrashResultSubmitError String
| EmptyTrashResultOk
type FulltextReindexResult type FulltextReindexResult
= FulltextReindexInitial = FulltextReindexInitial
@ -68,6 +77,9 @@ init flags settings =
( cm, cc ) = ( cm, cc ) =
Comp.ClassifierSettingsForm.init flags settings.classifier Comp.ClassifierSettingsForm.init flags settings.classifier
( em, ec ) =
Comp.EmptyTrashForm.init flags settings.emptyTrashSchedule
in in
( { langModel = ( { langModel =
Comp.Dropdown.makeSingleList Comp.Dropdown.makeSingleList
@ -80,8 +92,10 @@ init flags settings =
, fullTextReIndexResult = FulltextReindexInitial , fullTextReIndexResult = FulltextReindexInitial
, classifierModel = cm , classifierModel = cm
, startClassifierResult = ClassifierResultInitial , 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 |> Maybe.withDefault model.initSettings.language
, integrationEnabled = model.intEnabled , integrationEnabled = model.intEnabled
, classifier = cls , classifier = cls
, emptyTrashSchedule =
Comp.EmptyTrashForm.getSettings model.emptyTrashModel
|> Maybe.withDefault Data.CalEvent.everyMonth
|> Data.CalEvent.makeEvent
} }
) )
(Comp.ClassifierSettingsForm.getSettings (Comp.ClassifierSettingsForm.getSettings
@ -110,9 +128,12 @@ type Msg
| TriggerReIndex | TriggerReIndex
| TriggerReIndexResult (Result Http.Error BasicResult) | TriggerReIndexResult (Result Http.Error BasicResult)
| ClassifierSettingMsg Comp.ClassifierSettingsForm.Msg | ClassifierSettingMsg Comp.ClassifierSettingsForm.Msg
| EmptyTrashMsg Comp.EmptyTrashForm.Msg
| SaveSettings | SaveSettings
| StartClassifierTask | StartClassifierTask
| StartEmptyTrashTask
| StartClassifierResp (Result Http.Error BasicResult) | StartClassifierResp (Result Http.Error BasicResult)
| StartEmptyTrashResp (Result Http.Error BasicResult)
update : Flags -> Msg -> Model -> ( Model, Cmd Msg, Maybe CollectiveSettings ) update : Flags -> Msg -> Model -> ( Model, Cmd Msg, Maybe CollectiveSettings )
@ -188,6 +209,18 @@ update flags msg model =
, Nothing , Nothing
) )
EmptyTrashMsg lmsg ->
let
( cm, cc ) =
Comp.EmptyTrashForm.update flags lmsg model.emptyTrashModel
in
( { model
| emptyTrashModel = cm
}
, Cmd.map EmptyTrashMsg cc
, Nothing
)
SaveSettings -> SaveSettings ->
case getSettings model of case getSettings model of
Just s -> Just s ->
@ -199,6 +232,10 @@ update flags msg model =
StartClassifierTask -> StartClassifierTask ->
( model, Api.startClassifier flags StartClassifierResp, Nothing ) ( model, Api.startClassifier flags StartClassifierResp, Nothing )
StartEmptyTrashTask ->
( model, Api.startEmptyTrash flags StartEmptyTrashResp, Nothing )
StartClassifierResp (Ok br) -> StartClassifierResp (Ok br) ->
( { model ( { model
| startClassifierResult = | startClassifierResult =
@ -218,6 +255,24 @@ update flags msg model =
, Nothing , 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 --- View2
@ -257,7 +312,7 @@ view2 flags texts settings model =
, end = [] , end = []
, rootClasses = "mb-4" , rootClasses = "mb-4"
} }
, h3 [ class S.header3 ] , h2 [ class S.header2 ]
[ text texts.documentLanguage [ text texts.documentLanguage
] ]
, div [ class "mb-4" ] , div [ class "mb-4" ]
@ -279,8 +334,8 @@ view2 flags texts settings model =
[ ( "hidden", not flags.config.integrationEnabled ) [ ( "hidden", not flags.config.integrationEnabled )
] ]
] ]
[ h3 [ h2
[ class S.header3 [ class S.header2
] ]
[ text texts.integrationEndpoint [ text texts.integrationEndpoint
] ]
@ -311,8 +366,8 @@ view2 flags texts settings model =
[ ( "hidden", not flags.config.fullTextSearchEnabled ) [ ( "hidden", not flags.config.fullTextSearchEnabled )
] ]
] ]
[ h3 [ h2
[ class S.header3 ] [ class S.header2 ]
[ text texts.fulltextSearch ] [ text texts.fulltextSearch ]
, div , div
[ class "mb-4" ] [ class "mb-4" ]
@ -348,8 +403,8 @@ view2 flags texts settings model =
[ ( " hidden", not flags.config.showClassificationSettings ) [ ( " hidden", not flags.config.showClassificationSettings )
] ]
] ]
[ h3 [ h2
[ class S.header3 ] [ class S.header2 ]
[ text texts.autoTagging [ text texts.autoTagging
] ]
, div , 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 -> FulltextReindexSubmitError m ->
text 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
]

View File

@ -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
)
]
]

View File

@ -149,13 +149,19 @@ update ddm msg model =
view2 : Texts -> ViewConfig -> UiSettings -> Model -> ItemLight -> Html Msg view2 : Texts -> ViewConfig -> UiSettings -> Model -> ItemLight -> Html Msg
view2 texts cfg settings model item = view2 texts cfg settings model item =
let let
isConfirmed = isCreated =
item.state /= "created" item.state == "created"
isDeleted =
item.state == "deleted"
cardColor = cardColor =
if not isConfirmed then if isCreated then
"text-blue-500 dark:text-lightblue-500" "text-blue-500 dark:text-lightblue-500"
else if isDeleted then
"text-red-600 dark:text-orange-600"
else else
"" ""
@ -207,7 +213,7 @@ view2 texts cfg settings model item =
[ previewImage2 settings cardAction 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 , metaDataContent2 texts settings item
, notesContent2 settings item , notesContent2 settings item
, fulltextResultsContent2 item , fulltextResultsContent2 item
@ -293,11 +299,12 @@ mainContent2 :
-> List (Attribute Msg) -> List (Attribute Msg)
-> String -> String
-> Bool -> Bool
-> Bool
-> UiSettings -> UiSettings
-> ViewConfig -> ViewConfig
-> ItemLight -> ItemLight
-> Html Msg -> Html Msg
mainContent2 texts cardAction cardColor isConfirmed settings _ item = mainContent2 texts _ cardColor isCreated isDeleted settings _ item =
let let
dirIcon = dirIcon =
i i
@ -353,12 +360,22 @@ mainContent2 texts cardAction cardColor isConfirmed settings _ item =
[ classList [ classList
[ ( "absolute right-1 top-1 text-4xl", True ) [ ( "absolute right-1 top-1 text-4xl", True )
, ( cardColor, True ) , ( cardColor, True )
, ( "hidden", isConfirmed ) , ( "hidden", not isCreated )
] ]
, title texts.new , title texts.new
] ]
[ i [ class "ml-2 fa fa-exclamation-circle" ] [] [ 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 , div
[ classList [ classList
[ ( "opacity-75", True ) [ ( "opacity-75", True )

View File

@ -118,10 +118,28 @@ view texts settings model =
] ]
, True , True
) )
isDeleted =
model.item.state == "deleted"
isCreated =
model.item.state == "created"
in in
div [ class "flex flex-col pb-2" ] div [ class "flex flex-col pb-2" ]
[ div [ class "flex flex-row items-center text-2xl" ] [ div [ class "flex flex-row items-center text-2xl" ]
[ i [ if isDeleted then
div
[ classList
[ ( "text-red-500 dark:text-orange-600 text-4xl", True )
, ( "hidden", not isDeleted )
]
, title texts.basics.deleted
]
[ i [ class "mr-2 fa fa-trash-alt" ] []
]
else
i
[ classList [ classList
[ ( "hidden", Data.UiSettings.fieldHidden settings Data.Fields.Direction ) [ ( "hidden", Data.UiSettings.fieldHidden settings Data.Fields.Direction )
] ]
@ -135,13 +153,22 @@ view texts settings model =
[ text model.item.name [ text model.item.name
, div , div
[ classList [ 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" , class "ml-3 text-base label bg-blue-500 dark:bg-lightblue-500 text-white rounded-lg"
] ]
[ text texts.new [ text texts.new
, i [ class "fa fa-exclamation ml-2" ] [] , 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" ] []
]
] ]
] ]
] ]

View File

@ -339,6 +339,7 @@ type Msg
| RequestReprocessItem | RequestReprocessItem
| ReprocessItemConfirmed | ReprocessItemConfirmed
| ToggleSelectView | ToggleSelectView
| RestoreItem
type SaveNameState type SaveNameState

View File

@ -1604,6 +1604,9 @@ update key flags inav settings msg model =
, cmd , cmd
) )
RestoreItem ->
resultModelCmd ( model, Api.restoreItem flags model.item.id SaveResp )
--- Helper --- Helper

View File

@ -188,7 +188,19 @@ menuBar texts inav settings model =
] ]
[ i [ class "fa fa-redo" ] [] [ i [ class "fa fa-redo" ] []
] ]
, MB.CustomElement <| , 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 a
[ class S.deleteButton [ class S.deleteButton
, href "#" , href "#"

View File

@ -45,6 +45,7 @@ import Data.Fields
import Data.Flags exposing (Flags) import Data.Flags exposing (Flags)
import Data.ItemQuery as Q exposing (ItemQuery) import Data.ItemQuery as Q exposing (ItemQuery)
import Data.PersonUse import Data.PersonUse
import Data.SearchMode exposing (SearchMode)
import Data.UiSettings exposing (UiSettings) import Data.UiSettings exposing (UiSettings)
import DatePicker exposing (DatePicker) import DatePicker exposing (DatePicker)
import Html exposing (..) import Html exposing (..)
@ -89,6 +90,7 @@ type alias Model =
, customValues : CustomFieldValueCollect , customValues : CustomFieldValueCollect
, sourceModel : Maybe String , sourceModel : Maybe String
, openTabs : Set String , openTabs : Set String
, searchMode : SearchMode
} }
@ -133,6 +135,7 @@ init flags =
, customValues = Data.CustomFieldChange.emptyCollect , customValues = Data.CustomFieldChange.emptyCollect
, sourceModel = Nothing , sourceModel = Nothing
, openTabs = Set.fromList [ "Tags", "Inbox" ] , openTabs = Set.fromList [ "Tags", "Inbox" ]
, searchMode = Data.SearchMode.Normal
} }
@ -323,6 +326,7 @@ resetModel model =
model.customFieldModel model.customFieldModel
, customValues = Data.CustomFieldChange.emptyCollect , customValues = Data.CustomFieldChange.emptyCollect
, sourceModel = Nothing , sourceModel = Nothing
, searchMode = Data.SearchMode.Normal
} }
@ -343,6 +347,7 @@ type Msg
| FromDueDateMsg Comp.DatePicker.Msg | FromDueDateMsg Comp.DatePicker.Msg
| UntilDueDateMsg Comp.DatePicker.Msg | UntilDueDateMsg Comp.DatePicker.Msg
| ToggleInbox | ToggleInbox
| ToggleSearchMode
| GetOrgResp (Result Http.Error ReferenceList) | GetOrgResp (Result Http.Error ReferenceList)
| GetEquipResp (Result Http.Error EquipmentList) | GetEquipResp (Result Http.Error EquipmentList)
| GetPersonResp (Result Http.Error PersonList) | GetPersonResp (Result Http.Error PersonList)
@ -683,6 +688,24 @@ updateDrop ddm flags settings msg model =
, dragDrop = DD.DragDropData ddm Nothing , 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 -> FromDateMsg m ->
let let
( dp, event ) = ( dp, event ) =
@ -962,6 +985,7 @@ type SearchTab
| TabDueDate | TabDueDate
| TabSource | TabSource
| TabDirection | TabDirection
| TabTrashed
allTabs : List SearchTab allTabs : List SearchTab
@ -977,6 +1001,7 @@ allTabs =
, TabDueDate , TabDueDate
, TabSource , TabSource
, TabDirection , TabDirection
, TabTrashed
] ]
@ -1016,6 +1041,9 @@ tabName tab =
TabDirection -> TabDirection ->
"direction" "direction"
TabTrashed ->
"trashed"
findTab : Comp.Tabs.Tab msg -> Maybe SearchTab findTab : Comp.Tabs.Tab msg -> Maybe SearchTab
findTab tab = findTab tab =
@ -1053,6 +1081,9 @@ findTab tab =
"direction" -> "direction" ->
Just TabDirection Just TabDirection
"trashed" ->
Just TabTrashed
_ -> _ ->
Nothing Nothing
@ -1099,6 +1130,9 @@ searchTabState settings model tab =
Just TabInbox -> Just TabInbox ->
False False
Just TabTrashed ->
False
Nothing -> Nothing ->
False 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
}
]
}
] ]

View File

@ -21,6 +21,7 @@ module Data.ItemQuery exposing
import Api.Model.CustomFieldValue exposing (CustomFieldValue) import Api.Model.CustomFieldValue exposing (CustomFieldValue)
import Api.Model.ItemQuery as RQ import Api.Model.ItemQuery as RQ
import Data.Direction exposing (Direction) import Data.Direction exposing (Direction)
import Data.SearchMode exposing (SearchMode)
type TagMatch type TagMatch
@ -73,12 +74,13 @@ and list =
Just (And es) Just (And es)
request : Maybe ItemQuery -> RQ.ItemQuery request : SearchMode -> Maybe ItemQuery -> RQ.ItemQuery
request mq = request smode mq =
{ offset = Nothing { offset = Nothing
, limit = Nothing , limit = Nothing
, withDetails = Just True , withDetails = Just True
, query = renderMaybe mq , query = renderMaybe mq
, searchMode = Data.SearchMode.asString smode |> Just
} }

View File

@ -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"

View File

@ -15,6 +15,7 @@ module Messages.Basics exposing
type alias Texts = type alias Texts =
{ incoming : String { incoming : String
, outgoing : String , outgoing : String
, deleted : String
, tags : String , tags : String
, items : String , items : String
, submit : String , submit : String
@ -51,6 +52,7 @@ gb : Texts
gb = gb =
{ incoming = "Incoming" { incoming = "Incoming"
, outgoing = "Outgoing" , outgoing = "Outgoing"
, deleted = "Deleted"
, tags = "Tags" , tags = "Tags"
, items = "Items" , items = "Items"
, submit = "Submit" , submit = "Submit"
@ -92,6 +94,7 @@ de : Texts
de = de =
{ incoming = "Eingehend" { incoming = "Eingehend"
, outgoing = "Ausgehend" , outgoing = "Ausgehend"
, deleted = "Gelöscht"
, tags = "Tags" , tags = "Tags"
, items = "Dokumente" , items = "Dokumente"
, submit = "Speichern" , submit = "Speichern"

View File

@ -15,6 +15,7 @@ import Data.Language exposing (Language)
import Http import Http
import Messages.Basics import Messages.Basics
import Messages.Comp.ClassifierSettingsForm import Messages.Comp.ClassifierSettingsForm
import Messages.Comp.EmptyTrashForm
import Messages.Comp.HttpError import Messages.Comp.HttpError
import Messages.Data.Language import Messages.Data.Language
@ -22,6 +23,7 @@ import Messages.Data.Language
type alias Texts = type alias Texts =
{ basics : Messages.Basics.Texts { basics : Messages.Basics.Texts
, classifierSettingsForm : Messages.Comp.ClassifierSettingsForm.Texts , classifierSettingsForm : Messages.Comp.ClassifierSettingsForm.Texts
, emptyTrashForm : Messages.Comp.EmptyTrashForm.Texts
, httpError : Http.Error -> String , httpError : Http.Error -> String
, save : String , save : String
, saveSettings : String , saveSettings : String
@ -37,8 +39,10 @@ type alias Texts =
, startNow : String , startNow : String
, languageLabel : Language -> String , languageLabel : Language -> String
, classifierTaskStarted : String , classifierTaskStarted : String
, emptyTrashTaskStarted : String
, fulltextReindexSubmitted : String , fulltextReindexSubmitted : String
, fulltextReindexOkMissing : String , fulltextReindexOkMissing : String
, emptyTrash : String
} }
@ -46,6 +50,7 @@ gb : Texts
gb = gb =
{ basics = Messages.Basics.gb { basics = Messages.Basics.gb
, classifierSettingsForm = Messages.Comp.ClassifierSettingsForm.gb , classifierSettingsForm = Messages.Comp.ClassifierSettingsForm.gb
, emptyTrashForm = Messages.Comp.EmptyTrashForm.gb
, httpError = Messages.Comp.HttpError.gb , httpError = Messages.Comp.HttpError.gb
, save = "Save" , save = "Save"
, saveSettings = "Save Settings" , saveSettings = "Save Settings"
@ -65,9 +70,11 @@ gb =
, startNow = "Start now" , startNow = "Start now"
, languageLabel = Messages.Data.Language.gb , languageLabel = Messages.Data.Language.gb
, classifierTaskStarted = "Classifier task started." , classifierTaskStarted = "Classifier task started."
, emptyTrashTaskStarted = "Empty trash task started."
, fulltextReindexSubmitted = "Fulltext Re-Index started." , fulltextReindexSubmitted = "Fulltext Re-Index started."
, fulltextReindexOkMissing = , fulltextReindexOkMissing =
"Please type OK in the field if you really want to start re-indexing your data." "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 = de =
{ basics = Messages.Basics.de { basics = Messages.Basics.de
, classifierSettingsForm = Messages.Comp.ClassifierSettingsForm.de , classifierSettingsForm = Messages.Comp.ClassifierSettingsForm.de
, emptyTrashForm = Messages.Comp.EmptyTrashForm.de
, httpError = Messages.Comp.HttpError.de , httpError = Messages.Comp.HttpError.de
, save = "Speichern" , save = "Speichern"
, saveSettings = "Einstellungen speichern" , saveSettings = "Einstellungen speichern"
@ -94,7 +102,9 @@ de =
, startNow = "Jetzt starten" , startNow = "Jetzt starten"
, languageLabel = Messages.Data.Language.de , languageLabel = Messages.Data.Language.de
, classifierTaskStarted = "Kategorisierung gestartet." , classifierTaskStarted = "Kategorisierung gestartet."
, emptyTrashTaskStarted = "Papierkorb löschen gestartet."
, fulltextReindexSubmitted = "Volltext Neu-Indexierung gestartet." , fulltextReindexSubmitted = "Volltext Neu-Indexierung gestartet."
, fulltextReindexOkMissing = , fulltextReindexOkMissing =
"Bitte tippe OK in das Feld ein, wenn Du wirklich den Index neu erzeugen möchtest." "Bitte tippe OK in das Feld ein, wenn Du wirklich den Index neu erzeugen möchtest."
, emptyTrash = "Papierkorb löschen"
} }

View File

@ -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"
}

View File

@ -46,6 +46,7 @@ type alias Texts =
, unconfirmItemMetadata : String , unconfirmItemMetadata : String
, reprocessItem : String , reprocessItem : String
, deleteThisItem : String , deleteThisItem : String
, undeleteThisItem : String
, sentEmails : String , sentEmails : String
, sendThisItemViaEmail : String , sendThisItemViaEmail : String
, itemId : String , itemId : String
@ -79,6 +80,7 @@ gb =
, unconfirmItemMetadata = "Un-confirm item metadata" , unconfirmItemMetadata = "Un-confirm item metadata"
, reprocessItem = "Reprocess this item" , reprocessItem = "Reprocess this item"
, deleteThisItem = "Delete this item" , deleteThisItem = "Delete this item"
, undeleteThisItem = "Restore this item"
, sentEmails = "Sent E-Mails" , sentEmails = "Sent E-Mails"
, sendThisItemViaEmail = "Send this item via E-Mail" , sendThisItemViaEmail = "Send this item via E-Mail"
, itemId = "Item ID" , itemId = "Item ID"
@ -112,6 +114,7 @@ de =
, unconfirmItemMetadata = "Widerrufe Bestätigung" , unconfirmItemMetadata = "Widerrufe Bestätigung"
, reprocessItem = "Das Dokument erneut verarbeiten" , reprocessItem = "Das Dokument erneut verarbeiten"
, deleteThisItem = "Das Dokument löschen" , deleteThisItem = "Das Dokument löschen"
, undeleteThisItem = "Das Dokument wiederherstellen"
, sentEmails = "Versendete E-Mails" , sentEmails = "Versendete E-Mails"
, sendThisItemViaEmail = "Sende dieses Dokument via E-Mail" , sendThisItemViaEmail = "Sende dieses Dokument via E-Mail"
, itemId = "Dokument-ID" , itemId = "Dokument-ID"

View File

@ -46,6 +46,7 @@ type alias Texts =
, sourceTab : String , sourceTab : String
, searchInItemSource : String , searchInItemSource : String
, direction : Direction -> String , direction : Direction -> String
, trashcan : String
} }
@ -77,6 +78,7 @@ gb =
, sourceTab = "Source" , sourceTab = "Source"
, searchInItemSource = "Search in item source" , searchInItemSource = "Search in item source"
, direction = Messages.Data.Direction.gb , direction = Messages.Data.Direction.gb
, trashcan = "Trash"
} }
@ -108,4 +110,5 @@ de =
, sourceTab = "Quelle" , sourceTab = "Quelle"
, searchInItemSource = "Suche in Dokumentquelle" , searchInItemSource = "Suche in Dokumentquelle"
, direction = Messages.Data.Direction.de , direction = Messages.Data.Direction.de
, trashcan = "Papierkorb"
} }

View File

@ -30,9 +30,11 @@ type alias Texts =
, powerSearchPlaceholder : String , powerSearchPlaceholder : String
, reallyReprocessQuestion : String , reallyReprocessQuestion : String
, reallyDeleteQuestion : String , reallyDeleteQuestion : String
, reallyRestoreQuestion : String
, editSelectedItems : Int -> String , editSelectedItems : Int -> String
, reprocessSelectedItems : Int -> String , reprocessSelectedItems : Int -> String
, deleteSelectedItems : Int -> String , deleteSelectedItems : Int -> String
, undeleteSelectedItems : Int -> String
, selectAllVisible : String , selectAllVisible : String
, selectNone : String , selectNone : String
, resetSearchForm : String , resetSearchForm : String
@ -54,9 +56,11 @@ gb =
, powerSearchPlaceholder = "Search query " , powerSearchPlaceholder = "Search query "
, reallyReprocessQuestion = "Really reprocess all selected items? Metadata of unconfirmed items may change." , reallyReprocessQuestion = "Really reprocess all selected items? Metadata of unconfirmed items may change."
, reallyDeleteQuestion = "Really delete all selected items?" , reallyDeleteQuestion = "Really delete all selected items?"
, reallyRestoreQuestion = "Really restore all selected items?"
, editSelectedItems = \n -> "Edit " ++ String.fromInt n ++ " selected items" , editSelectedItems = \n -> "Edit " ++ String.fromInt n ++ " selected items"
, reprocessSelectedItems = \n -> "Reprocess " ++ String.fromInt n ++ " selected items" , reprocessSelectedItems = \n -> "Reprocess " ++ String.fromInt n ++ " selected items"
, deleteSelectedItems = \n -> "Delete " ++ 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" , selectAllVisible = "Select all visible"
, selectNone = "Select none" , selectNone = "Select none"
, resetSearchForm = "Reset search form" , resetSearchForm = "Reset search form"
@ -78,9 +82,11 @@ de =
, powerSearchPlaceholder = "Suchanfrage" , powerSearchPlaceholder = "Suchanfrage"
, reallyReprocessQuestion = "Wirklich die gewählten Dokumente neu verarbeiten? Die Metadaten von nicht bestätigten Dokumenten können sich dabei ändern." , 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?" , reallyDeleteQuestion = "Wirklich alle gewählten Dokumente löschen?"
, reallyRestoreQuestion = "Wirklich alle gewählten Dokumente wiederherstellen?"
, editSelectedItems = \n -> "Ändere " ++ String.fromInt n ++ " gewählte Dokumente" , editSelectedItems = \n -> "Ändere " ++ String.fromInt n ++ " gewählte Dokumente"
, reprocessSelectedItems = \n -> "Erneute Verarbeitung von " ++ String.fromInt n ++ " gewählten Dokumenten" , reprocessSelectedItems = \n -> "Erneute Verarbeitung von " ++ String.fromInt n ++ " gewählten Dokumenten"
, deleteSelectedItems = \n -> "Lösche " ++ String.fromInt n ++ " gewählte Dokumente" , 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" , selectAllVisible = "Wähle alle Dokumente in der Liste"
, selectNone = "Wähle alle Dokumente ab" , selectNone = "Wähle alle Dokumente ab"
, resetSearchForm = "Suchformular zurücksetzen" , resetSearchForm = "Suchformular zurücksetzen"

View File

@ -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 + model.insights.outgoingCount)) texts.basics.items
, stats (String.fromInt model.insights.incomingCount) texts.basics.incoming , stats (String.fromInt model.insights.incomingCount) texts.basics.incoming
, stats (String.fromInt model.insights.outgoingCount) texts.basics.outgoing , stats (String.fromInt model.insights.outgoingCount) texts.basics.outgoing
, stats (String.fromInt model.insights.deletedCount) texts.basics.deleted
] ]
] ]
, div , div

View File

@ -68,6 +68,7 @@ type alias Model =
type ConfirmModalValue type ConfirmModalValue
= ConfirmReprocessItems = ConfirmReprocessItems
| ConfirmDelete | ConfirmDelete
| ConfirmRestore
type alias SelectViewModel = type alias SelectViewModel =
@ -185,7 +186,9 @@ type Msg
| SelectAllItems | SelectAllItems
| SelectNoItems | SelectNoItems
| RequestDeleteSelected | RequestDeleteSelected
| RequestRestoreSelected
| DeleteSelectedConfirmed | DeleteSelectedConfirmed
| RestoreSelectedConfirmed
| CloseConfirmModal | CloseConfirmModal
| EditSelectedItems | EditSelectedItems
| EditMenuMsg Comp.ItemDetail.MultiEditMenu.Msg | EditMenuMsg Comp.ItemDetail.MultiEditMenu.Msg
@ -214,6 +217,7 @@ type SelectActionMode
| DeleteSelected | DeleteSelected
| EditSelected | EditSelected
| ReprocessSelected | ReprocessSelected
| RestoreSelected
type alias SearchParam = type alias SearchParam =
@ -239,7 +243,7 @@ doSearchDefaultCmd : SearchParam -> Model -> Cmd Msg
doSearchDefaultCmd param model = doSearchDefaultCmd param model =
let let
smask = smask =
Q.request <| Q.request model.searchMenuModel.searchMode <|
Q.and Q.and
[ Comp.SearchMenu.getItemQuery model.searchMenuModel [ Comp.SearchMenu.getItemQuery model.searchMenuModel
, Maybe.map Q.Fragment model.powerSearchInput.input , Maybe.map Q.Fragment model.powerSearchInput.input

View File

@ -23,6 +23,7 @@ import Data.Flags exposing (Flags)
import Data.ItemQuery as Q import Data.ItemQuery as Q
import Data.ItemSelection import Data.ItemSelection
import Data.Items import Data.Items
import Data.SearchMode exposing (SearchMode)
import Data.UiSettings exposing (UiSettings) import Data.UiSettings exposing (UiSettings)
import Page exposing (Page(..)) import Page exposing (Page(..))
import Page.Home.Data exposing (..) import Page.Home.Data exposing (..)
@ -360,6 +361,28 @@ update mId key flags settings msg model =
_ -> _ ->
noSub ( model, Cmd.none ) 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) -> DeleteAllResp (Ok res) ->
if res.success then if res.success then
@ -468,6 +491,29 @@ update mId key flags settings msg model =
_ -> _ ->
noSub ( model, Cmd.none ) 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 -> EditSelectedItems ->
case model.viewMode of case model.viewMode of
SelectView svm -> SelectView svm ->
@ -548,7 +594,7 @@ update mId key flags settings msg model =
case model.viewMode of case model.viewMode of
SelectView svm -> SelectView svm ->
-- replace changed items in the view -- 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 ) noSub ( nm, Cmd.none )
@ -717,8 +763,8 @@ replaceItems model newItems =
{ model | itemListModel = newList } { model | itemListModel = newList }
loadChangedItems : Flags -> Set String -> Cmd Msg loadChangedItems : Flags -> SearchMode -> Set String -> Cmd Msg
loadChangedItems flags ids = loadChangedItems flags smode ids =
if Set.isEmpty ids then if Set.isEmpty ids then
Cmd.none Cmd.none
@ -728,7 +774,7 @@ loadChangedItems flags ids =
Set.toList ids Set.toList ids
searchInit = searchInit =
Q.request (Just <| Q.ItemIdIn idList) Q.request smode (Just <| Q.ItemIdIn idList)
search = search =
{ searchInit { searchInit

View File

@ -78,6 +78,14 @@ confirmModal texts model =
texts.basics.yes texts.basics.yes
texts.basics.no texts.basics.no
texts.reallyDeleteQuestion texts.reallyDeleteQuestion
ConfirmRestore ->
Comp.ConfirmModal.defaultSettings
RestoreSelectedConfirmed
CloseConfirmModal
texts.basics.yes
texts.basics.no
texts.reallyRestoreQuestion
in in
case model.viewMode of case model.viewMode of
SelectView svm -> SelectView svm ->
@ -264,6 +272,16 @@ editMenuBar texts model svm =
, ( "bg-gray-200 dark:bg-bluegray-600", svm.action == DeleteSelected ) , ( "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 = , end =
[ MB.CustomButton [ MB.CustomButton

View File

@ -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 " " 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 : String
deleteLabel = 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" "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"