diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index e0ecf20f..5932a998 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -87,12 +87,6 @@ paths: The upload meta data can be used to tell, whether multiple files are one item, or if each file should become a single item. By default, each file will be a one item. - - Only certain file types are supported: - - * application/pdf - - Support for more types might be added. parameters: - $ref: "#/components/parameters/id" requestBody: @@ -115,6 +109,48 @@ paths: application/json: schema: $ref: "#/components/schemas/BasicResult" + /open/upload/item/{itemId}/{id}: + post: + tags: [ Upload ] + summary: Upload files to docspell. + description: | + Upload a file to docspell for processing. The id is a *source + id* configured by a collective. Files are submitted for + processing which eventually resuts in an item in the inbox of + the corresponding collective. This endpoint associates the + files to an existing item identified by its `itemId`. + + The request must be a `multipart/form-data` request, where the + first part has name `meta`, is optional and may contain upload + metadata as JSON. Checkout the structure `ItemUploadMeta` at + the end if it is not shown here. Other parts specify the + files. Multiple files can be specified, but at least on is + required. + + Upload meta data is ignored. + parameters: + - $ref: "#/components/parameters/id" + - $ref: "#/components/parameters/itemId" + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + meta: + $ref: "#/components/schemas/ItemUploadMeta" + file: + type: array + items: + type: string + format: binary + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" /sec/checkfile/{checksum}: get: tags: [ Upload ] @@ -155,12 +191,6 @@ paths: The upload meta data can be used to tell, whether multiple files are one item, or if each file should become a single item. By default, each file will be a one item. - - Only certain file types are supported: - - * application/pdf - - Support for more types might be added. security: - authTokenHeader: [] requestBody: @@ -183,6 +213,50 @@ paths: application/json: schema: $ref: "#/components/schemas/BasicResult" + /sec/upload/{itemId}: + post: + tags: [ Upload ] + summary: Upload files to docspell. + description: | + Upload files to docspell for processing. This route is meant + for authenticated users that upload files to their account. + This endpoint will associate the files to an existing item + identified by its `itemId`. + + Everything else is the same as with the + `/open/upload/item/{itemId}/{id}` endpoint. + + The request must be a "multipart/form-data" request, where the + first part is optional and may contain upload metadata as + JSON. Other parts specify the files. Multiple files can be + specified, but at least on is required. + + The upload meta data is ignored, since the item already + exists. + security: + - authTokenHeader: [] + parameters: + - $ref: "#/components/parameters/itemId" + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + meta: + $ref: "#/components/schemas/ItemUploadMeta" + file: + type: array + items: + type: string + format: binary + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" /open/signup/register: post: tags: [ Registration ] @@ -3156,6 +3230,13 @@ components: required: true schema: type: string + itemId: + name: itemId + in: path + description: An identifier for an item + required: true + schema: + type: string full: name: full in: query diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/UploadRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/UploadRoutes.scala index 77dfb427..afec48a4 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/UploadRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/UploadRoutes.scala @@ -39,6 +39,19 @@ object UploadRoutes { result <- backend.upload.submit(updata, user.account, true, None) res <- Ok(basicResult(result)) } yield res + + case req @ POST -> Root / "item" / Ident(itemId) => + for { + multipart <- req.as[Multipart[F]] + updata <- readMultipart( + multipart, + logger, + Priority.High, + cfg.backend.files.validMimeTypes + ) + result <- backend.upload.submit(updata, user.account, true, Some(itemId)) + res <- Ok(basicResult(result)) + } yield res } } @@ -59,6 +72,19 @@ object UploadRoutes { result <- backend.upload.submit(updata, id, true, None) res <- Ok(basicResult(result)) } yield res + + case req @ POST -> Root / "item" / Ident(itemId) / Ident(id) => + for { + multipart <- req.as[Multipart[F]] + updata <- readMultipart( + multipart, + logger, + Priority.Low, + cfg.backend.files.validMimeTypes + ) + result <- backend.upload.submit(updata, id, true, Some(itemId)) + res <- Ok(basicResult(result)) + } yield res } } } diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index 37b2d4f4..4a085d4f 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -71,6 +71,7 @@ module Api exposing , submitNotifyDueItems , updateScanMailbox , upload + , uploadAmend , uploadSingle , versionInfo ) @@ -429,7 +430,42 @@ createImapSettings flags mname ems receive = --- Upload -upload : Flags -> Maybe String -> ItemUploadMeta -> List File -> (String -> Result Http.Error BasicResult -> msg) -> List (Cmd msg) +uploadAmend : + Flags + -> String + -> List File + -> (String -> Result Http.Error BasicResult -> msg) + -> List (Cmd msg) +uploadAmend flags itemId files receive = + let + mkReq file = + let + fid = + Util.File.makeFileId file + + path = + "/api/v1/sec/upload/item/" ++ itemId + in + Http2.authPostTrack + { url = flags.config.baseUrl ++ path + , account = getAccount flags + , body = + Http.multipartBody <| + [ Http.filePart "file[]" file ] + , expect = Http.expectJson (receive fid) Api.Model.BasicResult.decoder + , tracker = fid + } + in + List.map mkReq files + + +upload : + Flags + -> Maybe String + -> ItemUploadMeta + -> List File + -> (String -> Result Http.Error BasicResult -> msg) + -> List (Cmd msg) upload flags sourceId meta files receive = let metaStr = @@ -457,7 +493,14 @@ upload flags sourceId meta files receive = List.map mkReq files -uploadSingle : Flags -> Maybe String -> ItemUploadMeta -> String -> List File -> (Result Http.Error BasicResult -> msg) -> Cmd msg +uploadSingle : + Flags + -> Maybe String + -> ItemUploadMeta + -> String + -> List File + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg uploadSingle flags sourceId meta track files receive = let metaStr = diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail.elm b/modules/webapp/src/main/elm/Comp/ItemDetail.elm index 6ef0569a..3f24726b 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail.elm @@ -25,6 +25,7 @@ import Browser.Navigation as Nav import Comp.AttachmentMeta import Comp.DatePicker import Comp.Dropdown exposing (isDropdownChangeMsg) +import Comp.Dropzone import Comp.ItemMail import Comp.MarkdownInput import Comp.SentMails @@ -34,12 +35,16 @@ import Data.Flags exposing (Flags) import Data.Icons as Icons import DatePicker exposing (DatePicker) import Dict exposing (Dict) +import File exposing (File) import Html exposing (..) import Html.Attributes exposing (..) import Html.Events exposing (onCheck, onClick, onInput) import Http import Markdown import Page exposing (Page(..)) +import Ports +import Set exposing (Set) +import Util.File exposing (makeFileId) import Util.Http import Util.List import Util.Maybe @@ -77,6 +82,12 @@ type alias Model = , attachMetaOpen : Bool , pdfNativeView : Bool , deleteAttachConfirm : Comp.YesNoDimmer.Model + , addFilesOpen : Bool + , addFilesModel : Comp.Dropzone.Model + , selectedFiles : List File + , completed : Set String + , errored : Set String + , loading : Set String } @@ -165,6 +176,12 @@ emptyModel = , attachMetaOpen = False , pdfNativeView = False , deleteAttachConfirm = Comp.YesNoDimmer.emptyModel + , addFilesOpen = False + , addFilesModel = Comp.Dropzone.init Comp.Dropzone.defaultSettings + , selectedFiles = [] + , completed = Set.empty + , errored = Set.empty + , loading = Set.empty } @@ -221,6 +238,12 @@ type Msg | RequestDeleteAttachment String | DeleteAttachConfirm String Comp.YesNoDimmer.Msg | DeleteAttachResp (Result Http.Error BasicResult) + | AddFilesToggle + | AddFilesMsg Comp.Dropzone.Msg + | AddFilesSubmitUpload + | AddFilesUploadResp String (Result Http.Error BasicResult) + | AddFilesProgress String Http.Progress + | AddFilesReset @@ -334,6 +357,42 @@ setDueDate flags model date = Api.setItemDueDate flags model.item.id (OptionalDate date) SaveResp +isLoading : Model -> File -> Bool +isLoading model file = + Set.member (makeFileId file) model.loading + + +isCompleted : Model -> File -> Bool +isCompleted model file = + Set.member (makeFileId file) model.completed + + +isError : Model -> File -> Bool +isError model file = + Set.member (makeFileId file) model.errored + + +isIdle : Model -> File -> Bool +isIdle model file = + not (isLoading model file || isCompleted model file || isError model file) + + +setCompleted : Model -> String -> Set String +setCompleted model fileid = + Set.insert fileid model.completed + + +setErrored : Model -> String -> Set String +setErrored model fileid = + Set.insert fileid model.errored + + +isSuccessAll : Model -> Bool +isSuccessAll model = + List.map makeFileId model.selectedFiles + |> List.all (\id -> Set.member id model.completed) + + update : Nav.Key -> Flags -> Maybe String -> Msg -> Model -> ( Model, Cmd Msg ) update key flags next msg model = case msg of @@ -430,6 +489,9 @@ update key flags next msg model = ) m5 + ( m7, c7 ) = + update key flags next AddFilesReset m6 + proposalCmd = if item.state == "created" then Api.getItemProposals flags item.id GetProposalResp @@ -437,7 +499,7 @@ update key flags next msg model = else Cmd.none in - ( { m6 + ( { m7 | item = item , nameModel = item.name , notesModel = item.notes @@ -453,6 +515,7 @@ update key flags next msg model = , c4 , c5 , c6 + , c7 , getOptions flags , proposalCmd , Api.getSentMails flags item.id SentMailsResp @@ -980,6 +1043,107 @@ update key flags next msg model = (DeleteAttachConfirm id Comp.YesNoDimmer.activate) model + AddFilesToggle -> + ( { model | addFilesOpen = not model.addFilesOpen } + , Cmd.none + ) + + AddFilesMsg lm -> + let + ( dm, dc, df ) = + Comp.Dropzone.update lm model.addFilesModel + + nextFiles = + model.selectedFiles ++ df + in + ( { model | addFilesModel = dm, selectedFiles = nextFiles } + , Cmd.map AddFilesMsg dc + ) + + AddFilesReset -> + ( { model + | selectedFiles = [] + , addFilesModel = Comp.Dropzone.init Comp.Dropzone.defaultSettings + , completed = Set.empty + , errored = Set.empty + , loading = Set.empty + } + , Cmd.none + ) + + AddFilesSubmitUpload -> + let + fileids = + List.map makeFileId model.selectedFiles + + uploads = + Cmd.batch (Api.uploadAmend flags model.item.id model.selectedFiles AddFilesUploadResp) + + tracker = + Sub.batch <| List.map (\id -> Http.track id (AddFilesProgress id)) fileids + + ( cm2, _, _ ) = + Comp.Dropzone.update (Comp.Dropzone.setActive False) model.addFilesModel + in + ( { model | loading = Set.fromList fileids, addFilesModel = cm2 }, uploads ) + + AddFilesUploadResp fileid (Ok res) -> + let + compl = + if res.success then + setCompleted model fileid + + else + model.completed + + errs = + if not res.success then + setErrored model fileid + + else + model.errored + + load = + Set.remove fileid model.loading + + newModel = + { model | completed = compl, errored = errs, loading = load } + in + ( newModel + , Ports.setProgress ( fileid, 100 ) + ) + + AddFilesUploadResp fileid (Err _) -> + let + errs = + setErrored model fileid + + load = + Set.remove fileid model.loading + in + ( { model | errored = errs, loading = load }, Cmd.none ) + + AddFilesProgress fileid progress -> + let + percent = + case progress of + Http.Sending p -> + Http.fractionSent p + |> (*) 100 + |> round + + _ -> + 0 + + updateBars = + if percent == 0 then + Cmd.none + + else + Ports.setProgress ( fileid, percent ) + in + ( model, updateBars ) + -- view @@ -1001,7 +1165,7 @@ view inav model = , div [ classList [ ( "ui ablue-comp menu", True ) - , ( "top attached", model.mailOpen ) + , ( "top attached", model.mailOpen || model.addFilesOpen ) ] ] [ a [ class "item", Page.href HomePage ] @@ -1066,8 +1230,24 @@ view inav model = ] [ Icons.editNotesIcon ] + , a + [ classList + [ ( "toggle item", True ) + , ( "active", model.addFilesOpen ) + ] + , if model.addFilesOpen then + title "Close" + + else + title "Add Files" + , onClick AddFilesToggle + , href "#" + ] + [ Icons.addFilesIcon + ] ] , renderMailForm model + , renderAddFilesForm model , div [ class "ui grid" ] [ Html.map DeleteItemConfirm (Comp.YesNoDimmer.view model.deleteItemConfirm) , div @@ -1756,3 +1936,94 @@ renderMailForm model = |> text ] ] + + +renderAddFilesForm : Model -> Html Msg +renderAddFilesForm model = + div + [ classList + [ ( "ui bottom attached segment", True ) + , ( "invisible hidden", not model.addFilesOpen ) + ] + ] + [ h4 [ class "ui header" ] + [ text "Add more files to this item" + ] + , Html.map AddFilesMsg (Comp.Dropzone.view model.addFilesModel) + , button + [ class "ui primary button" + , href "#" + , onClick AddFilesSubmitUpload + ] + [ text "Submit" + ] + , button + [ class "ui secondary button" + , href "#" + , onClick AddFilesReset + ] + [ text "Reset" + ] + , div + [ classList + [ ( "ui success message", True ) + , ( "invisible hidden", model.selectedFiles == [] || not (isSuccessAll model) ) + ] + ] + [ text "All files have been uploaded. They are being processed, some data " + , text "may not be available immediately. " + , a + [ class "link" + , href "#" + , onClick ReloadItem + ] + [ text "Refresh now" + ] + ] + , div [ class "ui items" ] + (List.map (renderFileItem model) model.selectedFiles) + ] + + +renderFileItem : Model -> File -> Html Msg +renderFileItem model file = + let + name = + File.name file + + size = + File.size file + |> toFloat + |> Util.Size.bytesReadable Util.Size.B + in + div [ class "item" ] + [ i + [ classList + [ ( "large", True ) + , ( "file outline icon", isIdle model file ) + , ( "loading spinner icon", isLoading model file ) + , ( "green check icon", isCompleted model file ) + , ( "red bolt icon", isError model file ) + ] + ] + [] + , div [ class "middle aligned content" ] + [ div [ class "header" ] + [ text name + ] + , div [ class "right floated meta" ] + [ text size + ] + , div [ class "description" ] + [ div + [ classList + [ ( "ui small indicating progress", True ) + ] + , id (makeFileId file) + ] + [ div [ class "bar" ] + [] + ] + ] + ] + ] diff --git a/modules/webapp/src/main/elm/Data/Icons.elm b/modules/webapp/src/main/elm/Data/Icons.elm index ed90ebb3..13d63503 100644 --- a/modules/webapp/src/main/elm/Data/Icons.elm +++ b/modules/webapp/src/main/elm/Data/Icons.elm @@ -1,5 +1,7 @@ module Data.Icons exposing - ( concerned + ( addFiles + , addFilesIcon + , concerned , concernedIcon , correspondent , correspondentIcon @@ -51,3 +53,13 @@ editNotes = editNotesIcon : Html msg editNotesIcon = i [ class editNotes ] [] + + +addFiles : String +addFiles = + "file plus icon" + + +addFilesIcon : Html msg +addFilesIcon = + i [ class addFiles ] [] diff --git a/modules/webapp/src/main/elm/Page/Upload/Update.elm b/modules/webapp/src/main/elm/Page/Upload/Update.elm index b12e8904..9f8eb06b 100644 --- a/modules/webapp/src/main/elm/Page/Upload/Update.elm +++ b/modules/webapp/src/main/elm/Page/Upload/Update.elm @@ -41,7 +41,12 @@ update sourceId flags msg model = uploads = if model.singleItem then - Api.uploadSingle flags sourceId meta uploadAllTracker model.files (SingleUploadResp uploadAllTracker) + Api.uploadSingle flags + sourceId + meta + uploadAllTracker + model.files + (SingleUploadResp uploadAllTracker) else Cmd.batch (Api.upload flags sourceId meta model.files SingleUploadResp)