mirror of
				https://github.com/TheAnachronism/docspell.git
				synced 2025-10-30 21:40:12 +00:00 
			
		
		
		
	Merge pull request #101 from eikek/feature/delete-attachment
Delete single attachments
This commit is contained in:
		| @@ -4,6 +4,7 @@ | |||||||
|  |  | ||||||
| *Unknown* | *Unknown* | ||||||
|  |  | ||||||
|  | - Allow to delete attachments of an item. | ||||||
| - Allow to be notified via e-mail for items with a due date. This uses | - Allow to be notified via e-mail for items with a due date. This uses | ||||||
|   the periodic-task framework introduced in the last release. |   the periodic-task framework introduced in the last release. | ||||||
| - Fix issues when converting HTML with unkown links. This especially | - Fix issues when converting HTML with unkown links. This especially | ||||||
|   | |||||||
| @@ -66,7 +66,7 @@ trait OItem[F[_]] { | |||||||
|  |  | ||||||
|   def getProposals(item: Ident, collective: Ident): F[MetaProposalList] |   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]] |   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 findByFileSource(checksum: String, sourceId: Ident): F[Vector[RItem]] | ||||||
|  |  | ||||||
|  |   def deleteAttachment(id: Ident, collective: Ident): F[Int] | ||||||
| } | } | ||||||
|  |  | ||||||
| object OItem { | object OItem { | ||||||
| @@ -292,7 +293,7 @@ object OItem { | |||||||
|           .attempt |           .attempt | ||||||
|           .map(AddResult.fromUpdate) |           .map(AddResult.fromUpdate) | ||||||
|  |  | ||||||
|       def delete(itemId: Ident, collective: Ident): F[Int] = |       def deleteItem(itemId: Ident, collective: Ident): F[Int] = | ||||||
|         QItem.delete(store)(itemId, collective) |         QItem.delete(store)(itemId, collective) | ||||||
|  |  | ||||||
|       def getProposals(item: Ident, collective: Ident): F[MetaProposalList] = |       def getProposals(item: Ident, collective: Ident): F[MetaProposalList] = | ||||||
| @@ -310,5 +311,7 @@ object OItem { | |||||||
|           items <- OptionT.liftF(QItem.findByChecksum(checksum, coll)) |           items <- OptionT.liftF(QItem.findByChecksum(checksum, coll)) | ||||||
|         } yield items).getOrElse(Vector.empty)) |         } yield items).getOrElse(Vector.empty)) | ||||||
|  |  | ||||||
|  |       def deleteAttachment(id: Ident, collective: Ident): F[Int] = | ||||||
|  |         QAttachment.deleteSingleAttachment(store)(id, collective) | ||||||
|     }) |     }) | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1168,6 +1168,28 @@ paths: | |||||||
|                 $ref: "#/components/schemas/ItemProposals" |                 $ref: "#/components/schemas/ItemProposals" | ||||||
|  |  | ||||||
|   /sec/attachment/{id}: |   /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: |     head: | ||||||
|       tags: [ Attachment ] |       tags: [ Attachment ] | ||||||
|       summary: Get an attachment file. |       summary: Get an attachment file. | ||||||
|   | |||||||
| @@ -119,6 +119,14 @@ object AttachmentRoutes { | |||||||
|           md = rm.map(Conversions.mkAttachmentMeta) |           md = rm.map(Conversions.mkAttachmentMeta) | ||||||
|           resp <- md.map(Ok(_)).getOrElse(NotFound(BasicResult(false, "Not found."))) |           resp <- md.map(Ok(_)).getOrElse(NotFound(BasicResult(false, "Not found."))) | ||||||
|         } yield resp |         } 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 | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -138,7 +138,7 @@ object ItemRoutes { | |||||||
|  |  | ||||||
|       case DELETE -> Root / Ident(id) => |       case DELETE -> Root / Ident(id) => | ||||||
|         for { |         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.") |           res = BasicResult(n > 0, if (n > 0) "Item deleted" else "Item deletion failed.") | ||||||
|           resp <- Ok(res) |           resp <- Ok(res) | ||||||
|         } yield resp |         } yield resp | ||||||
|   | |||||||
| @@ -30,6 +30,9 @@ case class Column(name: String, ns: String = "", alias: String = "") { | |||||||
|   def is(c: Column): Fragment = |   def is(c: Column): Fragment = | ||||||
|     f ++ fr"=" ++ c.f |     f ++ fr"=" ++ c.f | ||||||
|  |  | ||||||
|  |   def isSubquery(sq: Fragment): Fragment = | ||||||
|  |     f ++ fr"=" ++ fr"(" ++ sq ++ fr")" | ||||||
|  |  | ||||||
|   def isNot[A: Put](value: A): Fragment = |   def isNot[A: Put](value: A): Fragment = | ||||||
|     f ++ fr"<> $value" |     f ++ fr"<> $value" | ||||||
|  |  | ||||||
|   | |||||||
| @@ -15,28 +15,39 @@ import docspell.common.syntax.all._ | |||||||
| object QAttachment { | object QAttachment { | ||||||
|   private[this] val logger = org.log4s.getLogger |   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 { |     for { | ||||||
|       raFile <- store |       files <- store.transact(loadFiles) | ||||||
|         .transact(RAttachment.findByIdAndCollective(attachId, coll)) |       k <- if (files._3 == 1) deleteArchive(store)(attachId) | ||||||
|         .map(_.map(_.fileId)) |       else store.transact(RAttachmentArchive.delete(attachId)) | ||||||
|       rsFile <- store |  | ||||||
|         .transact(RAttachmentSource.findByIdAndCollective(attachId, coll)) |  | ||||||
|         .map(_.map(_.fileId)) |  | ||||||
|       aaFile <- store |  | ||||||
|         .transact(RAttachmentArchive.findByIdAndCollective(attachId, coll)) |  | ||||||
|         .map(_.map(_.fileId)) |  | ||||||
|       n <- store.transact(RAttachment.delete(attachId)) |       n <- store.transact(RAttachment.delete(attachId)) | ||||||
|       f <- Stream |       f <- Stream | ||||||
|         .emits(raFile.toSeq ++ rsFile.toSeq ++ aaFile.toSeq) |         .emits(files._1.toSeq ++ files._2.toSeq) | ||||||
|         .map(_.id) |         .map(_.id) | ||||||
|         .flatMap(store.bitpeace.delete) |         .flatMap(store.bitpeace.delete) | ||||||
|         .map(flag => if (flag) 1 else 0) |         .map(flag => if (flag) 1 else 0) | ||||||
|         .compile |         .compile | ||||||
|         .foldMonoid |         .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 { |     for { | ||||||
|       _ <- logger.fdebug[F](s"Deleting attachment: ${ra.id.id}") |       _ <- logger.fdebug[F](s"Deleting attachment: ${ra.id.id}") | ||||||
|       s <- store.transact(RAttachmentSource.findById(ra.id)) |       s <- store.transact(RAttachmentSource.findById(ra.id)) | ||||||
|   | |||||||
| @@ -124,6 +124,8 @@ object RAttachment { | |||||||
|     q.query[(RAttachment, FileMeta)].to[Vector] |     q.query[(RAttachment, FileMeta)].to[Vector] | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   /** Deletes the attachment and its related source and meta records. | ||||||
|  |     */ | ||||||
|   def delete(attachId: Ident): ConnectionIO[Int] = |   def delete(attachId: Ident): ConnectionIO[Int] = | ||||||
|     for { |     for { | ||||||
|       n0 <- RAttachmentMeta.delete(attachId) |       n0 <- RAttachmentMeta.delete(attachId) | ||||||
|   | |||||||
| @@ -91,4 +91,13 @@ object RAttachmentArchive { | |||||||
|       .to[Vector] |       .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 | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -3,6 +3,7 @@ module Api exposing | |||||||
|     , changePassword |     , changePassword | ||||||
|     , checkCalEvent |     , checkCalEvent | ||||||
|     , createMailSettings |     , createMailSettings | ||||||
|  |     , deleteAttachment | ||||||
|     , deleteEquip |     , deleteEquip | ||||||
|     , deleteItem |     , deleteItem | ||||||
|     , deleteMailSettings |     , 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 | --- Attachment Metadata | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
| @@ -60,7 +60,7 @@ type alias Model = | |||||||
|     , nameModel : String |     , nameModel : String | ||||||
|     , notesModel : Maybe String |     , notesModel : Maybe String | ||||||
|     , notesField : NotesField |     , notesField : NotesField | ||||||
|     , deleteConfirm : Comp.YesNoDimmer.Model |     , deleteItemConfirm : Comp.YesNoDimmer.Model | ||||||
|     , itemDatePicker : DatePicker |     , itemDatePicker : DatePicker | ||||||
|     , itemDate : Maybe Int |     , itemDate : Maybe Int | ||||||
|     , itemProposals : ItemProposals |     , itemProposals : ItemProposals | ||||||
| @@ -75,6 +75,7 @@ type alias Model = | |||||||
|     , attachMeta : Dict String Comp.AttachmentMeta.Model |     , attachMeta : Dict String Comp.AttachmentMeta.Model | ||||||
|     , attachMetaOpen : Bool |     , attachMetaOpen : Bool | ||||||
|     , pdfNativeView : Bool |     , pdfNativeView : Bool | ||||||
|  |     , deleteAttachConfirm : Comp.YesNoDimmer.Model | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -147,7 +148,7 @@ emptyModel = | |||||||
|     , nameModel = "" |     , nameModel = "" | ||||||
|     , notesModel = Nothing |     , notesModel = Nothing | ||||||
|     , notesField = ViewNotes |     , notesField = ViewNotes | ||||||
|     , deleteConfirm = Comp.YesNoDimmer.emptyModel |     , deleteItemConfirm = Comp.YesNoDimmer.emptyModel | ||||||
|     , itemDatePicker = Comp.DatePicker.emptyModel |     , itemDatePicker = Comp.DatePicker.emptyModel | ||||||
|     , itemDate = Nothing |     , itemDate = Nothing | ||||||
|     , itemProposals = Api.Model.ItemProposals.empty |     , itemProposals = Api.Model.ItemProposals.empty | ||||||
| @@ -162,6 +163,7 @@ emptyModel = | |||||||
|     , attachMeta = Dict.empty |     , attachMeta = Dict.empty | ||||||
|     , attachMetaOpen = False |     , attachMetaOpen = False | ||||||
|     , pdfNativeView = False |     , pdfNativeView = False | ||||||
|  |     , deleteAttachConfirm = Comp.YesNoDimmer.emptyModel | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -198,7 +200,7 @@ type Msg | |||||||
|     | SetDueDateSuggestion Int |     | SetDueDateSuggestion Int | ||||||
|     | ItemDatePickerMsg Comp.DatePicker.Msg |     | ItemDatePickerMsg Comp.DatePicker.Msg | ||||||
|     | DueDatePickerMsg Comp.DatePicker.Msg |     | DueDatePickerMsg Comp.DatePicker.Msg | ||||||
|     | YesNoMsg Comp.YesNoDimmer.Msg |     | DeleteItemConfirm Comp.YesNoDimmer.Msg | ||||||
|     | RequestDelete |     | RequestDelete | ||||||
|     | SaveResp (Result Http.Error BasicResult) |     | SaveResp (Result Http.Error BasicResult) | ||||||
|     | DeleteResp (Result Http.Error BasicResult) |     | DeleteResp (Result Http.Error BasicResult) | ||||||
| @@ -215,6 +217,9 @@ type Msg | |||||||
|     | AttachMetaClick String |     | AttachMetaClick String | ||||||
|     | AttachMetaMsg String Comp.AttachmentMeta.Msg |     | AttachMetaMsg String Comp.AttachmentMeta.Msg | ||||||
|     | TogglePdfNativeView |     | TogglePdfNativeView | ||||||
|  |     | RequestDeleteAttachment String | ||||||
|  |     | DeleteAttachConfirm String Comp.YesNoDimmer.Msg | ||||||
|  |     | DeleteAttachResp (Result Http.Error BasicResult) | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -676,10 +681,10 @@ update key flags next msg model = | |||||||
|         RemoveDueDate -> |         RemoveDueDate -> | ||||||
|             ( { model | dueDate = Nothing }, setDueDate flags model Nothing ) |             ( { model | dueDate = Nothing }, setDueDate flags model Nothing ) | ||||||
|  |  | ||||||
|         YesNoMsg m -> |         DeleteItemConfirm m -> | ||||||
|             let |             let | ||||||
|                 ( cm, confirmed ) = |                 ( cm, confirmed ) = | ||||||
|                     Comp.YesNoDimmer.update m model.deleteConfirm |                     Comp.YesNoDimmer.update m model.deleteItemConfirm | ||||||
|  |  | ||||||
|                 cmd = |                 cmd = | ||||||
|                     if confirmed then |                     if confirmed then | ||||||
| @@ -688,10 +693,10 @@ update key flags next msg model = | |||||||
|                     else |                     else | ||||||
|                         Cmd.none |                         Cmd.none | ||||||
|             in |             in | ||||||
|             ( { model | deleteConfirm = cm }, cmd ) |             ( { model | deleteItemConfirm = cm }, cmd ) | ||||||
|  |  | ||||||
|         RequestDelete -> |         RequestDelete -> | ||||||
|             update key flags next (YesNoMsg Comp.YesNoDimmer.activate) model |             update key flags next (DeleteItemConfirm Comp.YesNoDimmer.activate) model | ||||||
|  |  | ||||||
|         SetCorrOrgSuggestion idname -> |         SetCorrOrgSuggestion idname -> | ||||||
|             ( model, setCorrOrg flags model (Just idname) ) |             ( model, setCorrOrg flags model (Just idname) ) | ||||||
| @@ -943,6 +948,37 @@ update key flags next msg model = | |||||||
|             , Cmd.none |             , 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 | -- view | ||||||
| @@ -1017,7 +1053,7 @@ view inav model = | |||||||
|             ] |             ] | ||||||
|         , renderMailForm model |         , renderMailForm model | ||||||
|         , div [ class "ui grid" ] |         , div [ class "ui grid" ] | ||||||
|             [ Html.map YesNoMsg (Comp.YesNoDimmer.view model.deleteConfirm) |             [ Html.map DeleteItemConfirm (Comp.YesNoDimmer.view model.deleteItemConfirm) | ||||||
|             , div |             , div | ||||||
|                 [ classList |                 [ classList | ||||||
|                     [ ( "four wide column", True ) |                     [ ( "four wide column", True ) | ||||||
| @@ -1206,7 +1242,8 @@ renderAttachmentView model pos attach = | |||||||
|             , ( "active", attachmentVisible model pos ) |             , ( "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" ] |             [ div [ class "horizontally fitted item" ] | ||||||
|                 [ i [ class "file outline icon" ] [] |                 [ i [ class "file outline icon" ] [] | ||||||
|                 , text attachName |                 , text attachName | ||||||
| @@ -1227,6 +1264,16 @@ renderAttachmentView model pos attach = | |||||||
|                 ] |                 ] | ||||||
|             , div [ class "right menu" ] |             , div [ class "right menu" ] | ||||||
|                 [ a |                 [ a | ||||||
|  |                     [ classList | ||||||
|  |                         [ ( "item", True ) | ||||||
|  |                         ] | ||||||
|  |                     , title "Delete this file permanently" | ||||||
|  |                     , href "#" | ||||||
|  |                     , onClick (RequestDeleteAttachment attach.id) | ||||||
|  |                     ] | ||||||
|  |                     [ i [ class "red trash icon" ] [] | ||||||
|  |                     ] | ||||||
|  |                 , a | ||||||
|                     [ classList |                     [ classList | ||||||
|                         [ ( "item", True ) |                         [ ( "item", True ) | ||||||
|                         , ( "invisible", not hasArchive ) |                         , ( "invisible", not hasArchive ) | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user