Merge pull request #768 from stefan-scheidewig/docspell-626-grouped_deletion_of_attachments

Docspell-626: Grouped deletion of attachments
This commit is contained in:
eikek 2021-04-18 22:55:58 +02:00 committed by GitHub
commit 9f322e667d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 358 additions and 62 deletions

View File

@ -1,18 +1,16 @@
package docspell.backend.ops
import cats.data.NonEmptyList
import cats.data.OptionT
import cats.data.{NonEmptyList, OptionT}
import cats.effect.{Effect, Resource}
import cats.implicits._
import docspell.backend.JobFactory
import docspell.common._
import docspell.ftsclient.FtsClient
import docspell.store.UpdateResult
import docspell.store.queries.{QAttachment, QItem, QMoveAttachment}
import docspell.store.queue.JobQueue
import docspell.store.records._
import docspell.store.{AddResult, Store}
import docspell.store.{AddResult, Store, UpdateResult}
import doobie.implicits._
import org.log4s.getLogger
@ -140,6 +138,11 @@ trait OItem[F[_]] {
def deleteAttachment(id: Ident, collective: Ident): F[Int]
def deleteAttachmentMultiple(
attachments: NonEmptyList[Ident],
collective: Ident
): F[Int]
def moveAttachmentBefore(itemId: Ident, source: Ident, target: Ident): F[AddResult]
def setAttachmentName(
@ -602,6 +605,20 @@ object OItem {
.deleteSingleAttachment(store)(id, collective)
.flatTap(_ => fts.removeAttachment(logger, id))
def deleteAttachmentMultiple(
attachments: NonEmptyList[Ident],
collective: Ident
): F[Int] =
for {
attachmentIds <- store.transact(
RAttachment.filterAttachments(attachments, collective)
)
results <- attachmentIds.traverse(attachment =>
deleteAttachment(attachment, collective)
)
n = results.sum
} yield n
def setAttachmentName(
attachId: Ident,
name: Option[String],

View File

@ -2789,6 +2789,28 @@ paths:
schema:
$ref: "#/components/schemas/BasicResult"
/sec/attachments/delete:
post:
tags:
- Attachment (Multi Edit)
summary: Delete multiple attachments.
description: |
Given a list of attachment ids, deletes 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/queue/state:
get:
tags: [ Job Queue ]

View File

@ -80,6 +80,7 @@ object RestServer {
"item" -> ItemRoutes(cfg, pools.blocker, restApp.backend, token),
"items" -> ItemMultiRoutes(restApp.backend, token),
"attachment" -> AttachmentRoutes(pools.blocker, restApp.backend, token),
"attachments" -> AttachmentMultiRoutes(restApp.backend, token),
"upload" -> UploadRoutes.secured(restApp.backend, cfg, token),
"checkfile" -> CheckFileRoutes.secured(restApp.backend, token),
"email/send" -> MailSendRoutes(restApp.backend, token),

View File

@ -0,0 +1,33 @@
package docspell.restserver.conv
import cats.data.NonEmptyList
import cats.implicits._
import cats.{ApplicativeError, MonadError}
import docspell.common.Ident
import io.circe.DecodingFailure
trait MultiIdSupport {
protected def readId[F[_]](
id: String
)(implicit F: ApplicativeError[F, Throwable]): F[Ident] =
Ident
.fromString(id)
.fold(
err => F.raiseError(DecodingFailure(err, Nil)),
F.pure
)
protected def readIds[F[_]](ids: List[String])(implicit
F: MonadError[F, Throwable]
): F[NonEmptyList[Ident]] =
ids.traverse(readId[F]).map(NonEmptyList.fromList).flatMap {
case Some(nel) => nel.pure[F]
case None =>
F.raiseError(
DecodingFailure("Empty list found, at least one element required", Nil)
)
}
}

View File

@ -0,0 +1,40 @@
package docspell.restserver.routes
import cats.effect.Effect
import cats.implicits._
import docspell.backend.BackendApp
import docspell.backend.auth.AuthToken
import docspell.restapi.model._
import docspell.restserver.conv.MultiIdSupport
import org.http4s.HttpRoutes
import org.http4s.circe.CirceEntityDecoder._
import org.http4s.circe.CirceEntityEncoder._
import org.http4s.dsl.Http4sDsl
object AttachmentMultiRoutes extends MultiIdSupport {
def apply[F[_]: Effect](
backend: BackendApp[F],
user: AuthToken
): HttpRoutes[F] = {
val dsl = new Http4sDsl[F] {}
import dsl._
HttpRoutes.of { case req @ POST -> Root / "delete" =>
for {
json <- req.as[IdList]
attachments <- readIds[F](json.ids)
n <- backend.item.deleteAttachmentMultiple(attachments, user.account.collective)
res = BasicResult(
n > 0,
if (n > 0) "Attachment(s) deleted" else "Attachment deletion failed."
)
resp <- Ok(res)
} yield resp
}
}
}

View File

@ -1,25 +1,21 @@
package docspell.restserver.routes
import cats.ApplicativeError
import cats.MonadError
import cats.data.NonEmptyList
import cats.effect._
import cats.implicits._
import docspell.backend.BackendApp
import docspell.backend.auth.AuthToken
import docspell.backend.ops.OCustomFields.{RemoveValue, SetValue}
import docspell.common.{Ident, ItemState}
import docspell.common.ItemState
import docspell.restapi.model._
import docspell.restserver.conv.Conversions
import docspell.restserver.conv.{Conversions, MultiIdSupport}
import io.circe.DecodingFailure
import org.http4s.HttpRoutes
import org.http4s.circe.CirceEntityDecoder._
import org.http4s.circe.CirceEntityEncoder._
import org.http4s.dsl.Http4sDsl
object ItemMultiRoutes {
object ItemMultiRoutes extends MultiIdSupport {
def apply[F[_]: Effect](
backend: BackendApp[F],
@ -215,25 +211,4 @@ object ItemMultiRoutes {
def notEmpty: Option[String] =
Option(str).notEmpty
}
private def readId[F[_]](
id: String
)(implicit F: ApplicativeError[F, Throwable]): F[Ident] =
Ident
.fromString(id)
.fold(
err => F.raiseError(DecodingFailure(err, Nil)),
F.pure
)
private def readIds[F[_]](ids: List[String])(implicit
F: MonadError[F, Throwable]
): F[NonEmptyList[Ident]] =
ids.traverse(readId[F]).map(NonEmptyList.fromList).flatMap {
case Some(nel) => nel.pure[F]
case None =>
F.raiseError(
DecodingFailure("Empty list found, at least one element required", Nil)
)
}
}

View File

@ -301,4 +301,19 @@ object RAttachment {
coll.map(cid => i.cid === cid)
).build.query[RAttachment].streamWithChunkSize(chunkSize)
}
def filterAttachments(
attachments: NonEmptyList[Ident],
coll: Ident
): ConnectionIO[Vector[Ident]] = {
val a = RAttachment.as("a")
val i = RItem.as("i")
Select(
select(a.id),
from(a)
.innerJoin(i, i.id === a.itemId),
i.cid === coll && a.id.in(attachments)
).build.query[Ident].to[Vector]
}
}

View File

@ -19,6 +19,7 @@ module Api exposing
, createScanMailbox
, deleteAllItems
, deleteAttachment
, deleteAttachments
, deleteCustomField
, deleteCustomValue
, deleteCustomValueMultiple
@ -611,6 +612,24 @@ deleteAttachment flags attachId receive =
--- Delete Attachments
deleteAttachments :
Flags
-> Set String
-> (Result Http.Error BasicResult -> msg)
-> Cmd msg
deleteAttachments flags attachIds receive =
Http2.authPost
{ url = flags.config.baseUrl ++ "/api/v1/sec/attachments/delete"
, account = getAccount flags
, body = Http.jsonBody (Api.Model.IdList.encode (Set.toList attachIds |> IdList))
, expect = Http.expectJson receive Api.Model.BasicResult.decoder
}
--- Attachment Metadata

View File

@ -4,8 +4,11 @@ module Comp.ItemDetail.Model exposing
, Msg(..)
, NotesField(..)
, SaveNameState(..)
, SelectActionMode(..)
, UpdateResult
, ViewMode(..)
, emptyModel
, initSelectViewModel
, isEditNotes
, personMatchesOrg
, resultModel
@ -105,9 +108,26 @@ type alias Model =
, allPersons : Dict String Person
, attachmentDropdownOpen : Bool
, editMenuTabsOpen : Set String
, viewMode : ViewMode
}
type ViewMode
= SimpleView
| SelectView SelectViewModel
type alias SelectViewModel =
{ ids : Set String
, action : SelectActionMode
}
type SelectActionMode
= NoneAction
| DeleteSelected
type NotesField
= ViewNotes
| EditNotes Comp.MarkdownInput.Model
@ -185,6 +205,14 @@ emptyModel =
, allPersons = Dict.empty
, attachmentDropdownOpen = False
, editMenuTabsOpen = Set.empty
, viewMode = SimpleView
}
initSelectViewModel : SelectViewModel
initSelectViewModel =
{ ids = Set.empty
, action = NoneAction
}
@ -194,6 +222,7 @@ type Msg
| Init
| SetItem ItemDetail
| SetActiveAttachment Int
| ToggleAttachment String
| TagDropdownMsg (Comp.Dropdown.Msg Tag)
| DirDropdownMsg (Comp.Dropdown.Msg Direction)
| OrgDropdownMsg (Comp.Dropdown.Msg IdName)
@ -239,6 +268,8 @@ type Msg
| TogglePdfNativeView Bool
| RequestDeleteAttachment String
| DeleteAttachConfirmed String
| RequestDeleteSelected
| DeleteSelectedConfirmed
| AttachModalCancelled
| DeleteAttachResp (Result Http.Error BasicResult)
| AddFilesToggle
@ -283,6 +314,7 @@ type Msg
| ReprocessFileResp (Result Http.Error BasicResult)
| RequestReprocessItem
| ReprocessItemConfirmed
| ToggleSelectView
type SaveNameState

View File

@ -4,13 +4,7 @@ import Api
import Api.Model.Attachment exposing (Attachment)
import Comp.AttachmentMeta
import Comp.ConfirmModal
import Comp.ItemDetail.Model
exposing
( Model
, Msg(..)
, NotesField(..)
, SaveNameState(..)
)
import Comp.ItemDetail.Model exposing (Model, Msg(..), NotesField(..), SaveNameState(..), ViewMode(..))
import Comp.MenuBar as MB
import Data.UiSettings exposing (UiSettings)
import Dict
@ -19,7 +13,7 @@ import Html.Attributes exposing (..)
import Html.Events exposing (onClick, onInput)
import Html5.DragDrop as DD
import Messages.Comp.ItemDetail.SingleAttachment exposing (Texts)
import Page exposing (Page(..))
import Set
import Styles as S
import Util.Maybe
import Util.Size
@ -87,6 +81,7 @@ view texts settings model pos attach =
- toggle thumbs
- name + size
- eye icon to open it
- toggle multi select
- menu
- rename
- meta data
@ -112,6 +107,28 @@ attachHeader texts settings model _ attach =
multiAttach =
List.length model.item.attachments > 1
selectPossible =
multiAttach && model.attachMenuOpen
selectView =
case model.viewMode of
SelectView _ ->
True
SimpleView ->
False
selectToggleText =
case model.viewMode of
SelectView _ ->
texts.exitSelectMode
SimpleView ->
texts.selectModeTitle
noAttachmentsSelected =
List.isEmpty model.item.attachments
attachSelectToggle mobile =
a
[ href "#"
@ -143,15 +160,43 @@ attachHeader texts settings model _ attach =
, title texts.openFileInNewTab
, class S.secondaryBasicButton
, class "ml-2"
, classList [ ( "hidden", selectView ) ]
]
[ i [ class "fa fa-eye font-thin" ] []
]
, a
[ classList
[ ( S.secondaryBasicButton ++ " text-sm", True )
, ( "bg-gray-200 dark:bg-bluegray-600", selectView )
, ( "hidden", not selectPossible )
, ( "ml-2", True )
]
, href "#"
, title selectToggleText
, onClick ToggleSelectView
]
[ i [ class "fa fa-tasks" ] []
]
, a
[ classList
[ ( S.deleteButton, True )
, ( "disabled", noAttachmentsSelected )
, ( "hidden", not selectPossible || not selectView )
, ( "ml-2", True )
]
, href "#"
, title texts.deleteAttachments
, onClick RequestDeleteSelected
]
[ i [ class "fa fa-trash" ] []
]
, MB.viewItem <|
MB.Dropdown
{ linkIcon = "fa fa-bars"
, linkClass =
[ ( "ml-2", True )
, ( S.secondaryBasicButton, True )
, ( "hidden", selectView )
]
, toggleMenu = ToggleAttachmentDropdown
, menuOpen = model.attachmentDropdownOpen
@ -310,8 +355,33 @@ menuItem texts model pos attach =
[ ( "bg-gray-300 dark:bg-bluegray-700 current-drop-target", enable )
]
active =
model.visibleAttach == pos
iconClass =
case model.viewMode of
SelectView svm ->
if Set.member attach.id svm.ids then
"fa fa-check-circle ml-1"
else
"fa fa-circle ml-1"
SimpleView ->
"fa fa-check-circle ml-1"
visible =
case model.viewMode of
SelectView _ ->
True
SimpleView ->
model.visibleAttach == pos
msg =
case model.viewMode of
SelectView _ ->
ToggleAttachment attach.id
SimpleView ->
SetActiveAttachment pos
in
a
([ classList <|
@ -322,18 +392,18 @@ menuItem texts model pos attach =
, class "flex flex-col relative border rounded px-1 py-1 mr-2"
, class " hover:shadow dark:hover:border-bluegray-500"
, href "#"
, onClick (SetActiveAttachment pos)
, onClick msg
]
++ DD.draggable AttachDDMsg attach.id
++ DD.droppable AttachDDMsg attach.id
)
[ div
[ classList
[ ( "hidden", not active )
[ ( "hidden", not visible )
]
, class "absolute right-1 top-1 text-blue-400 dark:text-lightblue-400 text-xl"
]
[ i [ class "fa fa-check-circle ml-1" ] []
[ i [ class iconClass ] []
]
, div [ class "flex-grow" ]
[ img

View File

@ -23,21 +23,8 @@ import Comp.DetailEdit
import Comp.Dropdown exposing (isDropdownChangeMsg)
import Comp.Dropzone
import Comp.EquipmentForm
import Comp.ItemDetail.EditForm
import Comp.ItemDetail.FieldTabState as FTabState
import Comp.ItemDetail.Model
exposing
( AttachmentRename
, Model
, Msg(..)
, NotesField(..)
, SaveNameState(..)
, UpdateResult
, isEditNotes
, resultModel
, resultModelCmd
, resultModelCmdSub
)
import Comp.ItemDetail.Model exposing (AttachmentRename, Model, Msg(..), NotesField(..), SaveNameState(..), SelectActionMode(..), UpdateResult, ViewMode(..), initSelectViewModel, isEditNotes, resultModel, resultModelCmd, resultModelCmdSub)
import Comp.ItemMail
import Comp.KeyInput
import Comp.LinkTarget
@ -54,8 +41,6 @@ import Data.PersonUse
import Data.UiSettings exposing (UiSettings)
import DatePicker
import Dict
import Html exposing (..)
import Html.Attributes exposing (..)
import Html5.DragDrop as DD
import Http
import Page exposing (Page(..))
@ -282,6 +267,23 @@ update key flags inav settings msg model =
, attachRename = Nothing
}
ToggleAttachment id ->
case model.viewMode of
SelectView svm ->
let
svm_ =
if Set.member id svm.ids then
{ svm | ids = Set.remove id svm.ids }
else
{ svm | ids = Set.insert id svm.ids }
in
resultModel
{ model | viewMode = SelectView svm_ }
SimpleView ->
resultModel model
ToggleMenu ->
resultModel
{ model | menuOpen = not model.menuOpen }
@ -938,6 +940,49 @@ update key flags inav settings msg model =
in
resultModel model_
RequestDeleteSelected ->
case model.viewMode of
SelectView svm ->
if Set.isEmpty svm.ids then
resultModel model
else
let
confirmModal =
Comp.ConfirmModal.defaultSettings
DeleteSelectedConfirmed
AttachModalCancelled
"Ok"
"Cancel"
"Really delete these files?"
model_ =
{ model
| viewMode =
SelectView
{ svm
| action = DeleteSelected
}
, attachModal = Just confirmModal
}
in
resultModel model_
SimpleView ->
resultModel model
DeleteSelectedConfirmed ->
case model.viewMode of
SelectView svm ->
let
cmd =
Api.deleteAttachments flags svm.ids DeleteAttachResp
in
resultModelCmd ( { model | attachModal = Nothing, viewMode = SimpleView }, cmd )
SimpleView ->
resultModel model
AddFilesToggle ->
resultModel
{ model
@ -1360,7 +1405,11 @@ update key flags inav settings msg model =
withSub ( model_, Cmd.none )
ToggleAttachMenu ->
resultModel { model | attachMenuOpen = not model.attachMenuOpen }
resultModel
{ model
| attachMenuOpen = not model.attachMenuOpen
, viewMode = SimpleView
}
UiSettingsUpdated ->
let
@ -1571,6 +1620,23 @@ update key flags inav settings msg model =
in
resultModelCmd ( { model | itemModal = Nothing }, cmd )
ToggleSelectView ->
let
( nextView, cmd ) =
case model.viewMode of
SimpleView ->
( SelectView initSelectViewModel, Cmd.none )
SelectView _ ->
( SimpleView, Cmd.none )
in
withSub
( { model
| viewMode = nextView
}
, cmd
)
--- Helper

View File

@ -15,6 +15,9 @@ type alias Texts =
, viewExtractedData : String
, reprocessFile : String
, deleteThisFile : String
, selectModeTitle : String
, exitSelectMode : String
, deleteAttachments : String
}
@ -31,4 +34,7 @@ gb =
, viewExtractedData = "View extracted data"
, reprocessFile = "Re-process this file"
, deleteThisFile = "Delete this file"
, selectModeTitle = "Select Mode"
, exitSelectMode = "Exit Select Mode"
, deleteAttachments = "Delete attachments"
}