mirror of
				https://github.com/TheAnachronism/docspell.git
				synced 2025-10-31 09:30: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* | ||||
|  | ||||
| - 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 | ||||
|   | ||||
| @@ -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) | ||||
|     }) | ||||
| } | ||||
|   | ||||
| @@ -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. | ||||
|   | ||||
| @@ -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 | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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" | ||||
|  | ||||
|   | ||||
| @@ -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)) | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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 | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -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 ) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user