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

View File

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

View File

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

View File

@ -19,7 +19,10 @@ import docspell.store.queries.SearchSummary
import org.log4s.getLogger
/** A "porcelain" api on top of OFulltext and OItemSearch. */
/** A "porcelain" api on top of OFulltext and OItemSearch. This takes
* care of restricting the items to a subset, e.g. only items that
* have a "valid" state.
*/
trait OSimpleSearch[F[_]] {
/** Search for items using the given query and optional fulltext
@ -36,7 +39,7 @@ trait OSimpleSearch[F[_]] {
* and not the results.
*/
def searchSummary(
useFTS: Boolean
settings: StatsSettings
)(q: Query, fulltextQuery: Option[String]): F[SearchSummary]
/** Calls `search` by parsing the given query string into a query that
@ -53,12 +56,12 @@ trait OSimpleSearch[F[_]] {
* results.
*/
final def searchSummaryByString(
useFTS: Boolean
settings: StatsSettings
)(fix: Query.Fix, q: ItemQueryString)(implicit
F: Applicative[F]
): F[StringSearchResult[SearchSummary]] =
OSimpleSearch.applySearch[F, SearchSummary](fix, q)((iq, fts) =>
searchSummary(useFTS)(iq, fts)
searchSummary(settings)(iq, fts)
)
}
@ -83,7 +86,12 @@ object OSimpleSearch {
batch: Batch,
useFTS: Boolean,
resolveDetails: Boolean,
maxNoteLen: Int
maxNoteLen: Int,
searchMode: SearchMode
)
final case class StatsSettings(
useFTS: Boolean,
searchMode: SearchMode
)
sealed trait Items {
@ -214,7 +222,11 @@ object OSimpleSearch {
// 1. fulltext only if fulltextQuery.isDefined && q.isEmpty && useFTS
// 2. sql+fulltext if fulltextQuery.isDefined && q.nonEmpty && useFTS
// 3. sql-only else (if fulltextQuery.isEmpty || !useFTS)
val validItemQuery = q.withFix(_.andQuery(ItemQuery.Expr.ValidItemStates))
val validItemQuery =
settings.searchMode match {
case SearchMode.Trashed => q.withFix(_.andQuery(ItemQuery.Expr.Trashed))
case SearchMode.Normal => q.withFix(_.andQuery(ItemQuery.Expr.ValidItemStates))
}
fulltextQuery match {
case Some(ftq) if settings.useFTS =>
if (q.isEmpty) {
@ -267,18 +279,24 @@ object OSimpleSearch {
}
def searchSummary(
useFTS: Boolean
)(q: Query, fulltextQuery: Option[String]): F[SearchSummary] =
settings: StatsSettings
)(q: Query, fulltextQuery: Option[String]): F[SearchSummary] = {
val validItemQuery =
settings.searchMode match {
case SearchMode.Trashed => q.withFix(_.andQuery(ItemQuery.Expr.Trashed))
case SearchMode.Normal => q.withFix(_.andQuery(ItemQuery.Expr.ValidItemStates))
}
fulltextQuery match {
case Some(ftq) if useFTS =>
case Some(ftq) if settings.useFTS =>
if (q.isEmpty)
fts.findIndexOnlySummary(q.fix.account, OFulltext.FtsInput(ftq))
else
fts
.findItemsSummary(q, OFulltext.FtsInput(ftq))
.findItemsSummary(validItemQuery, OFulltext.FtsInput(ftq))
case _ =>
is.findItemsSummary(q)
is.findItemsSummary(validItemQuery)
}
}
}
}

View File

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

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

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.ftssolr.SolrFtsClient
import docspell.joex.analysis.RegexNerFile
import docspell.joex.emptytrash._
import docspell.joex.fts.{MigrationTask, ReIndexTask}
import docspell.joex.hk._
import docspell.joex.learn.LearnClassifierTask
@ -33,7 +34,7 @@ import docspell.joex.scheduler._
import docspell.joexapi.client.JoexClient
import docspell.store.Store
import docspell.store.queue._
import docspell.store.records.RJobLog
import docspell.store.records.{REmptyTrashSetting, RJobLog}
import emil.javamail._
import org.http4s.blaze.client.BlazeClientBuilder
@ -76,11 +77,23 @@ final class JoexAppImpl[F[_]: Async](
HouseKeepingTask
.periodicTask[F](cfg.houseKeeping.schedule)
.flatMap(pstore.insert) *>
scheduleEmptyTrashTasks *>
MigrationTask.job.flatMap(queue.insertIfNew) *>
AllPreviewsTask
.job(MakePreviewArgs.StoreMode.WhenMissing, None)
.flatMap(queue.insertIfNew) *>
AllPageCountTask.job.flatMap(queue.insertIfNew)
private def scheduleEmptyTrashTasks: F[Unit] =
store
.transact(
REmptyTrashSetting.findForAllCollectives(EmptyTrashArgs.defaultSchedule, 50)
)
.evalMap(es => EmptyTrashTask.periodicTask(es.cid, es.schedule))
.evalMap(pstore.insert)
.compile
.drain
}
object JoexAppImpl {
@ -94,16 +107,17 @@ object JoexAppImpl {
for {
httpClient <- BlazeClientBuilder[F](clientEC).resource
client = JoexClient(httpClient)
store <- Store.create(cfg.jdbc, connectEC)
queue <- JobQueue(store)
pstore <- PeriodicTaskStore.create(store)
nodeOps <- ONode(store)
joex <- OJoex(client, store)
upload <- OUpload(store, queue, cfg.files, joex)
fts <- createFtsClient(cfg)(httpClient)
itemOps <- OItem(store, fts, queue, joex)
analyser <- TextAnalyser.create[F](cfg.textAnalysis.textAnalysisConfig)
regexNer <- RegexNerFile(cfg.textAnalysis.regexNerFileConfig, store)
store <- Store.create(cfg.jdbc, connectEC)
queue <- JobQueue(store)
pstore <- PeriodicTaskStore.create(store)
nodeOps <- ONode(store)
joex <- OJoex(client, store)
upload <- OUpload(store, queue, cfg.files, joex)
fts <- createFtsClient(cfg)(httpClient)
itemOps <- OItem(store, fts, queue, joex)
itemSearchOps <- OItemSearch(store)
analyser <- TextAnalyser.create[F](cfg.textAnalysis.textAnalysisConfig)
regexNer <- RegexNerFile(cfg.textAnalysis.regexNerFileConfig, store)
javaEmil =
JavaMailEmil(Settings.defaultSettings.copy(debug = cfg.mailDebug))
sch <- SchedulerBuilder(cfg.scheduler, store)
@ -206,6 +220,13 @@ object JoexAppImpl {
AllPageCountTask.onCancel[F]
)
)
.withTask(
JobTask.json(
EmptyTrashArgs.taskName,
EmptyTrashTask[F](itemOps, itemSearchOps),
EmptyTrashTask.onCancel[F]
)
)
.resource
psch <- PeriodicScheduler.create(
cfg.periodicScheduler,

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.scheduler.Task
import docspell.store.records._
import docspell.store.usertask.UserTaskScope
import com.github.eikek.calev._
@ -36,11 +37,10 @@ object HouseKeepingTask {
RPeriodicTask
.createJson(
true,
UserTaskScope(DocspellSystem.taskGroup),
taskName,
DocspellSystem.taskGroup,
(),
"Docspell house-keeping",
DocspellSystem.taskGroup,
Priority.Low,
ce,
None

View File

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

View File

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

View File

@ -1136,6 +1136,27 @@ paths:
schema:
$ref: "#/components/schemas/BasicResult"
/sec/collective/emptytrash/startonce:
post:
operationId: "sec-collective-emptytrash-start-now"
tags: [ Collective ]
summary: Starts the empty trash task
description: |
Submits a task to remove all items from the database that have
been "soft-deleted". This task is also run periodically and
can be triggered here to be immediatly submitted.
The request is empty, settings are used from the collective.
security:
- authTokenHeader: []
responses:
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/BasicResult"
/sec/user:
get:
operationId: "sec-user-get-all"
@ -1478,6 +1499,7 @@ paths:
- $ref: "#/components/parameters/limit"
- $ref: "#/components/parameters/offset"
- $ref: "#/components/parameters/withDetails"
- $ref: "#/components/parameters/searchMode"
responses:
200:
description: Ok
@ -1576,6 +1598,7 @@ paths:
- authTokenHeader: []
parameters:
- $ref: "#/components/parameters/q"
- $ref: "#/components/parameters/searchMode"
responses:
200:
description: Ok
@ -1607,7 +1630,9 @@ paths:
tags: [ Item ]
summary: Delete an item.
description: |
Delete an item and all its data permanently.
Delete an item and all its data. This is a "soft delete", the
item is still in the database and can be undeleted. A periodic
job will eventually remove this item from the database.
security:
- authTokenHeader: []
parameters:
@ -1619,6 +1644,26 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/BasicResult"
/sec/item/{id}/restore:
post:
operationId: "sec-item-restore-by-id"
tags: [ Item ]
summary: Restore a deleted item.
description: |
A deleted item can be restored as long it is still in the
database. This action sets the item state to `created`.
security:
- authTokenHeader: []
parameters:
- $ref: "#/components/parameters/id"
responses:
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/BasicResult"
/sec/item/{id}/tags:
put:
operationId: "sec-item-get-tags"
@ -2305,6 +2350,29 @@ paths:
schema:
$ref: "#/components/schemas/BasicResult"
/sec/items/restoreAll:
post:
operationId: "sec-items-restore-all"
tags:
- Item (Multi Edit)
summary: Restore multiple items.
description: |
Given a list of item ids, restores all of them.
security:
- authTokenHeader: []
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/IdList"
responses:
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/BasicResult"
/sec/items/tags:
post:
operationId: "sec-items-add-all-tags"
@ -4112,6 +4180,16 @@ components:
withDetails:
type: boolean
default: false
searchMode:
type: string
format: searchmode
enum:
- normal
- trashed
default: normal
description: |
Specify whether the search query should apply to
soft-deleted items or not.
query:
type: string
description: |
@ -4569,6 +4647,7 @@ components:
required:
- incomingCount
- outgoingCount
- deletedCount
- itemSize
- tagCloud
properties:
@ -4578,6 +4657,9 @@ components:
outgoingCount:
type: integer
format: int32
deletedCount:
type: integer
format: int32
itemSize:
type: integer
format: int64
@ -5185,6 +5267,7 @@ components:
- language
- integrationEnabled
- classifier
- emptyTrashSchedule
properties:
language:
type: string
@ -5194,6 +5277,9 @@ components:
description: |
Whether the collective has the integration endpoint
enabled.
emptyTrashSchedule:
type: string
format: calevent
classifier:
$ref: "#/components/schemas/ClassifierSetting"
@ -5834,6 +5920,13 @@ components:
description: Whether to return details to each item.
schema:
type: boolean
searchMode:
name: searchMode
in: query
description: Whether to search in soft-deleted items only.
schema:
type: string
format: searchmode
name:
name: name
in: path

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 =>
tables.item.state.in(ItemState.validStates)
case Expr.Trashed =>
tables.item.state === ItemState.Deleted
case Expr.TagIdsMatch(op, tags) =>
val ids = tags.toList.flatMap(s => Ident.fromString(s).toOption)
Nel

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

@ -99,6 +99,8 @@ module Api exposing
, removeTagsMultiple
, reprocessItem
, reprocessMultiple
, restoreAllItems
, restoreItem
, saveClientSettings
, sendMail
, setAttachmentName
@ -128,6 +130,7 @@ module Api exposing
, setTagsMultiple
, setUnconfirmed
, startClassifier
, startEmptyTrash
, startOnceNotifyDueItems
, startOnceScanMailbox
, startReIndex
@ -994,6 +997,19 @@ startClassifier flags receive =
}
startEmptyTrash :
Flags
-> (Result Http.Error BasicResult -> msg)
-> Cmd msg
startEmptyTrash flags receive =
Http2.authPost
{ url = flags.config.baseUrl ++ "/api/v1/sec/collective/emptytrash/startonce"
, account = getAccount flags
, body = Http.emptyBody
, expect = Http.expectJson receive Api.Model.BasicResult.decoder
}
getTagCloud : Flags -> (Result Http.Error TagCloud -> msg) -> Cmd msg
getTagCloud flags receive =
Http2.authGet
@ -1676,6 +1692,20 @@ deleteAllItems flags ids receive =
}
restoreAllItems :
Flags
-> Set String
-> (Result Http.Error BasicResult -> msg)
-> Cmd msg
restoreAllItems flags ids receive =
Http2.authPost
{ url = flags.config.baseUrl ++ "/api/v1/sec/items/restoreAll"
, account = getAccount flags
, body = Http.jsonBody (Api.Model.IdList.encode (IdList (Set.toList ids)))
, expect = Http.expectJson receive Api.Model.BasicResult.decoder
}
--- Item
@ -1973,6 +2003,16 @@ setUnconfirmed flags item receive =
}
restoreItem : Flags -> String -> (Result Http.Error BasicResult -> msg) -> Cmd msg
restoreItem flags item receive =
Http2.authPost
{ url = flags.config.baseUrl ++ "/api/v1/sec/item/" ++ item ++ "/restore"
, account = getAccount flags
, body = Http.emptyBody
, expect = Http.expectJson receive Api.Model.BasicResult.decoder
}
deleteItem : Flags -> String -> (Result Http.Error BasicResult -> msg) -> Cmd msg
deleteItem flags item receive =
Http2.authDelete

View File

@ -20,7 +20,9 @@ import Api.Model.CollectiveSettings exposing (CollectiveSettings)
import Comp.Basic as B
import Comp.ClassifierSettingsForm
import Comp.Dropdown
import Comp.EmptyTrashForm
import Comp.MenuBar as MB
import Data.CalEvent
import Data.DropdownStyle as DS
import Data.Flags exposing (Flags)
import Data.Language exposing (Language)
@ -41,6 +43,8 @@ type alias Model =
, fullTextReIndexResult : FulltextReindexResult
, classifierModel : Comp.ClassifierSettingsForm.Model
, startClassifierResult : ClassifierResult
, emptyTrashModel : Comp.EmptyTrashForm.Model
, startEmptyTrashResult : EmptyTrashResult
}
@ -50,6 +54,11 @@ type ClassifierResult
| ClassifierResultSubmitError String
| ClassifierResultOk
type EmptyTrashResult
= EmptyTrashResultInitial
| EmptyTrashResultHttpError Http.Error
| EmptyTrashResultSubmitError String
| EmptyTrashResultOk
type FulltextReindexResult
= FulltextReindexInitial
@ -68,6 +77,9 @@ init flags settings =
( cm, cc ) =
Comp.ClassifierSettingsForm.init flags settings.classifier
( em, ec ) =
Comp.EmptyTrashForm.init flags settings.emptyTrashSchedule
in
( { langModel =
Comp.Dropdown.makeSingleList
@ -80,8 +92,10 @@ init flags settings =
, fullTextReIndexResult = FulltextReindexInitial
, classifierModel = cm
, startClassifierResult = ClassifierResultInitial
, emptyTrashModel = em
, startEmptyTrashResult = EmptyTrashResultInitial
}
, Cmd.map ClassifierSettingMsg cc
, Cmd.batch [ Cmd.map ClassifierSettingMsg cc, Cmd.map EmptyTrashMsg ec ]
)
@ -96,6 +110,10 @@ getSettings model =
|> Maybe.withDefault model.initSettings.language
, integrationEnabled = model.intEnabled
, classifier = cls
, emptyTrashSchedule =
Comp.EmptyTrashForm.getSettings model.emptyTrashModel
|> Maybe.withDefault Data.CalEvent.everyMonth
|> Data.CalEvent.makeEvent
}
)
(Comp.ClassifierSettingsForm.getSettings
@ -110,9 +128,12 @@ type Msg
| TriggerReIndex
| TriggerReIndexResult (Result Http.Error BasicResult)
| ClassifierSettingMsg Comp.ClassifierSettingsForm.Msg
| EmptyTrashMsg Comp.EmptyTrashForm.Msg
| SaveSettings
| StartClassifierTask
| StartEmptyTrashTask
| StartClassifierResp (Result Http.Error BasicResult)
| StartEmptyTrashResp (Result Http.Error BasicResult)
update : Flags -> Msg -> Model -> ( Model, Cmd Msg, Maybe CollectiveSettings )
@ -188,6 +209,18 @@ update flags msg model =
, Nothing
)
EmptyTrashMsg lmsg ->
let
( cm, cc ) =
Comp.EmptyTrashForm.update flags lmsg model.emptyTrashModel
in
( { model
| emptyTrashModel = cm
}
, Cmd.map EmptyTrashMsg cc
, Nothing
)
SaveSettings ->
case getSettings model of
Just s ->
@ -199,6 +232,10 @@ update flags msg model =
StartClassifierTask ->
( model, Api.startClassifier flags StartClassifierResp, Nothing )
StartEmptyTrashTask ->
( model, Api.startEmptyTrash flags StartEmptyTrashResp, Nothing )
StartClassifierResp (Ok br) ->
( { model
| startClassifierResult =
@ -218,6 +255,24 @@ update flags msg model =
, Nothing
)
StartEmptyTrashResp (Ok br) ->
( { model
| startEmptyTrashResult =
if br.success then
EmptyTrashResultOk
else
EmptyTrashResultSubmitError br.message
}
, Cmd.none
, Nothing
)
StartEmptyTrashResp (Err err) ->
( { model | startEmptyTrashResult = EmptyTrashResultHttpError err }
, Cmd.none
, Nothing
)
--- View2
@ -257,7 +312,7 @@ view2 flags texts settings model =
, end = []
, rootClasses = "mb-4"
}
, h3 [ class S.header3 ]
, h2 [ class S.header2 ]
[ text texts.documentLanguage
]
, div [ class "mb-4" ]
@ -279,8 +334,8 @@ view2 flags texts settings model =
[ ( "hidden", not flags.config.integrationEnabled )
]
]
[ h3
[ class S.header3
[ h2
[ class S.header2
]
[ text texts.integrationEndpoint
]
@ -311,8 +366,8 @@ view2 flags texts settings model =
[ ( "hidden", not flags.config.fullTextSearchEnabled )
]
]
[ h3
[ class S.header3 ]
[ h2
[ class S.header2 ]
[ text texts.fulltextSearch ]
, div
[ class "mb-4" ]
@ -348,8 +403,8 @@ view2 flags texts settings model =
[ ( " hidden", not flags.config.showClassificationSettings )
]
]
[ h3
[ class S.header3 ]
[ h2
[ class S.header2 ]
[ text texts.autoTagging
]
, div
@ -371,6 +426,28 @@ view2 flags texts settings model =
]
]
]
, div []
[ h2 [ class S.header2 ]
[ text texts.emptyTrash
]
, div [ class "mb-4" ]
[ Html.map EmptyTrashMsg
(Comp.EmptyTrashForm.view texts.emptyTrashForm
settings
model.emptyTrashModel
)
, div [ class "flex flex-row justify-end" ]
[ B.secondaryBasicButton
{ handler = onClick StartEmptyTrashTask
, icon = "fa fa-play"
, label = texts.startNow
, disabled = model.emptyTrashModel.schedule == Nothing
, attrs = [ href "#" ]
}
, renderEmptyTrashResultMessage texts model.startEmptyTrashResult
]
]
]
]
@ -427,3 +504,38 @@ renderFulltextReindexResultMessage texts result =
FulltextReindexSubmitError m ->
text m
renderEmptyTrashResultMessage : Texts -> EmptyTrashResult -> Html msg
renderEmptyTrashResultMessage texts result =
let
isSuccess =
case result of
EmptyTrashResultOk ->
True
_ ->
False
isError =
not isSuccess
in
div
[ classList
[ ( S.errorMessage, isError )
, ( S.successMessage, isSuccess )
, ( "hidden", result == EmptyTrashResultInitial )
]
]
[ case result of
EmptyTrashResultInitial ->
text ""
EmptyTrashResultOk ->
text texts.emptyTrashTaskStarted
EmptyTrashResultHttpError err ->
text (texts.httpError err)
EmptyTrashResultSubmitError m ->
text m
]

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

View File

@ -118,30 +118,57 @@ view texts settings model =
]
, True
)
isDeleted =
model.item.state == "deleted"
isCreated =
model.item.state == "created"
in
div [ class "flex flex-col pb-2" ]
[ div [ class "flex flex-row items-center text-2xl" ]
[ i
[ classList
[ ( "hidden", Data.UiSettings.fieldHidden settings Data.Fields.Direction )
[ if isDeleted then
div
[ classList
[ ( "text-red-500 dark:text-orange-600 text-4xl", True )
, ( "hidden", not isDeleted )
]
, title texts.basics.deleted
]
, class (Data.Direction.iconFromString2 model.item.direction)
, class "mr-2"
, title model.item.direction
]
[]
[ i [ class "mr-2 fa fa-trash-alt" ] []
]
else
i
[ classList
[ ( "hidden", Data.UiSettings.fieldHidden settings Data.Fields.Direction )
]
, class (Data.Direction.iconFromString2 model.item.direction)
, class "mr-2"
, title model.item.direction
]
[]
, div [ class "flex-grow ml-1 flex flex-col" ]
[ div [ class "flex flex-row items-center font-semibold" ]
[ text model.item.name
, div
[ classList
[ ( "hidden", model.item.state /= "created" )
[ ( "hidden", not isCreated )
]
, class "ml-3 text-base label bg-blue-500 dark:bg-lightblue-500 text-white rounded-lg"
]
[ text texts.new
, i [ class "fa fa-exclamation ml-2" ] []
]
, div
[ classList
[ ( "hidden", not isDeleted )
]
, class "ml-3 text-base label bg-red-500 dark:bg-orange-500 text-white rounded-lg"
]
[ text texts.basics.deleted
, i [ class "fa fa-exclamation ml-2" ] []
]
]
]
]

View File

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

View File

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

View File

@ -188,15 +188,27 @@ menuBar texts inav settings model =
]
[ i [ class "fa fa-redo" ] []
]
, MB.CustomElement <|
a
[ class S.deleteButton
, href "#"
, onClick RequestDelete
, title texts.deleteThisItem
]
[ i [ class "fa fa-trash" ] []
]
, if model.item.state == "deleted" then
MB.CustomElement <|
a
[ class S.undeleteButton
, href "#"
, onClick RestoreItem
, title texts.undeleteThisItem
]
[ i [ class "fa fa-trash-restore" ] []
]
else
MB.CustomElement <|
a
[ class S.deleteButton
, href "#"
, onClick RequestDelete
, title texts.deleteThisItem
]
[ i [ class "fa fa-trash" ] []
]
]
, rootClasses = "mb-2"
}

View File

@ -45,6 +45,7 @@ import Data.Fields
import Data.Flags exposing (Flags)
import Data.ItemQuery as Q exposing (ItemQuery)
import Data.PersonUse
import Data.SearchMode exposing (SearchMode)
import Data.UiSettings exposing (UiSettings)
import DatePicker exposing (DatePicker)
import Html exposing (..)
@ -89,6 +90,7 @@ type alias Model =
, customValues : CustomFieldValueCollect
, sourceModel : Maybe String
, openTabs : Set String
, searchMode : SearchMode
}
@ -133,6 +135,7 @@ init flags =
, customValues = Data.CustomFieldChange.emptyCollect
, sourceModel = Nothing
, openTabs = Set.fromList [ "Tags", "Inbox" ]
, searchMode = Data.SearchMode.Normal
}
@ -323,6 +326,7 @@ resetModel model =
model.customFieldModel
, customValues = Data.CustomFieldChange.emptyCollect
, sourceModel = Nothing
, searchMode = Data.SearchMode.Normal
}
@ -343,6 +347,7 @@ type Msg
| FromDueDateMsg Comp.DatePicker.Msg
| UntilDueDateMsg Comp.DatePicker.Msg
| ToggleInbox
| ToggleSearchMode
| GetOrgResp (Result Http.Error ReferenceList)
| GetEquipResp (Result Http.Error EquipmentList)
| GetPersonResp (Result Http.Error PersonList)
@ -683,6 +688,24 @@ updateDrop ddm flags settings msg model =
, dragDrop = DD.DragDropData ddm Nothing
}
ToggleSearchMode ->
let
current =
model.searchMode
next =
if current == Data.SearchMode.Normal then
Data.SearchMode.Trashed
else
Data.SearchMode.Normal
in
{ model = { model | searchMode = next }
, cmd = Cmd.none
, stateChange = True
, dragDrop = DD.DragDropData ddm Nothing
}
FromDateMsg m ->
let
( dp, event ) =
@ -962,6 +985,7 @@ type SearchTab
| TabDueDate
| TabSource
| TabDirection
| TabTrashed
allTabs : List SearchTab
@ -977,6 +1001,7 @@ allTabs =
, TabDueDate
, TabSource
, TabDirection
, TabTrashed
]
@ -1016,6 +1041,9 @@ tabName tab =
TabDirection ->
"direction"
TabTrashed ->
"trashed"
findTab : Comp.Tabs.Tab msg -> Maybe SearchTab
findTab tab =
@ -1053,6 +1081,9 @@ findTab tab =
"direction" ->
Just TabDirection
"trashed" ->
Just TabTrashed
_ ->
Nothing
@ -1099,6 +1130,9 @@ searchTabState settings model tab =
Just TabInbox ->
False
Just TabTrashed ->
False
Nothing ->
False
@ -1447,4 +1481,18 @@ searchTabs texts ddd flags settings model =
)
]
}
, { name = tabName TabTrashed
, title = texts.trashcan
, titleRight = []
, info = Nothing
, body =
[ MB.viewItem <|
MB.Checkbox
{ id = "trashed"
, value = model.searchMode == Data.SearchMode.Trashed
, label = texts.trashcan
, tagger = \_ -> ToggleSearchMode
}
]
}
]

View File

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

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 =
{ incoming : String
, outgoing : String
, deleted : String
, tags : String
, items : String
, submit : String
@ -51,6 +52,7 @@ gb : Texts
gb =
{ incoming = "Incoming"
, outgoing = "Outgoing"
, deleted = "Deleted"
, tags = "Tags"
, items = "Items"
, submit = "Submit"
@ -92,6 +94,7 @@ de : Texts
de =
{ incoming = "Eingehend"
, outgoing = "Ausgehend"
, deleted = "Gelöscht"
, tags = "Tags"
, items = "Dokumente"
, submit = "Speichern"

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -78,6 +78,14 @@ confirmModal texts model =
texts.basics.yes
texts.basics.no
texts.reallyDeleteQuestion
ConfirmRestore ->
Comp.ConfirmModal.defaultSettings
RestoreSelectedConfirmed
CloseConfirmModal
texts.basics.yes
texts.basics.no
texts.reallyRestoreQuestion
in
case model.viewMode of
SelectView svm ->
@ -264,6 +272,16 @@ editMenuBar texts model svm =
, ( "bg-gray-200 dark:bg-bluegray-600", svm.action == DeleteSelected )
]
}
, MB.CustomButton
{ tagger = RequestRestoreSelected
, label = ""
, icon = Just "fa fa-trash-restore"
, title = texts.undeleteSelectedItems selectCount
, inputClass =
[ ( btnStyle, True )
, ( "bg-gray-200 dark:bg-bluegray-600", svm.action == RestoreSelected )
]
}
]
, end =
[ MB.CustomButton

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 "
undeleteButton : String
undeleteButton =
" rounded my-auto whitespace-nowrap border border-green-500 dark:border-lightgreen-500 text-green-500 dark:text-lightgreen-500 text-center px-4 py-2 shadow-none focus:outline-none focus:ring focus:ring-opacity-75 hover:bg-green-600 hover:text-white dark:hover:text-white dark:hover:bg-lightgreen-500 dark:hover:text-bluegray-900 "
deleteLabel : String
deleteLabel =
"label my-auto whitespace-nowrap border border-red-500 dark:border-lightred-500 text-red-500 dark:text-orange-500 text-center focus:outline-none focus:ring focus:ring-opacity-75 hover:bg-red-600 hover:text-white dark:hover:text-white dark:hover:bg-orange-500 dark:hover:text-bluegray-900"