diff --git a/Changelog.md b/Changelog.md index 7d53dd30..7f6376d5 100644 --- a/Changelog.md +++ b/Changelog.md @@ -4,6 +4,7 @@ *Unknown* +- Allow to delete attachments of an item. - Allow to be notified via e-mail for items with a due date. This uses the periodic-task framework introduced in the last release. - Fix issues when converting HTML with unkown links. This especially diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala index b34e19d8..5361fb5f 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala @@ -66,7 +66,7 @@ trait OItem[F[_]] { def getProposals(item: Ident, collective: Ident): F[MetaProposalList] - def delete(itemId: Ident, collective: Ident): F[Int] + def deleteItem(itemId: Ident, collective: Ident): F[Int] def findAttachmentMeta(id: Ident, collective: Ident): F[Option[RAttachmentMeta]] @@ -74,6 +74,7 @@ trait OItem[F[_]] { def findByFileSource(checksum: String, sourceId: Ident): F[Vector[RItem]] + def deleteAttachment(id: Ident, collective: Ident): F[Int] } object OItem { @@ -292,7 +293,7 @@ object OItem { .attempt .map(AddResult.fromUpdate) - def delete(itemId: Ident, collective: Ident): F[Int] = + def deleteItem(itemId: Ident, collective: Ident): F[Int] = QItem.delete(store)(itemId, collective) def getProposals(item: Ident, collective: Ident): F[MetaProposalList] = @@ -310,5 +311,7 @@ object OItem { items <- OptionT.liftF(QItem.findByChecksum(checksum, coll)) } yield items).getOrElse(Vector.empty)) + def deleteAttachment(id: Ident, collective: Ident): F[Int] = + QAttachment.deleteSingleAttachment(store)(id, collective) }) } diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 2b8e167a..13e55f73 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -1168,6 +1168,28 @@ paths: $ref: "#/components/schemas/ItemProposals" /sec/attachment/{id}: + delete: + tags: [ Attachment ] + summary: Delete an attachment. + description: | + Deletes a single attachment with all its related data like + file, the original file, extracted text, results from analysis + etc. + + If the attachment is part of an archive, the archive is only + deleted, if it is the last entry left. Archives are otherwise + not deleted, if there are remaining attachments available. + security: + - authTokenHeader: [] + parameters: + - $ref: "#/components/parameters/id" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" head: tags: [ Attachment ] summary: Get an attachment file. diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/AttachmentRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/AttachmentRoutes.scala index 27fe803d..d02375a9 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/AttachmentRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/AttachmentRoutes.scala @@ -119,6 +119,14 @@ object AttachmentRoutes { md = rm.map(Conversions.mkAttachmentMeta) resp <- md.map(Ok(_)).getOrElse(NotFound(BasicResult(false, "Not found."))) } yield resp + + case DELETE -> Root / Ident(id) => + for { + n <- backend.item.deleteAttachment(id, user.account.collective) + res = if (n == 0) BasicResult(false, "Attachment not found") + else BasicResult(true, "Attachment deleted.") + resp <- Ok(res) + } yield resp } } diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala index 0bf3f5dc..881b5129 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala @@ -138,7 +138,7 @@ object ItemRoutes { case DELETE -> Root / Ident(id) => for { - n <- backend.item.delete(id, user.account.collective) + n <- backend.item.deleteItem(id, user.account.collective) res = BasicResult(n > 0, if (n > 0) "Item deleted" else "Item deletion failed.") resp <- Ok(res) } yield resp diff --git a/modules/store/src/main/scala/docspell/store/impl/Column.scala b/modules/store/src/main/scala/docspell/store/impl/Column.scala index a42a09a3..5f47a4db 100644 --- a/modules/store/src/main/scala/docspell/store/impl/Column.scala +++ b/modules/store/src/main/scala/docspell/store/impl/Column.scala @@ -30,6 +30,9 @@ case class Column(name: String, ns: String = "", alias: String = "") { def is(c: Column): Fragment = f ++ fr"=" ++ c.f + def isSubquery(sq: Fragment): Fragment = + f ++ fr"=" ++ fr"(" ++ sq ++ fr")" + def isNot[A: Put](value: A): Fragment = f ++ fr"<> $value" diff --git a/modules/store/src/main/scala/docspell/store/queries/QAttachment.scala b/modules/store/src/main/scala/docspell/store/queries/QAttachment.scala index e95a2acf..9f4a78b3 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QAttachment.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QAttachment.scala @@ -15,28 +15,39 @@ import docspell.common.syntax.all._ object QAttachment { private[this] val logger = org.log4s.getLogger - def deleteById[F[_]: Sync](store: Store[F])(attachId: Ident, coll: Ident): F[Int] = + /** Deletes an attachment, its related source and meta data records. + * It will only delete an related archive file, if this is the last + * attachment in that archive. + */ + def deleteSingleAttachment[F[_]: Sync]( + store: Store[F] + )(attachId: Ident, coll: Ident): F[Int] = { + val loadFiles = for { + ra <- RAttachment.findByIdAndCollective(attachId, coll).map(_.map(_.fileId)) + rs <- RAttachmentSource.findByIdAndCollective(attachId, coll).map(_.map(_.fileId)) + ne <- RAttachmentArchive.countEntries(attachId) + } yield (ra, rs, ne) + for { - raFile <- store - .transact(RAttachment.findByIdAndCollective(attachId, coll)) - .map(_.map(_.fileId)) - rsFile <- store - .transact(RAttachmentSource.findByIdAndCollective(attachId, coll)) - .map(_.map(_.fileId)) - aaFile <- store - .transact(RAttachmentArchive.findByIdAndCollective(attachId, coll)) - .map(_.map(_.fileId)) + files <- store.transact(loadFiles) + k <- if (files._3 == 1) deleteArchive(store)(attachId) + else store.transact(RAttachmentArchive.delete(attachId)) n <- store.transact(RAttachment.delete(attachId)) f <- Stream - .emits(raFile.toSeq ++ rsFile.toSeq ++ aaFile.toSeq) + .emits(files._1.toSeq ++ files._2.toSeq) .map(_.id) .flatMap(store.bitpeace.delete) .map(flag => if (flag) 1 else 0) .compile .foldMonoid - } yield n + f + } yield n + k + f + } - def deleteAttachment[F[_]: Sync](store: Store[F])(ra: RAttachment): F[Int] = + /** This deletes the attachment and *all* its related files. This used + * when deleting an item and should not be used to delete a + * *single* attachment where the item should stay. + */ + private def deleteAttachment[F[_]: Sync](store: Store[F])(ra: RAttachment): F[Int] = for { _ <- logger.fdebug[F](s"Deleting attachment: ${ra.id.id}") s <- store.transact(RAttachmentSource.findById(ra.id)) diff --git a/modules/store/src/main/scala/docspell/store/records/RAttachment.scala b/modules/store/src/main/scala/docspell/store/records/RAttachment.scala index 8272e1d6..d79fa439 100644 --- a/modules/store/src/main/scala/docspell/store/records/RAttachment.scala +++ b/modules/store/src/main/scala/docspell/store/records/RAttachment.scala @@ -124,6 +124,8 @@ object RAttachment { q.query[(RAttachment, FileMeta)].to[Vector] } + /** Deletes the attachment and its related source and meta records. + */ def delete(attachId: Ident): ConnectionIO[Int] = for { n0 <- RAttachmentMeta.delete(attachId) diff --git a/modules/store/src/main/scala/docspell/store/records/RAttachmentArchive.scala b/modules/store/src/main/scala/docspell/store/records/RAttachmentArchive.scala index b09881d7..2d1b34e0 100644 --- a/modules/store/src/main/scala/docspell/store/records/RAttachmentArchive.scala +++ b/modules/store/src/main/scala/docspell/store/records/RAttachmentArchive.scala @@ -91,4 +91,13 @@ object RAttachmentArchive { .to[Vector] } + /** If the given attachment id has an associated archive, this returns + * the number of all associated attachments. Returns 0 if there is + * no archive for the given attachment. + */ + def countEntries(attachId: Ident): ConnectionIO[Int] = { + val qFileId = selectSimple(Seq(fileId), table, id.is(attachId)) + val q = selectCount(id, table, fileId.isSubquery(qFileId)) + q.query[Int].unique + } } diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index 747944e4..ec4581ab 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -3,6 +3,7 @@ module Api exposing , changePassword , checkCalEvent , createMailSettings + , deleteAttachment , deleteEquip , deleteItem , deleteMailSettings @@ -183,6 +184,23 @@ checkCalEvent flags input receive = +--- Delete Attachment + + +deleteAttachment : + Flags + -> String + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +deleteAttachment flags attachId receive = + Http2.authDelete + { url = flags.config.baseUrl ++ "/api/v1/sec/attachment/" ++ attachId + , account = getAccount flags + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + + --- Attachment Metadata diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail.elm b/modules/webapp/src/main/elm/Comp/ItemDetail.elm index 8df1a9cd..57ff0a61 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail.elm @@ -60,7 +60,7 @@ type alias Model = , nameModel : String , notesModel : Maybe String , notesField : NotesField - , deleteConfirm : Comp.YesNoDimmer.Model + , deleteItemConfirm : Comp.YesNoDimmer.Model , itemDatePicker : DatePicker , itemDate : Maybe Int , itemProposals : ItemProposals @@ -75,6 +75,7 @@ type alias Model = , attachMeta : Dict String Comp.AttachmentMeta.Model , attachMetaOpen : Bool , pdfNativeView : Bool + , deleteAttachConfirm : Comp.YesNoDimmer.Model } @@ -147,7 +148,7 @@ emptyModel = , nameModel = "" , notesModel = Nothing , notesField = ViewNotes - , deleteConfirm = Comp.YesNoDimmer.emptyModel + , deleteItemConfirm = Comp.YesNoDimmer.emptyModel , itemDatePicker = Comp.DatePicker.emptyModel , itemDate = Nothing , itemProposals = Api.Model.ItemProposals.empty @@ -162,6 +163,7 @@ emptyModel = , attachMeta = Dict.empty , attachMetaOpen = False , pdfNativeView = False + , deleteAttachConfirm = Comp.YesNoDimmer.emptyModel } @@ -198,7 +200,7 @@ type Msg | SetDueDateSuggestion Int | ItemDatePickerMsg Comp.DatePicker.Msg | DueDatePickerMsg Comp.DatePicker.Msg - | YesNoMsg Comp.YesNoDimmer.Msg + | DeleteItemConfirm Comp.YesNoDimmer.Msg | RequestDelete | SaveResp (Result Http.Error BasicResult) | DeleteResp (Result Http.Error BasicResult) @@ -215,6 +217,9 @@ type Msg | AttachMetaClick String | AttachMetaMsg String Comp.AttachmentMeta.Msg | TogglePdfNativeView + | RequestDeleteAttachment String + | DeleteAttachConfirm String Comp.YesNoDimmer.Msg + | DeleteAttachResp (Result Http.Error BasicResult) @@ -676,10 +681,10 @@ update key flags next msg model = RemoveDueDate -> ( { model | dueDate = Nothing }, setDueDate flags model Nothing ) - YesNoMsg m -> + DeleteItemConfirm m -> let ( cm, confirmed ) = - Comp.YesNoDimmer.update m model.deleteConfirm + Comp.YesNoDimmer.update m model.deleteItemConfirm cmd = if confirmed then @@ -688,10 +693,10 @@ update key flags next msg model = else Cmd.none in - ( { model | deleteConfirm = cm }, cmd ) + ( { model | deleteItemConfirm = cm }, cmd ) RequestDelete -> - update key flags next (YesNoMsg Comp.YesNoDimmer.activate) model + update key flags next (DeleteItemConfirm Comp.YesNoDimmer.activate) model SetCorrOrgSuggestion idname -> ( model, setCorrOrg flags model (Just idname) ) @@ -943,6 +948,37 @@ update key flags next msg model = , Cmd.none ) + DeleteAttachConfirm attachId lmsg -> + let + ( cm, confirmed ) = + Comp.YesNoDimmer.update lmsg model.deleteAttachConfirm + + cmd = + if confirmed then + Api.deleteAttachment flags attachId DeleteAttachResp + + else + Cmd.none + in + ( { model | deleteAttachConfirm = cm }, cmd ) + + DeleteAttachResp (Ok res) -> + if res.success then + update key flags next ReloadItem model + + else + ( model, Cmd.none ) + + DeleteAttachResp (Err _) -> + ( model, Cmd.none ) + + RequestDeleteAttachment id -> + update key + flags + next + (DeleteAttachConfirm id Comp.YesNoDimmer.activate) + model + -- view @@ -1017,7 +1053,7 @@ view inav model = ] , renderMailForm model , div [ class "ui grid" ] - [ Html.map YesNoMsg (Comp.YesNoDimmer.view model.deleteConfirm) + [ Html.map DeleteItemConfirm (Comp.YesNoDimmer.view model.deleteItemConfirm) , div [ classList [ ( "four wide column", True ) @@ -1206,7 +1242,8 @@ renderAttachmentView model pos attach = , ( "active", attachmentVisible model pos ) ] ] - [ div [ class "ui small secondary menu" ] + [ Html.map (DeleteAttachConfirm attach.id) (Comp.YesNoDimmer.view model.deleteAttachConfirm) + , div [ class "ui small secondary menu" ] [ div [ class "horizontally fitted item" ] [ i [ class "file outline icon" ] [] , text attachName @@ -1227,6 +1264,16 @@ renderAttachmentView model pos attach = ] , div [ class "right menu" ] [ a + [ classList + [ ( "item", True ) + ] + , title "Delete this file permanently" + , href "#" + , onClick (RequestDeleteAttachment attach.id) + ] + [ i [ class "red trash icon" ] [] + ] + , a [ classList [ ( "item", True ) , ( "invisible", not hasArchive )