From f08fcce4c755460324a27fcef56b8039cdd3a168 Mon Sep 17 00:00:00 2001 From: totti4ever <49901208+totti4ever@users.noreply.github.com> Date: Thu, 22 Oct 2020 23:05:44 +0200 Subject: [PATCH 01/22] Fixed typo at error detail spooling --- tools/import-paperless/import-paperless.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/import-paperless/import-paperless.sh b/tools/import-paperless/import-paperless.sh index 2cb762cb..aa75dac0 100755 --- a/tools/import-paperless/import-paperless.sh +++ b/tools/import-paperless/import-paperless.sh @@ -66,7 +66,7 @@ function curl_call() { curl_result=$(eval $curl_cmd) if [ "$curl_result" == '"Authentication failed."' ] || [ "$curl_result" == 'Response timed out' ]; then - printf "\nNew login required (§curl_result)... " + printf "\nNew login required ($curl_result)... " login printf "%${#len_resultset}s" " "; printf " .." curl_call $1 From 3e752487e4b20c6a7521587b948aa94c399b99a5 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Thu, 22 Oct 2020 23:33:46 +0200 Subject: [PATCH 02/22] Remove unused argument --- modules/webapp/src/main/elm/App/View.elm | 11 +---------- modules/webapp/src/main/elm/Page/Home/View.elm | 4 ++-- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/modules/webapp/src/main/elm/App/View.elm b/modules/webapp/src/main/elm/App/View.elm index 2223a342..fd14c87a 100644 --- a/modules/webapp/src/main/elm/App/View.elm +++ b/modules/webapp/src/main/elm/App/View.elm @@ -178,16 +178,7 @@ viewLogin model = viewHome : Model -> Html Msg viewHome model = - let - mid = - case model.page of - HomePage -> - Util.Maybe.fromString model.itemDetailModel.detail.item.id - - _ -> - Nothing - in - Html.map HomeMsg (Page.Home.View.view mid model.flags model.uiSettings model.homeModel) + Html.map HomeMsg (Page.Home.View.view model.flags model.uiSettings model.homeModel) menuEntry : Model -> Page -> List (Html Msg) -> Html Msg diff --git a/modules/webapp/src/main/elm/Page/Home/View.elm b/modules/webapp/src/main/elm/Page/Home/View.elm index 32653c43..ce4d8969 100644 --- a/modules/webapp/src/main/elm/Page/Home/View.elm +++ b/modules/webapp/src/main/elm/Page/Home/View.elm @@ -14,8 +14,8 @@ import Page.Home.Data exposing (..) import Util.Html -view : Maybe String -> Flags -> UiSettings -> Model -> Html Msg -view current flags settings model = +view : Flags -> UiSettings -> Model -> Html Msg +view flags settings model = div [ class "home-page ui padded grid" ] [ div [ classList From 6ea9193b5e0a16e351c66f32394fbe66988006d2 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Thu, 22 Oct 2020 23:33:57 +0200 Subject: [PATCH 03/22] Only scroll to the last visited item when initializing the page When searching again on that page, scrolling/highlighting should not happen. It now happens whenever coming to this page (not only when going back from detail view). Fixes: #373 --- .../webapp/src/main/elm/Page/Home/Data.elm | 26 +++++++++---------- .../webapp/src/main/elm/Page/Home/Update.elm | 25 ++++++++++-------- 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/modules/webapp/src/main/elm/Page/Home/Data.elm b/modules/webapp/src/main/elm/Page/Home/Data.elm index cb671eb5..0c995564 100644 --- a/modules/webapp/src/main/elm/Page/Home/Data.elm +++ b/modules/webapp/src/main/elm/Page/Home/Data.elm @@ -89,7 +89,7 @@ type Msg | SearchMenuMsg Comp.SearchMenu.Msg | ResetSearch | ItemCardListMsg Comp.ItemCardList.Msg - | ItemSearchResp (Result Http.Error ItemLightList) + | ItemSearchResp Bool (Result Http.Error ItemLightList) | ItemSearchAddResp (Result Http.Error ItemLightList) | DoSearch | ToggleSearchMenu @@ -136,21 +136,21 @@ itemNav id model = } -doSearchCmd : Flags -> UiSettings -> Int -> Model -> Cmd Msg -doSearchCmd flags settings offset model = +doSearchCmd : Flags -> UiSettings -> Int -> Bool -> Model -> Cmd Msg +doSearchCmd flags settings offset scroll model = case model.searchType of BasicSearch -> - doSearchDefaultCmd flags settings offset model + doSearchDefaultCmd flags settings offset scroll model ContentSearch -> - doSearchDefaultCmd flags settings offset model + doSearchDefaultCmd flags settings offset scroll model ContentOnlySearch -> - doSearchIndexCmd flags settings offset model + doSearchIndexCmd flags settings offset scroll model -doSearchDefaultCmd : Flags -> UiSettings -> Int -> Model -> Cmd Msg -doSearchDefaultCmd flags settings offset model = +doSearchDefaultCmd : Flags -> UiSettings -> Int -> Bool -> Model -> Cmd Msg +doSearchDefaultCmd flags settings offset scroll model = let smask = Comp.SearchMenu.getItemSearch model.searchMenuModel @@ -162,14 +162,14 @@ doSearchDefaultCmd flags settings offset model = } in if offset == 0 then - Api.itemSearch flags mask ItemSearchResp + Api.itemSearch flags mask (ItemSearchResp scroll) else Api.itemSearch flags mask ItemSearchAddResp -doSearchIndexCmd : Flags -> UiSettings -> Int -> Model -> Cmd Msg -doSearchIndexCmd flags settings offset model = +doSearchIndexCmd : Flags -> UiSettings -> Int -> Bool -> Model -> Cmd Msg +doSearchIndexCmd flags settings offset scroll model = case model.contentOnlySearch of Just q -> let @@ -180,7 +180,7 @@ doSearchIndexCmd flags settings offset model = } in if offset == 0 then - Api.itemIndexSearch flags mask ItemSearchResp + Api.itemIndexSearch flags mask (ItemSearchResp scroll) else Api.itemIndexSearch flags mask ItemSearchAddResp @@ -195,7 +195,7 @@ doSearchIndexCmd flags settings offset model = mask = { emptyMask | limit = settings.itemSearchPageSize } in - Api.itemSearch flags mask ItemSearchResp + Api.itemSearch flags mask (ItemSearchResp scroll) resultsBelowLimit : UiSettings -> Model -> Bool diff --git a/modules/webapp/src/main/elm/Page/Home/Update.elm b/modules/webapp/src/main/elm/Page/Home/Update.elm index edb3d70b..9e01cb06 100644 --- a/modules/webapp/src/main/elm/Page/Home/Update.elm +++ b/modules/webapp/src/main/elm/Page/Home/Update.elm @@ -26,7 +26,7 @@ update mId key flags settings msg model = Init -> Util.Update.andThen2 [ update mId key flags settings (SearchMenuMsg Comp.SearchMenu.Init) - , doSearch flags settings + , doSearch flags settings True ] model @@ -61,7 +61,7 @@ update mId key flags settings msg model = ( m2, c2, s2 ) = if nextState.stateChange && not model.searchInProgress then - doSearch flags settings newModel + doSearch flags settings False newModel else withSub ( newModel, Cmd.none ) @@ -91,7 +91,7 @@ update mId key flags settings msg model = , Cmd.batch [ Cmd.map ItemCardListMsg result.cmd ] ) - ItemSearchResp (Ok list) -> + ItemSearchResp scroll (Ok list) -> let noff = settings.itemSearchPageSize @@ -105,7 +105,11 @@ update mId key flags settings msg model = in Util.Update.andThen2 [ update mId key flags settings (ItemCardListMsg (Comp.ItemCardList.SetResults list)) - , scrollToCard mId + , if scroll then + scrollToCard mId + + else + \next -> ( next, Cmd.none, Sub.none ) ] m @@ -124,7 +128,6 @@ update mId key flags settings msg model = in Util.Update.andThen2 [ update mId key flags settings (ItemCardListMsg (Comp.ItemCardList.AddResults list)) - , scrollToCard mId ] m @@ -136,7 +139,7 @@ update mId key flags settings msg model = , Cmd.none ) - ItemSearchResp (Err _) -> + ItemSearchResp _ (Err _) -> withSub ( { model | searchInProgress = False @@ -153,7 +156,7 @@ update mId key flags settings msg model = withSub ( model, Cmd.none ) else - doSearch flags settings nm + doSearch flags settings False nm ToggleSearchMenu -> withSub @@ -272,8 +275,8 @@ scrollToCard mId model = ( model, Cmd.none, Sub.none ) -doSearch : Flags -> UiSettings -> Model -> ( Model, Cmd Msg, Sub Msg ) -doSearch flags settings model = +doSearch : Flags -> UiSettings -> Bool -> Model -> ( Model, Cmd Msg, Sub Msg ) +doSearch flags settings scroll model = let stype = if @@ -289,7 +292,7 @@ doSearch flags settings model = { model | searchType = stype } searchCmd = - doSearchCmd flags settings 0 model_ + doSearchCmd flags settings 0 scroll model_ ( newThrottle, cmd ) = Throttle.try searchCmd model.throttle @@ -308,7 +311,7 @@ doSearchMore : Flags -> UiSettings -> Model -> ( Model, Cmd Msg ) doSearchMore flags settings model = let cmd = - doSearchCmd flags settings model.searchOffset model + doSearchCmd flags settings model.searchOffset False model in ( { model | moreInProgress = True } , cmd From 55cfc4c908626cafce192512f794699ceda5a887 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Fri, 23 Oct 2020 20:57:19 +0200 Subject: [PATCH 04/22] Allow to select multiple items for deletion and edit --- .../webapp/src/main/elm/Comp/ItemCardList.elm | 123 +++- .../src/main/elm/Comp/ItemDetail/EditMenu.elm | 693 ++++++++++++++++++ .../main/elm/Comp/ItemDetail/FormChange.elm | 44 ++ .../src/main/elm/Comp/ItemDetail/View.elm | 1 - .../webapp/src/main/elm/Comp/YesNoDimmer.elm | 17 + .../src/main/elm/Data/ItemSelection.elm | 32 + modules/webapp/src/main/elm/Data/Items.elm | 14 + .../webapp/src/main/elm/Page/Home/Data.elm | 76 +- .../webapp/src/main/elm/Page/Home/Update.elm | 188 ++++- .../webapp/src/main/elm/Page/Home/View.elm | 185 ++++- modules/webapp/src/main/webjar/docspell.css | 3 + 11 files changed, 1326 insertions(+), 50 deletions(-) create mode 100644 modules/webapp/src/main/elm/Comp/ItemDetail/EditMenu.elm create mode 100644 modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm create mode 100644 modules/webapp/src/main/elm/Data/ItemSelection.elm diff --git a/modules/webapp/src/main/elm/Comp/ItemCardList.elm b/modules/webapp/src/main/elm/Comp/ItemCardList.elm index 945febf5..7b0614c4 100644 --- a/modules/webapp/src/main/elm/Comp/ItemCardList.elm +++ b/modules/webapp/src/main/elm/Comp/ItemCardList.elm @@ -1,6 +1,7 @@ module Comp.ItemCardList exposing ( Model , Msg(..) + , ViewConfig , init , nextItem , prevItem @@ -17,12 +18,16 @@ import Data.Direction import Data.Fields import Data.Flags exposing (Flags) import Data.Icons as Icons +import Data.ItemSelection exposing (ItemSelection) import Data.Items import Data.UiSettings exposing (UiSettings) import Html exposing (..) import Html.Attributes exposing (..) +import Html.Events exposing (onClick) import Markdown import Page exposing (Page(..)) +import Set exposing (Set) +import Util.Html import Util.ItemDragDrop as DD import Util.List import Util.String @@ -38,6 +43,7 @@ type Msg = SetResults ItemLightList | AddResults ItemLightList | ItemDDMsg DD.Msg + | ToggleSelectItem (Set String) String init : Model @@ -75,6 +81,7 @@ type alias UpdateResult = { model : Model , cmd : Cmd Msg , dragModel : DD.Model + , selection : ItemSelection } @@ -91,51 +98,78 @@ updateDrag dm _ msg model = newModel = { model | results = list } in - UpdateResult newModel Cmd.none dm + UpdateResult newModel Cmd.none dm Data.ItemSelection.Inactive AddResults list -> if list.groups == [] then - UpdateResult model Cmd.none dm + UpdateResult model Cmd.none dm Data.ItemSelection.Inactive else let newModel = { model | results = Data.Items.concat model.results list } in - UpdateResult newModel Cmd.none dm + UpdateResult newModel Cmd.none dm Data.ItemSelection.Inactive ItemDDMsg lm -> let ddd = DD.update lm dm in - UpdateResult model Cmd.none ddd.model + UpdateResult model Cmd.none ddd.model Data.ItemSelection.Inactive + + ToggleSelectItem ids id -> + let + newSet = + if Set.member id ids then + Set.remove id ids + + else + Set.insert id ids + in + UpdateResult model Cmd.none dm (Data.ItemSelection.Active newSet) --- View -view : Maybe String -> UiSettings -> Model -> Html Msg -view current settings model = +type alias ViewConfig = + { current : Maybe String + , selection : ItemSelection + } + + +isSelected : ViewConfig -> String -> Bool +isSelected cfg id = + case cfg.selection of + Data.ItemSelection.Active ids -> + Set.member id ids + + Data.ItemSelection.Inactive -> + False + + +view : ViewConfig -> UiSettings -> Model -> Html Msg +view cfg settings model = div [ class "ui container" ] - (List.map (viewGroup current settings) model.results.groups) + (List.map (viewGroup cfg settings) model.results.groups) -viewGroup : Maybe String -> UiSettings -> ItemLightGroup -> Html Msg -viewGroup current settings group = +viewGroup : ViewConfig -> UiSettings -> ItemLightGroup -> Html Msg +viewGroup cfg settings group = div [ class "item-group" ] [ div [ class "ui horizontal divider header item-list" ] [ i [ class "calendar alternate outline icon" ] [] , text group.name ] , div [ class "ui stackable three cards" ] - (List.map (viewItem current settings) group.items) + (List.map (viewItem cfg settings) group.items) ] -viewItem : Maybe String -> UiSettings -> ItemLight -> Html Msg -viewItem current settings item = +viewItem : ViewConfig -> UiSettings -> ItemLight -> Html Msg +viewItem cfg settings item = let dirIcon = i [ class (Data.Direction.iconFromMaybe item.direction) ] [] @@ -163,43 +197,68 @@ viewItem current settings item = isConfirmed = item.state /= "created" - newColor = - "blue" + cardColor = + if isSelected cfg item.id then + "purple" + + else if not isConfirmed then + "blue" + + else + "" fieldHidden f = Data.UiSettings.fieldHidden settings f + + cardAction = + case cfg.selection of + Data.ItemSelection.Inactive -> + Page.href (ItemDetailPage item.id) + + Data.ItemSelection.Active ids -> + onClick (ToggleSelectItem ids item.id) in a ([ classList [ ( "ui fluid card", True ) - , ( newColor, not isConfirmed ) - , ( "current", current == Just item.id ) + , ( cardColor, True ) + , ( "current", cfg.current == Just item.id ) ] , id item.id - , Page.href (ItemDetailPage item.id) + , href "#" + , cardAction ] ++ DD.draggable ItemDDMsg item.id ) [ div [ class "content" ] - [ if fieldHidden Data.Fields.Direction then - div [ class "header" ] - [ Util.String.underscoreToSpace item.name |> text - ] + [ case cfg.selection of + Data.ItemSelection.Active ids -> + div [ class "header" ] + [ Util.Html.checkbox (Set.member item.id ids) + , Util.String.underscoreToSpace item.name + |> text + ] - else - div - [ class "header" - , Data.Direction.labelFromMaybe item.direction - |> title - ] - [ dirIcon - , Util.String.underscoreToSpace item.name - |> text - ] + Data.ItemSelection.Inactive -> + if fieldHidden Data.Fields.Direction then + div [ class "header" ] + [ Util.String.underscoreToSpace item.name |> text + ] + + else + div + [ class "header" + , Data.Direction.labelFromMaybe item.direction + |> title + ] + [ dirIcon + , Util.String.underscoreToSpace item.name + |> text + ] , div [ classList [ ( "ui right corner label", True ) - , ( newColor, True ) + , ( cardColor, True ) , ( "invisible", isConfirmed ) ] , title "New" diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/EditMenu.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/EditMenu.elm new file mode 100644 index 00000000..561f1ef8 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/EditMenu.elm @@ -0,0 +1,693 @@ +module Comp.ItemDetail.EditMenu exposing + ( Model + , Msg + , SaveNameState(..) + , defaultViewConfig + , init + , loadModel + , update + , view + ) + +import Api +import Api.Model.EquipmentList exposing (EquipmentList) +import Api.Model.FolderItem exposing (FolderItem) +import Api.Model.FolderList exposing (FolderList) +import Api.Model.IdName exposing (IdName) +import Api.Model.ItemProposals exposing (ItemProposals) +import Api.Model.ReferenceList exposing (ReferenceList) +import Api.Model.Tag exposing (Tag) +import Api.Model.TagList exposing (TagList) +import Comp.DatePicker +import Comp.DetailEdit +import Comp.Dropdown exposing (isDropdownChangeMsg) +import Comp.ItemDetail.FormChange exposing (FormChange(..)) +import Data.Direction exposing (Direction) +import Data.Fields +import Data.Flags exposing (Flags) +import Data.Icons as Icons +import Data.UiSettings exposing (UiSettings) +import DatePicker exposing (DatePicker) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onClick, onInput) +import Http +import Markdown +import Page exposing (Page(..)) +import Task +import Throttle exposing (Throttle) +import Time +import Util.Folder exposing (mkFolderOption) +import Util.List +import Util.Maybe +import Util.Tag + + + +--- Model + + +type SaveNameState + = Saving + | SaveSuccess + | SaveFailed + + +type alias Model = + { tagModel : Comp.Dropdown.Model Tag + , nameModel : String + , nameSaveThrottle : Throttle Msg + , folderModel : Comp.Dropdown.Model IdName + , allFolders : List FolderItem + , directionModel : Comp.Dropdown.Model Direction + , itemDatePicker : DatePicker + , itemDate : Maybe Int + , itemProposals : ItemProposals + , dueDate : Maybe Int + , dueDatePicker : DatePicker + , corrOrgModel : Comp.Dropdown.Model IdName + , corrPersonModel : Comp.Dropdown.Model IdName + , concPersonModel : Comp.Dropdown.Model IdName + , concEquipModel : Comp.Dropdown.Model IdName + , modalEdit : Maybe Comp.DetailEdit.Model + } + + +type Msg + = ItemDatePickerMsg Comp.DatePicker.Msg + | DueDatePickerMsg Comp.DatePicker.Msg + | SetName String + | SaveName + | UpdateThrottle + | RemoveDueDate + | RemoveDate + | FolderDropdownMsg (Comp.Dropdown.Msg IdName) + | TagDropdownMsg (Comp.Dropdown.Msg Tag) + | DirDropdownMsg (Comp.Dropdown.Msg Direction) + | OrgDropdownMsg (Comp.Dropdown.Msg IdName) + | CorrPersonMsg (Comp.Dropdown.Msg IdName) + | ConcPersonMsg (Comp.Dropdown.Msg IdName) + | ConcEquipMsg (Comp.Dropdown.Msg IdName) + | GetTagsResp (Result Http.Error TagList) + | GetOrgResp (Result Http.Error ReferenceList) + | GetPersonResp (Result Http.Error ReferenceList) + | GetEquipResp (Result Http.Error EquipmentList) + | GetFolderResp (Result Http.Error FolderList) + + +init : Model +init = + { tagModel = + Util.Tag.makeDropdownModel + , directionModel = + Comp.Dropdown.makeSingleList + { makeOption = + \entry -> + { value = Data.Direction.toString entry + , text = Data.Direction.toString entry + , additional = "" + } + , options = Data.Direction.all + , placeholder = "Choose a direction…" + , selected = Nothing + } + , corrOrgModel = + Comp.Dropdown.makeSingle + { makeOption = \e -> { value = e.id, text = e.name, additional = "" } + , placeholder = "" + } + , corrPersonModel = + Comp.Dropdown.makeSingle + { makeOption = \e -> { value = e.id, text = e.name, additional = "" } + , placeholder = "" + } + , concPersonModel = + Comp.Dropdown.makeSingle + { makeOption = \e -> { value = e.id, text = e.name, additional = "" } + , placeholder = "" + } + , concEquipModel = + Comp.Dropdown.makeSingle + { makeOption = \e -> { value = e.id, text = e.name, additional = "" } + , placeholder = "" + } + , folderModel = + Comp.Dropdown.makeSingle + { makeOption = \e -> { value = e.id, text = e.name, additional = "" } + , placeholder = "" + } + , allFolders = [] + , nameModel = "" + , nameSaveThrottle = Throttle.create 1 + , itemDatePicker = Comp.DatePicker.emptyModel + , itemDate = Nothing + , itemProposals = Api.Model.ItemProposals.empty + , dueDate = Nothing + , dueDatePicker = Comp.DatePicker.emptyModel + , modalEdit = Nothing + } + + +loadModel : Flags -> Cmd Msg +loadModel flags = + let + ( _, dpc ) = + Comp.DatePicker.init + in + Cmd.batch + [ Api.getTags flags "" GetTagsResp + , Api.getOrgLight flags GetOrgResp + , Api.getPersonsLight flags GetPersonResp + , Api.getEquipments flags "" GetEquipResp + , Api.getFolders flags "" False GetFolderResp + , Cmd.map ItemDatePickerMsg dpc + , Cmd.map DueDatePickerMsg dpc + ] + + +isFolderMember : Model -> Bool +isFolderMember model = + let + selected = + Comp.Dropdown.getSelected model.folderModel + |> List.head + |> Maybe.map .id + in + Util.Folder.isFolderMember model.allFolders selected + + + +--- Update + + +type alias UpdateResult = + { model : Model + , cmd : Cmd Msg + , sub : Sub Msg + , change : FormChange + } + + +resultNoCmd : FormChange -> Model -> UpdateResult +resultNoCmd change model = + UpdateResult model Cmd.none Sub.none change + + +resultNone : Model -> UpdateResult +resultNone model = + resultNoCmd NoFormChange model + + +update : Flags -> Msg -> Model -> UpdateResult +update flags msg model = + case msg of + TagDropdownMsg m -> + let + ( m2, _ ) = + Comp.Dropdown.update m model.tagModel + + newModel = + { model | tagModel = m2 } + + change = + if isDropdownChangeMsg m then + Comp.Dropdown.getSelected newModel.tagModel + |> Util.List.distinct + |> List.map (\t -> IdName t.id t.name) + |> ReferenceList + |> TagChange + + else + NoFormChange + in + resultNoCmd change newModel + + GetTagsResp (Ok tags) -> + let + tagList = + Comp.Dropdown.SetOptions tags.items + in + update flags (TagDropdownMsg tagList) model + + GetTagsResp (Err _) -> + resultNone model + + FolderDropdownMsg m -> + let + ( m2, _ ) = + Comp.Dropdown.update m model.folderModel + + newModel = + { model | folderModel = m2 } + + idref = + Comp.Dropdown.getSelected m2 |> List.head + + change = + if isDropdownChangeMsg m then + FolderChange idref + + else + NoFormChange + in + resultNoCmd change newModel + + GetFolderResp (Ok fs) -> + let + model_ = + { model + | allFolders = fs.items + , folderModel = + Comp.Dropdown.setMkOption + (mkFolderOption flags fs.items) + model.folderModel + } + + mkIdName fitem = + IdName fitem.id fitem.name + + opts = + fs.items + |> List.map mkIdName + |> Comp.Dropdown.SetOptions + in + update flags (FolderDropdownMsg opts) model_ + + GetFolderResp (Err _) -> + resultNone model + + DirDropdownMsg m -> + let + ( m2, _ ) = + Comp.Dropdown.update m model.directionModel + + newModel = + { model | directionModel = m2 } + + change = + if isDropdownChangeMsg m then + let + dir = + Comp.Dropdown.getSelected m2 |> List.head + in + case dir of + Just d -> + DirectionChange d + + Nothing -> + NoFormChange + + else + NoFormChange + in + resultNoCmd change newModel + + OrgDropdownMsg m -> + let + ( m2, _ ) = + Comp.Dropdown.update m model.corrOrgModel + + newModel = + { model | corrOrgModel = m2 } + + idref = + Comp.Dropdown.getSelected m2 |> List.head + + change = + if isDropdownChangeMsg m then + OrgChange idref + + else + NoFormChange + in + resultNoCmd change newModel + + GetOrgResp (Ok orgs) -> + let + opts = + Comp.Dropdown.SetOptions orgs.items + in + update flags (OrgDropdownMsg opts) model + + GetOrgResp (Err _) -> + resultNone model + + CorrPersonMsg m -> + let + ( m2, _ ) = + Comp.Dropdown.update m model.corrPersonModel + + newModel = + { model | corrPersonModel = m2 } + + idref = + Comp.Dropdown.getSelected m2 |> List.head + + change = + if isDropdownChangeMsg m then + CorrPersonChange idref + + else + NoFormChange + in + resultNoCmd change newModel + + ConcPersonMsg m -> + let + ( m2, _ ) = + Comp.Dropdown.update m model.concPersonModel + + newModel = + { model | concPersonModel = m2 } + + idref = + Comp.Dropdown.getSelected m2 |> List.head + + change = + if isDropdownChangeMsg m then + ConcPersonChange idref + + else + NoFormChange + in + resultNoCmd change newModel + + GetPersonResp (Ok ps) -> + let + opts = + Comp.Dropdown.SetOptions ps.items + + res1 = + update flags (CorrPersonMsg opts) model + + res2 = + update flags (ConcPersonMsg opts) res1.model + in + res2 + + GetPersonResp (Err _) -> + resultNone model + + ConcEquipMsg m -> + let + ( m2, _ ) = + Comp.Dropdown.update m model.concEquipModel + + newModel = + { model | concEquipModel = m2 } + + idref = + Comp.Dropdown.getSelected m2 |> List.head + + change = + if isDropdownChangeMsg m then + EquipChange idref + + else + NoFormChange + in + resultNoCmd change newModel + + GetEquipResp (Ok equips) -> + let + opts = + Comp.Dropdown.SetOptions + (List.map (\e -> IdName e.id e.name) + equips.items + ) + in + update flags (ConcEquipMsg opts) model + + GetEquipResp (Err _) -> + resultNone model + + ItemDatePickerMsg m -> + let + ( dp, event ) = + Comp.DatePicker.updateDefault m model.itemDatePicker + in + case event of + DatePicker.Picked date -> + let + newModel = + { model | itemDatePicker = dp, itemDate = Just (Comp.DatePicker.midOfDay date) } + in + resultNoCmd (ItemDateChange newModel.itemDate) newModel + + _ -> + resultNone { model | itemDatePicker = dp } + + RemoveDate -> + resultNoCmd (ItemDateChange Nothing) { model | itemDate = Nothing } + + DueDatePickerMsg m -> + let + ( dp, event ) = + Comp.DatePicker.updateDefault m model.dueDatePicker + in + case event of + DatePicker.Picked date -> + let + newModel = + { model | dueDatePicker = dp, dueDate = Just (Comp.DatePicker.midOfDay date) } + in + resultNoCmd (DueDateChange newModel.dueDate) newModel + + _ -> + resultNone { model | dueDatePicker = dp } + + RemoveDueDate -> + resultNoCmd (DueDateChange Nothing) { model | dueDate = Nothing } + + SetName str -> + case Util.Maybe.fromString str of + Just newName -> + let + cmd_ = + Task.succeed () + |> Task.perform (\_ -> SaveName) + + ( newThrottle, cmd ) = + Throttle.try cmd_ model.nameSaveThrottle + + newModel = + { model + | nameSaveThrottle = newThrottle + , nameModel = newName + } + + sub = + nameThrottleSub newModel + in + UpdateResult newModel cmd sub NoFormChange + + Nothing -> + resultNone { model | nameModel = str } + + SaveName -> + case Util.Maybe.fromString model.nameModel of + Just n -> + resultNoCmd (NameChange n) model + + Nothing -> + resultNone model + + UpdateThrottle -> + let + ( newThrottle, cmd ) = + Throttle.update model.nameSaveThrottle + + newModel = + { model | nameSaveThrottle = newThrottle } + + sub = + nameThrottleSub newModel + in + UpdateResult newModel cmd sub NoFormChange + + +nameThrottleSub : Model -> Sub Msg +nameThrottleSub model = + Throttle.ifNeeded + (Time.every 400 (\_ -> UpdateThrottle)) + model.nameSaveThrottle + + + +--- View + + +type alias ViewConfig = + { menuClass : String + , nameState : SaveNameState + } + + +defaultViewConfig : ViewConfig +defaultViewConfig = + { menuClass = "ui vertical segment" + , nameState = SaveSuccess + } + + +view : ViewConfig -> UiSettings -> Model -> Html Msg +view = + renderEditForm + + +renderEditForm : ViewConfig -> UiSettings -> Model -> Html Msg +renderEditForm cfg settings model = + let + fieldVisible field = + Data.UiSettings.fieldVisible settings field + + optional fields html = + if + List.map fieldVisible fields + |> List.foldl (||) False + then + html + + else + span [ class "invisible hidden" ] [] + in + div [ class cfg.menuClass ] + [ div [ class "ui form warning" ] + [ optional [ Data.Fields.Tag ] <| + div [ class "field" ] + [ label [] + [ Icons.tagsIcon "grey" + , text "Tags" + ] + , Html.map TagDropdownMsg (Comp.Dropdown.view settings model.tagModel) + ] + , div [ class " field" ] + [ label [] [ text "Name" ] + , div [ class "ui icon input" ] + [ input [ type_ "text", value model.nameModel, onInput SetName ] [] + , i + [ classList + [ ( "green check icon", cfg.nameState == SaveSuccess ) + , ( "red exclamation triangle icon", cfg.nameState == SaveFailed ) + , ( "sync loading icon", cfg.nameState == Saving ) + ] + ] + [] + ] + ] + , optional [ Data.Fields.Folder ] <| + div [ class "field" ] + [ label [] + [ Icons.folderIcon "grey" + , text "Folder" + ] + , Html.map FolderDropdownMsg (Comp.Dropdown.view settings model.folderModel) + , div + [ classList + [ ( "ui warning message", True ) + , ( "hidden", isFolderMember model ) + ] + ] + [ Markdown.toHtml [] """ +You are **not a member** of this folder. This item will be **hidden** +from any search now. Use a folder where you are a member of to make this +item visible. This message will disappear then. + """ + ] + ] + , optional [ Data.Fields.Direction ] <| + div [ class "field" ] + [ label [] + [ Icons.directionIcon "grey" + , text "Direction" + ] + , Html.map DirDropdownMsg (Comp.Dropdown.view settings model.directionModel) + ] + , optional [ Data.Fields.Date ] <| + div [ class "field" ] + [ label [] + [ Icons.dateIcon "grey" + , text "Date" + ] + , div [ class "ui action input" ] + [ Html.map ItemDatePickerMsg + (Comp.DatePicker.viewTime + model.itemDate + actionInputDatePicker + model.itemDatePicker + ) + , a [ class "ui icon button", href "", onClick RemoveDate ] + [ i [ class "trash alternate outline icon" ] [] + ] + ] + ] + , optional [ Data.Fields.DueDate ] <| + div [ class " field" ] + [ label [] + [ Icons.dueDateIcon "grey" + , text "Due Date" + ] + , div [ class "ui action input" ] + [ Html.map DueDatePickerMsg + (Comp.DatePicker.viewTime + model.dueDate + actionInputDatePicker + model.dueDatePicker + ) + , a [ class "ui icon button", href "", onClick RemoveDueDate ] + [ i [ class "trash alternate outline icon" ] [] ] + ] + ] + , optional [ Data.Fields.CorrOrg, Data.Fields.CorrPerson ] <| + h4 [ class "ui dividing header" ] + [ Icons.correspondentIcon "" + , text "Correspondent" + ] + , optional [ Data.Fields.CorrOrg ] <| + div [ class "field" ] + [ label [] + [ Icons.organizationIcon "grey" + , text "Organization" + ] + , Html.map OrgDropdownMsg (Comp.Dropdown.view settings model.corrOrgModel) + ] + , optional [ Data.Fields.CorrPerson ] <| + div [ class "field" ] + [ label [] + [ Icons.personIcon "grey" + , text "Person" + ] + , Html.map CorrPersonMsg (Comp.Dropdown.view settings model.corrPersonModel) + ] + , optional [ Data.Fields.ConcPerson, Data.Fields.ConcEquip ] <| + h4 [ class "ui dividing header" ] + [ Icons.concernedIcon + , text "Concerning" + ] + , optional [ Data.Fields.ConcPerson ] <| + div [ class "field" ] + [ label [] + [ Icons.personIcon "grey" + , text "Person" + ] + , Html.map ConcPersonMsg (Comp.Dropdown.view settings model.concPersonModel) + ] + , optional [ Data.Fields.ConcEquip ] <| + div [ class "field" ] + [ label [] + [ Icons.equipmentIcon "grey" + , text "Equipment" + ] + , Html.map ConcEquipMsg (Comp.Dropdown.view settings model.concEquipModel) + ] + ] + ] + + +actionInputDatePicker : DatePicker.Settings +actionInputDatePicker = + let + ds = + Comp.DatePicker.defaultSettings + in + { ds | containerClassList = [ ( "ui action input", True ) ] } diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm new file mode 100644 index 00000000..4cb0c91a --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm @@ -0,0 +1,44 @@ +module Comp.ItemDetail.FormChange exposing + ( FormChange(..) + , multiUpdate + ) + +import Api +import Api.Model.BasicResult exposing (BasicResult) +import Api.Model.IdName exposing (IdName) +import Api.Model.ItemsAndRefs exposing (ItemsAndRefs) +import Api.Model.ReferenceList exposing (ReferenceList) +import Data.Direction exposing (Direction) +import Data.Flags exposing (Flags) +import Http +import Set exposing (Set) + + +type FormChange + = NoFormChange + | TagChange ReferenceList + | FolderChange (Maybe IdName) + | DirectionChange Direction + | OrgChange (Maybe IdName) + | CorrPersonChange (Maybe IdName) + | ConcPersonChange (Maybe IdName) + | EquipChange (Maybe IdName) + | ItemDateChange (Maybe Int) + | DueDateChange (Maybe Int) + | NameChange String + + +multiUpdate : + Flags + -> Set String + -> FormChange + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +multiUpdate flags ids change receive = + let + items = + Set.toList ids + in + case change of + _ -> + Cmd.none diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm index ba2b76b9..40a5aeaa 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/View.elm @@ -24,7 +24,6 @@ import File exposing (File) import Html exposing (..) import Html.Attributes exposing (..) import Html.Events exposing (onCheck, onClick, onInput) -import Html5.DragDrop as DD import Markdown import Page exposing (Page(..)) import Set diff --git a/modules/webapp/src/main/elm/Comp/YesNoDimmer.elm b/modules/webapp/src/main/elm/Comp/YesNoDimmer.elm index 927d6463..8421bdb5 100644 --- a/modules/webapp/src/main/elm/Comp/YesNoDimmer.elm +++ b/modules/webapp/src/main/elm/Comp/YesNoDimmer.elm @@ -6,6 +6,8 @@ module Comp.YesNoDimmer exposing , defaultSettings , disable , emptyModel + , initActive + , initInactive , update , view , view2 @@ -27,6 +29,18 @@ emptyModel = } +initInactive : Model +initInactive = + { active = False + } + + +initActive : Model +initActive = + { active = True + } + + type Msg = Activate | Disable @@ -40,6 +54,7 @@ type alias Settings = , confirmButton : String , cancelButton : String , invertedDimmer : Bool + , extraClass : String } @@ -51,6 +66,7 @@ defaultSettings = , confirmButton = "Yes, do it!" , cancelButton = "No" , invertedDimmer = False + , extraClass = "" } @@ -87,6 +103,7 @@ view2 active settings model = div [ classList [ ( "ui dimmer", True ) + , ( settings.extraClass, True ) , ( "inverted", settings.invertedDimmer ) , ( "active", active && model.active ) ] diff --git a/modules/webapp/src/main/elm/Data/ItemSelection.elm b/modules/webapp/src/main/elm/Data/ItemSelection.elm new file mode 100644 index 00000000..96ea8e5e --- /dev/null +++ b/modules/webapp/src/main/elm/Data/ItemSelection.elm @@ -0,0 +1,32 @@ +module Data.ItemSelection exposing + ( ItemSelection(..) + , isActive + , isSelected + ) + +import Set exposing (Set) + + +type ItemSelection + = Inactive + | Active (Set String) + + +isSelected : String -> ItemSelection -> Bool +isSelected id set = + case set of + Inactive -> + False + + Active ids -> + Set.member id ids + + +isActive : ItemSelection -> Bool +isActive sel = + case sel of + Active _ -> + True + + Inactive -> + False diff --git a/modules/webapp/src/main/elm/Data/Items.elm b/modules/webapp/src/main/elm/Data/Items.elm index 04b9aaad..9c393b3e 100644 --- a/modules/webapp/src/main/elm/Data/Items.elm +++ b/modules/webapp/src/main/elm/Data/Items.elm @@ -1,12 +1,14 @@ module Data.Items exposing ( concat , first + , idSet , length ) import Api.Model.ItemLight exposing (ItemLight) import Api.Model.ItemLightGroup exposing (ItemLightGroup) import Api.Model.ItemLightList exposing (ItemLightList) +import Set exposing (Set) import Util.List @@ -65,3 +67,15 @@ lastGroup : ItemLightList -> Maybe ItemLightGroup lastGroup list = List.reverse list.groups |> List.head + + +idSet : ItemLightList -> Set String +idSet items = + List.map idSetGroup items.groups + |> List.foldl Set.union Set.empty + + +idSetGroup : ItemLightGroup -> Set String +idSetGroup group = + List.map .id group.items + |> Set.fromList diff --git a/modules/webapp/src/main/elm/Page/Home/Data.elm b/modules/webapp/src/main/elm/Page/Home/Data.elm index 0c995564..22709a6f 100644 --- a/modules/webapp/src/main/elm/Page/Home/Data.elm +++ b/modules/webapp/src/main/elm/Page/Home/Data.elm @@ -2,12 +2,18 @@ module Page.Home.Data exposing ( Model , Msg(..) , SearchType(..) + , SelectActionMode(..) + , SelectViewModel + , ViewMode(..) , defaultSearchType , doSearchCmd , init + , initSelectViewModel , itemNav + , menuCollapsed , resultsBelowLimit , searchTypeString + , selectActive ) import Api @@ -16,12 +22,16 @@ import Api.Model.ItemSearch import Browser.Dom as Dom import Comp.FixedDropdown import Comp.ItemCardList +import Comp.ItemDetail.EditMenu import Comp.SearchMenu +import Comp.YesNoDimmer import Data.Flags exposing (Flags) import Data.ItemNav exposing (ItemNav) +import Data.ItemSelection exposing (ItemSelection) import Data.Items import Data.UiSettings exposing (UiSettings) import Http +import Set exposing (Set) import Throttle exposing (Throttle) import Util.Html exposing (KeyCode(..)) import Util.ItemDragDrop as DD @@ -31,7 +41,7 @@ type alias Model = { searchMenuModel : Comp.SearchMenu.Model , itemListModel : Comp.ItemCardList.Model , searchInProgress : Bool - , menuCollapsed : Bool + , viewMode : ViewMode , searchOffset : Int , moreAvailable : Bool , moreInProgress : Bool @@ -45,6 +55,29 @@ type alias Model = } +type alias SelectViewModel = + { ids : Set String + , action : SelectActionMode + , deleteAllConfirm : Comp.YesNoDimmer.Model + , editModel : Comp.ItemDetail.EditMenu.Model + } + + +initSelectViewModel : SelectViewModel +initSelectViewModel = + { ids = Set.empty + , action = NoneAction + , deleteAllConfirm = Comp.YesNoDimmer.initActive + , editModel = Comp.ItemDetail.EditMenu.init + } + + +type ViewMode + = SimpleView + | SearchView + | SelectView SelectViewModel + + init : Flags -> Model init flags = let @@ -58,7 +91,6 @@ init flags = { searchMenuModel = Comp.SearchMenu.init , itemListModel = Comp.ItemCardList.init , searchInProgress = False - , menuCollapsed = True , searchOffset = 0 , moreAvailable = True , moreInProgress = False @@ -72,6 +104,7 @@ init flags = , dragDropData = DD.DragDropData DD.init Nothing , scrollToCard = Nothing + , viewMode = SimpleView } @@ -84,6 +117,32 @@ defaultSearchType flags = BasicSearch +menuCollapsed : Model -> Bool +menuCollapsed model = + case model.viewMode of + SimpleView -> + True + + SearchView -> + False + + SelectView _ -> + False + + +selectActive : Model -> Bool +selectActive model = + case model.viewMode of + SimpleView -> + False + + SearchView -> + False + + SelectView _ -> + True + + type Msg = Init | SearchMenuMsg Comp.SearchMenu.Msg @@ -93,6 +152,7 @@ type Msg | ItemSearchAddResp (Result Http.Error ItemLightList) | DoSearch | ToggleSearchMenu + | ToggleSelectView | LoadMore | UpdateThrottle | SetBasicSearch String @@ -101,6 +161,12 @@ type Msg | SetContentOnly String | ScrollResult (Result Dom.Error ()) | ClearItemDetailId + | SelectAllItems + | SelectNoItems + | RequestDeleteSelected + | DeleteSelectedConfirmMsg Comp.YesNoDimmer.Msg + | EditSelectedItems + | EditMenuMsg Comp.ItemDetail.EditMenu.Msg type SearchType @@ -109,6 +175,12 @@ type SearchType | ContentOnlySearch +type SelectActionMode + = NoneAction + | DeleteSelected + | EditSelected + + searchTypeString : SearchType -> String searchTypeString st = case st of diff --git a/modules/webapp/src/main/elm/Page/Home/Update.elm b/modules/webapp/src/main/elm/Page/Home/Update.elm index 9e01cb06..61a21cee 100644 --- a/modules/webapp/src/main/elm/Page/Home/Update.elm +++ b/modules/webapp/src/main/elm/Page/Home/Update.elm @@ -3,13 +3,18 @@ module Page.Home.Update exposing (update) import Browser.Navigation as Nav import Comp.FixedDropdown import Comp.ItemCardList +import Comp.ItemDetail.EditMenu import Comp.SearchMenu +import Comp.YesNoDimmer import Data.Flags exposing (Flags) +import Data.ItemSelection +import Data.Items import Data.UiSettings exposing (UiSettings) import Page exposing (Page(..)) import Page.Home.Data exposing (..) import Process import Scroll +import Set import Task import Throttle import Time @@ -82,10 +87,19 @@ update mId key flags settings msg model = flags m model.itemListModel + + nextView = + case ( model.viewMode, result.selection ) of + ( SelectView svm, Data.ItemSelection.Active ids ) -> + SelectView { svm | ids = ids } + + ( v, _ ) -> + v in withSub ( { model | itemListModel = result.model + , viewMode = nextView , dragDropData = DD.DragDropData result.dragModel Nothing } , Cmd.batch [ Cmd.map ItemCardListMsg result.cmd ] @@ -159,11 +173,43 @@ update mId key flags settings msg model = doSearch flags settings False nm ToggleSearchMenu -> + let + nextView = + case model.viewMode of + SimpleView -> + SearchView + + SearchView -> + SimpleView + + SelectView _ -> + SimpleView + in withSub - ( { model | menuCollapsed = not model.menuCollapsed } + ( { model | viewMode = nextView } , Cmd.none ) + ToggleSelectView -> + let + ( nextView, cmd ) = + case model.viewMode of + SimpleView -> + ( SelectView initSelectViewModel, loadEditModel flags ) + + SearchView -> + ( SelectView initSelectViewModel, loadEditModel flags ) + + SelectView _ -> + ( SearchView, Cmd.none ) + in + withSub + ( { model + | viewMode = nextView + } + , cmd + ) + LoadMore -> if model.moreAvailable then doSearchMore flags settings model |> withSub @@ -253,6 +299,139 @@ update mId key flags settings msg model = ClearItemDetailId -> noSub ( { model | scrollToCard = Nothing }, Cmd.none ) + SelectAllItems -> + case model.viewMode of + SelectView svm -> + let + visible = + Data.Items.idSet model.itemListModel.results + + svm_ = + { svm | ids = Set.union svm.ids visible } + in + noSub + ( { model | viewMode = SelectView svm_ } + , Cmd.none + ) + + _ -> + noSub ( model, Cmd.none ) + + SelectNoItems -> + case model.viewMode of + SelectView svm -> + let + svm_ = + { svm | ids = Set.empty } + in + noSub + ( { model | viewMode = SelectView svm_ } + , Cmd.none + ) + + _ -> + noSub ( model, Cmd.none ) + + DeleteSelectedConfirmMsg lmsg -> + case model.viewMode of + SelectView svm -> + let + ( confirmModel, confirmed ) = + Comp.YesNoDimmer.update lmsg svm.deleteAllConfirm + + cmd = + if confirmed then + Cmd.none + + else + Cmd.none + + act = + if confirmModel.active || confirmed then + DeleteSelected + + else + NoneAction + in + noSub + ( { model + | viewMode = + SelectView + { svm + | deleteAllConfirm = confirmModel + , action = act + } + } + , cmd + ) + + _ -> + noSub ( model, Cmd.none ) + + RequestDeleteSelected -> + case model.viewMode of + SelectView svm -> + if svm.ids == Set.empty then + noSub ( model, Cmd.none ) + + else + let + lmsg = + DeleteSelectedConfirmMsg Comp.YesNoDimmer.activate + + model_ = + { model | viewMode = SelectView { svm | action = DeleteSelected } } + in + update mId key flags settings lmsg model_ + + _ -> + noSub ( model, Cmd.none ) + + EditSelectedItems -> + case model.viewMode of + SelectView svm -> + if svm.action == EditSelected then + noSub + ( { model | viewMode = SelectView { svm | action = NoneAction } } + , Cmd.none + ) + + else if svm.ids == Set.empty then + noSub ( model, Cmd.none ) + + else + noSub + ( { model | viewMode = SelectView { svm | action = EditSelected } } + , Cmd.none + ) + + _ -> + noSub ( model, Cmd.none ) + + EditMenuMsg lmsg -> + case model.viewMode of + SelectView svm -> + let + res = + Comp.ItemDetail.EditMenu.update flags lmsg svm.editModel + + svm_ = + { svm | editModel = res.model } + + cmd_ = + Cmd.map EditMenuMsg res.cmd + + sub_ = + Sub.map EditMenuMsg res.sub + + _ = + Debug.log "change" res.change + in + ( { model | viewMode = SelectView svm_ }, cmd_, sub_ ) + + _ -> + noSub ( model, Cmd.none ) + --- Helpers @@ -275,12 +454,17 @@ scrollToCard mId model = ( model, Cmd.none, Sub.none ) +loadEditModel : Flags -> Cmd Msg +loadEditModel flags = + Cmd.map EditMenuMsg (Comp.ItemDetail.EditMenu.loadModel flags) + + doSearch : Flags -> UiSettings -> Bool -> Model -> ( Model, Cmd Msg, Sub Msg ) doSearch flags settings scroll model = let stype = if - not model.menuCollapsed + not (menuCollapsed model) || Util.String.isNothingOrBlank model.contentOnlySearch then BasicSearch diff --git a/modules/webapp/src/main/elm/Page/Home/View.elm b/modules/webapp/src/main/elm/Page/Home/View.elm index ce4d8969..84e36c89 100644 --- a/modules/webapp/src/main/elm/Page/Home/View.elm +++ b/modules/webapp/src/main/elm/Page/Home/View.elm @@ -3,26 +3,51 @@ module Page.Home.View exposing (view) import Api.Model.ItemSearch import Comp.FixedDropdown import Comp.ItemCardList +import Comp.ItemDetail.EditMenu import Comp.SearchMenu +import Comp.YesNoDimmer import Data.Flags exposing (Flags) +import Data.ItemSelection import Data.UiSettings exposing (UiSettings) import Html exposing (..) import Html.Attributes exposing (..) import Html.Events exposing (onClick, onInput) import Page exposing (Page(..)) import Page.Home.Data exposing (..) +import Set import Util.Html view : Flags -> UiSettings -> Model -> Html Msg view flags settings model = + let + itemViewCfg = + case model.viewMode of + SelectView svm -> + Comp.ItemCardList.ViewConfig + model.scrollToCard + (Data.ItemSelection.Active svm.ids) + + _ -> + Comp.ItemCardList.ViewConfig + model.scrollToCard + Data.ItemSelection.Inactive + + selectAction = + case model.viewMode of + SelectView svm -> + svm.action + + _ -> + NoneAction + in div [ class "home-page ui padded grid" ] [ div [ classList [ ( "sixteen wide mobile six wide tablet four wide computer search-menu column" , True ) - , ( "invisible hidden", model.menuCollapsed ) + , ( "invisible hidden", menuCollapsed model ) ] ] [ div @@ -38,6 +63,17 @@ view flags settings model = ] , div [ class "right floated menu" ] [ a + [ classList + [ ( "borderless item", True ) + , ( "active", selectActive model ) + ] + , href "#" + , title "Select items" + , onClick ToggleSelectView + ] + [ i [ class "tasks icon" ] [] + ] + , a [ class "borderless item" , onClick ResetSearch , title "Reset form" @@ -63,26 +99,30 @@ view flags settings model = ] ] , div [ class "" ] - [ Html.map SearchMenuMsg - (Comp.SearchMenu.viewDrop model.dragDropData - flags - settings - model.searchMenuModel - ) - ] + (viewLeftMenu flags settings model) ] , div [ classList [ ( "sixteen wide mobile ten wide tablet twelve wide computer column" - , not model.menuCollapsed + , not (menuCollapsed model) ) - , ( "sixteen wide column", model.menuCollapsed ) + , ( "sixteen wide column", menuCollapsed model ) , ( "item-card-list", True ) ] ] - [ viewSearchBar flags model + [ viewBar flags model + , case model.viewMode of + SelectView svm -> + Html.map DeleteSelectedConfirmMsg + (Comp.YesNoDimmer.view2 (selectAction == DeleteSelected) + deleteAllDimmer + svm.deleteAllConfirm + ) + + _ -> + span [ class "invisible" ] [] , Html.map ItemCardListMsg - (Comp.ItemCardList.view model.scrollToCard settings model.itemListModel) + (Comp.ItemCardList.view itemViewCfg settings model.itemListModel) ] , div [ classList @@ -117,6 +157,113 @@ view flags settings model = ] +viewLeftMenu : Flags -> UiSettings -> Model -> List (Html Msg) +viewLeftMenu flags settings model = + let + searchMenu = + [ Html.map SearchMenuMsg + (Comp.SearchMenu.viewDrop model.dragDropData + flags + settings + model.searchMenuModel + ) + ] + in + case model.viewMode of + SelectView svm -> + case svm.action of + EditSelected -> + let + cfg = + Comp.ItemDetail.EditMenu.defaultViewConfig + in + [ div [ class "ui dividing header" ] + [ text "Multi-Edit" + ] + , div [ class "ui info message" ] + [ text "Note that a change here immediatly affects all selected items on the right!" + ] + , Html.map EditMenuMsg + (Comp.ItemDetail.EditMenu.view cfg settings svm.editModel) + ] + + _ -> + searchMenu + + _ -> + searchMenu + + +viewBar : Flags -> Model -> Html Msg +viewBar flags model = + case model.viewMode of + SimpleView -> + viewSearchBar flags model + + SearchView -> + div [ class "hidden invisible" ] [] + + SelectView svm -> + viewActionBar flags svm model + + +viewActionBar : Flags -> SelectViewModel -> Model -> Html Msg +viewActionBar _ svm model = + let + selectCount = + Set.size svm.ids |> String.fromInt + in + div + [ class "ui ablue-comp icon menu" + ] + [ a + [ classList + [ ( "borderless item", True ) + , ( "active", svm.action == EditSelected ) + ] + , href "#" + , title <| "Edit " ++ selectCount ++ " selected items" + , onClick EditSelectedItems + ] + [ i [ class "ui edit icon" ] [] + ] + , a + [ classList + [ ( "borderless item", True ) + , ( "active", svm.action == DeleteSelected ) + ] + , href "#" + , title <| "Delete " ++ selectCount ++ " selected items" + , onClick RequestDeleteSelected + ] + [ i [ class "trash icon" ] [] + ] + , div [ class "right menu" ] + [ a + [ class "item" + , href "#" + , onClick SelectAllItems + , title "Select all" + ] + [ i [ class "check square outline icon" ] [] + ] + , a + [ class "borderless item" + , href "#" + , title "Select none" + , onClick SelectNoItems + ] + [ i [ class "square outline icon" ] [] + ] + , div [ class "borderless label item" ] + [ div [ class "ui circular purple icon label" ] + [ text selectCount + ] + ] + ] + ] + + viewSearchBar : Flags -> Model -> Html Msg viewSearchBar flags model = let @@ -145,7 +292,7 @@ viewSearchBar flags model = in div [ classList - [ ( "invisible hidden", not model.menuCollapsed ) + [ ( "invisible hidden", not (menuCollapsed model) ) , ( "ui secondary stackable menu container", True ) ] ] @@ -221,3 +368,15 @@ hasMoreSearch model = Api.Model.ItemSearch.empty in is_ /= Api.Model.ItemSearch.empty + + +deleteAllDimmer : Comp.YesNoDimmer.Settings +deleteAllDimmer = + { message = "Really delete all selected items?" + , headerIcon = "exclamation icon" + , headerClass = "ui inverted icon header" + , confirmButton = "Yes" + , cancelButton = "No" + , invertedDimmer = False + , extraClass = "top aligned" + } diff --git a/modules/webapp/src/main/webjar/docspell.css b/modules/webapp/src/main/webjar/docspell.css index 31ee7eda..e24781e6 100644 --- a/modules/webapp/src/main/webjar/docspell.css +++ b/modules/webapp/src/main/webjar/docspell.css @@ -31,6 +31,9 @@ margin: 0 1em; } +.default-layout .ui.icon.menu .label.item { + padding: 0 0.5em 0 0; +} .default-layout .right-float { float: right; From 5f2f20c8d75eaea8a08b67611dcd6317339f8aea Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sun, 25 Oct 2020 15:08:44 +0100 Subject: [PATCH 05/22] Extend http api for multi-edit --- .../src/main/resources/docspell-openapi.yml | 410 +++++++++++++++++- 1 file changed, 408 insertions(+), 2 deletions(-) diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 37e5c95e..8fdfd848 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -1384,7 +1384,9 @@ paths: tags: [ Item ] summary: Set new set of tags. description: | - Update the tags associated to an item. + Update the tags associated to an item. This will remove all + existing ones and sets the given tags, such that after this + returns, the item has exactly the tags as given. security: - authTokenHeader: [] parameters: @@ -1845,6 +1847,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ItemProposals" + /sec/item/{itemId}/reprocess: post: tags: [ Item ] @@ -1895,6 +1898,331 @@ paths: schema: $ref: "#/components/schemas/BasicResult" + + /sec/items/deleteAll: + post: + tags: + - Item (Multi Edit) + summary: Delete multiple items. + description: | + Given a list of item ids, deletes all of them. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/IdList" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/items/tags: + post: + tags: + - Item (Multi Edit) + summary: Add tags to multiple items + description: | + Add the given tags to all given items. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ItemsAndRefs" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + put: + tags: + - Item (Multi Edit) + summary: Sets tags to multiple items + description: | + Sets the given tags to all given items. If the tag list is + empty, then tags are removed from the items. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ItemsAndRefs" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/items/name: + put: + tags: + - Item (Multi Edit) + summary: Change the name of multiple items + description: | + Sets the name of multiple items at once. The name must not be + empty. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ItemsAndName" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/items/folder: + put: + tags: + - Item (Multi Edit) + summary: Sets a folder to multiple items. + description: | + Given a folder id, sets it on all given items. If the folder + reference is not present, the folder is removed from all + items. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ItemsAndRef" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/items/direction: + put: + tags: + - Item (Multi Edit) + summary: Set the direction of multiple items + description: | + Given multiple item ids and a direction value, sets it to all + items. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ItemsAndDirection" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/items/date: + put: + tags: + - Item (Multi Edit) + summary: Set the date of multiple items + description: | + Given multiple item ids and a date, sets it to all items as + the item date. If no date is present, remove the date from the + items. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ItemsAndDate" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/items/duedate: + put: + tags: + - Item (Multi Edit) + summary: Set the direction of multiple items + description: | + Given multiple item ids and a date value, sets it to all items + as the due date. If the date is missing, remove the due-date + from the items. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ItemsAndDate" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/items/corrOrg: + put: + tags: + - Item (Multi Edit) + summary: Sets an organization to multiple items. + description: | + Given an organization id, sets it on all given items. If the + organization is missing, the reference is removed from all + items. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ItemsAndRef" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/items/corrPerson: + put: + tags: + - Item (Multi Edit) + summary: Sets an correspondent person to multiple items. + description: | + Given an person id, sets it on all given items as + correspondent person. If the person is missing, the reference + is removed from all items. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ItemsAndRef" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/items/concPerson: + put: + tags: + - Item (Multi Edit) + summary: Sets an concerning person to multiple items. + description: | + Given an person id, sets it on all given items as concerning + person. If the person is missing, it is removed from all + items. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ItemsAndRef" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/items/concEquipment: + put: + tags: + - Item (Multi Edit) + summary: Sets an equipment to multiple items. + description: | + Given an equipment id, sets it on all given items. If no + equipment is given, the reference is removed from all given + items. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ItemsAndRef" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/items/confirm: + put: + tags: + - Item (Multi Edit) + summary: Confirm multiple items. + description: | + Given a list of item ids, confirm all of them. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/IdList" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/items/unconfirm: + put: + tags: + - Item (Multi Edit) + summary: Un-confirm multiple items. + description: | + Given a list of item ids, un-confirm all of them. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/IdList" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + + /sec/attachment/{id}: delete: tags: [ Attachment ] @@ -2702,6 +3030,84 @@ paths: components: schemas: + ItemsAndRefs: + description: | + Holds a list of item ids and a list of ids of some other + related entity (e.g. tags). + required: + - items + - refs + properties: + items: + type: array + items: + type: string + format: ident + refs: + type: array + items: + type: string + format: ident + ItemsAndRef: + description: | + Holds a list of item ids and a single optional id of some + other related entity (e.g. person, org). + required: + - items + properties: + items: + type: array + items: + type: string + format: ident + ref: + type: string + format: ident + ItemsAndName: + description: | + Holds a list of item ids and an item name. + required: + - items + - name + properties: + items: + type: array + items: + type: string + format: ident + name: + type: string + ItemsAndDirection: + description: | + Holds a list of item ids and a direction value. + required: + - items + - direction + properties: + items: + type: array + items: + type: string + format: ident + direction: + type: string + format: direction + ItemsAndDate: + description: | + Holds a list of item ids and a date value. + required: + - items + properties: + items: + type: array + items: + type: string + format: ident + date: + type: integer + format: date-time + + JobPriority: description: | Transfer the priority of a job. @@ -3828,7 +4234,7 @@ components: format: date-time ReferenceList: description: - Listing of items. + Listing of entities with their id and a name. required: - items properties: From 458fa7edd9298e3bcdbded2d957427faac0d88bb Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sun, 25 Oct 2020 21:36:29 +0100 Subject: [PATCH 06/22] Allow to search in a defined item subset --- modules/restapi/src/main/resources/docspell-openapi.yml | 2 ++ .../src/main/scala/docspell/restserver/conv/Conversions.scala | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 8fdfd848..dcc81815 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -4483,6 +4483,8 @@ components: dueDateUntil: type: integer format: date-time + itemSubset: + $ref: "#/components/schemas/IdList" ItemLight: description: | An item with only a few important properties. diff --git a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala index 1def28d4..4e872606 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala @@ -134,7 +134,9 @@ trait Conversions { m.dueDateFrom, m.dueDateUntil, m.allNames, - None, + m.itemSubset + .map(_.ids.flatMap(i => Ident.fromString(i).toOption).toSet) + .filter(_.nonEmpty), None ) From 5735a47199d605f6cbcb88d967959f4b0e52fed9 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sun, 25 Oct 2020 22:21:39 +0100 Subject: [PATCH 07/22] Replace changed item cards --- modules/webapp/src/main/elm/Data/Items.elm | 41 ++++++++++ .../webapp/src/main/elm/Page/Home/Data.elm | 4 +- .../webapp/src/main/elm/Page/Home/Update.elm | 75 ++++++++++++++++++- 3 files changed, 115 insertions(+), 5 deletions(-) diff --git a/modules/webapp/src/main/elm/Data/Items.elm b/modules/webapp/src/main/elm/Data/Items.elm index 9c393b3e..5e9d124e 100644 --- a/modules/webapp/src/main/elm/Data/Items.elm +++ b/modules/webapp/src/main/elm/Data/Items.elm @@ -3,11 +3,13 @@ module Data.Items exposing , first , idSet , length + , replaceIn ) import Api.Model.ItemLight exposing (ItemLight) import Api.Model.ItemLightGroup exposing (ItemLightGroup) import Api.Model.ItemLightList exposing (ItemLightList) +import Dict exposing (Dict) import Set exposing (Set) import Util.List @@ -79,3 +81,42 @@ idSetGroup : ItemLightGroup -> Set String idSetGroup group = List.map .id group.items |> Set.fromList + + +replaceIn : ItemLightList -> ItemLightList -> ItemLightList +replaceIn origin replacements = + let + newItems = + mkItemDict replacements + + replaceItem item = + case Dict.get item.id newItems of + Just ni -> + ni + + Nothing -> + item + + replaceGroup g = + List.map replaceItem g.items + |> ItemLightGroup g.name + in + List.map replaceGroup origin.groups + |> ItemLightList + + + +--- Helper + + +mkItemDict : ItemLightList -> Dict String ItemLight +mkItemDict list = + let + insertItems : Dict String ItemLight -> List ItemLight -> Dict String ItemLight + insertItems dict items = + List.foldl (\i -> \d -> Dict.insert i.id i d) dict items + + insertGroup dict groups = + List.foldl (\g -> \d -> insertItems d g.items) dict groups + in + insertGroup Dict.empty list.groups diff --git a/modules/webapp/src/main/elm/Page/Home/Data.elm b/modules/webapp/src/main/elm/Page/Home/Data.elm index 22709a6f..27a64671 100644 --- a/modules/webapp/src/main/elm/Page/Home/Data.elm +++ b/modules/webapp/src/main/elm/Page/Home/Data.elm @@ -17,6 +17,7 @@ module Page.Home.Data exposing ) import Api +import Api.Model.BasicResult exposing (BasicResult) import Api.Model.ItemLightList exposing (ItemLightList) import Api.Model.ItemSearch import Browser.Dom as Dom @@ -27,7 +28,6 @@ import Comp.SearchMenu import Comp.YesNoDimmer import Data.Flags exposing (Flags) import Data.ItemNav exposing (ItemNav) -import Data.ItemSelection exposing (ItemSelection) import Data.Items import Data.UiSettings exposing (UiSettings) import Http @@ -167,6 +167,8 @@ type Msg | DeleteSelectedConfirmMsg Comp.YesNoDimmer.Msg | EditSelectedItems | EditMenuMsg Comp.ItemDetail.EditMenu.Msg + | MultiUpdateResp (Result Http.Error BasicResult) + | ReplaceChangedItemsResp (Result Http.Error ItemLightList) type SearchType diff --git a/modules/webapp/src/main/elm/Page/Home/Update.elm b/modules/webapp/src/main/elm/Page/Home/Update.elm index 61a21cee..05a8259b 100644 --- a/modules/webapp/src/main/elm/Page/Home/Update.elm +++ b/modules/webapp/src/main/elm/Page/Home/Update.elm @@ -1,9 +1,14 @@ module Page.Home.Update exposing (update) +import Api +import Api.Model.IdList exposing (IdList) +import Api.Model.ItemLightList exposing (ItemLightList) +import Api.Model.ItemSearch exposing (ItemSearch) import Browser.Navigation as Nav import Comp.FixedDropdown import Comp.ItemCardList import Comp.ItemDetail.EditMenu +import Comp.ItemDetail.FormChange import Comp.SearchMenu import Comp.YesNoDimmer import Data.Flags exposing (Flags) @@ -14,7 +19,7 @@ import Page exposing (Page(..)) import Page.Home.Data exposing (..) import Process import Scroll -import Set +import Set exposing (Set) import Task import Throttle import Time @@ -424,19 +429,81 @@ update mId key flags settings msg model = sub_ = Sub.map EditMenuMsg res.sub - _ = - Debug.log "change" res.change + upCmd = + Comp.ItemDetail.FormChange.multiUpdate flags + svm.ids + res.change + MultiUpdateResp in - ( { model | viewMode = SelectView svm_ }, cmd_, sub_ ) + ( { model | viewMode = SelectView svm_ }, Cmd.batch [ cmd_, upCmd ], sub_ ) _ -> noSub ( model, Cmd.none ) + MultiUpdateResp (Ok res) -> + if res.success then + case model.viewMode of + SelectView svm -> + -- replace changed items in the view + noSub ( model, loadChangedItems flags svm.ids ) + + _ -> + noSub ( model, Cmd.none ) + + else + noSub ( model, Cmd.none ) + + MultiUpdateResp (Err _) -> + noSub ( model, Cmd.none ) + + ReplaceChangedItemsResp (Ok items) -> + noSub ( replaceItems model items, Cmd.none ) + + ReplaceChangedItemsResp (Err _) -> + noSub ( model, Cmd.none ) + --- Helpers +replaceItems : Model -> ItemLightList -> Model +replaceItems model newItems = + let + listModel = + model.itemListModel + + changed = + Data.Items.replaceIn listModel.results newItems + + newList = + { listModel | results = changed } + in + { model | itemListModel = newList } + + +loadChangedItems : Flags -> Set String -> Cmd Msg +loadChangedItems flags ids = + if Set.isEmpty ids then + Cmd.none + + else + let + searchInit = + Api.Model.ItemSearch.empty + + idList = + IdList (Set.toList ids) + + search = + { searchInit + | itemSubset = Just idList + , limit = Set.size ids + } + in + Api.itemSearch flags search ReplaceChangedItemsResp + + scrollToCard : Maybe String -> Model -> ( Model, Cmd Msg, Sub Msg ) scrollToCard mId model = let From 7ad37c8d2653a320434cfd3b19f90a9219a687e5 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 26 Oct 2020 11:54:04 +0100 Subject: [PATCH 08/22] Editing tags for multiple items --- .../scala/docspell/backend/ops/OItem.scala | 97 ++++++--- .../src/main/resources/docspell-openapi.yml | 7 +- .../docspell/restserver/RestServer.scala | 1 + .../restserver/routes/ItemMultiRoutes.scala | 189 ++++++++++++++++++ .../scala/docspell/store/impl/Column.scala | 7 +- .../scala/docspell/store/records/RItem.scala | 10 +- .../docspell/store/records/RTagItem.scala | 25 ++- modules/webapp/src/main/elm/Api.elm | 35 ++++ .../main/elm/Comp/ItemDetail/FormChange.elm | 7 + .../webapp/src/main/elm/Page/Home/Update.elm | 5 +- 10 files changed, 340 insertions(+), 43 deletions(-) create mode 100644 modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala 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 da3efce2..0e855320 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala @@ -1,5 +1,6 @@ package docspell.backend.ops +import cats.data.NonEmptyList import cats.data.OptionT import cats.effect.{Effect, Resource} import cats.implicits._ @@ -13,21 +14,38 @@ import docspell.store.queue.JobQueue import docspell.store.records._ import docspell.store.{AddResult, Store} -import doobie._ import doobie.implicits._ import org.log4s.getLogger trait OItem[F[_]] { /** Sets the given tags (removing all existing ones). */ - def setTags(item: Ident, tagIds: List[Ident], collective: Ident): F[AddResult] + def setTags(item: Ident, tagIds: List[Ident], collective: Ident): F[UpdateResult] + + /** Sets tags for multiple items. The tags of the items will be + * replaced with the given ones. Same as `setTags` but for multiple + * items. + */ + def setTagsMultipleItems( + items: NonEmptyList[Ident], + tags: List[Ident], + collective: Ident + ): F[UpdateResult] /** Create a new tag and add it to the item. */ def addNewTag(item: Ident, tag: RTag): F[AddResult] - /** Apply all tags to the given item. Tags must exist, but can be IDs or names. */ + /** Apply all tags to the given item. Tags must exist, but can be IDs + * or names. Existing tags on the item are left unchanged. + */ def linkTags(item: Ident, tags: List[String], collective: Ident): F[UpdateResult] + def linkTagsMultipleItems( + items: NonEmptyList[Ident], + tags: List[String], + collective: Ident + ): F[UpdateResult] + /** Toggles tags of the given item. Tags must exist, but can be IDs or names. */ def toggleTags(item: Ident, tags: List[String], collective: Ident): F[UpdateResult] @@ -55,7 +73,14 @@ trait OItem[F[_]] { def setName(item: Ident, name: String, collective: Ident): F[AddResult] - def setState(item: Ident, state: ItemState, collective: Ident): F[AddResult] + def setState(item: Ident, state: ItemState, collective: Ident): F[AddResult] = + setStates(NonEmptyList.of(item), state, collective) + + def setStates( + item: NonEmptyList[Ident], + state: ItemState, + collective: Ident + ): F[AddResult] def setItemDate(item: Ident, date: Option[Timestamp], collective: Ident): F[AddResult] @@ -130,21 +155,30 @@ object OItem { item: Ident, tags: List[String], collective: Ident + ): F[UpdateResult] = + linkTagsMultipleItems(NonEmptyList.of(item), tags, collective) + + def linkTagsMultipleItems( + items: NonEmptyList[Ident], + tags: List[String], + collective: Ident ): F[UpdateResult] = tags.distinct match { case Nil => UpdateResult.success.pure[F] - case kws => - val db = + case ws => + store.transact { (for { - _ <- OptionT(RItem.checkByIdAndCollective(item, collective)) - given <- OptionT.liftF(RTag.findAllByNameOrId(kws, collective)) - exist <- OptionT.liftF(RTagItem.findAllIn(item, given.map(_.tagId))) + itemIds <- OptionT + .liftF(RItem.filterItems(items, collective)) + .filter(_.nonEmpty) + given <- OptionT.liftF(RTag.findAllByNameOrId(ws, collective)) _ <- OptionT.liftF( - RTagItem.setAllTags(item, given.map(_.tagId).diff(exist.map(_.tagId))) + itemIds.traverse(item => + RTagItem.appendTags(item, given.map(_.tagId).toList) + ) ) } yield UpdateResult.success).getOrElse(UpdateResult.notFound) - - store.transact(db) + } } def toggleTags( @@ -169,20 +203,23 @@ object OItem { store.transact(db) } - def setTags(item: Ident, tagIds: List[Ident], collective: Ident): F[AddResult] = { - val db = for { - cid <- RItem.getCollective(item) - nd <- - if (cid.contains(collective)) RTagItem.deleteItemTags(item) - else 0.pure[ConnectionIO] - ni <- - if (tagIds.nonEmpty && cid.contains(collective)) - RTagItem.insertItemTags(item, tagIds) - else 0.pure[ConnectionIO] - } yield nd + ni + def setTags( + item: Ident, + tagIds: List[Ident], + collective: Ident + ): F[UpdateResult] = + setTagsMultipleItems(NonEmptyList.of(item), tagIds, collective) - store.transact(db).attempt.map(AddResult.fromUpdate) - } + def setTagsMultipleItems( + items: NonEmptyList[Ident], + tags: List[Ident], + collective: Ident + ): F[UpdateResult] = + UpdateResult.fromUpdate(store.transact(for { + k <- RTagItem.deleteItemTags(items, collective) + res <- items.traverse(i => RTagItem.setAllTags(i, tags)) + n = res.fold + } yield k + n)) def addNewTag(item: Ident, tag: RTag): F[AddResult] = (for { @@ -192,7 +229,7 @@ object OItem { _ <- addres match { case AddResult.Success => OptionT.liftF( - store.transact(RTagItem.insertItemTags(item, List(tag.tagId))) + store.transact(RTagItem.setAllTags(item, List(tag.tagId))) ) case AddResult.EntityExists(_) => OptionT.pure[F](0) @@ -371,9 +408,13 @@ object OItem { onSuccessIgnoreError(fts.updateItemName(logger, item, collective, name)) ) - def setState(item: Ident, state: ItemState, collective: Ident): F[AddResult] = + def setStates( + items: NonEmptyList[Ident], + state: ItemState, + collective: Ident + ): F[AddResult] = store - .transact(RItem.updateStateForCollective(item, state, collective)) + .transact(RItem.updateStateForCollective(items, state, collective)) .attempt .map(AddResult.fromUpdate) diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index dcc81815..9a051e7e 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -1927,7 +1927,10 @@ paths: - Item (Multi Edit) summary: Add tags to multiple items description: | - Add the given tags to all given items. + Add the given tags to all given items. The tags that are + currently attached to the items are not changed. If there are + new tags in the given list, then they are added. Otherwise, + the item is left unchanged. security: - authTokenHeader: [] requestBody: @@ -1948,7 +1951,7 @@ paths: summary: Sets tags to multiple items description: | Sets the given tags to all given items. If the tag list is - empty, then tags are removed from the items. + empty, then all tags are removed from the items. security: - authTokenHeader: [] requestBody: diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala index 6d856522..de4dfbfb 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala @@ -73,6 +73,7 @@ object RestServer { "collective" -> CollectiveRoutes(restApp.backend, token), "queue" -> JobQueueRoutes(restApp.backend, token), "item" -> ItemRoutes(cfg, restApp.backend, token), + "items" -> ItemMultiRoutes(restApp.backend, token), "attachment" -> AttachmentRoutes(restApp.backend, token), "upload" -> UploadRoutes.secured(restApp.backend, cfg, token), "checkfile" -> CheckFileRoutes.secured(restApp.backend, token), diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala new file mode 100644 index 00000000..e064c18b --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala @@ -0,0 +1,189 @@ +package docspell.restserver.routes + +import cats.ApplicativeError +import cats.MonadError +import cats.data.NonEmptyList +import cats.effect._ +import cats.implicits._ + +import docspell.backend.BackendApp +import docspell.backend.auth.AuthToken +import docspell.common.{Ident, ItemState} +import docspell.restapi.model._ +import docspell.restserver.conv.Conversions + +import io.circe.DecodingFailure +import org.http4s.HttpRoutes +import org.http4s.circe.CirceEntityDecoder._ +import org.http4s.circe.CirceEntityEncoder._ +import org.http4s.dsl.Http4sDsl + +object ItemMultiRoutes { +// private[this] val logger = getLogger + + def apply[F[_]: Effect]( + backend: BackendApp[F], + user: AuthToken + ): HttpRoutes[F] = { + val dsl = new Http4sDsl[F] {} + import dsl._ + + HttpRoutes.of { + case req @ PUT -> Root / "confirm" => + for { + json <- req.as[IdList] + data <- readIds[F](json.ids) + res <- backend.item.setStates( + data, + ItemState.Confirmed, + user.account.collective + ) + resp <- Ok(Conversions.basicResult(res, "Item data confirmed")) + } yield resp + + case req @ PUT -> Root / "unconfirm" => + for { + json <- req.as[IdList] + data <- readIds[F](json.ids) + res <- backend.item.setStates( + data, + ItemState.Created, + user.account.collective + ) + resp <- Ok(Conversions.basicResult(res, "Item back to created.")) + } yield resp + + case req @ PUT -> Root / "tags" => + for { + json <- req.as[ItemsAndRefs] + items <- readIds[F](json.items) + tags <- json.refs.traverse(readId[F]) + res <- backend.item.setTagsMultipleItems(items, tags, user.account.collective) + resp <- Ok(Conversions.basicResult(res, "Tags updated")) + } yield resp + + case req @ POST -> Root / "tags" => + for { + json <- req.as[ItemsAndRefs] + items <- readIds[F](json.items) + res <- backend.item.linkTagsMultipleItems( + items, + json.refs, + user.account.collective + ) + resp <- Ok(Conversions.basicResult(res, "Tags added.")) + } yield resp + + // case req @ PUT -> Root / "direction" => + // for { + // dir <- req.as[DirectionValue] + // res <- backend.item.setDirection(id, dir.direction, user.account.collective) + // resp <- Ok(Conversions.basicResult(res, "Direction updated")) + // } yield resp + + // case req @ PUT -> Root / "folder" => + // for { + // idref <- req.as[OptionalId] + // res <- backend.item.setFolder(id, idref.id, user.account.collective) + // resp <- Ok(Conversions.basicResult(res, "Folder updated")) + // } yield resp + + // case req @ PUT -> Root / "corrOrg" => + // for { + // idref <- req.as[OptionalId] + // res <- backend.item.setCorrOrg(id, idref.id, user.account.collective) + // resp <- Ok(Conversions.basicResult(res, "Correspondent organization updated")) + // } yield resp + + // case req @ PUT -> Root / "corrPerson" => + // for { + // idref <- req.as[OptionalId] + // res <- backend.item.setCorrPerson(id, idref.id, user.account.collective) + // resp <- Ok(Conversions.basicResult(res, "Correspondent person updated")) + // } yield resp + + // case req @ PUT -> Root / "concPerson" => + // for { + // idref <- req.as[OptionalId] + // res <- backend.item.setConcPerson(id, idref.id, user.account.collective) + // resp <- Ok(Conversions.basicResult(res, "Concerned person updated")) + // } yield resp + + // case req @ PUT -> Root / "concEquipment" => + // for { + // idref <- req.as[OptionalId] + // res <- backend.item.setConcEquip(id, idref.id, user.account.collective) + // resp <- Ok(Conversions.basicResult(res, "Concerned equipment updated")) + // } yield resp + + // case req @ PUT -> Root / "name" => + // for { + // text <- req.as[OptionalText] + // res <- backend.item.setName( + // id, + // text.text.notEmpty.getOrElse(""), + // user.account.collective + // ) + // resp <- Ok(Conversions.basicResult(res, "Name updated")) + // } yield resp + + // case req @ PUT -> Root / "duedate" => + // for { + // date <- req.as[OptionalDate] + // _ <- logger.fdebug(s"Setting item due date to ${date.date}") + // res <- backend.item.setItemDueDate(id, date.date, user.account.collective) + // resp <- Ok(Conversions.basicResult(res, "Item due date updated")) + // } yield resp + + // case req @ PUT -> Root / "date" => + // for { + // date <- req.as[OptionalDate] + // _ <- logger.fdebug(s"Setting item date to ${date.date}") + // res <- backend.item.setItemDate(id, date.date, user.account.collective) + // resp <- Ok(Conversions.basicResult(res, "Item date updated")) + // } yield resp + + // case req @ POST -> Root / "reprocess" => + // for { + // data <- req.as[IdList] + // ids = data.ids.flatMap(s => Ident.fromString(s).toOption) + // _ <- logger.fdebug(s"Re-process item ${id.id}") + // res <- backend.item.reprocess(id, ids, user.account, true) + // resp <- Ok(Conversions.basicResult(res, "Re-process task submitted.")) + // } yield resp + + // case POST -> Root / "deleteAll" => + // for { + // 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 + } + } + + implicit final class OptionString(opt: Option[String]) { + def notEmpty: Option[String] = + opt.map(_.trim).filter(_.nonEmpty) + } + + private def readId[F[_]]( + id: String + )(implicit F: ApplicativeError[F, Throwable]): F[Ident] = + Ident + .fromString(id) + .fold( + err => F.raiseError(DecodingFailure(err, Nil)), + F.pure + ) + + private def readIds[F[_]](ids: List[String])(implicit + F: MonadError[F, Throwable] + ): F[NonEmptyList[Ident]] = + ids.traverse(readId[F]).map(NonEmptyList.fromList).flatMap { + case Some(nel) => nel.pure[F] + case None => + F.raiseError( + DecodingFailure("Empty list found, at least one element required", Nil) + ) + } +} 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 87253163..4dec4d6c 100644 --- a/modules/store/src/main/scala/docspell/store/impl/Column.scala +++ b/modules/store/src/main/scala/docspell/store/impl/Column.scala @@ -57,7 +57,12 @@ case class Column(name: String, ns: String = "", alias: String = "") { f ++ fr"IN (" ++ commas(values) ++ fr")" def isIn[A: Put](values: NonEmptyList[A]): Fragment = - isIn(values.map(a => sql"$a").toList) + values.tail match { + case Nil => + is(values.head) + case _ => + isIn(values.map(a => sql"$a").toList) + } def isLowerIn[A: Put](values: NonEmptyList[A]): Fragment = fr"lower(" ++ f ++ fr") IN (" ++ commas(values.map(a => sql"$a").toList) ++ fr")" diff --git a/modules/store/src/main/scala/docspell/store/records/RItem.scala b/modules/store/src/main/scala/docspell/store/records/RItem.scala index a0025ddb..fddf12e3 100644 --- a/modules/store/src/main/scala/docspell/store/records/RItem.scala +++ b/modules/store/src/main/scala/docspell/store/records/RItem.scala @@ -132,7 +132,7 @@ object RItem { } yield n def updateStateForCollective( - itemId: Ident, + itemIds: NonEmptyList[Ident], itemState: ItemState, coll: Ident ): ConnectionIO[Int] = @@ -140,7 +140,7 @@ object RItem { t <- currentTime n <- updateRow( table, - and(id.is(itemId), cid.is(coll)), + and(id.isIn(itemIds), cid.is(coll)), commas(state.setTo(itemState), updated.setTo(t)) ).update.run } yield n @@ -324,4 +324,10 @@ object RItem { val empty: Option[Ident] = None updateRow(table, folder.is(folderId), folder.setTo(empty)).update.run } + + def filterItemsFragment(items: NonEmptyList[Ident], coll: Ident): Fragment = + selectSimple(Seq(id), table, and(cid.is(coll), id.isIn(items))) + + def filterItems(items: NonEmptyList[Ident], coll: Ident): ConnectionIO[Vector[Ident]] = + filterItemsFragment(items, coll).query[Ident].to[Vector] } diff --git a/modules/store/src/main/scala/docspell/store/records/RTagItem.scala b/modules/store/src/main/scala/docspell/store/records/RTagItem.scala index 706e64b4..c9aad9db 100644 --- a/modules/store/src/main/scala/docspell/store/records/RTagItem.scala +++ b/modules/store/src/main/scala/docspell/store/records/RTagItem.scala @@ -30,18 +30,17 @@ object RTagItem { def deleteItemTags(item: Ident): ConnectionIO[Int] = deleteFrom(table, itemId.is(item)).update.run + def deleteItemTags(items: NonEmptyList[Ident], cid: Ident): ConnectionIO[Int] = { + val itemsFiltered = + RItem.filterItemsFragment(items, cid) + val sql = fr"DELETE FROM" ++ table ++ fr"WHERE" ++ itemId.isIn(itemsFiltered) + + sql.update.run + } + def deleteTag(tid: Ident): ConnectionIO[Int] = deleteFrom(table, tagId.is(tid)).update.run - def insertItemTags(item: Ident, tags: Seq[Ident]): ConnectionIO[Int] = - for { - tagValues <- tags.toList.traverse(id => - Ident.randomId[ConnectionIO].map(rid => RTagItem(rid, item, id)) - ) - tagFrag = tagValues.map(v => fr"${v.tagItemId},${v.itemId},${v.tagId}") - ins <- insertRows(table, all, tagFrag).update.run - } yield ins - def findByItem(item: Ident): ConnectionIO[Vector[RTagItem]] = selectSimple(all, table, itemId.is(item)).query[RTagItem].to[Vector] @@ -76,4 +75,12 @@ object RTagItem { entities.map(v => fr"${v.tagItemId},${v.itemId},${v.tagId}") ).update.run } yield n + + def appendTags(item: Ident, tags: List[Ident]): ConnectionIO[Int] = + for { + existing <- findByItem(item) + toadd = tags.toSet.diff(existing.map(_.tagId).toSet) + n <- setAllTags(item, toadd.toSeq) + } yield n + } diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index c90865ab..dc648f75 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -5,6 +5,7 @@ module Api exposing , addCorrPerson , addMember , addTag + , addTagsMultiple , cancelJob , changeFolderName , changePassword @@ -88,6 +89,7 @@ module Api exposing , setItemNotes , setJobPrio , setTags + , setTagsMultiple , setUnconfirmed , startClassifier , startOnceNotifyDueItems @@ -130,6 +132,7 @@ import Api.Model.ItemLightList exposing (ItemLightList) import Api.Model.ItemProposals exposing (ItemProposals) import Api.Model.ItemSearch exposing (ItemSearch) import Api.Model.ItemUploadMeta exposing (ItemUploadMeta) +import Api.Model.ItemsAndRefs exposing (ItemsAndRefs) import Api.Model.JobPriority exposing (JobPriority) import Api.Model.JobQueueState exposing (JobQueueState) import Api.Model.MoveAttachment exposing (MoveAttachment) @@ -1262,6 +1265,38 @@ getJobQueueStateTask flags = +--- Item (Mulit Edit) + + +setTagsMultiple : + Flags + -> ItemsAndRefs + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +setTagsMultiple flags data receive = + Http2.authPut + { url = flags.config.baseUrl ++ "/api/v1/sec/items/tags" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ItemsAndRefs.encode data) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +addTagsMultiple : + Flags + -> ItemsAndRefs + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +addTagsMultiple flags data receive = + Http2.authPost + { url = flags.config.baseUrl ++ "/api/v1/sec/items/tags" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ItemsAndRefs.encode data) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + + --- Item diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm index 4cb0c91a..4a483c44 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm @@ -40,5 +40,12 @@ multiUpdate flags ids change receive = Set.toList ids in case change of + TagChange tags -> + let + data = + ItemsAndRefs items (List.map .id tags.items) + in + Api.setTagsMultiple flags data receive + _ -> Cmd.none diff --git a/modules/webapp/src/main/elm/Page/Home/Update.elm b/modules/webapp/src/main/elm/Page/Home/Update.elm index 05a8259b..9d0884ea 100644 --- a/modules/webapp/src/main/elm/Page/Home/Update.elm +++ b/modules/webapp/src/main/elm/Page/Home/Update.elm @@ -435,7 +435,10 @@ update mId key flags settings msg model = res.change MultiUpdateResp in - ( { model | viewMode = SelectView svm_ }, Cmd.batch [ cmd_, upCmd ], sub_ ) + ( { model | viewMode = SelectView svm_ } + , Cmd.batch [ cmd_, upCmd ] + , sub_ + ) _ -> noSub ( model, Cmd.none ) From 17472fa4caca7135a74760eabae0c513e4d415ef Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 26 Oct 2020 12:17:55 +0100 Subject: [PATCH 09/22] Edit name of multiple items --- .../scala/docspell/backend/ops/OItem.scala | 87 +++++++++++++------ .../restserver/routes/ItemMultiRoutes.scala | 28 +++--- modules/webapp/src/main/elm/Api.elm | 16 ++++ .../main/elm/Comp/ItemDetail/FormChange.elm | 8 ++ 4 files changed, 100 insertions(+), 39 deletions(-) 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 0e855320..ed235531 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala @@ -51,7 +51,7 @@ trait OItem[F[_]] { def setDirection(item: Ident, direction: Direction, collective: Ident): F[AddResult] - def setFolder(item: Ident, folder: Option[Ident], collective: Ident): F[AddResult] + def setFolder(item: Ident, folder: Option[Ident], collective: Ident): F[UpdateResult] def setCorrOrg(item: Ident, org: Option[Ident], collective: Ident): F[AddResult] @@ -69,9 +69,15 @@ trait OItem[F[_]] { def addConcEquip(item: Ident, equip: REquipment): F[AddResult] - def setNotes(item: Ident, notes: Option[String], collective: Ident): F[AddResult] + def setNotes(item: Ident, notes: Option[String], collective: Ident): F[UpdateResult] - def setName(item: Ident, name: String, collective: Ident): F[AddResult] + def setName(item: Ident, name: String, collective: Ident): F[UpdateResult] + + def setNameMultiple( + items: NonEmptyList[Ident], + name: String, + collective: Ident + ): F[UpdateResult] def setState(item: Ident, state: ItemState, collective: Ident): F[AddResult] = setStates(NonEmptyList.of(item), state, collective) @@ -102,7 +108,7 @@ trait OItem[F[_]] { attachId: Ident, name: Option[String], collective: Ident - ): F[AddResult] + ): F[UpdateResult] /** Submits the item for re-processing. The list of attachment ids can * be used to only re-process a subset of the item's attachments. @@ -253,11 +259,12 @@ object OItem { item: Ident, folder: Option[Ident], collective: Ident - ): F[AddResult] = - store - .transact(RItem.updateFolder(item, collective, folder)) - .attempt - .map(AddResult.fromUpdate) + ): F[UpdateResult] = + UpdateResult + .fromUpdate( + store + .transact(RItem.updateFolder(item, collective, folder)) + ) .flatTap( onSuccessIgnoreError(fts.updateFolder(logger, item, collective, folder)) ) @@ -390,24 +397,47 @@ object OItem { item: Ident, notes: Option[String], collective: Ident - ): F[AddResult] = - store - .transact(RItem.updateNotes(item, collective, notes)) - .attempt - .map(AddResult.fromUpdate) + ): F[UpdateResult] = + UpdateResult + .fromUpdate( + store + .transact(RItem.updateNotes(item, collective, notes)) + ) .flatTap( onSuccessIgnoreError(fts.updateItemNotes(logger, item, collective, notes)) ) - def setName(item: Ident, name: String, collective: Ident): F[AddResult] = - store - .transact(RItem.updateName(item, collective, name)) - .attempt - .map(AddResult.fromUpdate) + def setName(item: Ident, name: String, collective: Ident): F[UpdateResult] = + UpdateResult + .fromUpdate( + store + .transact(RItem.updateName(item, collective, name)) + ) .flatTap( onSuccessIgnoreError(fts.updateItemName(logger, item, collective, name)) ) + def setNameMultiple( + items: NonEmptyList[Ident], + name: String, + collective: Ident + ): F[UpdateResult] = + for { + results <- items.traverse(i => setName(i, name, collective)) + err <- results.traverse { + case UpdateResult.NotFound => + logger.info("An item was not found when updating the name") *> 0.pure[F] + case UpdateResult.Failure(err) => + logger.error(err)("An item failed to update its name") *> 1.pure[F] + case UpdateResult.Success => + 0.pure[F] + } + res = + if (results.size == err.fold) + UpdateResult.failure(new Exception("All items failed to update")) + else UpdateResult.success + } yield res + def setStates( items: NonEmptyList[Ident], state: ItemState, @@ -455,11 +485,12 @@ object OItem { attachId: Ident, name: Option[String], collective: Ident - ): F[AddResult] = - store - .transact(RAttachment.updateName(attachId, collective, name)) - .attempt - .map(AddResult.fromUpdate) + ): F[UpdateResult] = + UpdateResult + .fromUpdate( + store + .transact(RAttachment.updateName(attachId, collective, name)) + ) .flatTap( onSuccessIgnoreError( OptionT(store.transact(RAttachment.findItemId(attachId))) @@ -499,17 +530,17 @@ object OItem { _ <- if (notifyJoex) joex.notifyAllNodes else ().pure[F] } yield UpdateResult.success - private def onSuccessIgnoreError(update: F[Unit])(ar: AddResult): F[Unit] = + private def onSuccessIgnoreError(update: F[Unit])(ar: UpdateResult): F[Unit] = ar match { - case AddResult.Success => + case UpdateResult.Success => update.attempt.flatMap { case Right(()) => ().pure[F] case Left(ex) => logger.warn(s"Error updating full-text index: ${ex.getMessage}") } - case AddResult.Failure(_) => + case UpdateResult.Failure(_) => ().pure[F] - case AddResult.EntityExists(_) => + case UpdateResult.NotFound => ().pure[F] } }) diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala index e064c18b..9b4a0441 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala @@ -74,6 +74,19 @@ object ItemMultiRoutes { resp <- Ok(Conversions.basicResult(res, "Tags added.")) } yield resp + case req @ PUT -> Root / "name" => + for { + json <- req.as[ItemsAndName] + items <- readIds[F](json.items) + res <- backend.item.setNameMultiple( + items, + json.name.notEmpty.getOrElse(""), + user.account.collective + ) + resp <- Ok(Conversions.basicResult(res, "Name updated")) + } yield resp + + // case req @ PUT -> Root / "direction" => // for { // dir <- req.as[DirectionValue] @@ -116,17 +129,6 @@ object ItemMultiRoutes { // resp <- Ok(Conversions.basicResult(res, "Concerned equipment updated")) // } yield resp - // case req @ PUT -> Root / "name" => - // for { - // text <- req.as[OptionalText] - // res <- backend.item.setName( - // id, - // text.text.notEmpty.getOrElse(""), - // user.account.collective - // ) - // resp <- Ok(Conversions.basicResult(res, "Name updated")) - // } yield resp - // case req @ PUT -> Root / "duedate" => // for { // date <- req.as[OptionalDate] @@ -165,6 +167,10 @@ object ItemMultiRoutes { def notEmpty: Option[String] = opt.map(_.trim).filter(_.nonEmpty) } + implicit final class StringOps(str: String) { + def notEmpty: Option[String] = + Option(str).notEmpty + } private def readId[F[_]]( id: String diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index dc648f75..6811a90f 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -88,6 +88,7 @@ module Api exposing , setItemName , setItemNotes , setJobPrio + , setNameMultiple , setTags , setTagsMultiple , setUnconfirmed @@ -132,6 +133,7 @@ import Api.Model.ItemLightList exposing (ItemLightList) import Api.Model.ItemProposals exposing (ItemProposals) import Api.Model.ItemSearch exposing (ItemSearch) import Api.Model.ItemUploadMeta exposing (ItemUploadMeta) +import Api.Model.ItemsAndName exposing (ItemsAndName) import Api.Model.ItemsAndRefs exposing (ItemsAndRefs) import Api.Model.JobPriority exposing (JobPriority) import Api.Model.JobQueueState exposing (JobQueueState) @@ -1296,6 +1298,20 @@ addTagsMultiple flags data receive = } +setNameMultiple : + Flags + -> ItemsAndName + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +setNameMultiple flags data receive = + Http2.authPut + { url = flags.config.baseUrl ++ "/api/v1/sec/items/name" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ItemsAndName.encode data) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + --- Item diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm index 4a483c44..1768f310 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm @@ -6,6 +6,7 @@ module Comp.ItemDetail.FormChange exposing import Api import Api.Model.BasicResult exposing (BasicResult) import Api.Model.IdName exposing (IdName) +import Api.Model.ItemsAndName exposing (ItemsAndName) import Api.Model.ItemsAndRefs exposing (ItemsAndRefs) import Api.Model.ReferenceList exposing (ReferenceList) import Data.Direction exposing (Direction) @@ -47,5 +48,12 @@ multiUpdate flags ids change receive = in Api.setTagsMultiple flags data receive + NameChange name -> + let + data = + ItemsAndName items name + in + Api.setNameMultiple flags data receive + _ -> Cmd.none From 42c989a6cd17edc4a3335eb7758cc6197355d338 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 26 Oct 2020 12:39:44 +0100 Subject: [PATCH 10/22] Edit folder of multiple items --- .../scala/docspell/backend/ops/OItem.scala | 27 +++++++++++++++++++ .../restserver/routes/ItemMultiRoutes.scala | 16 +++++------ modules/webapp/src/main/elm/Api.elm | 16 +++++++++++ .../main/elm/Comp/ItemDetail/FormChange.elm | 8 ++++++ 4 files changed, 59 insertions(+), 8 deletions(-) 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 ed235531..f8e8b4a7 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala @@ -53,6 +53,12 @@ trait OItem[F[_]] { def setFolder(item: Ident, folder: Option[Ident], collective: Ident): F[UpdateResult] + def setFolderMultiple( + items: NonEmptyList[Ident], + folder: Option[Ident], + collective: Ident + ): F[UpdateResult] + def setCorrOrg(item: Ident, org: Option[Ident], collective: Ident): F[AddResult] def addCorrOrg(item: Ident, org: OOrganization.OrgAndContacts): F[AddResult] @@ -269,6 +275,27 @@ object OItem { onSuccessIgnoreError(fts.updateFolder(logger, item, collective, folder)) ) + def setFolderMultiple( + items: NonEmptyList[Ident], + folder: Option[Ident], + collective: Ident + ): F[UpdateResult] = + for { + results <- items.traverse(i => setFolder(i, folder, collective)) + err <- results.traverse { + case UpdateResult.NotFound => + logger.info("An item was not found when updating the folder") *> 0.pure[F] + case UpdateResult.Failure(err) => + logger.error(err)("An item failed to update its folder") *> 1.pure[F] + case UpdateResult.Success => + 0.pure[F] + } + res = + if (results.size == err.fold) + UpdateResult.failure(new Exception("All items failed to update")) + else UpdateResult.success + } yield res + def setCorrOrg(item: Ident, org: Option[Ident], collective: Ident): F[AddResult] = store .transact(RItem.updateCorrOrg(item, collective, org)) diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala index 9b4a0441..2c691c80 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala @@ -76,7 +76,7 @@ object ItemMultiRoutes { case req @ PUT -> Root / "name" => for { - json <- req.as[ItemsAndName] + json <- req.as[ItemsAndName] items <- readIds[F](json.items) res <- backend.item.setNameMultiple( items, @@ -86,6 +86,13 @@ object ItemMultiRoutes { resp <- Ok(Conversions.basicResult(res, "Name updated")) } yield resp + case req @ PUT -> Root / "folder" => + for { + json <- req.as[ItemsAndRef] + items <- readIds[F](json.items) + res <- backend.item.setFolderMultiple(items, json.ref, user.account.collective) + resp <- Ok(Conversions.basicResult(res, "Folder updated")) + } yield resp // case req @ PUT -> Root / "direction" => // for { @@ -94,13 +101,6 @@ object ItemMultiRoutes { // resp <- Ok(Conversions.basicResult(res, "Direction updated")) // } yield resp - // case req @ PUT -> Root / "folder" => - // for { - // idref <- req.as[OptionalId] - // res <- backend.item.setFolder(id, idref.id, user.account.collective) - // resp <- Ok(Conversions.basicResult(res, "Folder updated")) - // } yield resp - // case req @ PUT -> Root / "corrOrg" => // for { // idref <- req.as[OptionalId] diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index 6811a90f..077975ca 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -83,6 +83,7 @@ module Api exposing , setCorrPerson , setDirection , setFolder + , setFolderMultiple , setItemDate , setItemDueDate , setItemName @@ -134,6 +135,7 @@ import Api.Model.ItemProposals exposing (ItemProposals) import Api.Model.ItemSearch exposing (ItemSearch) import Api.Model.ItemUploadMeta exposing (ItemUploadMeta) import Api.Model.ItemsAndName exposing (ItemsAndName) +import Api.Model.ItemsAndRef exposing (ItemsAndRef) import Api.Model.ItemsAndRefs exposing (ItemsAndRefs) import Api.Model.JobPriority exposing (JobPriority) import Api.Model.JobQueueState exposing (JobQueueState) @@ -1312,6 +1314,20 @@ setNameMultiple flags data receive = } +setFolderMultiple : + Flags + -> ItemsAndRef + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +setFolderMultiple flags data receive = + Http2.authPut + { url = flags.config.baseUrl ++ "/api/v1/sec/items/folder" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ItemsAndRef.encode data) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + --- Item diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm index 1768f310..8ac01514 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm @@ -7,6 +7,7 @@ import Api import Api.Model.BasicResult exposing (BasicResult) import Api.Model.IdName exposing (IdName) import Api.Model.ItemsAndName exposing (ItemsAndName) +import Api.Model.ItemsAndRef exposing (ItemsAndRef) import Api.Model.ItemsAndRefs exposing (ItemsAndRefs) import Api.Model.ReferenceList exposing (ReferenceList) import Data.Direction exposing (Direction) @@ -55,5 +56,12 @@ multiUpdate flags ids change receive = in Api.setNameMultiple flags data receive + FolderChange id -> + let + data = + ItemsAndRef items (Maybe.map .id id) + in + Api.setFolderMultiple flags data receive + _ -> Cmd.none From d4043634ac42e68228d147a52d63c010f9ceff6f Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 26 Oct 2020 12:48:15 +0100 Subject: [PATCH 11/22] Edit direction of multiple items --- .../scala/docspell/backend/ops/OItem.scala | 18 +++++++++++------- .../restserver/routes/ItemMultiRoutes.scala | 13 +++++++------ .../restserver/routes/ItemRoutes.scala | 9 +++++++-- .../scala/docspell/store/records/RItem.scala | 8 ++++++-- modules/webapp/src/main/elm/Api.elm | 16 ++++++++++++++++ .../main/elm/Comp/ItemDetail/FormChange.elm | 8 ++++++++ 6 files changed, 55 insertions(+), 17 deletions(-) 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 f8e8b4a7..e9fb35b9 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala @@ -49,7 +49,11 @@ trait OItem[F[_]] { /** Toggles tags of the given item. Tags must exist, but can be IDs or names. */ def toggleTags(item: Ident, tags: List[String], collective: Ident): F[UpdateResult] - def setDirection(item: Ident, direction: Direction, collective: Ident): F[AddResult] + def setDirection( + item: NonEmptyList[Ident], + direction: Direction, + collective: Ident + ): F[UpdateResult] def setFolder(item: Ident, folder: Option[Ident], collective: Ident): F[UpdateResult] @@ -252,14 +256,14 @@ object OItem { .getOrElse(AddResult.Failure(new Exception("Collective mismatch"))) def setDirection( - item: Ident, + items: NonEmptyList[Ident], direction: Direction, collective: Ident - ): F[AddResult] = - store - .transact(RItem.updateDirection(item, collective, direction)) - .attempt - .map(AddResult.fromUpdate) + ): F[UpdateResult] = + UpdateResult.fromUpdate( + store + .transact(RItem.updateDirection(items, collective, direction)) + ) def setFolder( item: Ident, diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala index 2c691c80..4114b9bd 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala @@ -94,12 +94,13 @@ object ItemMultiRoutes { resp <- Ok(Conversions.basicResult(res, "Folder updated")) } yield resp - // case req @ PUT -> Root / "direction" => - // for { - // dir <- req.as[DirectionValue] - // res <- backend.item.setDirection(id, dir.direction, user.account.collective) - // resp <- Ok(Conversions.basicResult(res, "Direction updated")) - // } yield resp + case req @ PUT -> Root / "direction" => + for { + json <- req.as[ItemsAndDirection] + items <- readIds[F](json.items) + res <- backend.item.setDirection(items, json.direction, user.account.collective) + resp <- Ok(Conversions.basicResult(res, "Direction updated")) + } yield resp // case req @ PUT -> Root / "corrOrg" => // for { 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 a033791d..3cbb0c3f 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala @@ -1,5 +1,6 @@ package docspell.restserver.routes +import cats.data.NonEmptyList import cats.effect._ import cats.implicits._ @@ -165,8 +166,12 @@ object ItemRoutes { case req @ PUT -> Root / Ident(id) / "direction" => for { - dir <- req.as[DirectionValue] - res <- backend.item.setDirection(id, dir.direction, user.account.collective) + dir <- req.as[DirectionValue] + res <- backend.item.setDirection( + NonEmptyList.of(id), + dir.direction, + user.account.collective + ) resp <- Ok(Conversions.basicResult(res, "Direction updated")) } yield resp diff --git a/modules/store/src/main/scala/docspell/store/records/RItem.scala b/modules/store/src/main/scala/docspell/store/records/RItem.scala index fddf12e3..88599a4f 100644 --- a/modules/store/src/main/scala/docspell/store/records/RItem.scala +++ b/modules/store/src/main/scala/docspell/store/records/RItem.scala @@ -145,12 +145,16 @@ object RItem { ).update.run } yield n - def updateDirection(itemId: Ident, coll: Ident, dir: Direction): ConnectionIO[Int] = + def updateDirection( + itemIds: NonEmptyList[Ident], + coll: Ident, + dir: Direction + ): ConnectionIO[Int] = for { t <- currentTime n <- updateRow( table, - and(id.is(itemId), cid.is(coll)), + and(id.isIn(itemIds), cid.is(coll)), commas(incoming.setTo(dir), updated.setTo(t)) ).update.run } yield n diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index 077975ca..bb0ad81b 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -82,6 +82,7 @@ module Api exposing , setCorrOrg , setCorrPerson , setDirection + , setDirectionMultiple , setFolder , setFolderMultiple , setItemDate @@ -134,6 +135,7 @@ import Api.Model.ItemLightList exposing (ItemLightList) import Api.Model.ItemProposals exposing (ItemProposals) import Api.Model.ItemSearch exposing (ItemSearch) import Api.Model.ItemUploadMeta exposing (ItemUploadMeta) +import Api.Model.ItemsAndDirection exposing (ItemsAndDirection) import Api.Model.ItemsAndName exposing (ItemsAndName) import Api.Model.ItemsAndRef exposing (ItemsAndRef) import Api.Model.ItemsAndRefs exposing (ItemsAndRefs) @@ -1328,6 +1330,20 @@ setFolderMultiple flags data receive = } +setDirectionMultiple : + Flags + -> ItemsAndDirection + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +setDirectionMultiple flags data receive = + Http2.authPut + { url = flags.config.baseUrl ++ "/api/v1/sec/items/direction" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ItemsAndDirection.encode data) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + --- Item diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm index 8ac01514..53f491a7 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm @@ -6,6 +6,7 @@ module Comp.ItemDetail.FormChange exposing import Api import Api.Model.BasicResult exposing (BasicResult) import Api.Model.IdName exposing (IdName) +import Api.Model.ItemsAndDirection exposing (ItemsAndDirection) import Api.Model.ItemsAndName exposing (ItemsAndName) import Api.Model.ItemsAndRef exposing (ItemsAndRef) import Api.Model.ItemsAndRefs exposing (ItemsAndRefs) @@ -63,5 +64,12 @@ multiUpdate flags ids change receive = in Api.setFolderMultiple flags data receive + DirectionChange dir -> + let + data = + ItemsAndDirection items (Data.Direction.toString dir) + in + Api.setDirectionMultiple flags data receive + _ -> Cmd.none From 7ade7dd70b10034bdda9671a44909f5bbf720e45 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 26 Oct 2020 12:49:30 +0100 Subject: [PATCH 12/22] Display direction icon when editing multiple items --- modules/webapp/src/main/elm/Comp/ItemCardList.elm | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/webapp/src/main/elm/Comp/ItemCardList.elm b/modules/webapp/src/main/elm/Comp/ItemCardList.elm index 7b0614c4..b2b110ec 100644 --- a/modules/webapp/src/main/elm/Comp/ItemCardList.elm +++ b/modules/webapp/src/main/elm/Comp/ItemCardList.elm @@ -235,6 +235,7 @@ viewItem cfg settings item = Data.ItemSelection.Active ids -> div [ class "header" ] [ Util.Html.checkbox (Set.member item.id ids) + , dirIcon , Util.String.underscoreToSpace item.name |> text ] From 2e6026b817ef9c4d70a845bb8e1a841757bd935d Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 26 Oct 2020 13:16:03 +0100 Subject: [PATCH 13/22] Edit dates of multiple items --- .../scala/docspell/backend/ops/OItem.scala | 38 ++++++++++--------- .../docspell/joex/process/LinkProposal.scala | 13 ++++++- .../restserver/routes/ItemMultiRoutes.scala | 32 ++++++++-------- .../restserver/routes/ItemRoutes.scala | 12 +++++- .../scala/docspell/store/records/RItem.scala | 30 ++++++++------- modules/webapp/src/main/elm/Api.elm | 31 +++++++++++++++ .../main/elm/Comp/ItemDetail/FormChange.elm | 15 ++++++++ 7 files changed, 121 insertions(+), 50 deletions(-) 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 e9fb35b9..5d1e816c 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala @@ -98,13 +98,17 @@ trait OItem[F[_]] { collective: Ident ): F[AddResult] - def setItemDate(item: Ident, date: Option[Timestamp], collective: Ident): F[AddResult] - - def setItemDueDate( - item: Ident, + def setItemDate( + item: NonEmptyList[Ident], date: Option[Timestamp], collective: Ident - ): F[AddResult] + ): F[UpdateResult] + + def setItemDueDate( + item: NonEmptyList[Ident], + date: Option[Timestamp], + collective: Ident + ): F[UpdateResult] def getProposals(item: Ident, collective: Ident): F[MetaProposalList] @@ -480,24 +484,24 @@ object OItem { .map(AddResult.fromUpdate) def setItemDate( - item: Ident, + items: NonEmptyList[Ident], date: Option[Timestamp], collective: Ident - ): F[AddResult] = - store - .transact(RItem.updateDate(item, collective, date)) - .attempt - .map(AddResult.fromUpdate) + ): F[UpdateResult] = + UpdateResult.fromUpdate( + store + .transact(RItem.updateDate(items, collective, date)) + ) def setItemDueDate( - item: Ident, + items: NonEmptyList[Ident], date: Option[Timestamp], collective: Ident - ): F[AddResult] = - store - .transact(RItem.updateDueDate(item, collective, date)) - .attempt - .map(AddResult.fromUpdate) + ): F[UpdateResult] = + UpdateResult.fromUpdate( + store + .transact(RItem.updateDueDate(items, collective, date)) + ) def deleteItem(itemId: Ident, collective: Ident): F[Int] = QItem diff --git a/modules/joex/src/main/scala/docspell/joex/process/LinkProposal.scala b/modules/joex/src/main/scala/docspell/joex/process/LinkProposal.scala index fde5fcd3..8d4fc493 100644 --- a/modules/joex/src/main/scala/docspell/joex/process/LinkProposal.scala +++ b/modules/joex/src/main/scala/docspell/joex/process/LinkProposal.scala @@ -1,5 +1,6 @@ package docspell.joex.process +import cats.data.NonEmptyList import cats.effect.Sync import cats.implicits._ @@ -88,7 +89,11 @@ object LinkProposal { val ts = Timestamp.from(ld.atStartOfDay(Timestamp.UTC)) ctx.logger.debug(s"Updating item date ${value.id}") *> ctx.store.transact( - RItem.updateDate(itemId, ctx.args.meta.collective, Some(ts)) + RItem.updateDate( + NonEmptyList.of(itemId), + ctx.args.meta.collective, + Some(ts) + ) ) case None => ctx.logger.info(s"Cannot read value '${value.id}' into a date.") *> @@ -100,7 +105,11 @@ object LinkProposal { val ts = Timestamp.from(ld.atStartOfDay(Timestamp.UTC)) ctx.logger.debug(s"Updating item due-date suggestion ${value.id}") *> ctx.store.transact( - RItem.updateDueDate(itemId, ctx.args.meta.collective, Some(ts)) + RItem.updateDueDate( + NonEmptyList.of(itemId), + ctx.args.meta.collective, + Some(ts) + ) ) case None => ctx.logger.info(s"Cannot read value '${value.id}' into a date.") *> diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala index 4114b9bd..6239d0a3 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala @@ -102,6 +102,22 @@ object ItemMultiRoutes { resp <- Ok(Conversions.basicResult(res, "Direction updated")) } yield resp + case req @ PUT -> Root / "date" => + for { + json <- req.as[ItemsAndDate] + items <- readIds[F](json.items) + res <- backend.item.setItemDate(items, json.date, user.account.collective) + resp <- Ok(Conversions.basicResult(res, "Item date updated")) + } yield resp + + case req @ PUT -> Root / "duedate" => + for { + json <- req.as[ItemsAndDate] + items <- readIds[F](json.items) + res <- backend.item.setItemDueDate(items, json.date, user.account.collective) + resp <- Ok(Conversions.basicResult(res, "Item due date updated")) + } yield resp + // case req @ PUT -> Root / "corrOrg" => // for { // idref <- req.as[OptionalId] @@ -130,22 +146,6 @@ object ItemMultiRoutes { // resp <- Ok(Conversions.basicResult(res, "Concerned equipment updated")) // } yield resp - // case req @ PUT -> Root / "duedate" => - // for { - // date <- req.as[OptionalDate] - // _ <- logger.fdebug(s"Setting item due date to ${date.date}") - // res <- backend.item.setItemDueDate(id, date.date, user.account.collective) - // resp <- Ok(Conversions.basicResult(res, "Item due date updated")) - // } yield resp - - // case req @ PUT -> Root / "date" => - // for { - // date <- req.as[OptionalDate] - // _ <- logger.fdebug(s"Setting item date to ${date.date}") - // res <- backend.item.setItemDate(id, date.date, user.account.collective) - // resp <- Ok(Conversions.basicResult(res, "Item date updated")) - // } yield resp - // case req @ POST -> Root / "reprocess" => // for { // data <- req.as[IdList] 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 3cbb0c3f..9fa473cf 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala @@ -264,7 +264,11 @@ object ItemRoutes { for { date <- req.as[OptionalDate] _ <- logger.fdebug(s"Setting item due date to ${date.date}") - res <- backend.item.setItemDueDate(id, date.date, user.account.collective) + res <- backend.item.setItemDueDate( + NonEmptyList.of(id), + date.date, + user.account.collective + ) resp <- Ok(Conversions.basicResult(res, "Item due date updated")) } yield resp @@ -272,7 +276,11 @@ object ItemRoutes { for { date <- req.as[OptionalDate] _ <- logger.fdebug(s"Setting item date to ${date.date}") - res <- backend.item.setItemDate(id, date.date, user.account.collective) + res <- backend.item.setItemDate( + NonEmptyList.of(id), + date.date, + user.account.collective + ) resp <- Ok(Conversions.basicResult(res, "Item date updated")) } yield resp diff --git a/modules/store/src/main/scala/docspell/store/records/RItem.scala b/modules/store/src/main/scala/docspell/store/records/RItem.scala index 88599a4f..dbb15dfd 100644 --- a/modules/store/src/main/scala/docspell/store/records/RItem.scala +++ b/modules/store/src/main/scala/docspell/store/records/RItem.scala @@ -285,18 +285,8 @@ object RItem { ).update.run } yield n - def updateDate(itemId: Ident, coll: Ident, date: Option[Timestamp]): ConnectionIO[Int] = - for { - t <- currentTime - n <- updateRow( - table, - and(id.is(itemId), cid.is(coll)), - commas(itemDate.setTo(date), updated.setTo(t)) - ).update.run - } yield n - - def updateDueDate( - itemId: Ident, + def updateDate( + itemIds: NonEmptyList[Ident], coll: Ident, date: Option[Timestamp] ): ConnectionIO[Int] = @@ -304,7 +294,21 @@ object RItem { t <- currentTime n <- updateRow( table, - and(id.is(itemId), cid.is(coll)), + and(id.isIn(itemIds), cid.is(coll)), + commas(itemDate.setTo(date), updated.setTo(t)) + ).update.run + } yield n + + def updateDueDate( + itemIds: NonEmptyList[Ident], + coll: Ident, + date: Option[Timestamp] + ): ConnectionIO[Int] = + for { + t <- currentTime + n <- updateRow( + table, + and(id.isIn(itemIds), cid.is(coll)), commas(dueDate.setTo(date), updated.setTo(t)) ).update.run } yield n diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index bb0ad81b..39f36b7e 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -81,8 +81,10 @@ module Api exposing , setConfirmed , setCorrOrg , setCorrPerson + , setDateMultiple , setDirection , setDirectionMultiple + , setDueDateMultiple , setFolder , setFolderMultiple , setItemDate @@ -135,6 +137,7 @@ import Api.Model.ItemLightList exposing (ItemLightList) import Api.Model.ItemProposals exposing (ItemProposals) import Api.Model.ItemSearch exposing (ItemSearch) import Api.Model.ItemUploadMeta exposing (ItemUploadMeta) +import Api.Model.ItemsAndDate exposing (ItemsAndDate) import Api.Model.ItemsAndDirection exposing (ItemsAndDirection) import Api.Model.ItemsAndName exposing (ItemsAndName) import Api.Model.ItemsAndRef exposing (ItemsAndRef) @@ -1344,6 +1347,34 @@ setDirectionMultiple flags data receive = } +setDateMultiple : + Flags + -> ItemsAndDate + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +setDateMultiple flags data receive = + Http2.authPut + { url = flags.config.baseUrl ++ "/api/v1/sec/items/date" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ItemsAndDate.encode data) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +setDueDateMultiple : + Flags + -> ItemsAndDate + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +setDueDateMultiple flags data receive = + Http2.authPut + { url = flags.config.baseUrl ++ "/api/v1/sec/items/duedate" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ItemsAndDate.encode data) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + --- Item diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm index 53f491a7..5edd9da8 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm @@ -6,6 +6,7 @@ module Comp.ItemDetail.FormChange exposing import Api import Api.Model.BasicResult exposing (BasicResult) import Api.Model.IdName exposing (IdName) +import Api.Model.ItemsAndDate exposing (ItemsAndDate) import Api.Model.ItemsAndDirection exposing (ItemsAndDirection) import Api.Model.ItemsAndName exposing (ItemsAndName) import Api.Model.ItemsAndRef exposing (ItemsAndRef) @@ -71,5 +72,19 @@ multiUpdate flags ids change receive = in Api.setDirectionMultiple flags data receive + ItemDateChange date -> + let + data = + ItemsAndDate items date + in + Api.setDateMultiple flags data receive + + DueDateChange date -> + let + data = + ItemsAndDate items date + in + Api.setDueDateMultiple flags data receive + _ -> Cmd.none From 26e89bf84eb7d322d8b95cf8ef9517829df58948 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 26 Oct 2020 13:35:47 +0100 Subject: [PATCH 14/22] Edit org/person/equipment of multiple items --- .../scala/docspell/backend/ops/OItem.scala | 95 +++++++++++++------ .../docspell/joex/process/LinkProposal.scala | 24 ++++- .../restserver/routes/ItemMultiRoutes.scala | 53 ++++++----- .../restserver/routes/ItemRoutes.scala | 32 +++++-- .../scala/docspell/store/records/RItem.scala | 20 ++-- modules/webapp/src/main/elm/Api.elm | 60 ++++++++++++ .../main/elm/Comp/ItemDetail/FormChange.elm | 28 ++++++ 7 files changed, 236 insertions(+), 76 deletions(-) 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 5d1e816c..ff27b29a 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala @@ -63,19 +63,35 @@ trait OItem[F[_]] { collective: Ident ): F[UpdateResult] - def setCorrOrg(item: Ident, org: Option[Ident], collective: Ident): F[AddResult] + def setCorrOrg( + items: NonEmptyList[Ident], + org: Option[Ident], + collective: Ident + ): F[UpdateResult] def addCorrOrg(item: Ident, org: OOrganization.OrgAndContacts): F[AddResult] - def setCorrPerson(item: Ident, person: Option[Ident], collective: Ident): F[AddResult] + def setCorrPerson( + items: NonEmptyList[Ident], + person: Option[Ident], + collective: Ident + ): F[UpdateResult] def addCorrPerson(item: Ident, person: OOrganization.PersonAndContacts): F[AddResult] - def setConcPerson(item: Ident, person: Option[Ident], collective: Ident): F[AddResult] + def setConcPerson( + items: NonEmptyList[Ident], + person: Option[Ident], + collective: Ident + ): F[UpdateResult] def addConcPerson(item: Ident, person: OOrganization.PersonAndContacts): F[AddResult] - def setConcEquip(item: Ident, equip: Option[Ident], collective: Ident): F[AddResult] + def setConcEquip( + items: NonEmptyList[Ident], + equip: Option[Ident], + collective: Ident + ): F[UpdateResult] def addConcEquip(item: Ident, equip: REquipment): F[AddResult] @@ -304,11 +320,15 @@ object OItem { else UpdateResult.success } yield res - def setCorrOrg(item: Ident, org: Option[Ident], collective: Ident): F[AddResult] = - store - .transact(RItem.updateCorrOrg(item, collective, org)) - .attempt - .map(AddResult.fromUpdate) + def setCorrOrg( + items: NonEmptyList[Ident], + org: Option[Ident], + collective: Ident + ): F[UpdateResult] = + UpdateResult.fromUpdate( + store + .transact(RItem.updateCorrOrg(items, collective, org)) + ) def addCorrOrg(item: Ident, org: OOrganization.OrgAndContacts): F[AddResult] = (for { @@ -319,7 +339,11 @@ object OItem { case AddResult.Success => OptionT.liftF( store.transact( - RItem.updateCorrOrg(item, org.org.cid, Some(org.org.oid)) + RItem.updateCorrOrg( + NonEmptyList.of(item), + org.org.cid, + Some(org.org.oid) + ) ) ) case AddResult.EntityExists(_) => @@ -331,14 +355,14 @@ object OItem { .getOrElse(AddResult.Failure(new Exception("Collective mismatch"))) def setCorrPerson( - item: Ident, + items: NonEmptyList[Ident], person: Option[Ident], collective: Ident - ): F[AddResult] = - store - .transact(RItem.updateCorrPerson(item, collective, person)) - .attempt - .map(AddResult.fromUpdate) + ): F[UpdateResult] = + UpdateResult.fromUpdate( + store + .transact(RItem.updateCorrPerson(items, collective, person)) + ) def addCorrPerson( item: Ident, @@ -353,7 +377,11 @@ object OItem { OptionT.liftF( store.transact( RItem - .updateCorrPerson(item, person.person.cid, Some(person.person.pid)) + .updateCorrPerson( + NonEmptyList.of(item), + person.person.cid, + Some(person.person.pid) + ) ) ) case AddResult.EntityExists(_) => @@ -365,14 +393,14 @@ object OItem { .getOrElse(AddResult.Failure(new Exception("Collective mismatch"))) def setConcPerson( - item: Ident, + items: NonEmptyList[Ident], person: Option[Ident], collective: Ident - ): F[AddResult] = - store - .transact(RItem.updateConcPerson(item, collective, person)) - .attempt - .map(AddResult.fromUpdate) + ): F[UpdateResult] = + UpdateResult.fromUpdate( + store + .transact(RItem.updateConcPerson(items, collective, person)) + ) def addConcPerson( item: Ident, @@ -387,7 +415,11 @@ object OItem { OptionT.liftF( store.transact( RItem - .updateConcPerson(item, person.person.cid, Some(person.person.pid)) + .updateConcPerson( + NonEmptyList.of(item), + person.person.cid, + Some(person.person.pid) + ) ) ) case AddResult.EntityExists(_) => @@ -399,14 +431,14 @@ object OItem { .getOrElse(AddResult.Failure(new Exception("Collective mismatch"))) def setConcEquip( - item: Ident, + items: NonEmptyList[Ident], equip: Option[Ident], collective: Ident - ): F[AddResult] = - store - .transact(RItem.updateConcEquip(item, collective, equip)) - .attempt - .map(AddResult.fromUpdate) + ): F[UpdateResult] = + UpdateResult.fromUpdate( + store + .transact(RItem.updateConcEquip(items, collective, equip)) + ) def addConcEquip(item: Ident, equip: REquipment): F[AddResult] = (for { @@ -417,7 +449,8 @@ object OItem { case AddResult.Success => OptionT.liftF( store.transact( - RItem.updateConcEquip(item, equip.cid, Some(equip.eid)) + RItem + .updateConcEquip(NonEmptyList.of(item), equip.cid, Some(equip.eid)) ) ) case AddResult.EntityExists(_) => diff --git a/modules/joex/src/main/scala/docspell/joex/process/LinkProposal.scala b/modules/joex/src/main/scala/docspell/joex/process/LinkProposal.scala index 8d4fc493..58df16ac 100644 --- a/modules/joex/src/main/scala/docspell/joex/process/LinkProposal.scala +++ b/modules/joex/src/main/scala/docspell/joex/process/LinkProposal.scala @@ -66,22 +66,38 @@ object LinkProposal { case MetaProposalType.CorrOrg => ctx.logger.debug(s"Updating item organization with: ${value.id}") *> ctx.store.transact( - RItem.updateCorrOrg(itemId, ctx.args.meta.collective, Some(value)) + RItem.updateCorrOrg( + NonEmptyList.of(itemId), + ctx.args.meta.collective, + Some(value) + ) ) case MetaProposalType.ConcPerson => ctx.logger.debug(s"Updating item concerning person with: $value") *> ctx.store.transact( - RItem.updateConcPerson(itemId, ctx.args.meta.collective, Some(value)) + RItem.updateConcPerson( + NonEmptyList.of(itemId), + ctx.args.meta.collective, + Some(value) + ) ) case MetaProposalType.CorrPerson => ctx.logger.debug(s"Updating item correspondent person with: $value") *> ctx.store.transact( - RItem.updateCorrPerson(itemId, ctx.args.meta.collective, Some(value)) + RItem.updateCorrPerson( + NonEmptyList.of(itemId), + ctx.args.meta.collective, + Some(value) + ) ) case MetaProposalType.ConcEquip => ctx.logger.debug(s"Updating item concerning equipment with: $value") *> ctx.store.transact( - RItem.updateConcEquip(itemId, ctx.args.meta.collective, Some(value)) + RItem.updateConcEquip( + NonEmptyList.of(itemId), + ctx.args.meta.collective, + Some(value) + ) ) case MetaProposalType.DocDate => MetaProposal.parseDate(value) match { diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala index 6239d0a3..860db77d 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala @@ -19,7 +19,6 @@ import org.http4s.circe.CirceEntityEncoder._ import org.http4s.dsl.Http4sDsl object ItemMultiRoutes { -// private[this] val logger = getLogger def apply[F[_]: Effect]( backend: BackendApp[F], @@ -118,33 +117,37 @@ object ItemMultiRoutes { resp <- Ok(Conversions.basicResult(res, "Item due date updated")) } yield resp - // case req @ PUT -> Root / "corrOrg" => - // for { - // idref <- req.as[OptionalId] - // res <- backend.item.setCorrOrg(id, idref.id, user.account.collective) - // resp <- Ok(Conversions.basicResult(res, "Correspondent organization updated")) - // } yield resp + case req @ PUT -> Root / "corrOrg" => + for { + json <- req.as[ItemsAndRef] + items <- readIds[F](json.items) + res <- backend.item.setCorrOrg(items, json.ref, user.account.collective) + resp <- Ok(Conversions.basicResult(res, "Correspondent organization updated")) + } yield resp - // case req @ PUT -> Root / "corrPerson" => - // for { - // idref <- req.as[OptionalId] - // res <- backend.item.setCorrPerson(id, idref.id, user.account.collective) - // resp <- Ok(Conversions.basicResult(res, "Correspondent person updated")) - // } yield resp + case req @ PUT -> Root / "corrPerson" => + for { + json <- req.as[ItemsAndRef] + items <- readIds[F](json.items) + res <- backend.item.setCorrPerson(items, json.ref, user.account.collective) + resp <- Ok(Conversions.basicResult(res, "Correspondent person updated")) + } yield resp - // case req @ PUT -> Root / "concPerson" => - // for { - // idref <- req.as[OptionalId] - // res <- backend.item.setConcPerson(id, idref.id, user.account.collective) - // resp <- Ok(Conversions.basicResult(res, "Concerned person updated")) - // } yield resp + case req @ PUT -> Root / "concPerson" => + for { + json <- req.as[ItemsAndRef] + items <- readIds[F](json.items) + res <- backend.item.setConcPerson(items, json.ref, user.account.collective) + resp <- Ok(Conversions.basicResult(res, "Concerned person updated")) + } yield resp - // case req @ PUT -> Root / "concEquipment" => - // for { - // idref <- req.as[OptionalId] - // res <- backend.item.setConcEquip(id, idref.id, user.account.collective) - // resp <- Ok(Conversions.basicResult(res, "Concerned equipment updated")) - // } yield resp + case req @ PUT -> Root / "concEquipment" => + for { + json <- req.as[ItemsAndRef] + items <- readIds[F](json.items) + res <- backend.item.setConcEquip(items, json.ref, user.account.collective) + resp <- Ok(Conversions.basicResult(res, "Concerned equipment updated")) + } yield resp // case req @ POST -> Root / "reprocess" => // for { 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 9fa473cf..1966a6f1 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala @@ -185,8 +185,12 @@ object ItemRoutes { case req @ PUT -> Root / Ident(id) / "corrOrg" => for { idref <- req.as[OptionalId] - res <- backend.item.setCorrOrg(id, idref.id, user.account.collective) - resp <- Ok(Conversions.basicResult(res, "Correspondent organization updated")) + res <- backend.item.setCorrOrg( + NonEmptyList.of(id), + idref.id, + user.account.collective + ) + resp <- Ok(Conversions.basicResult(res, "Correspondent organization updated")) } yield resp case req @ POST -> Root / Ident(id) / "corrOrg" => @@ -200,8 +204,12 @@ object ItemRoutes { case req @ PUT -> Root / Ident(id) / "corrPerson" => for { idref <- req.as[OptionalId] - res <- backend.item.setCorrPerson(id, idref.id, user.account.collective) - resp <- Ok(Conversions.basicResult(res, "Correspondent person updated")) + res <- backend.item.setCorrPerson( + NonEmptyList.of(id), + idref.id, + user.account.collective + ) + resp <- Ok(Conversions.basicResult(res, "Correspondent person updated")) } yield resp case req @ POST -> Root / Ident(id) / "corrPerson" => @@ -215,8 +223,12 @@ object ItemRoutes { case req @ PUT -> Root / Ident(id) / "concPerson" => for { idref <- req.as[OptionalId] - res <- backend.item.setConcPerson(id, idref.id, user.account.collective) - resp <- Ok(Conversions.basicResult(res, "Concerned person updated")) + res <- backend.item.setConcPerson( + NonEmptyList.of(id), + idref.id, + user.account.collective + ) + resp <- Ok(Conversions.basicResult(res, "Concerned person updated")) } yield resp case req @ POST -> Root / Ident(id) / "concPerson" => @@ -230,8 +242,12 @@ object ItemRoutes { case req @ PUT -> Root / Ident(id) / "concEquipment" => for { idref <- req.as[OptionalId] - res <- backend.item.setConcEquip(id, idref.id, user.account.collective) - resp <- Ok(Conversions.basicResult(res, "Concerned equipment updated")) + res <- backend.item.setConcEquip( + NonEmptyList.of(id), + idref.id, + user.account.collective + ) + resp <- Ok(Conversions.basicResult(res, "Concerned equipment updated")) } yield resp case req @ POST -> Root / Ident(id) / "concEquipment" => diff --git a/modules/store/src/main/scala/docspell/store/records/RItem.scala b/modules/store/src/main/scala/docspell/store/records/RItem.scala index dbb15dfd..a023e136 100644 --- a/modules/store/src/main/scala/docspell/store/records/RItem.scala +++ b/modules/store/src/main/scala/docspell/store/records/RItem.scala @@ -159,12 +159,16 @@ object RItem { ).update.run } yield n - def updateCorrOrg(itemId: Ident, coll: Ident, org: Option[Ident]): ConnectionIO[Int] = + def updateCorrOrg( + itemIds: NonEmptyList[Ident], + coll: Ident, + org: Option[Ident] + ): ConnectionIO[Int] = for { t <- currentTime n <- updateRow( table, - and(id.is(itemId), cid.is(coll)), + and(id.isIn(itemIds), cid.is(coll)), commas(corrOrg.setTo(org), updated.setTo(t)) ).update.run } yield n @@ -180,7 +184,7 @@ object RItem { } yield n def updateCorrPerson( - itemId: Ident, + itemIds: NonEmptyList[Ident], coll: Ident, person: Option[Ident] ): ConnectionIO[Int] = @@ -188,7 +192,7 @@ object RItem { t <- currentTime n <- updateRow( table, - and(id.is(itemId), cid.is(coll)), + and(id.isIn(itemIds), cid.is(coll)), commas(corrPerson.setTo(person), updated.setTo(t)) ).update.run } yield n @@ -204,7 +208,7 @@ object RItem { } yield n def updateConcPerson( - itemId: Ident, + itemIds: NonEmptyList[Ident], coll: Ident, person: Option[Ident] ): ConnectionIO[Int] = @@ -212,7 +216,7 @@ object RItem { t <- currentTime n <- updateRow( table, - and(id.is(itemId), cid.is(coll)), + and(id.isIn(itemIds), cid.is(coll)), commas(concPerson.setTo(person), updated.setTo(t)) ).update.run } yield n @@ -228,7 +232,7 @@ object RItem { } yield n def updateConcEquip( - itemId: Ident, + itemIds: NonEmptyList[Ident], coll: Ident, equip: Option[Ident] ): ConnectionIO[Int] = @@ -236,7 +240,7 @@ object RItem { t <- currentTime n <- updateRow( table, - and(id.is(itemId), cid.is(coll)), + and(id.isIn(itemIds), cid.is(coll)), commas(concEquipment.setTo(equip), updated.setTo(t)) ).update.run } yield n diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index 39f36b7e..d6a8edbb 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -77,10 +77,14 @@ module Api exposing , setAttachmentName , setCollectiveSettings , setConcEquip + , setConcEquipmentMultiple , setConcPerson + , setConcPersonMultiple , setConfirmed , setCorrOrg + , setCorrOrgMultiple , setCorrPerson + , setCorrPersonMultiple , setDateMultiple , setDirection , setDirectionMultiple @@ -1375,6 +1379,62 @@ setDueDateMultiple flags data receive = } +setCorrOrgMultiple : + Flags + -> ItemsAndRef + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +setCorrOrgMultiple flags data receive = + Http2.authPut + { url = flags.config.baseUrl ++ "/api/v1/sec/items/corrOrg" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ItemsAndRef.encode data) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +setCorrPersonMultiple : + Flags + -> ItemsAndRef + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +setCorrPersonMultiple flags data receive = + Http2.authPut + { url = flags.config.baseUrl ++ "/api/v1/sec/items/corrPerson" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ItemsAndRef.encode data) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +setConcPersonMultiple : + Flags + -> ItemsAndRef + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +setConcPersonMultiple flags data receive = + Http2.authPut + { url = flags.config.baseUrl ++ "/api/v1/sec/items/concPerson" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ItemsAndRef.encode data) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +setConcEquipmentMultiple : + Flags + -> ItemsAndRef + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +setConcEquipmentMultiple flags data receive = + Http2.authPut + { url = flags.config.baseUrl ++ "/api/v1/sec/items/concEquipment" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.ItemsAndRef.encode data) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + --- Item diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm index 5edd9da8..c6dc36dc 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm @@ -86,5 +86,33 @@ multiUpdate flags ids change receive = in Api.setDueDateMultiple flags data receive + OrgChange ref -> + let + data = + ItemsAndRef items (Maybe.map .id ref) + in + Api.setCorrOrgMultiple flags data receive + + CorrPersonChange ref -> + let + data = + ItemsAndRef items (Maybe.map .id ref) + in + Api.setCorrPersonMultiple flags data receive + + ConcPersonChange ref -> + let + data = + ItemsAndRef items (Maybe.map .id ref) + in + Api.setConcPersonMultiple flags data receive + + EquipChange ref -> + let + data = + ItemsAndRef items (Maybe.map .id ref) + in + Api.setConcEquipmentMultiple flags data receive + _ -> Cmd.none From 9193d7ca51d0d04d4f639a99d2220b80ec22a784 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 26 Oct 2020 14:03:56 +0100 Subject: [PATCH 15/22] Send multiple items to reprocessing --- .../scala/docspell/backend/ops/OItem.scala | 20 +++++++++++++++ .../src/main/resources/docspell-openapi.yml | 25 +++++++++++++++++++ .../restserver/routes/ItemMultiRoutes.scala | 15 ++++++----- .../main/elm/Comp/ItemDetail/FormChange.elm | 2 +- 4 files changed, 53 insertions(+), 9 deletions(-) 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 ff27b29a..65c00771 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala @@ -152,6 +152,12 @@ trait OItem[F[_]] { notifyJoex: Boolean ): F[UpdateResult] + def reprocessAll( + items: NonEmptyList[Ident], + account: AccountId, + notifyJoex: Boolean + ): F[UpdateResult] + /** Submits a task that finds all non-converted pdfs and triggers * converting them using ocrmypdf. Each file is converted by a * separate task. @@ -587,6 +593,20 @@ object OItem { _ <- OptionT.liftF(if (notifyJoex) joex.notifyAllNodes else ().pure[F]) } yield UpdateResult.success).getOrElse(UpdateResult.notFound) + def reprocessAll( + items: NonEmptyList[Ident], + account: AccountId, + notifyJoex: Boolean + ): F[UpdateResult] = + UpdateResult.fromUpdate(for { + items <- store.transact(RItem.filterItems(items, account.collective)) + jobs <- items + .map(item => ReProcessItemArgs(item, Nil)) + .traverse(arg => JobFactory.reprocessItem[F](arg, account, Priority.Low)) + _ <- queue.insertAllIfNew(jobs) + _ <- if (notifyJoex) joex.notifyAllNodes else ().pure[F] + } yield items.size) + def convertAllPdf( collective: Option[Ident], account: AccountId, diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 9a051e7e..2eabe73f 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -2225,6 +2225,31 @@ paths: schema: $ref: "#/components/schemas/BasicResult" + /sec/items/reprocess: + post: + tags: + - Item (Multi Edit) + summary: Submit multiple items to re-processing + description: | + Given a list of item-ids, submits all these items for + reprocessing. All attachments of these items will be + reprocessed. Item metadata is not changed. + security: + - authTokenHeader: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/IdList" + + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" + /sec/attachment/{id}: delete: diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala index 860db77d..9865e71f 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala @@ -149,14 +149,13 @@ object ItemMultiRoutes { resp <- Ok(Conversions.basicResult(res, "Concerned equipment updated")) } yield resp - // case req @ POST -> Root / "reprocess" => - // for { - // data <- req.as[IdList] - // ids = data.ids.flatMap(s => Ident.fromString(s).toOption) - // _ <- logger.fdebug(s"Re-process item ${id.id}") - // res <- backend.item.reprocess(id, ids, user.account, true) - // resp <- Ok(Conversions.basicResult(res, "Re-process task submitted.")) - // } yield resp + case req @ POST -> Root / "reprocess" => + for { + json <- req.as[IdList] + items <- readIds[F](json.ids) + res <- backend.item.reprocessAll(items, user.account, true) + resp <- Ok(Conversions.basicResult(res, "Re-process task(s) submitted.")) + } yield resp // case POST -> Root / "deleteAll" => // for { diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm index c6dc36dc..7d9c1c85 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/FormChange.elm @@ -114,5 +114,5 @@ multiUpdate flags ids change receive = in Api.setConcEquipmentMultiple flags data receive - _ -> + NoFormChange -> Cmd.none From 998aad5627679989e395cb8f76f37ae9f129d62b Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 26 Oct 2020 14:18:42 +0100 Subject: [PATCH 16/22] Delete multiple items --- .../scala/docspell/backend/ops/OItem.scala | 9 ++++++++ .../restserver/routes/ItemMultiRoutes.scala | 23 +++++++++++-------- modules/webapp/src/main/elm/Api.elm | 17 ++++++++++++++ .../webapp/src/main/elm/Page/Home/Data.elm | 1 + .../webapp/src/main/elm/Page/Home/Update.elm | 16 ++++++++++++- 5 files changed, 56 insertions(+), 10 deletions(-) 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 65c00771..fd3e5344 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala @@ -130,6 +130,8 @@ trait OItem[F[_]] { def deleteItem(itemId: Ident, collective: Ident): F[Int] + def deleteItemMultiple(items: NonEmptyList[Ident], collective: Ident): F[Int] + def deleteAttachment(id: Ident, collective: Ident): F[Int] def moveAttachmentBefore(itemId: Ident, source: Ident, target: Ident): F[AddResult] @@ -547,6 +549,13 @@ object OItem { .delete(store)(itemId, collective) .flatTap(_ => fts.removeItem(logger, itemId)) + def deleteItemMultiple(items: NonEmptyList[Ident], collective: Ident): F[Int] = + for { + itemIds <- store.transact(RItem.filterItems(items, collective)) + results <- itemIds.traverse(item => deleteItem(item, collective)) + n = results.fold(0)(_ + _) + } yield n + def getProposals(item: Ident, collective: Ident): F[MetaProposalList] = store.transact(QAttachment.getMetaProposals(item, collective)) diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala index 9865e71f..3cb50f6e 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala @@ -151,18 +151,23 @@ object ItemMultiRoutes { case req @ POST -> Root / "reprocess" => for { - json <- req.as[IdList] + json <- req.as[IdList] items <- readIds[F](json.ids) - res <- backend.item.reprocessAll(items, user.account, true) - resp <- Ok(Conversions.basicResult(res, "Re-process task(s) submitted.")) + res <- backend.item.reprocessAll(items, user.account, true) + resp <- Ok(Conversions.basicResult(res, "Re-process task(s) submitted.")) } yield resp - // case POST -> Root / "deleteAll" => - // for { - // 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 + case req @ POST -> Root / "deleteAll" => + for { + json <- req.as[IdList] + items <- readIds[F](json.ids) + n <- backend.item.deleteItemMultiple(items, user.account.collective) + res = BasicResult( + n > 0, + if (n > 0) "Item(s) deleted" else "Item deletion failed." + ) + resp <- Ok(res) + } yield resp } } diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index d6a8edbb..77ee3c20 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -15,6 +15,7 @@ module Api exposing , createNewFolder , createNotifyDueItems , createScanMailbox + , deleteAllItems , deleteAttachment , deleteEquip , deleteFolder @@ -130,6 +131,7 @@ import Api.Model.EquipmentList exposing (EquipmentList) import Api.Model.FolderDetail exposing (FolderDetail) import Api.Model.FolderList exposing (FolderList) import Api.Model.GenInvite exposing (GenInvite) +import Api.Model.IdList exposing (IdList) import Api.Model.IdResult exposing (IdResult) import Api.Model.ImapSettings exposing (ImapSettings) import Api.Model.ImapSettingsList exposing (ImapSettingsList) @@ -182,6 +184,7 @@ import Data.Priority exposing (Priority) import File exposing (File) import Http import Json.Encode as JsonEncode +import Set exposing (Set) import Task import Url import Util.File @@ -1435,6 +1438,20 @@ setConcEquipmentMultiple flags data receive = } +deleteAllItems : + Flags + -> Set String + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +deleteAllItems flags ids receive = + Http2.authPost + { url = flags.config.baseUrl ++ "/api/v1/sec/items/deleteAll" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.IdList.encode (IdList (Set.toList ids))) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + --- Item diff --git a/modules/webapp/src/main/elm/Page/Home/Data.elm b/modules/webapp/src/main/elm/Page/Home/Data.elm index 27a64671..2c6909a8 100644 --- a/modules/webapp/src/main/elm/Page/Home/Data.elm +++ b/modules/webapp/src/main/elm/Page/Home/Data.elm @@ -169,6 +169,7 @@ type Msg | EditMenuMsg Comp.ItemDetail.EditMenu.Msg | MultiUpdateResp (Result Http.Error BasicResult) | ReplaceChangedItemsResp (Result Http.Error ItemLightList) + | DeleteAllResp (Result Http.Error BasicResult) type SearchType diff --git a/modules/webapp/src/main/elm/Page/Home/Update.elm b/modules/webapp/src/main/elm/Page/Home/Update.elm index 9d0884ea..b9c3684f 100644 --- a/modules/webapp/src/main/elm/Page/Home/Update.elm +++ b/modules/webapp/src/main/elm/Page/Home/Update.elm @@ -346,7 +346,7 @@ update mId key flags settings msg model = cmd = if confirmed then - Cmd.none + Api.deleteAllItems flags svm.ids DeleteAllResp else Cmd.none @@ -373,6 +373,20 @@ update mId key flags settings msg model = _ -> noSub ( model, Cmd.none ) + DeleteAllResp (Ok res) -> + if res.success then + let + nm = + { model | viewMode = SearchView } + in + doSearch flags settings False nm + + else + noSub ( model, Cmd.none ) + + DeleteAllResp (Err _) -> + noSub ( model, Cmd.none ) + RequestDeleteSelected -> case model.viewMode of SelectView svm -> From 0eabb7d5893d3003bf01990f4027056c4dbc9bbe Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 26 Oct 2020 14:44:26 +0100 Subject: [PATCH 17/22] Change tooltip of new select-items button --- modules/webapp/src/main/elm/Page/Home/View.elm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/webapp/src/main/elm/Page/Home/View.elm b/modules/webapp/src/main/elm/Page/Home/View.elm index 84e36c89..593a29a3 100644 --- a/modules/webapp/src/main/elm/Page/Home/View.elm +++ b/modules/webapp/src/main/elm/Page/Home/View.elm @@ -68,7 +68,7 @@ view flags settings model = , ( "active", selectActive model ) ] , href "#" - , title "Select items" + , title "Toggle select items" , onClick ToggleSelectView ] [ i [ class "tasks icon" ] [] From 0f8420011867cd6b99941fbe59f7d63124ed2482 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 26 Oct 2020 15:32:58 +0100 Subject: [PATCH 18/22] Indicate saving when changing name for multiple items --- .../webapp/src/main/elm/Page/Home/Data.elm | 7 +- .../webapp/src/main/elm/Page/Home/Update.elm | 64 +++++++++++++++---- .../webapp/src/main/elm/Page/Home/View.elm | 7 +- 3 files changed, 63 insertions(+), 15 deletions(-) diff --git a/modules/webapp/src/main/elm/Page/Home/Data.elm b/modules/webapp/src/main/elm/Page/Home/Data.elm index 2c6909a8..9754a70f 100644 --- a/modules/webapp/src/main/elm/Page/Home/Data.elm +++ b/modules/webapp/src/main/elm/Page/Home/Data.elm @@ -23,7 +23,8 @@ import Api.Model.ItemSearch import Browser.Dom as Dom import Comp.FixedDropdown import Comp.ItemCardList -import Comp.ItemDetail.EditMenu +import Comp.ItemDetail.EditMenu exposing (SaveNameState(..)) +import Comp.ItemDetail.FormChange exposing (FormChange) import Comp.SearchMenu import Comp.YesNoDimmer import Data.Flags exposing (Flags) @@ -60,6 +61,7 @@ type alias SelectViewModel = , action : SelectActionMode , deleteAllConfirm : Comp.YesNoDimmer.Model , editModel : Comp.ItemDetail.EditMenu.Model + , saveNameState : SaveNameState } @@ -69,6 +71,7 @@ initSelectViewModel = , action = NoneAction , deleteAllConfirm = Comp.YesNoDimmer.initActive , editModel = Comp.ItemDetail.EditMenu.init + , saveNameState = SaveSuccess } @@ -167,7 +170,7 @@ type Msg | DeleteSelectedConfirmMsg Comp.YesNoDimmer.Msg | EditSelectedItems | EditMenuMsg Comp.ItemDetail.EditMenu.Msg - | MultiUpdateResp (Result Http.Error BasicResult) + | MultiUpdateResp FormChange (Result Http.Error BasicResult) | ReplaceChangedItemsResp (Result Http.Error ItemLightList) | DeleteAllResp (Result Http.Error BasicResult) diff --git a/modules/webapp/src/main/elm/Page/Home/Update.elm b/modules/webapp/src/main/elm/Page/Home/Update.elm index b9c3684f..35cc0147 100644 --- a/modules/webapp/src/main/elm/Page/Home/Update.elm +++ b/modules/webapp/src/main/elm/Page/Home/Update.elm @@ -3,12 +3,12 @@ module Page.Home.Update exposing (update) import Api import Api.Model.IdList exposing (IdList) import Api.Model.ItemLightList exposing (ItemLightList) -import Api.Model.ItemSearch exposing (ItemSearch) +import Api.Model.ItemSearch import Browser.Navigation as Nav import Comp.FixedDropdown import Comp.ItemCardList -import Comp.ItemDetail.EditMenu -import Comp.ItemDetail.FormChange +import Comp.ItemDetail.EditMenu exposing (SaveNameState(..)) +import Comp.ItemDetail.FormChange exposing (FormChange(..)) import Comp.SearchMenu import Comp.YesNoDimmer import Data.Flags exposing (Flags) @@ -435,7 +435,16 @@ update mId key flags settings msg model = Comp.ItemDetail.EditMenu.update flags lmsg svm.editModel svm_ = - { svm | editModel = res.model } + { svm + | editModel = res.model + , saveNameState = + case res.change of + NameChange _ -> + Saving + + _ -> + svm.saveNameState + } cmd_ = Cmd.map EditMenuMsg res.cmd @@ -447,7 +456,7 @@ update mId key flags settings msg model = Comp.ItemDetail.FormChange.multiUpdate flags svm.ids res.change - MultiUpdateResp + (MultiUpdateResp res.change) in ( { model | viewMode = SelectView svm_ } , Cmd.batch [ cmd_, upCmd ] @@ -457,21 +466,28 @@ update mId key flags settings msg model = _ -> noSub ( model, Cmd.none ) - MultiUpdateResp (Ok res) -> + MultiUpdateResp change (Ok res) -> + let + nm = + updateSelectViewNameState res.success model change + in if res.success then case model.viewMode of SelectView svm -> -- replace changed items in the view - noSub ( model, loadChangedItems flags svm.ids ) + noSub ( nm, loadChangedItems flags svm.ids ) _ -> - noSub ( model, Cmd.none ) + noSub ( nm, Cmd.none ) else - noSub ( model, Cmd.none ) + noSub ( nm, Cmd.none ) - MultiUpdateResp (Err _) -> - noSub ( model, Cmd.none ) + MultiUpdateResp change (Err _) -> + ( updateSelectViewNameState False model change + , Cmd.none + , Sub.none + ) ReplaceChangedItemsResp (Ok items) -> noSub ( replaceItems model items, Cmd.none ) @@ -484,6 +500,32 @@ update mId key flags settings msg model = --- Helpers +updateSelectViewNameState : Bool -> Model -> FormChange -> Model +updateSelectViewNameState success model change = + case model.viewMode of + SelectView svm -> + case change of + NameChange _ -> + let + svm_ = + { svm + | saveNameState = + if success then + SaveSuccess + + else + SaveFailed + } + in + { model | viewMode = SelectView svm_ } + + _ -> + model + + _ -> + model + + replaceItems : Model -> ItemLightList -> Model replaceItems model newItems = let diff --git a/modules/webapp/src/main/elm/Page/Home/View.elm b/modules/webapp/src/main/elm/Page/Home/View.elm index 593a29a3..47e3b813 100644 --- a/modules/webapp/src/main/elm/Page/Home/View.elm +++ b/modules/webapp/src/main/elm/Page/Home/View.elm @@ -174,8 +174,11 @@ viewLeftMenu flags settings model = case svm.action of EditSelected -> let - cfg = + cfg_ = Comp.ItemDetail.EditMenu.defaultViewConfig + + cfg = + { cfg_ | nameState = svm.saveNameState } in [ div [ class "ui dividing header" ] [ text "Multi-Edit" @@ -208,7 +211,7 @@ viewBar flags model = viewActionBar : Flags -> SelectViewModel -> Model -> Html Msg -viewActionBar _ svm model = +viewActionBar _ svm _ = let selectCount = Set.size svm.ids |> String.fromInt From dd89e05cc2c79f3eb5c0e3e0934ed71e6e764cce Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 26 Oct 2020 19:48:29 +0100 Subject: [PATCH 19/22] Convert exceptions when converting to pdf into an error result The file processing tries pdf conversion once and keeps going if it fails. Some errors (e.g. timeouts) are raised via an exception. Issue: #387 --- .../main/scala/docspell/convert/extern/ExternConv.scala | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/modules/convert/src/main/scala/docspell/convert/extern/ExternConv.scala b/modules/convert/src/main/scala/docspell/convert/extern/ExternConv.scala index dcb02206..c0212f09 100644 --- a/modules/convert/src/main/scala/docspell/convert/extern/ExternConv.scala +++ b/modules/convert/src/main/scala/docspell/convert/extern/ExternConv.scala @@ -58,6 +58,13 @@ private[extern] object ExternConv { } .compile .lastOrError + .attempt + .flatMap { + case Right(v) => + v.pure[F] + case Left(ex) => + handler.run(ConversionResult.failure(ex)) + } def readResult[F[_]: Sync: ContextShift]( blocker: Blocker, From 0114bb4d72e815953eb326dc20bb44eb6a3dea35 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 26 Oct 2020 22:35:25 +0100 Subject: [PATCH 20/22] Use source name from config file for integration endpoint uploads Fixes: #389 --- .../src/main/scala/docspell/common/DocspellSystem.scala | 3 ++- modules/restserver/src/main/resources/reference.conf | 4 ++++ .../src/main/scala/docspell/restserver/Config.scala | 1 + .../main/scala/docspell/restserver/conv/Conversions.scala | 5 +++-- .../restserver/routes/IntegrationEndpointRoutes.scala | 3 ++- .../scala/docspell/restserver/routes/UploadRoutes.scala | 1 + nix/module-server.nix | 8 ++++++++ 7 files changed, 21 insertions(+), 4 deletions(-) diff --git a/modules/common/src/main/scala/docspell/common/DocspellSystem.scala b/modules/common/src/main/scala/docspell/common/DocspellSystem.scala index 21247829..52cbb717 100644 --- a/modules/common/src/main/scala/docspell/common/DocspellSystem.scala +++ b/modules/common/src/main/scala/docspell/common/DocspellSystem.scala @@ -2,7 +2,8 @@ package docspell.common object DocspellSystem { - val taskGroup = Ident.unsafe("docspell-system") + val user = Ident.unsafe("docspell-system") + val taskGroup = user val migrationTaskTracker = Ident.unsafe("full-text-index-tracker") } diff --git a/modules/restserver/src/main/resources/reference.conf b/modules/restserver/src/main/resources/reference.conf index f3fd0278..d78d5233 100644 --- a/modules/restserver/src/main/resources/reference.conf +++ b/modules/restserver/src/main/resources/reference.conf @@ -73,6 +73,10 @@ docspell.server { # The priority to use when submitting files through this endpoint. priority = "low" + # The name used for the item "source" property when uploaded + # through this endpoint. + source-name = "integration" + # IPv4 addresses to allow access. An empty list, if enabled, # prohibits all requests. IP addresses may be specified as simple # globs: a part marked as `*' matches any octet, like in diff --git a/modules/restserver/src/main/scala/docspell/restserver/Config.scala b/modules/restserver/src/main/scala/docspell/restserver/Config.scala index dead60f9..f90616a6 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/Config.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/Config.scala @@ -28,6 +28,7 @@ object Config { case class IntegrationEndpoint( enabled: Boolean, priority: Priority, + sourceName: String, allowedIps: IntegrationEndpoint.AllowedIps, httpBasic: IntegrationEndpoint.HttpBasic, httpHeader: IntegrationEndpoint.HttpHeader diff --git a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala index 4e872606..3b40c1d8 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala @@ -273,6 +273,7 @@ trait Conversions { // upload def readMultipart[F[_]: Effect]( mp: Multipart[F], + sourceName: String, logger: Logger, prio: Priority, validFileTypes: Seq[MimeType] @@ -300,7 +301,7 @@ trait Conversions { m.multiple, UploadMeta( m.direction, - "webapp", + sourceName, m.folder, validFileTypes, m.skipDuplicates.getOrElse(false) @@ -309,7 +310,7 @@ trait Conversions { ) ) .getOrElse( - (true, UploadMeta(None, "webapp", None, validFileTypes, false)).pure[F] + (true, UploadMeta(None, sourceName, None, validFileTypes, false)).pure[F] ) val files = mp.parts diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/IntegrationEndpointRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/IntegrationEndpointRoutes.scala index 6ff78e13..86b6342d 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/IntegrationEndpointRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/IntegrationEndpointRoutes.scala @@ -99,11 +99,12 @@ object IntegrationEndpointRoutes { multipart <- req.as[Multipart[F]] updata <- readMultipart( multipart, + cfg.integrationEndpoint.sourceName, logger, cfg.integrationEndpoint.priority, cfg.backend.files.validMimeTypes ) - account = AccountId(coll, Ident.unsafe("docspell-system")) + account = AccountId(coll, DocspellSystem.user) result <- backend.upload.submit(updata, account, true, None) res <- Ok(basicResult(result)) } yield res 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 e3b21a04..f50c5da9 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/UploadRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/UploadRoutes.scala @@ -78,6 +78,7 @@ object UploadRoutes { multipart <- req.as[Multipart[F]] updata <- readMultipart( multipart, + "webapp", logger, prio, cfg.backend.files.validMimeTypes diff --git a/nix/module-server.nix b/nix/module-server.nix index 6b070cc7..cd23429f 100644 --- a/nix/module-server.nix +++ b/nix/module-server.nix @@ -23,6 +23,7 @@ let integration-endpoint = { enabled = false; priority = "low"; + source-name = "integration"; allowed-ips = { enabled = false; ips = [ "127.0.0.1" ]; @@ -214,6 +215,13 @@ in { default = defaults.integration-endpoint.priority; description = "The priority to use when submitting files through this endpoint."; }; + source-name = mkOption { + type = types.str; + default = defaults.integration-endpoint.source-name; + description = '' + The name used for the item "source" property when uploaded through this endpoint. + ''; + }; allowed-ips = mkOption { type = types.submodule({ options = { From b59696a9d3fdfaa82e2a5ea5401006774f105f87 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 26 Oct 2020 22:02:31 +0100 Subject: [PATCH 21/22] Make sure to only remove/retry items in premature states --- .../docspell/joex/process/CreateItem.scala | 3 ++- .../docspell/joex/process/ItemHandler.scala | 7 +++-- .../scala/docspell/store/queries/QItem.scala | 27 ++++++++++++------- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/modules/joex/src/main/scala/docspell/joex/process/CreateItem.scala b/modules/joex/src/main/scala/docspell/joex/process/CreateItem.scala index f7dc0ada..92d275fa 100644 --- a/modules/joex/src/main/scala/docspell/joex/process/CreateItem.scala +++ b/modules/joex/src/main/scala/docspell/joex/process/CreateItem.scala @@ -121,9 +121,10 @@ object CreateItem { private def findExisting[F[_]: Sync]: Task[F, ProcessItemArgs, Option[ItemData]] = Task { ctx => + val states = ItemState.invalidStates.toList.toSet val fileMetaIds = ctx.args.files.map(_.fileMetaId).toSet for { - cand <- ctx.store.transact(QItem.findByFileIds(fileMetaIds.toSeq)) + cand <- ctx.store.transact(QItem.findByFileIds(fileMetaIds.toSeq, states)) _ <- if (cand.nonEmpty) ctx.logger.warn(s"Found ${cand.size} existing item with these files.") diff --git a/modules/joex/src/main/scala/docspell/joex/process/ItemHandler.scala b/modules/joex/src/main/scala/docspell/joex/process/ItemHandler.scala index a5ef178b..757493d6 100644 --- a/modules/joex/src/main/scala/docspell/joex/process/ItemHandler.scala +++ b/modules/joex/src/main/scala/docspell/joex/process/ItemHandler.scala @@ -103,10 +103,13 @@ object ItemHandler { ) } - def deleteByFileIds[F[_]: Sync: ContextShift]: Task[F, Args, Unit] = + private def deleteByFileIds[F[_]: Sync: ContextShift]: Task[F, Args, Unit] = Task { ctx => + val states = ItemState.invalidStates.toList.toSet for { - items <- ctx.store.transact(QItem.findByFileIds(ctx.args.files.map(_.fileMetaId))) + items <- ctx.store.transact( + QItem.findByFileIds(ctx.args.files.map(_.fileMetaId), states) + ) _ <- if (items.nonEmpty) ctx.logger.info(s"Deleting items ${items.map(_.id.id)}") else diff --git a/modules/store/src/main/scala/docspell/store/queries/QItem.scala b/modules/store/src/main/scala/docspell/store/queries/QItem.scala index 1a66d42e..a5311500 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala @@ -493,13 +493,15 @@ object QItem { private def findByFileIdsQuery( fileMetaIds: NonEmptyList[Ident], - limit: Option[Int] + limit: Option[Int], + states: Set[ItemState] ): Fragment = { val IC = RItem.Columns.all.map(_.prefix("i")) val aItem = RAttachment.Columns.itemId.prefix("a") val aId = RAttachment.Columns.id.prefix("a") val aFileId = RAttachment.Columns.fileId.prefix("a") val iId = RItem.Columns.id.prefix("i") + val iState = RItem.Columns.state.prefix("i") val sId = RAttachmentSource.Columns.id.prefix("s") val sFileId = RAttachmentSource.Columns.fileId.prefix("s") val rId = RAttachmentArchive.Columns.id.prefix("r") @@ -516,11 +518,15 @@ object QItem { fr"LEFT OUTER JOIN" ++ RAttachmentArchive.table ++ fr"r ON" ++ aId.is(rId) ++ fr"LEFT OUTER JOIN" ++ RFileMeta.table ++ fr"m3 ON" ++ m3Id.is(rFileId) - val q = selectSimple( - IC, - from, - and(or(m1Id.isIn(fileMetaIds), m2Id.isIn(fileMetaIds), m3Id.isIn(fileMetaIds))) - ) + val fileCond = + or(m1Id.isIn(fileMetaIds), m2Id.isIn(fileMetaIds), m3Id.isIn(fileMetaIds)) + val cond = NonEmptyList.fromList(states.toList) match { + case Some(nel) => + and(fileCond, iState.isIn(nel)) + case None => + fileCond + } + val q = selectSimple(IC, from, cond) limit match { case Some(n) => q ++ fr"LIMIT $n" @@ -531,15 +537,18 @@ object QItem { def findOneByFileIds(fileMetaIds: Seq[Ident]): ConnectionIO[Option[RItem]] = NonEmptyList.fromList(fileMetaIds.toList) match { case Some(nel) => - findByFileIdsQuery(nel, Some(1)).query[RItem].option + findByFileIdsQuery(nel, Some(1), Set.empty).query[RItem].option case None => (None: Option[RItem]).pure[ConnectionIO] } - def findByFileIds(fileMetaIds: Seq[Ident]): ConnectionIO[Vector[RItem]] = + def findByFileIds( + fileMetaIds: Seq[Ident], + states: Set[ItemState] + ): ConnectionIO[Vector[RItem]] = NonEmptyList.fromList(fileMetaIds.toList) match { case Some(nel) => - findByFileIdsQuery(nel, None).query[RItem].to[Vector] + findByFileIdsQuery(nel, None, states).query[RItem].to[Vector] case None => Vector.empty[RItem].pure[ConnectionIO] } From ab1139523aa929f8de1d041edcc415b91025986f Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 26 Oct 2020 22:08:20 +0100 Subject: [PATCH 22/22] Let the convert-all task retry when pdf conversion fails --- .../joex/src/main/scala/docspell/joex/pdfconv/PdfConvTask.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/joex/src/main/scala/docspell/joex/pdfconv/PdfConvTask.scala b/modules/joex/src/main/scala/docspell/joex/pdfconv/PdfConvTask.scala index 07cc7c36..5069f0ec 100644 --- a/modules/joex/src/main/scala/docspell/joex/pdfconv/PdfConvTask.scala +++ b/modules/joex/src/main/scala/docspell/joex/pdfconv/PdfConvTask.scala @@ -110,7 +110,7 @@ object PdfConvTask { ctx.logger.warn(s"Unable to convert '${mime}' file ${ctx.args}: $reason") case ConversionResult.Failure(ex) => - ctx.logger.error(ex)(s"Failure converting file ${ctx.args}: ${ex.getMessage}") + Sync[F].raiseError(ex) }) def ocrMyPdf(lang: Language): F[Unit] =