mirror of
				https://github.com/TheAnachronism/docspell.git
				synced 2025-10-31 17:50:11 +00:00 
			
		
		
		
	| @@ -9,6 +9,7 @@ | ||||
| - New feature "Integration Endpoint". Allows an admin to upload files | ||||
|   to any collective using a separate endpoint. | ||||
| - New feature: add files to existing items. | ||||
| - New feature: reorder attachments via drag and drop. | ||||
| - The document list on the front-page has been rewritten. The table is | ||||
|   removed and documents are now presented in a “card view”. | ||||
| - Amend the mail-to-pdf conversion to include the e-mail date. | ||||
| @@ -47,6 +48,8 @@ The joex and rest-server component have new config sections: | ||||
| - The data used in `/sec/collective/settings` was extended with a | ||||
|   boolean value to enable/disable the "integration endpoint" for a | ||||
|   collective. | ||||
| - Add `/sec/item/{itemId}/attachment/movebefore` to move an attachment | ||||
|   before another. | ||||
|  | ||||
|  | ||||
| ## v0.5.0 | ||||
|   | ||||
							
								
								
									
										1
									
								
								elm.json
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								elm.json
									
									
									
									
									
								
							| @@ -20,6 +20,7 @@ | ||||
|             "elm/url": "1.0.0", | ||||
|             "elm-explorations/markdown": "1.0.0", | ||||
|             "justinmimbs/date": "3.1.2", | ||||
|             "norpan/elm-html5-drag-drop": "3.1.4", | ||||
|             "ryannhg/date-format": "2.3.0", | ||||
|             "truqu/elm-base64": "2.0.4" | ||||
|         }, | ||||
|   | ||||
| @@ -75,6 +75,8 @@ trait OItem[F[_]] { | ||||
|   def findByFileSource(checksum: String, sourceId: Ident): F[Vector[RItem]] | ||||
|  | ||||
|   def deleteAttachment(id: Ident, collective: Ident): F[Int] | ||||
|  | ||||
|   def moveAttachmentBefore(itemId: Ident, source: Ident, target: Ident): F[AddResult] | ||||
| } | ||||
|  | ||||
| object OItem { | ||||
| @@ -121,6 +123,16 @@ object OItem { | ||||
|   def apply[F[_]: Effect](store: Store[F]): Resource[F, OItem[F]] = | ||||
|     Resource.pure[F, OItem[F]](new OItem[F] { | ||||
|  | ||||
|       def moveAttachmentBefore( | ||||
|           itemId: Ident, | ||||
|           source: Ident, | ||||
|           target: Ident | ||||
|       ): F[AddResult] = | ||||
|         store | ||||
|           .transact(QItem.moveAttachmentBefore(itemId, source, target)) | ||||
|           .attempt | ||||
|           .map(AddResult.fromUpdate) | ||||
|  | ||||
|       def findItem(id: Ident, collective: Ident): F[Option[ItemData]] = | ||||
|         store | ||||
|           .transact(QItem.findItem(id)) | ||||
|   | ||||
| @@ -1239,6 +1239,30 @@ paths: | ||||
|             application/json: | ||||
|               schema: | ||||
|                 $ref: "#/components/schemas/ItemProposals" | ||||
|   /sec/item/{itemId}/attachment/movebefore: | ||||
|     post: | ||||
|       tags: [ Item ] | ||||
|       summary: Reorder attachments within an item | ||||
|       description: | | ||||
|         Moves the `source` attachment before the `target` attachment, | ||||
|         such that `source` becomes the immediate neighbor of `target` | ||||
|         with a lower position. | ||||
|       security: | ||||
|         - authTokenHeader: [] | ||||
|       parameters: | ||||
|         - $ref: "#/components/parameters/itemId" | ||||
|       requestBody: | ||||
|         content: | ||||
|           application/json: | ||||
|             schema: | ||||
|               $ref: "#/components/schemas/MoveAttachment" | ||||
|       responses: | ||||
|         200: | ||||
|           description: Ok | ||||
|           content: | ||||
|             application/json: | ||||
|               schema: | ||||
|                 $ref: "#/components/schemas/BasicResult" | ||||
|  | ||||
|   /sec/attachment/{id}: | ||||
|     delete: | ||||
| @@ -1945,6 +1969,19 @@ paths: | ||||
|  | ||||
| components: | ||||
|   schemas: | ||||
|     MoveAttachment: | ||||
|       description: | | ||||
|         Data to move an attachment to another position. | ||||
|       required: | ||||
|         - source | ||||
|         - target | ||||
|       properties: | ||||
|         source: | ||||
|           type: string | ||||
|           format: ident | ||||
|         target: | ||||
|           type: string | ||||
|           format: ident | ||||
|     ScanMailboxSettingsList: | ||||
|       description: | | ||||
|         A list of scan-mailbox tasks. | ||||
|   | ||||
| @@ -137,6 +137,14 @@ object ItemRoutes { | ||||
|           resp <- Ok(ip) | ||||
|         } yield resp | ||||
|  | ||||
|       case req @ POST -> Root / Ident(id) / "attachment" / "movebefore" => | ||||
|         for { | ||||
|           data <- req.as[MoveAttachment] | ||||
|           _    <- logger.fdebug(s"Move item (${id.id}) attachment $data") | ||||
|           res  <- backend.item.moveAttachmentBefore(id, data.source, data.target) | ||||
|           resp <- Ok(Conversions.basicResult(res, "Attachment moved.")) | ||||
|         } yield resp | ||||
|  | ||||
|       case DELETE -> Root / Ident(id) => | ||||
|         for { | ||||
|           n <- backend.item.deleteItem(id, user.account.collective) | ||||
|   | ||||
| @@ -72,12 +72,18 @@ case class Column(name: String, ns: String = "", alias: String = "") { | ||||
|   def isGt[A: Put](a: A): Fragment = | ||||
|     f ++ fr"> $a" | ||||
|  | ||||
|   def isGte[A: Put](a: A): Fragment = | ||||
|     f ++ fr">= $a" | ||||
|  | ||||
|   def isGt(c: Column): Fragment = | ||||
|     f ++ fr">" ++ c.f | ||||
|  | ||||
|   def isLt[A: Put](a: A): Fragment = | ||||
|     f ++ fr"< $a" | ||||
|  | ||||
|   def isLte[A: Put](a: A): Fragment = | ||||
|     f ++ fr"<= $a" | ||||
|  | ||||
|   def isLt(c: Column): Fragment = | ||||
|     f ++ fr"<" ++ c.f | ||||
|  | ||||
| @@ -103,4 +109,10 @@ case class Column(name: String, ns: String = "", alias: String = "") { | ||||
|  | ||||
|   def max: Fragment = | ||||
|     fr"MAX(" ++ f ++ fr")" | ||||
|  | ||||
|   def increment[A: Put](a: A): Fragment = | ||||
|     f ++ fr"=" ++ f ++ fr"+ $a" | ||||
|  | ||||
|   def decrement[A: Put](a: A): Fragment = | ||||
|     f ++ fr"=" ++ f ++ fr"- $a" | ||||
| } | ||||
|   | ||||
| @@ -1,8 +1,9 @@ | ||||
| package docspell.store.queries | ||||
|  | ||||
| import bitpeace.FileMeta | ||||
| import cats.implicits._ | ||||
| import cats.effect.Sync | ||||
| import cats.data.OptionT | ||||
| import cats.implicits._ | ||||
| import fs2.Stream | ||||
| import doobie._ | ||||
| import doobie.implicits._ | ||||
| @@ -16,6 +17,44 @@ import org.log4s._ | ||||
| object QItem { | ||||
|   private[this] val logger = getLogger | ||||
|  | ||||
|   def moveAttachmentBefore( | ||||
|       itemId: Ident, | ||||
|       source: Ident, | ||||
|       target: Ident | ||||
|   ): ConnectionIO[Int] = { | ||||
|  | ||||
|     // rs < rt | ||||
|     def moveBack(rs: RAttachment, rt: RAttachment): ConnectionIO[Int] = | ||||
|       for { | ||||
|         n <- RAttachment.decPositions(itemId, rs.position, rt.position) | ||||
|         k <- RAttachment.updatePosition(rs.id, rt.position) | ||||
|       } yield n + k | ||||
|  | ||||
|     // rs > rt | ||||
|     def moveForward(rs: RAttachment, rt: RAttachment): ConnectionIO[Int] = | ||||
|       for { | ||||
|         n <- RAttachment.incPositions(itemId, rt.position, rs.position) | ||||
|         k <- RAttachment.updatePosition(rs.id, rt.position) | ||||
|       } yield n + k | ||||
|  | ||||
|     (for { | ||||
|       _ <- OptionT.liftF( | ||||
|         if (source == target) | ||||
|           Sync[ConnectionIO].raiseError(new Exception("Attachments are the same!")) | ||||
|         else ().pure[ConnectionIO] | ||||
|       ) | ||||
|       rs <- OptionT(RAttachment.findById(source)).filter(_.itemId == itemId) | ||||
|       rt <- OptionT(RAttachment.findById(target)).filter(_.itemId == itemId) | ||||
|       n <- OptionT.liftF( | ||||
|         if (rs.position == rt.position || rs.position + 1 == rt.position) | ||||
|           0.pure[ConnectionIO] | ||||
|         else if (rs.position < rt.position) moveBack(rs, rt) | ||||
|         else moveForward(rs, rt) | ||||
|       ) | ||||
|     } yield n).getOrElse(0) | ||||
|  | ||||
|   } | ||||
|  | ||||
|   case class ItemData( | ||||
|       item: RItem, | ||||
|       corrOrg: Option[ROrganization], | ||||
|   | ||||
| @@ -38,6 +38,20 @@ object RAttachment { | ||||
|       fr"${v.id},${v.itemId},${v.fileId.id},${v.position},${v.created},${v.name}" | ||||
|     ).update.run | ||||
|  | ||||
|   def decPositions(iId: Ident, lowerBound: Int, upperBound: Int): ConnectionIO[Int] = | ||||
|     updateRow( | ||||
|       table, | ||||
|       and(itemId.is(iId), position.isGte(lowerBound), position.isLte(upperBound)), | ||||
|       position.decrement(1) | ||||
|     ).update.run | ||||
|  | ||||
|   def incPositions(iId: Ident, lowerBound: Int, upperBound: Int): ConnectionIO[Int] = | ||||
|     updateRow( | ||||
|       table, | ||||
|       and(itemId.is(iId), position.isGte(lowerBound), position.isLte(upperBound)), | ||||
|       position.increment(1) | ||||
|     ).update.run | ||||
|  | ||||
|   def nextPosition(id: Ident): ConnectionIO[Int] = | ||||
|     for { | ||||
|       max <- selectSimple(position.max, table, itemId.is(id)).query[Option[Int]].unique | ||||
|   | ||||
| @@ -42,6 +42,7 @@ module Api exposing | ||||
|     , login | ||||
|     , loginSession | ||||
|     , logout | ||||
|     , moveAttachmentBefore | ||||
|     , newInvite | ||||
|     , postEquipment | ||||
|     , postNewUser | ||||
| @@ -100,6 +101,7 @@ import Api.Model.ItemProposals exposing (ItemProposals) | ||||
| import Api.Model.ItemSearch exposing (ItemSearch) | ||||
| import Api.Model.ItemUploadMeta exposing (ItemUploadMeta) | ||||
| import Api.Model.JobQueueState exposing (JobQueueState) | ||||
| import Api.Model.MoveAttachment exposing (MoveAttachment) | ||||
| import Api.Model.NotificationSettings exposing (NotificationSettings) | ||||
| import Api.Model.OptionalDate exposing (OptionalDate) | ||||
| import Api.Model.OptionalId exposing (OptionalId) | ||||
| @@ -1009,6 +1011,21 @@ getJobQueueStateTask flags = | ||||
| -- Item | ||||
|  | ||||
|  | ||||
| moveAttachmentBefore : | ||||
|     Flags | ||||
|     -> String | ||||
|     -> MoveAttachment | ||||
|     -> (Result Http.Error BasicResult -> msg) | ||||
|     -> Cmd msg | ||||
| moveAttachmentBefore flags itemId data receive = | ||||
|     Http2.authPost | ||||
|         { url = flags.config.baseUrl ++ "/api/v1/sec/item/" ++ itemId ++ "/attachment/movebefore" | ||||
|         , account = getAccount flags | ||||
|         , body = Http.jsonBody (Api.Model.MoveAttachment.encode data) | ||||
|         , expect = Http.expectJson receive Api.Model.BasicResult.decoder | ||||
|         } | ||||
|  | ||||
|  | ||||
| itemSearch : Flags -> ItemSearch -> (Result Http.Error ItemLightList -> msg) -> Cmd msg | ||||
| itemSearch flags search receive = | ||||
|     Http2.authPost | ||||
|   | ||||
| @@ -18,6 +18,7 @@ import Html exposing (..) | ||||
| import Html.Attributes exposing (..) | ||||
| import Html.Events exposing (..) | ||||
| import Json.Decode as D | ||||
| import Util.Html exposing (onDragEnter, onDragLeave, onDragOver, onDropFiles) | ||||
|  | ||||
|  | ||||
| type alias State = | ||||
| @@ -111,10 +112,10 @@ view : Model -> Html Msg | ||||
| view model = | ||||
|     div | ||||
|         [ classList (model.settings.classList model.state) | ||||
|         , hijackOn "dragenter" (D.succeed DragEnter) | ||||
|         , hijackOn "dragover" (D.succeed DragEnter) | ||||
|         , hijackOn "dragleave" (D.succeed DragLeave) | ||||
|         , hijackOn "drop" dropDecoder | ||||
|         , onDragEnter DragEnter | ||||
|         , onDragOver DragEnter | ||||
|         , onDragLeave DragLeave | ||||
|         , onDropFiles GotFiles | ||||
|         ] | ||||
|         [ div [ class "ui icon header" ] | ||||
|             [ i [ class "mouse pointer icon" ] [] | ||||
| @@ -156,18 +157,3 @@ filterMime settings files = | ||||
|  | ||||
|     else | ||||
|         List.filter pred files | ||||
|  | ||||
|  | ||||
| dropDecoder : D.Decoder Msg | ||||
| dropDecoder = | ||||
|     D.at [ "dataTransfer", "files" ] (D.oneOrMore GotFiles File.decoder) | ||||
|  | ||||
|  | ||||
| hijackOn : String -> D.Decoder msg -> Attribute msg | ||||
| hijackOn event decoder = | ||||
|     preventDefaultOn event (D.map hijack decoder) | ||||
|  | ||||
|  | ||||
| hijack : msg -> ( msg, Bool ) | ||||
| hijack msg = | ||||
|     ( msg, True ) | ||||
|   | ||||
| @@ -14,6 +14,7 @@ import Api.Model.EquipmentList exposing (EquipmentList) | ||||
| import Api.Model.IdName exposing (IdName) | ||||
| import Api.Model.ItemDetail exposing (ItemDetail) | ||||
| import Api.Model.ItemProposals exposing (ItemProposals) | ||||
| import Api.Model.MoveAttachment exposing (MoveAttachment) | ||||
| import Api.Model.OptionalDate exposing (OptionalDate) | ||||
| import Api.Model.OptionalId exposing (OptionalId) | ||||
| import Api.Model.OptionalText exposing (OptionalText) | ||||
| @@ -39,6 +40,7 @@ import File exposing (File) | ||||
| import Html exposing (..) | ||||
| import Html.Attributes exposing (..) | ||||
| import Html.Events exposing (onCheck, onClick, onInput) | ||||
| import Html5.DragDrop as DD | ||||
| import Http | ||||
| import Markdown | ||||
| import Page exposing (Page(..)) | ||||
| @@ -88,6 +90,7 @@ type alias Model = | ||||
|     , completed : Set String | ||||
|     , errored : Set String | ||||
|     , loading : Set String | ||||
|     , attachDD : DD.Model String String | ||||
|     } | ||||
|  | ||||
|  | ||||
| @@ -182,6 +185,7 @@ emptyModel = | ||||
|     , completed = Set.empty | ||||
|     , errored = Set.empty | ||||
|     , loading = Set.empty | ||||
|     , attachDD = DD.init | ||||
|     } | ||||
|  | ||||
|  | ||||
| @@ -244,6 +248,7 @@ type Msg | ||||
|     | AddFilesUploadResp String (Result Http.Error BasicResult) | ||||
|     | AddFilesProgress String Http.Progress | ||||
|     | AddFilesReset | ||||
|     | AttachDDMsg (DD.Msg String String) | ||||
|  | ||||
|  | ||||
|  | ||||
| @@ -1174,6 +1179,28 @@ update key flags next msg model = | ||||
|             in | ||||
|             noSub ( model, updateBars ) | ||||
|  | ||||
|         AttachDDMsg lm -> | ||||
|             let | ||||
|                 ( model_, result ) = | ||||
|                     DD.update lm model.attachDD | ||||
|  | ||||
|                 cmd = | ||||
|                     case result of | ||||
|                         Just ( src, trg, _ ) -> | ||||
|                             if src /= trg then | ||||
|                                 Api.moveAttachmentBefore flags | ||||
|                                     model.item.id | ||||
|                                     (MoveAttachment src trg) | ||||
|                                     SaveResp | ||||
|  | ||||
|                             else | ||||
|                                 Cmd.none | ||||
|  | ||||
|                         Nothing -> | ||||
|                             Cmd.none | ||||
|             in | ||||
|             noSub ( { model | attachDD = model_ }, cmd ) | ||||
|  | ||||
|  | ||||
|  | ||||
| -- view | ||||
| @@ -1419,20 +1446,38 @@ renderAttachmentsTabMenu model = | ||||
|                     [ text "E-Mails" | ||||
|                     ] | ||||
|                 ] | ||||
|  | ||||
|         highlight el = | ||||
|             let | ||||
|                 dropId = | ||||
|                     DD.getDropId model.attachDD | ||||
|  | ||||
|                 dragId = | ||||
|                     DD.getDragId model.attachDD | ||||
|  | ||||
|                 enable = | ||||
|                     Just el.id == dropId && dropId /= dragId | ||||
|             in | ||||
|             [ ( "current-drop-target", enable ) | ||||
|             ] | ||||
|     in | ||||
|     div [ class "ui top attached tabular menu" ] | ||||
|         (List.indexedMap | ||||
|             (\pos -> | ||||
|                 \el -> | ||||
|                     a | ||||
|                         [ classList | ||||
|                         ([ classList <| | ||||
|                             [ ( "item", True ) | ||||
|                             , ( "active", attachmentVisible model pos ) | ||||
|                             ] | ||||
|                         , title (Maybe.withDefault "No Name" el.name) | ||||
|                         , href "" | ||||
|                         , onClick (SetActiveAttachment pos) | ||||
|                         ] | ||||
|                                 ++ highlight el | ||||
|                          , title (Maybe.withDefault "No Name" el.name) | ||||
|                          , href "" | ||||
|                          , onClick (SetActiveAttachment pos) | ||||
|                          ] | ||||
|                             ++ DD.draggable AttachDDMsg el.id | ||||
|                             ++ DD.droppable AttachDDMsg el.id | ||||
|                         ) | ||||
|                         [ Maybe.map (Util.String.ellipsis 20) el.name | ||||
|                             |> Maybe.withDefault "No Name" | ||||
|                             |> text | ||||
|   | ||||
| @@ -4,13 +4,18 @@ module Util.Html exposing | ||||
|     , classActive | ||||
|     , intToKeyCode | ||||
|     , onClickk | ||||
|     , onDragEnter | ||||
|     , onDragLeave | ||||
|     , onDragOver | ||||
|     , onDropFiles | ||||
|     , onKeyUp | ||||
|     ) | ||||
|  | ||||
| import File exposing (File) | ||||
| import Html exposing (Attribute, Html, i) | ||||
| import Html.Attributes exposing (class) | ||||
| import Html.Events exposing (keyCode, on) | ||||
| import Json.Decode as Decode | ||||
| import Html.Events exposing (keyCode, on, preventDefaultOn) | ||||
| import Json.Decode as D | ||||
|  | ||||
|  | ||||
| checkboxChecked : Html msg | ||||
| @@ -68,12 +73,12 @@ intToKeyCode code = | ||||
|  | ||||
| onKeyUp : (Int -> msg) -> Attribute msg | ||||
| onKeyUp tagger = | ||||
|     on "keyup" (Decode.map tagger keyCode) | ||||
|     on "keyup" (D.map tagger keyCode) | ||||
|  | ||||
|  | ||||
| onClickk : msg -> Attribute msg | ||||
| onClickk msg = | ||||
|     Html.Events.preventDefaultOn "click" (Decode.map alwaysPreventDefault (Decode.succeed msg)) | ||||
|     Html.Events.preventDefaultOn "click" (D.map alwaysPreventDefault (D.succeed msg)) | ||||
|  | ||||
|  | ||||
| alwaysPreventDefault : msg -> ( msg, Bool ) | ||||
| @@ -92,3 +97,42 @@ classActive active classes = | ||||
|                     "" | ||||
|                ) | ||||
|         ) | ||||
|  | ||||
|  | ||||
| onDragEnter : msg -> Attribute msg | ||||
| onDragEnter m = | ||||
|     hijackOn "dragenter" (D.succeed m) | ||||
|  | ||||
|  | ||||
| onDragOver : msg -> Attribute msg | ||||
| onDragOver m = | ||||
|     hijackOn "dragover" (D.succeed m) | ||||
|  | ||||
|  | ||||
| onDragLeave : msg -> Attribute msg | ||||
| onDragLeave m = | ||||
|     hijackOn "dragleave" (D.succeed m) | ||||
|  | ||||
|  | ||||
| onDrop : msg -> Attribute msg | ||||
| onDrop m = | ||||
|     hijackOn "drop" (D.succeed m) | ||||
|  | ||||
|  | ||||
| onDropFiles : (File -> List File -> msg) -> Attribute msg | ||||
| onDropFiles tagger = | ||||
|     let | ||||
|         dropFilesDecoder = | ||||
|             D.at [ "dataTransfer", "files" ] (D.oneOrMore tagger File.decoder) | ||||
|     in | ||||
|     hijackOn "drop" dropFilesDecoder | ||||
|  | ||||
|  | ||||
| hijackOn : String -> D.Decoder msg -> Attribute msg | ||||
| hijackOn event decoder = | ||||
|     preventDefaultOn event (D.map hijack decoder) | ||||
|  | ||||
|  | ||||
| hijack : msg -> ( msg, Bool ) | ||||
| hijack msg = | ||||
|     ( msg, True ) | ||||
|   | ||||
| @@ -143,6 +143,10 @@ textarea.markdown-editor { | ||||
|     background: rgba(240,248,255,0.4); | ||||
| } | ||||
|  | ||||
| .default-layout .ui.menu .item.current-drop-target { | ||||
|     background: rgba(0,0,0,0.2); | ||||
| } | ||||
|  | ||||
| label span.muted { | ||||
|     font-size: smaller; | ||||
|     color: rgba(0,0,0,0.6); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user