From 73747c4ea3dbd786dcedd0fcfd9f9252df7a7783 Mon Sep 17 00:00:00 2001
From: eikek <eike.kettner@posteo.de>
Date: Sun, 8 May 2022 14:01:41 +0200
Subject: [PATCH] Basic ui for addons

---
 modules/webapp/src/main/elm/Api.elm           | 106 +++
 modules/webapp/src/main/elm/App/Update.elm    |   6 +-
 .../src/main/elm/Comp/AddonArchiveForm.elm    | 106 +++
 .../src/main/elm/Comp/AddonArchiveManage.elm  | 429 +++++++++++
 .../src/main/elm/Comp/AddonArchiveTable.elm   |  72 ++
 .../src/main/elm/Comp/AddonRunConfigForm.elm  | 709 ++++++++++++++++++
 .../main/elm/Comp/AddonRunConfigManage.elm    | 364 +++++++++
 .../src/main/elm/Comp/AddonRunConfigTable.elm |  79 ++
 .../src/main/elm/Comp/ItemDetail/Model.elm    |  16 +
 .../main/elm/Comp/ItemDetail/RunAddonForm.elm |  78 ++
 .../src/main/elm/Comp/ItemDetail/Update.elm   |  74 ++
 .../src/main/elm/Comp/ItemDetail/View2.elm    |  33 +
 .../webapp/src/main/elm/Data/AddonTrigger.elm |  59 ++
 modules/webapp/src/main/elm/Data/CalEvent.elm |   8 +-
 modules/webapp/src/main/elm/Data/Flags.elm    |   1 +
 modules/webapp/src/main/elm/Data/Icons.elm    |  22 +-
 .../webapp/src/main/elm/Data/ServerEvent.elm  |  25 +-
 .../elm/Messages/Comp/AddonArchiveForm.elm    |  54 ++
 .../elm/Messages/Comp/AddonArchiveManage.elm  |  86 +++
 .../elm/Messages/Comp/AddonArchiveTable.elm   |  42 ++
 .../elm/Messages/Comp/AddonRunConfigForm.elm  | 108 +++
 .../Messages/Comp/AddonRunConfigManage.elm    |  79 ++
 .../elm/Messages/Comp/AddonRunConfigTable.elm |  50 ++
 .../src/main/elm/Messages/Comp/ItemDetail.elm |  17 +
 .../Messages/Comp/ItemDetail/RunAddonForm.elm |  49 ++
 .../src/main/elm/Messages/Page/ManageData.elm |  18 +
 .../src/main/elm/Page/ManageData/Data.elm     |  18 +
 .../src/main/elm/Page/ManageData/Update.elm   |  49 +-
 .../src/main/elm/Page/ManageData/View2.elm    |  74 +-
 modules/webapp/src/main/elm/Styles.elm        |   7 +-
 modules/webapp/src/main/elm/Util/List.elm     |  30 +
 modules/webapp/src/main/elm/Util/String.elm   |  22 +-
 .../src/main/styles/custom-components.css     |   4 +
 33 files changed, 2881 insertions(+), 13 deletions(-)
 create mode 100644 modules/webapp/src/main/elm/Comp/AddonArchiveForm.elm
 create mode 100644 modules/webapp/src/main/elm/Comp/AddonArchiveManage.elm
 create mode 100644 modules/webapp/src/main/elm/Comp/AddonArchiveTable.elm
 create mode 100644 modules/webapp/src/main/elm/Comp/AddonRunConfigForm.elm
 create mode 100644 modules/webapp/src/main/elm/Comp/AddonRunConfigManage.elm
 create mode 100644 modules/webapp/src/main/elm/Comp/AddonRunConfigTable.elm
 create mode 100644 modules/webapp/src/main/elm/Comp/ItemDetail/RunAddonForm.elm
 create mode 100644 modules/webapp/src/main/elm/Data/AddonTrigger.elm
 create mode 100644 modules/webapp/src/main/elm/Messages/Comp/AddonArchiveForm.elm
 create mode 100644 modules/webapp/src/main/elm/Messages/Comp/AddonArchiveManage.elm
 create mode 100644 modules/webapp/src/main/elm/Messages/Comp/AddonArchiveTable.elm
 create mode 100644 modules/webapp/src/main/elm/Messages/Comp/AddonRunConfigForm.elm
 create mode 100644 modules/webapp/src/main/elm/Messages/Comp/AddonRunConfigManage.elm
 create mode 100644 modules/webapp/src/main/elm/Messages/Comp/AddonRunConfigTable.elm
 create mode 100644 modules/webapp/src/main/elm/Messages/Comp/ItemDetail/RunAddonForm.elm

diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm
index 919f8a1b..ff22e2a6 100644
--- a/modules/webapp/src/main/elm/Api.elm
+++ b/modules/webapp/src/main/elm/Api.elm
@@ -18,6 +18,14 @@ module Api exposing
     , addShare
     , addTag
     , addTagsMultiple
+    , addonRunConfigDelete
+    , addonRunConfigGet
+    , addonRunConfigSet
+    , addonRunExistingItem
+    , addonsDelete
+    , addonsGetAll
+    , addonsInstall
+    , addonsUpdate
     , attachmentPreviewURL
     , bookmarkNameExists
     , cancelJob
@@ -211,6 +219,11 @@ module Api exposing
     , versionInfo
     )
 
+import Api.Model.AddonList exposing (AddonList)
+import Api.Model.AddonRegister exposing (AddonRegister)
+import Api.Model.AddonRunConfig exposing (AddonRunConfig)
+import Api.Model.AddonRunConfigList exposing (AddonRunConfigList)
+import Api.Model.AddonRunExistingItem exposing (AddonRunExistingItem)
 import Api.Model.AttachmentMeta exposing (AttachmentMeta)
 import Api.Model.AuthResult exposing (AuthResult)
 import Api.Model.BasicResult exposing (BasicResult)
@@ -3156,6 +3169,99 @@ shareDownloadAllLink flags id =
 
 
 
+--- Addons
+
+
+addonsGetAll : Flags -> (Result Http.Error AddonList -> msg) -> Cmd msg
+addonsGetAll flags receive =
+    Http2.authGet
+        { url = flags.config.baseUrl ++ "/api/v1/sec/addon/archive"
+        , account = getAccount flags
+        , expect = Http.expectJson receive Api.Model.AddonList.decoder
+        }
+
+
+addonsDelete : Flags -> String -> (Result Http.Error BasicResult -> msg) -> Cmd msg
+addonsDelete flags addonId receive =
+    Http2.authDelete
+        { url = flags.config.baseUrl ++ "/api/v1/sec/addon/archive/" ++ addonId
+        , account = getAccount flags
+        , expect = Http.expectJson receive Api.Model.BasicResult.decoder
+        }
+
+
+addonsInstall : Flags -> AddonRegister -> (Result Http.Error BasicResult -> msg) -> Cmd msg
+addonsInstall flags addon receive =
+    Http2.authPost
+        { url = flags.config.baseUrl ++ "/api/v1/sec/addon/archive"
+        , account = getAccount flags
+        , body = Http.jsonBody (Api.Model.AddonRegister.encode addon)
+        , expect = Http.expectJson receive Api.Model.BasicResult.decoder
+        }
+
+
+addonsUpdate : Flags -> String -> (Result Http.Error BasicResult -> msg) -> Cmd msg
+addonsUpdate flags addonId receive =
+    Http2.authPut
+        { url = flags.config.baseUrl ++ "/api/v1/sec/addon/archive/" ++ addonId
+        , account = getAccount flags
+        , body = Http.emptyBody
+        , expect = Http.expectJson receive Api.Model.BasicResult.decoder
+        }
+
+
+addonRunConfigGet : Flags -> (Result Http.Error AddonRunConfigList -> msg) -> Cmd msg
+addonRunConfigGet flags receive =
+    Http2.authGet
+        { url = flags.config.baseUrl ++ "/api/v1/sec/addon/run-config"
+        , account = getAccount flags
+        , expect = Http.expectJson receive Api.Model.AddonRunConfigList.decoder
+        }
+
+
+addonRunConfigSet :
+    Flags
+    -> AddonRunConfig
+    -> (Result Http.Error BasicResult -> msg)
+    -> Cmd msg
+addonRunConfigSet flags cfg receive =
+    if cfg.id == "" then
+        Http2.authPost
+            { url = flags.config.baseUrl ++ "/api/v1/sec/addon/run-config"
+            , account = getAccount flags
+            , body = Http.jsonBody (Api.Model.AddonRunConfig.encode cfg)
+            , expect = Http.expectJson receive Api.Model.BasicResult.decoder
+            }
+
+    else
+        Http2.authPut
+            { url = flags.config.baseUrl ++ "/api/v1/sec/addon/run-config/" ++ cfg.id
+            , account = getAccount flags
+            , body = Http.jsonBody (Api.Model.AddonRunConfig.encode cfg)
+            , expect = Http.expectJson receive Api.Model.BasicResult.decoder
+            }
+
+
+addonRunConfigDelete : Flags -> String -> (Result Http.Error BasicResult -> msg) -> Cmd msg
+addonRunConfigDelete flags id receive =
+    Http2.authDelete
+        { url = flags.config.baseUrl ++ "/api/v1/sec/addon/run-config/" ++ id
+        , account = getAccount flags
+        , expect = Http.expectJson receive Api.Model.BasicResult.decoder
+        }
+
+
+addonRunExistingItem : Flags -> AddonRunExistingItem -> (Result Http.Error BasicResult -> msg) -> Cmd msg
+addonRunExistingItem flags input receive =
+    Http2.authPost
+        { url = flags.config.baseUrl ++ "/api/v1/sec/addon/run/existingitem"
+        , account = getAccount flags
+        , body = Http.jsonBody (Api.Model.AddonRunExistingItem.encode input)
+        , expect = Http.expectJson receive Api.Model.BasicResult.decoder
+        }
+
+
+
 --- Helper
 
 
diff --git a/modules/webapp/src/main/elm/App/Update.elm b/modules/webapp/src/main/elm/App/Update.elm
index 7f98c14c..67b9f416 100644
--- a/modules/webapp/src/main/elm/App/Update.elm
+++ b/modules/webapp/src/main/elm/App/Update.elm
@@ -14,6 +14,7 @@ import Api
 import App.Data exposing (..)
 import Browser exposing (UrlRequest(..))
 import Browser.Navigation as Nav
+import Comp.AddonArchiveManage
 import Comp.DownloadAll
 import Data.AppEvent exposing (AppEvent(..))
 import Data.Environment as Env
@@ -345,6 +346,9 @@ updateWithSub msg model =
                 Ok (JobsWaiting n) ->
                     ( { model | jobsWaiting = max 0 n }, Cmd.none, Sub.none )
 
+                Ok (AddonInstalled info) ->
+                    updateManageData (Page.ManageData.Data.AddonArchiveMsg <| Comp.AddonArchiveManage.addonInstallResult info) model
+
                 Err _ ->
                     ( model, Cmd.none, Sub.none )
 
@@ -640,7 +644,7 @@ updateManageData : Page.ManageData.Data.Msg -> Model -> ( Model, Cmd Msg, Sub Ms
 updateManageData lmsg model =
     let
         ( lm, lc, ls ) =
-            Page.ManageData.Update.update model.flags lmsg model.manageDataModel
+            Page.ManageData.Update.update model.flags model.uiSettings lmsg model.manageDataModel
     in
     ( { model | manageDataModel = lm }
     , Cmd.map ManageDataMsg lc
diff --git a/modules/webapp/src/main/elm/Comp/AddonArchiveForm.elm b/modules/webapp/src/main/elm/Comp/AddonArchiveForm.elm
new file mode 100644
index 00000000..5030eef5
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/AddonArchiveForm.elm
@@ -0,0 +1,106 @@
+{-
+   Copyright 2020 Eike K. & Contributors
+
+   SPDX-License-Identifier: AGPL-3.0-or-later
+-}
+
+
+module Comp.AddonArchiveForm exposing (Model, Msg, get, init, initWith, update, view)
+
+import Api.Model.Addon exposing (Addon)
+import Comp.Basic as B
+import Data.Flags exposing (Flags)
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onInput)
+import Messages.Comp.AddonArchiveForm exposing (Texts)
+import Styles as S
+import Util.Maybe
+
+
+type alias Model =
+    { addon : Addon
+    , url : Maybe String
+    }
+
+
+init : ( Model, Cmd Msg )
+init =
+    ( { addon = Api.Model.Addon.empty
+      , url = Nothing
+      }
+    , Cmd.none
+    )
+
+
+initWith : Addon -> ( Model, Cmd Msg )
+initWith a =
+    ( { addon = a
+      , url = a.url
+      }
+    , Cmd.none
+    )
+
+
+isValid : Model -> Bool
+isValid model =
+    model.url /= Nothing
+
+
+get : Model -> Maybe Addon
+get model =
+    let
+        a =
+            model.addon
+    in
+    if isValid model then
+        Just
+            { a
+                | url = model.url
+            }
+
+    else
+        Nothing
+
+
+type Msg
+    = SetUrl String
+
+
+update : Flags -> Msg -> Model -> ( Model, Cmd Msg )
+update _ msg model =
+    case msg of
+        SetUrl url ->
+            ( { model | url = Util.Maybe.fromString url }, Cmd.none )
+
+
+
+--- View
+
+
+view : Texts -> Model -> Html Msg
+view texts model =
+    div
+        [ class "flex flex-col" ]
+        [ div [ class "mb-4" ]
+            [ label
+                [ class S.inputLabel
+                ]
+                [ text texts.addonUrl
+                , B.inputRequired
+                ]
+            , input
+                [ type_ "text"
+                , placeholder texts.addonUrlPlaceholder
+                , class S.textInput
+                , classList [ ( "disabled", model.addon.id /= "" ) ]
+                , value (model.url |> Maybe.withDefault "")
+                , onInput SetUrl
+                , disabled (model.addon.id /= "")
+                ]
+                []
+            , span [ class "text-sm opacity-75" ]
+                [ text texts.installInfoText
+                ]
+            ]
+        ]
diff --git a/modules/webapp/src/main/elm/Comp/AddonArchiveManage.elm b/modules/webapp/src/main/elm/Comp/AddonArchiveManage.elm
new file mode 100644
index 00000000..68f460f5
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/AddonArchiveManage.elm
@@ -0,0 +1,429 @@
+{-
+   Copyright 2020 Eike K. & Contributors
+
+   SPDX-License-Identifier: AGPL-3.0-or-later
+-}
+
+
+module Comp.AddonArchiveManage exposing (Model, Msg, addonInstallResult, init, loadAddons, update, view)
+
+import Api
+import Api.Model.Addon exposing (Addon)
+import Api.Model.AddonList exposing (AddonList)
+import Api.Model.AddonRegister exposing (AddonRegister)
+import Api.Model.BasicResult exposing (BasicResult)
+import Comp.AddonArchiveForm
+import Comp.AddonArchiveTable
+import Comp.Basic as B
+import Comp.ItemDetail.Model exposing (Msg(..))
+import Comp.MenuBar as MB
+import Data.Flags exposing (Flags)
+import Data.ServerEvent exposing (AddonInfo)
+import Data.UiSettings exposing (UiSettings)
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onClick)
+import Http
+import Markdown
+import Messages.Comp.AddonArchiveManage exposing (Texts)
+import Page exposing (Page(..))
+import Styles as S
+
+
+type FormError
+    = FormErrorNone
+    | FormErrorHttp Http.Error
+    | FormErrorInvalid
+    | FormErrorSubmit String
+
+
+type ViewMode
+    = Table
+    | Form
+
+
+type DeleteConfirm
+    = DeleteConfirmOff
+    | DeleteConfirmOn
+
+
+type alias Model =
+    { viewMode : ViewMode
+    , addons : List Addon
+    , formModel : Comp.AddonArchiveForm.Model
+    , loading : Bool
+    , formError : FormError
+    , deleteConfirm : DeleteConfirm
+    }
+
+
+init : Flags -> ( Model, Cmd Msg )
+init flags =
+    let
+        ( fm, fc ) =
+            Comp.AddonArchiveForm.init
+    in
+    ( { viewMode = Table
+      , addons = []
+      , formModel = fm
+      , loading = False
+      , formError = FormErrorNone
+      , deleteConfirm = DeleteConfirmOff
+      }
+    , Cmd.batch
+        [ Cmd.map FormMsg fc
+        , Api.addonsGetAll flags LoadAddonsResp
+        ]
+    )
+
+
+type Msg
+    = LoadAddons
+    | TableMsg Comp.AddonArchiveTable.Msg
+    | FormMsg Comp.AddonArchiveForm.Msg
+    | InitNewAddon
+    | SetViewMode ViewMode
+    | Submit
+    | RequestDelete
+    | CancelDelete
+    | DeleteAddonNow String
+    | LoadAddonsResp (Result Http.Error AddonList)
+    | AddAddonResp (Result Http.Error BasicResult)
+    | UpdateAddonResp (Result Http.Error BasicResult)
+    | DeleteAddonResp (Result Http.Error BasicResult)
+    | AddonInstallResp AddonInfo
+
+
+loadAddons : Msg
+loadAddons =
+    LoadAddons
+
+
+addonInstallResult : AddonInfo -> Msg
+addonInstallResult info =
+    AddonInstallResp info
+
+
+
+--- update
+
+
+update : Flags -> Msg -> Model -> ( Model, Cmd Msg, Sub Msg )
+update flags msg model =
+    case msg of
+        InitNewAddon ->
+            let
+                ( bm, bc ) =
+                    Comp.AddonArchiveForm.init
+
+                nm =
+                    { model
+                        | viewMode = Form
+                        , formError = FormErrorNone
+                        , formModel = bm
+                    }
+            in
+            ( nm, Cmd.map FormMsg bc, Sub.none )
+
+        SetViewMode vm ->
+            ( { model | viewMode = vm, formError = FormErrorNone }
+            , if vm == Table then
+                Api.addonsGetAll flags LoadAddonsResp
+
+              else
+                Cmd.none
+            , Sub.none
+            )
+
+        FormMsg lm ->
+            let
+                ( fm, fc ) =
+                    Comp.AddonArchiveForm.update flags lm model.formModel
+            in
+            ( { model | formModel = fm, formError = FormErrorNone }
+            , Cmd.map FormMsg fc
+            , Sub.none
+            )
+
+        TableMsg lm ->
+            let
+                action =
+                    Comp.AddonArchiveTable.update lm
+            in
+            case action of
+                Comp.AddonArchiveTable.Selected addon ->
+                    let
+                        ( bm, bc ) =
+                            Comp.AddonArchiveForm.initWith addon
+                    in
+                    ( { model
+                        | viewMode = Form
+                        , formError = FormErrorNone
+                        , formModel = bm
+                      }
+                    , Cmd.map FormMsg bc
+                    , Sub.none
+                    )
+
+        RequestDelete ->
+            ( { model | deleteConfirm = DeleteConfirmOn }, Cmd.none, Sub.none )
+
+        CancelDelete ->
+            ( { model | deleteConfirm = DeleteConfirmOff }, Cmd.none, Sub.none )
+
+        DeleteAddonNow id ->
+            ( { model | deleteConfirm = DeleteConfirmOff, loading = True }
+            , Api.addonsDelete flags id DeleteAddonResp
+            , Sub.none
+            )
+
+        LoadAddons ->
+            ( { model | loading = True }
+            , Api.addonsGetAll flags LoadAddonsResp
+            , Sub.none
+            )
+
+        LoadAddonsResp (Ok list) ->
+            ( { model | loading = False, addons = list.items, formError = FormErrorNone }
+            , Cmd.none
+            , Sub.none
+            )
+
+        LoadAddonsResp (Err err) ->
+            ( { model | loading = False, formError = FormErrorHttp err }, Cmd.none, Sub.none )
+
+        AddonInstallResp info ->
+            if info.success then
+                ( { model | loading = False, viewMode = Table }, Api.addonsGetAll flags LoadAddonsResp, Sub.none )
+
+            else
+                ( { model | loading = False, formError = FormErrorSubmit info.message }, Cmd.none, Sub.none )
+
+        Submit ->
+            case Comp.AddonArchiveForm.get model.formModel of
+                Just data ->
+                    if data.id /= "" then
+                        ( { model | loading = True }
+                        , Api.addonsUpdate flags data.id UpdateAddonResp
+                        , Sub.none
+                        )
+
+                    else
+                        ( { model | loading = True }
+                        , Api.addonsInstall
+                            flags
+                            (AddonRegister <| Maybe.withDefault "" data.url)
+                            AddAddonResp
+                        , Sub.none
+                        )
+
+                Nothing ->
+                    ( { model | formError = FormErrorInvalid }, Cmd.none, Sub.none )
+
+        AddAddonResp (Ok res) ->
+            if res.success then
+                ( model, Cmd.none, Sub.none )
+
+            else
+                ( { model | loading = False, formError = FormErrorSubmit res.message }, Cmd.none, Sub.none )
+
+        AddAddonResp (Err err) ->
+            ( { model | loading = False, formError = FormErrorHttp err }, Cmd.none, Sub.none )
+
+        UpdateAddonResp (Ok res) ->
+            if res.success then
+                ( model, Cmd.none, Sub.none )
+
+            else
+                ( { model | loading = False, formError = FormErrorSubmit res.message }, Cmd.none, Sub.none )
+
+        UpdateAddonResp (Err err) ->
+            ( { model | loading = False, formError = FormErrorHttp err }, Cmd.none, Sub.none )
+
+        DeleteAddonResp (Ok res) ->
+            if res.success then
+                update flags (SetViewMode Table) { model | loading = False }
+
+            else
+                ( { model | formError = FormErrorSubmit res.message, loading = False }, Cmd.none, Sub.none )
+
+        DeleteAddonResp (Err err) ->
+            ( { model | formError = FormErrorHttp err, loading = False }, Cmd.none, Sub.none )
+
+
+
+--- view
+
+
+view : Texts -> UiSettings -> Flags -> Model -> Html Msg
+view texts settings flags model =
+    if model.viewMode == Table then
+        viewTable texts model
+
+    else
+        viewForm texts settings flags model
+
+
+viewTable : Texts -> Model -> Html Msg
+viewTable texts model =
+    div [ class "flex flex-col" ]
+        [ MB.view
+            { start =
+                []
+            , end =
+                [ MB.PrimaryButton
+                    { tagger = InitNewAddon
+                    , title = texts.createNewAddonArchive
+                    , icon = Just "fa fa-plus"
+                    , label = texts.newAddonArchive
+                    }
+                ]
+            , rootClasses = "mb-4"
+            , sticky = True
+            }
+        , div
+            [ class "flex flex-col"
+            ]
+            [ Html.map TableMsg
+                (Comp.AddonArchiveTable.view texts.addonArchiveTable model.addons)
+            ]
+        , B.loadingDimmer
+            { label = ""
+            , active = model.loading
+            }
+        ]
+
+
+viewForm : Texts -> UiSettings -> Flags -> Model -> Html Msg
+viewForm texts _ _ model =
+    let
+        newAddon =
+            model.formModel.addon.id == ""
+
+        isValid =
+            Comp.AddonArchiveForm.get model.formModel /= Nothing
+    in
+    div [ class "relative" ]
+        [ Html.form []
+            [ if newAddon then
+                h1 [ class S.header2 ]
+                    [ text texts.createNewAddonArchive
+                    ]
+
+              else
+                h1 [ class S.header2 ]
+                    [ text (Comp.AddonArchiveForm.get model.formModel |> Maybe.map .name |> Maybe.withDefault "Update")
+                    ]
+            , MB.view
+                { start =
+                    [ MB.SecondaryButton
+                        { tagger = SetViewMode Table
+                        , title = texts.basics.backToList
+                        , icon = Just "fa fa-arrow-left"
+                        , label = texts.basics.back
+                        }
+                    ]
+                , end =
+                    if not newAddon then
+                        [ MB.DeleteButton
+                            { tagger = RequestDelete
+                            , title = texts.deleteThisAddonArchive
+                            , icon = Just "fa fa-trash"
+                            , label = texts.basics.delete
+                            }
+                        ]
+
+                    else
+                        []
+                , rootClasses = "mb-4"
+                , sticky = True
+                }
+            , div
+                [ classList
+                    [ ( "hidden", model.formError == FormErrorNone )
+                    ]
+                , class "my-2"
+                , class S.errorMessage
+                ]
+                [ case model.formError of
+                    FormErrorNone ->
+                        text ""
+
+                    FormErrorHttp err ->
+                        text (texts.httpError err)
+
+                    FormErrorInvalid ->
+                        text texts.correctFormErrors
+
+                    FormErrorSubmit m ->
+                        text m
+                ]
+            , div []
+                [ Html.map FormMsg (Comp.AddonArchiveForm.view texts.addonArchiveForm model.formModel)
+                ]
+            , MB.view
+                { start =
+                    [ MB.PrimaryButton
+                        { tagger = Submit
+                        , title = texts.installNow
+                        , icon =
+                            if newAddon then
+                                Just "fa fa-save"
+
+                            else
+                                Just "fa fa-arrows-rotate"
+                        , label =
+                            if newAddon then
+                                texts.installNow
+
+                            else
+                                texts.updateNow
+                        }
+                    ]
+                , end = []
+                , rootClasses = "mb-4"
+                , sticky = False
+                }
+            , div
+                [ class "mb-4"
+                , classList [ ( "hidden", newAddon ) ]
+                ]
+                [ label [ class S.inputLabel ] [ text texts.description ]
+                , case model.formModel.addon.description of
+                    Just desc ->
+                        Markdown.toHtml [ class "markdown-preview" ] desc
+
+                    Nothing ->
+                        div [ class "italic" ] [ text "-" ]
+                ]
+            , B.loadingDimmer
+                { active = model.loading
+                , label = texts.basics.loading
+                }
+            , B.contentDimmer
+                (model.deleteConfirm == DeleteConfirmOn)
+                (div [ class "flex flex-col" ]
+                    [ div [ class "text-lg" ]
+                        [ i [ class "fa fa-info-circle mr-2" ] []
+                        , text texts.reallyDeleteAddonArchive
+                        ]
+                    , div [ class "mt-4 flex flex-row items-center" ]
+                        [ B.deleteButton
+                            { label = texts.basics.yes
+                            , icon = "fa fa-check"
+                            , disabled = False
+                            , handler = onClick (DeleteAddonNow model.formModel.addon.id)
+                            , attrs = [ href "#" ]
+                            }
+                        , B.secondaryButton
+                            { label = texts.basics.no
+                            , icon = "fa fa-times"
+                            , disabled = False
+                            , handler = onClick CancelDelete
+                            , attrs = [ href "#", class "ml-2" ]
+                            }
+                        ]
+                    ]
+                )
+            ]
+        ]
diff --git a/modules/webapp/src/main/elm/Comp/AddonArchiveTable.elm b/modules/webapp/src/main/elm/Comp/AddonArchiveTable.elm
new file mode 100644
index 00000000..4b58cb81
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/AddonArchiveTable.elm
@@ -0,0 +1,72 @@
+{-
+   Copyright 2020 Eike K. & Contributors
+
+   SPDX-License-Identifier: AGPL-3.0-or-later
+-}
+
+
+module Comp.AddonArchiveTable exposing (..)
+
+import Api.Model.Addon exposing (Addon)
+import Comp.Basic as B
+import Html exposing (Html, div, table, tbody, td, text, th, thead, tr)
+import Html.Attributes exposing (class)
+import Messages.Comp.AddonArchiveTable exposing (Texts)
+import Styles as S
+
+
+type Msg
+    = SelectAddon Addon
+
+
+type TableAction
+    = Selected Addon
+
+
+
+--- Update
+
+
+update : Msg -> TableAction
+update msg =
+    case msg of
+        SelectAddon addon ->
+            Selected addon
+
+
+
+--- View
+
+
+view : Texts -> List Addon -> Html Msg
+view texts addons =
+    table [ class S.tableMain ]
+        [ thead []
+            [ tr []
+                [ th [ class "" ] []
+                , th [ class "text-left" ]
+                    [ text texts.basics.name
+                    ]
+                , th [ class "text-left" ]
+                    [ text texts.version
+                    ]
+                ]
+            ]
+        , tbody []
+            (List.map (renderAddonLine texts) addons)
+        ]
+
+
+renderAddonLine : Texts -> Addon -> Html Msg
+renderAddonLine texts addon =
+    tr
+        [ class S.tableRow
+        ]
+        [ B.editLinkTableCell texts.basics.edit (SelectAddon addon)
+        , td [ class "text-left py-4 md:py-2" ]
+            [ text addon.name
+            ]
+        , td [ class "text-left" ]
+            [ text addon.version
+            ]
+        ]
diff --git a/modules/webapp/src/main/elm/Comp/AddonRunConfigForm.elm b/modules/webapp/src/main/elm/Comp/AddonRunConfigForm.elm
new file mode 100644
index 00000000..0ad60d8f
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/AddonRunConfigForm.elm
@@ -0,0 +1,709 @@
+{-
+   Copyright 2020 Eike K. & Contributors
+
+   SPDX-License-Identifier: AGPL-3.0-or-later
+-}
+
+
+module Comp.AddonRunConfigForm exposing (Model, Msg, get, init, initWith, update, view)
+
+import Api
+import Api.Model.Addon exposing (Addon)
+import Api.Model.AddonList exposing (AddonList)
+import Api.Model.AddonRef exposing (AddonRef)
+import Api.Model.AddonRunConfig exposing (AddonRunConfig)
+import Api.Model.User exposing (User)
+import Api.Model.UserList exposing (UserList)
+import Comp.Basic as B
+import Comp.CalEventInput
+import Comp.Dropdown
+import Comp.MenuBar as MB
+import Data.AddonTrigger exposing (AddonTrigger)
+import Data.CalEvent exposing (CalEvent)
+import Data.DropdownStyle as DS
+import Data.Flags exposing (Flags)
+import Data.TimeZone exposing (TimeZone)
+import Data.UiSettings exposing (UiSettings)
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onClick, onInput)
+import Http
+import Markdown
+import Messages.Comp.AddonRunConfigForm exposing (Texts)
+import Process
+import Styles as S
+import Task
+import Util.List
+import Util.String
+
+
+type alias Model =
+    { runConfig : AddonRunConfig
+    , name : String
+    , enabled : Bool
+    , userDropdown : Comp.Dropdown.Model User
+    , userId : Maybe String
+    , userList : List User
+    , scheduleModel : Maybe Comp.CalEventInput.Model
+    , schedule : Maybe CalEvent
+    , triggerDropdown : Comp.Dropdown.Model AddonTrigger
+    , addons : List AddonRef
+    , selectedAddon : Maybe AddonConfigModel
+    , existingAddonDropdown : Comp.Dropdown.Model Addon
+    , existingAddons : List Addon
+    , configApplied : Bool
+    }
+
+
+type alias AddonConfigModel =
+    { ref : AddonRef
+    , position : Int
+    , args : String
+    , readMore : Bool
+    }
+
+
+getRef : AddonConfigModel -> AddonRef
+getRef cfg =
+    let
+        a =
+            cfg.ref
+    in
+    { a | args = cfg.args }
+
+
+emptyModel : Model
+emptyModel =
+    { runConfig = Api.Model.AddonRunConfig.empty
+    , name = ""
+    , enabled = True
+    , userDropdown = Comp.Dropdown.makeSingle
+    , userId = Nothing
+    , userList = []
+    , scheduleModel = Nothing
+    , schedule = Nothing
+    , triggerDropdown =
+        Comp.Dropdown.makeMultipleList
+            { options = Data.AddonTrigger.all, selected = [] }
+    , addons = []
+    , selectedAddon = Nothing
+    , existingAddonDropdown = Comp.Dropdown.makeSingle
+    , existingAddons = []
+    , configApplied = False
+    }
+
+
+init : Flags -> ( Model, Cmd Msg )
+init flags =
+    ( emptyModel
+    , Cmd.batch
+        [ Api.getUsers flags UserListResp
+        , Api.addonsGetAll flags AddonListResp
+        ]
+    )
+
+
+initWith : Flags -> AddonRunConfig -> ( Model, Cmd Msg )
+initWith flags a =
+    let
+        ce =
+            Maybe.andThen Data.CalEvent.fromEvent a.schedule
+
+        ceInit =
+            Maybe.map (Comp.CalEventInput.init flags) ce
+
+        triggerModel =
+            Comp.Dropdown.makeMultipleList
+                { options = Data.AddonTrigger.all
+                , selected = Data.AddonTrigger.fromList a.trigger
+                }
+    in
+    ( { emptyModel
+        | runConfig = a
+        , name = a.name
+        , enabled = a.enabled
+        , scheduleModel = Maybe.map Tuple.first ceInit
+        , schedule = ce
+        , triggerDropdown = triggerModel
+        , userId = a.userId
+        , addons = a.addons
+      }
+    , Cmd.batch
+        [ Api.getUsers flags UserListResp
+        , Api.addonsGetAll flags AddonListResp
+        , Maybe.map Tuple.second ceInit
+            |> Maybe.map (Cmd.map ScheduleMsg)
+            |> Maybe.withDefault Cmd.none
+        ]
+    )
+
+
+isValid : Model -> Bool
+isValid model =
+    model.name
+        /= ""
+        && (Comp.Dropdown.getSelected model.triggerDropdown
+                |> List.isEmpty
+                |> not
+           )
+        && (List.isEmpty model.addons
+                |> not
+           )
+
+
+get : Model -> Maybe AddonRunConfig
+get model =
+    let
+        a =
+            model.runConfig
+    in
+    if isValid model then
+        Just
+            { a
+                | name = model.name
+                , enabled = model.enabled
+                , schedule = Maybe.map Data.CalEvent.makeEvent model.schedule
+                , trigger =
+                    Comp.Dropdown.getSelected model.triggerDropdown
+                        |> List.map Data.AddonTrigger.asString
+                , userId = model.userId
+                , addons = model.addons
+            }
+
+    else
+        Nothing
+
+
+type Msg
+    = SetName String
+    | UserListResp (Result Http.Error UserList)
+    | AddonListResp (Result Http.Error AddonList)
+    | ScheduleMsg Comp.CalEventInput.Msg
+    | UserDropdownMsg (Comp.Dropdown.Msg User)
+    | TriggerDropdownMsg (Comp.Dropdown.Msg AddonTrigger)
+    | AddonDropdownMsg (Comp.Dropdown.Msg Addon)
+    | Configure Int AddonRef
+    | Up Int
+    | Down Int
+    | Remove Int
+    | ToggleEnabled
+    | ConfigSetArgs String
+    | ConfigApply
+    | ConfigCancel
+    | AddSelectedAddon
+    | ConfigToggleReadMore
+    | ConfigArgsUpdated Bool
+
+
+
+--- Update
+
+
+update : Flags -> TimeZone -> Msg -> Model -> ( Model, Cmd Msg )
+update flags tz msg model =
+    case msg of
+        UserListResp (Ok list) ->
+            let
+                um =
+                    Comp.Dropdown.makeSingleList
+                        { options = list.items
+                        , selected = Nothing
+                        }
+            in
+            ( { model | userDropdown = um, userList = list.items }, Cmd.none )
+
+        UserListResp (Err err) ->
+            ( model, Cmd.none )
+
+        AddonListResp (Ok list) ->
+            let
+                am =
+                    Comp.Dropdown.makeSingleList
+                        { options = list.items
+                        , selected = Nothing
+                        }
+            in
+            ( { model | existingAddonDropdown = am, existingAddons = list.items }, Cmd.none )
+
+        AddonListResp (Err err) ->
+            ( model, Cmd.none )
+
+        UserDropdownMsg lm ->
+            let
+                ( um, cmd ) =
+                    Comp.Dropdown.update lm model.userDropdown
+
+                sel =
+                    Comp.Dropdown.getSelected um |> List.head
+            in
+            ( { model | userDropdown = um, userId = Maybe.map .id sel }, Cmd.map UserDropdownMsg cmd )
+
+        TriggerDropdownMsg lm ->
+            let
+                ( tm, tc ) =
+                    Comp.Dropdown.update lm model.triggerDropdown
+
+                ( nm, nc ) =
+                    initScheduleIfNeeded flags { model | triggerDropdown = tm } tz
+            in
+            ( nm, Cmd.batch [ Cmd.map TriggerDropdownMsg tc, nc ] )
+
+        ScheduleMsg lm ->
+            case model.scheduleModel of
+                Just m ->
+                    let
+                        ( cm, cc, ce ) =
+                            Comp.CalEventInput.update flags tz model.schedule lm m
+                    in
+                    ( { model | scheduleModel = Just cm, schedule = ce }, Cmd.map ScheduleMsg cc )
+
+                Nothing ->
+                    ( model, Cmd.none )
+
+        ToggleEnabled ->
+            ( { model | enabled = not model.enabled }, Cmd.none )
+
+        AddonDropdownMsg lm ->
+            let
+                ( am, ac ) =
+                    Comp.Dropdown.update lm model.existingAddonDropdown
+            in
+            ( { model | existingAddonDropdown = am }, Cmd.map AddonDropdownMsg ac )
+
+        Configure index ref ->
+            let
+                cfg =
+                    { ref = ref
+                    , position = index + 1
+                    , args = ref.args
+                    , readMore = False
+                    }
+            in
+            ( { model | selectedAddon = Just cfg }, Cmd.none )
+
+        ConfigCancel ->
+            ( { model | selectedAddon = Nothing }, Cmd.none )
+
+        ConfigToggleReadMore ->
+            case model.selectedAddon of
+                Just cfg ->
+                    ( { model | selectedAddon = Just { cfg | readMore = not cfg.readMore } }, Cmd.none )
+
+                Nothing ->
+                    ( model, Cmd.none )
+
+        ConfigArgsUpdated flag ->
+            ( { model | configApplied = flag }, Cmd.none )
+
+        ConfigSetArgs str ->
+            case model.selectedAddon of
+                Just cfg ->
+                    ( { model | selectedAddon = Just { cfg | args = str } }
+                    , Cmd.none
+                    )
+
+                Nothing ->
+                    ( model, Cmd.none )
+
+        ConfigApply ->
+            case model.selectedAddon of
+                Just cfg ->
+                    let
+                        na =
+                            getRef cfg
+
+                        addons =
+                            Util.List.replaceByIndex (cfg.position - 1) na model.addons
+                    in
+                    ( { model | addons = addons, configApplied = True }
+                    , Process.sleep 1200 |> Task.perform (\_ -> ConfigArgsUpdated False)
+                    )
+
+                Nothing ->
+                    ( model, Cmd.none )
+
+        AddSelectedAddon ->
+            let
+                sel =
+                    Comp.Dropdown.getSelected model.existingAddonDropdown |> List.head
+
+                ( dm, _ ) =
+                    Comp.Dropdown.update (Comp.Dropdown.SetSelection []) model.existingAddonDropdown
+
+                addon =
+                    Maybe.map
+                        (\a ->
+                            { addonId = a.id
+                            , name = a.name
+                            , version = a.version
+                            , description = a.description
+                            , args = ""
+                            }
+                        )
+                        sel
+
+                newAddons =
+                    Maybe.map (\e -> e :: model.addons) addon
+                        |> Maybe.withDefault model.addons
+            in
+            ( { model | addons = newAddons, existingAddonDropdown = dm, selectedAddon = Nothing }, Cmd.none )
+
+        Up curIndex ->
+            let
+                newAddons =
+                    Util.List.changePosition curIndex (curIndex - 1) model.addons
+            in
+            ( { model | addons = newAddons, selectedAddon = Nothing }, Cmd.none )
+
+        Down curIndex ->
+            let
+                newAddons =
+                    Util.List.changePosition (curIndex + 1) curIndex model.addons
+            in
+            ( { model | addons = newAddons, selectedAddon = Nothing }, Cmd.none )
+
+        SetName str ->
+            ( { model | name = str }, Cmd.none )
+
+        Remove index ->
+            ( { model | addons = Util.List.removeByIndex index model.addons, selectedAddon = Nothing }, Cmd.none )
+
+
+initScheduleIfNeeded : Flags -> Model -> TimeZone -> ( Model, Cmd Msg )
+initScheduleIfNeeded flags model tz =
+    let
+        hasTrigger =
+            Comp.Dropdown.getSelected model.triggerDropdown
+                |> List.any ((==) Data.AddonTrigger.Scheduled)
+
+        noModel =
+            model.scheduleModel == Nothing
+
+        hasModel =
+            not noModel
+
+        ce =
+            Data.CalEvent.everyMonthTz tz
+
+        ( cm, cc ) =
+            Comp.CalEventInput.init flags ce
+    in
+    if hasTrigger && noModel then
+        ( { model | scheduleModel = Just cm, schedule = Just ce }, Cmd.map ScheduleMsg cc )
+
+    else if not hasTrigger && hasModel then
+        ( { model | scheduleModel = Nothing, schedule = Nothing }, Cmd.none )
+
+    else
+        ( model, Cmd.none )
+
+
+
+--- View
+
+
+view : Texts -> UiSettings -> Model -> Html Msg
+view texts settings model =
+    let
+        userDs =
+            { makeOption = \user -> { text = user.login, additional = "" }
+            , placeholder = texts.basics.selectPlaceholder
+            , labelColor = \_ -> \_ -> ""
+            , style = DS.mainStyle
+            }
+
+        triggerDs =
+            { makeOption = \trigger -> { text = Data.AddonTrigger.asString trigger, additional = "" }
+            , placeholder = texts.basics.selectPlaceholder
+            , labelColor = \_ -> \_ -> ""
+            , style = DS.mainStyle
+            }
+    in
+    div
+        [ class "flex flex-col" ]
+        [ div [ class "mb-4" ]
+            [ div [ class "mb-4" ]
+                [ label
+                    [ class S.inputLabel
+                    ]
+                    [ text texts.basics.name
+                    , B.inputRequired
+                    ]
+                , input
+                    [ type_ "text"
+                    , placeholder texts.chooseName
+                    , value model.name
+                    , onInput SetName
+                    , class S.textInput
+                    , classList [ ( S.inputErrorBorder, model.name == "" ) ]
+                    ]
+                    []
+                ]
+            , div [ class "mb-4" ]
+                [ MB.viewItem <|
+                    MB.Checkbox
+                        { tagger = \_ -> ToggleEnabled
+                        , label = texts.enableDisable
+                        , value = model.enabled
+                        , id = "addon-run-config-enabled"
+                        }
+                ]
+            , div [ class "mb-4" ]
+                [ label
+                    [ class S.inputLabel
+                    ]
+                    [ text texts.impersonateUser
+                    ]
+                , Html.map UserDropdownMsg
+                    (Comp.Dropdown.view2 userDs settings model.userDropdown)
+                ]
+            , div [ class "mb-4" ]
+                [ label
+                    [ class S.inputLabel
+                    ]
+                    [ text texts.triggerRun
+                    , B.inputRequired
+                    ]
+                , Html.map TriggerDropdownMsg
+                    (Comp.Dropdown.view2 triggerDs settings model.triggerDropdown)
+                ]
+            , case model.scheduleModel of
+                Nothing ->
+                    span [ class "hidden" ] []
+
+                Just m ->
+                    div [ class "mb-4" ]
+                        [ label
+                            [ class S.inputLabel ]
+                            [ text texts.schedule
+                            ]
+                        , Html.map ScheduleMsg (Comp.CalEventInput.view2 texts.calEventInput "" model.schedule m)
+                        ]
+            ]
+        , div [ class "mb-4" ]
+            [ h2 [ class S.header2 ]
+                [ text texts.addons ]
+            , addonRef texts model
+            , div [ class "mb-4" ]
+                [ label [ class S.inputLabel ]
+                    [ text texts.includedAddons
+                    , B.inputRequired
+                    ]
+                , newAddon texts settings model
+                , div [ class "mb-4" ]
+                    [ div [ class "flex flex-col mb-4" ]
+                        (List.indexedMap (addonLine texts model) model.addons)
+                    ]
+                ]
+            ]
+        ]
+
+
+newAddon : Texts -> UiSettings -> Model -> Html Msg
+newAddon texts uiSettings model =
+    let
+        addonDs =
+            { makeOption = \addon -> { text = addon.name ++ " / " ++ addon.version, additional = "" }
+            , placeholder = texts.basics.selectPlaceholder
+            , labelColor = \_ -> \_ -> ""
+            , style = DS.mainStyle
+            }
+    in
+    div [ class "mb-4" ]
+        [ div [ class "flex flex-row" ]
+            [ div [ class "flex-grow mr-2" ]
+                [ Html.map AddonDropdownMsg
+                    (Comp.Dropdown.view2 addonDs uiSettings model.existingAddonDropdown)
+                ]
+            , B.primaryBasicButton
+                { label = texts.add
+                , icon = "fa fa-plus"
+                , disabled = List.isEmpty (Comp.Dropdown.getSelected model.existingAddonDropdown)
+                , handler = onClick AddSelectedAddon
+                , attrs = [ href "#" ]
+                }
+            ]
+        ]
+
+
+addonRef : Texts -> Model -> Html Msg
+addonRef texts model =
+    let
+        maybeRef =
+            Maybe.map .ref model.selectedAddon
+
+        refInfo =
+            case model.selectedAddon of
+                Nothing ->
+                    div [ class "mb-4" ]
+                        [ text "[ -- ]"
+                        ]
+
+                Just cfg ->
+                    let
+                        ( descr, requireFolding ) =
+                            case cfg.ref.description of
+                                Just d ->
+                                    let
+                                        part =
+                                            Util.String.firstSentenceOrMax 120 d
+
+                                        text =
+                                            if cfg.readMore then
+                                                d
+
+                                            else
+                                                Maybe.withDefault d part
+                                    in
+                                    ( Markdown.toHtml [ class "markdown-preview" ] text, part /= Nothing )
+
+                                Nothing ->
+                                    ( span [ class "italic" ] [ text "No description." ], False )
+                    in
+                    div [ class "flex flex-col mb-4" ]
+                        [ div [ class "mt-2" ]
+                            [ label [ class " font-semibold py-0.5 " ]
+                                [ text cfg.ref.name
+                                , text " "
+                                , text cfg.ref.version
+                                , text " (pos. "
+                                , text <| String.fromInt cfg.position
+                                , text ")"
+                                , span
+                                    [ classList [ ( "hidden", not requireFolding ) ]
+                                    , class "ml-2"
+                                    ]
+                                    [ a
+                                        [ class "px-4"
+                                        , class S.link
+                                        , href "#"
+                                        , onClick ConfigToggleReadMore
+                                        ]
+                                        [ if cfg.readMore then
+                                            text texts.readLess
+
+                                          else
+                                            text texts.readMore
+                                        ]
+                                    ]
+                                ]
+                            , div [ class "px-3 py-1 border-l dark:border-slate-600" ]
+                                [ descr
+                                ]
+                            ]
+                        ]
+    in
+    div
+        [ class "flex flex-col mb-3"
+        , classList [ ( "disabled", maybeRef == Nothing ) ]
+        ]
+        [ refInfo
+        , div [ class "mb-2" ]
+            [ label [ class S.inputLabel ] [ text texts.arguments ]
+            , textarea
+                [ Maybe.map .args model.selectedAddon |> Maybe.withDefault "" |> value
+                , class S.textAreaInput
+                , class "font-mono"
+                , rows 8
+                , onInput ConfigSetArgs
+                ]
+                []
+            ]
+        , MB.view
+            { start =
+                [ MB.PrimaryButton
+                    { tagger = ConfigApply
+                    , title = ""
+                    , icon = Just "fa fa-save"
+                    , label = texts.update
+                    }
+                , MB.SecondaryButton
+                    { tagger = ConfigCancel
+                    , title = texts.basics.cancel
+                    , icon = Just "fa fa-times"
+                    , label = texts.basics.cancel
+                    }
+                , MB.CustomElement <|
+                    div
+                        [ classList [ ( "hidden", not model.configApplied ) ]
+                        , class S.successText
+                        , class "inline-block min-w-fit font-semibold text-normal min-w-fit"
+                        ]
+                        [ text texts.argumentsUpdated
+                        , i [ class "fa fa-thumbs-up ml-2" ] []
+                        ]
+                ]
+            , end = []
+            , rootClasses = "mb-4 text-sm"
+            , sticky = False
+            }
+        ]
+
+
+addonLine : Texts -> Model -> Int -> AddonRef -> Html Msg
+addonLine texts model index ref =
+    let
+        isSelected =
+            case model.selectedAddon of
+                Just cfg ->
+                    cfg.position - 1 == index
+
+                Nothing ->
+                    False
+    in
+    div
+        [ class "flex flex-row items-center px-4 py-4 rounded shadow dark:border dark:border-slate-600 mb-2"
+        , classList [ ( "ring-2", isSelected ) ]
+        ]
+        [ div [ class "px-2 hidden sm:block" ]
+            [ span [ class "label rounded-full opacity-75" ]
+                [ text <| String.fromInt (index + 1)
+                ]
+            ]
+        , div [ class "px-4 font-semibold" ]
+            [ text ref.name
+            , text " v"
+            , text ref.version
+            ]
+        , div [ class "flex-grow" ]
+            []
+        , div [ class "px-2" ]
+            [ MB.view
+                { start = []
+                , end =
+                    [ MB.PrimaryButton
+                        { tagger = Configure index ref
+                        , title = texts.configureTitle
+                        , icon = Just "fa fa-cog"
+                        , label = texts.configureLabel
+                        }
+                    , MB.CustomElement <|
+                        B.secondaryButton
+                            { handler = onClick (Up index)
+                            , attrs = [ title "Move up", href "#" ]
+                            , icon = "fa fa-arrow-up"
+                            , label = ""
+                            , disabled = index == 0
+                            }
+                    , MB.CustomElement <|
+                        B.secondaryButton
+                            { handler = onClick (Down index)
+                            , attrs = [ title "Move down", href "#" ]
+                            , icon = "fa fa-arrow-down"
+                            , label = ""
+                            , disabled = index + 1 == List.length model.addons
+                            }
+                    , MB.CustomElement <|
+                        B.deleteButton
+                            { label = ""
+                            , icon = "fa fa-trash"
+                            , disabled = False
+                            , handler = onClick (Remove index)
+                            , attrs = [ href "#" ]
+                            }
+                    ]
+                , rootClasses = "text-sm"
+                , sticky = False
+                }
+            ]
+        ]
diff --git a/modules/webapp/src/main/elm/Comp/AddonRunConfigManage.elm b/modules/webapp/src/main/elm/Comp/AddonRunConfigManage.elm
new file mode 100644
index 00000000..641b821d
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/AddonRunConfigManage.elm
@@ -0,0 +1,364 @@
+{-
+   Copyright 2020 Eike K. & Contributors
+
+   SPDX-License-Identifier: AGPL-3.0-or-later
+-}
+
+
+module Comp.AddonRunConfigManage exposing (Model, Msg, init, loadConfigs, update, view)
+
+import Api
+import Api.Model.AddonRunConfig exposing (AddonRunConfig)
+import Api.Model.AddonRunConfigList exposing (AddonRunConfigList)
+import Api.Model.BasicResult exposing (BasicResult)
+import Comp.AddonRunConfigForm
+import Comp.AddonRunConfigTable
+import Comp.Basic as B
+import Comp.ItemDetail.Model exposing (Msg(..))
+import Comp.MenuBar as MB
+import Data.Flags exposing (Flags)
+import Data.TimeZone exposing (TimeZone)
+import Data.UiSettings exposing (UiSettings)
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onClick)
+import Http
+import Messages.Comp.AddonRunConfigManage exposing (Texts)
+import Page exposing (Page(..))
+import Styles as S
+
+
+type FormError
+    = FormErrorNone
+    | FormErrorHttp Http.Error
+    | FormErrorInvalid
+    | FormErrorSubmit String
+
+
+type ViewMode
+    = Table
+    | Form
+
+
+type DeleteConfirm
+    = DeleteConfirmOff
+    | DeleteConfirmOn
+
+
+type alias Model =
+    { viewMode : ViewMode
+    , runConfigs : List AddonRunConfig
+    , formModel : Comp.AddonRunConfigForm.Model
+    , loading : Bool
+    , formError : FormError
+    , deleteConfirm : DeleteConfirm
+    }
+
+
+init : Flags -> ( Model, Cmd Msg )
+init flags =
+    let
+        ( fm, fc ) =
+            Comp.AddonRunConfigForm.init flags
+    in
+    ( { viewMode = Table
+      , runConfigs = []
+      , formModel = fm
+      , loading = False
+      , formError = FormErrorNone
+      , deleteConfirm = DeleteConfirmOff
+      }
+    , Cmd.batch
+        [ Cmd.map FormMsg fc
+        , Api.addonRunConfigGet flags LoadConfigsResp
+        ]
+    )
+
+
+type Msg
+    = LoadRunConfigs
+    | TableMsg Comp.AddonRunConfigTable.Msg
+    | FormMsg Comp.AddonRunConfigForm.Msg
+    | InitNewConfig
+    | SetViewMode ViewMode
+    | Submit
+    | RequestDelete
+    | CancelDelete
+    | DeleteConfigNow String
+    | LoadConfigsResp (Result Http.Error AddonRunConfigList)
+    | AddConfigResp (Result Http.Error BasicResult)
+    | DeleteConfigResp (Result Http.Error BasicResult)
+
+
+loadConfigs : Msg
+loadConfigs =
+    LoadRunConfigs
+
+
+
+--- update
+
+
+update : Flags -> TimeZone -> Msg -> Model -> ( Model, Cmd Msg, Sub Msg )
+update flags tz msg model =
+    case msg of
+        InitNewConfig ->
+            let
+                ( bm, bc ) =
+                    Comp.AddonRunConfigForm.init flags
+
+                nm =
+                    { model
+                        | viewMode = Form
+                        , formError = FormErrorNone
+                        , formModel = bm
+                    }
+            in
+            ( nm, Cmd.map FormMsg bc, Sub.none )
+
+        SetViewMode vm ->
+            ( { model | viewMode = vm, formError = FormErrorNone }
+            , if vm == Table then
+                Api.addonRunConfigGet flags LoadConfigsResp
+
+              else
+                Cmd.none
+            , Sub.none
+            )
+
+        FormMsg lm ->
+            let
+                ( fm, fc ) =
+                    Comp.AddonRunConfigForm.update flags tz lm model.formModel
+            in
+            ( { model | formModel = fm, formError = FormErrorNone }
+            , Cmd.map FormMsg fc
+            , Sub.none
+            )
+
+        TableMsg lm ->
+            let
+                action =
+                    Comp.AddonRunConfigTable.update lm
+            in
+            case action of
+                Comp.AddonRunConfigTable.Selected addon ->
+                    let
+                        ( bm, bc ) =
+                            Comp.AddonRunConfigForm.initWith flags addon
+                    in
+                    ( { model
+                        | viewMode = Form
+                        , formError = FormErrorNone
+                        , formModel = bm
+                      }
+                    , Cmd.map FormMsg bc
+                    , Sub.none
+                    )
+
+        RequestDelete ->
+            ( { model | deleteConfirm = DeleteConfirmOn }, Cmd.none, Sub.none )
+
+        CancelDelete ->
+            ( { model | deleteConfirm = DeleteConfirmOff }, Cmd.none, Sub.none )
+
+        DeleteConfigNow id ->
+            ( { model | deleteConfirm = DeleteConfirmOff, loading = True }
+            , Api.addonRunConfigDelete flags id DeleteConfigResp
+            , Sub.none
+            )
+
+        LoadRunConfigs ->
+            ( { model | loading = True }
+            , Api.addonRunConfigGet flags LoadConfigsResp
+            , Sub.none
+            )
+
+        LoadConfigsResp (Ok list) ->
+            ( { model | loading = False, runConfigs = list.items, formError = FormErrorNone }
+            , Cmd.none
+            , Sub.none
+            )
+
+        LoadConfigsResp (Err err) ->
+            ( { model | loading = False, formError = FormErrorHttp err }, Cmd.none, Sub.none )
+
+        Submit ->
+            case Comp.AddonRunConfigForm.get model.formModel of
+                Just data ->
+                    ( { model | loading = True }, Api.addonRunConfigSet flags data AddConfigResp, Sub.none )
+
+                Nothing ->
+                    ( { model | formError = FormErrorInvalid }, Cmd.none, Sub.none )
+
+        AddConfigResp (Ok res) ->
+            if res.success then
+                ( { model | loading = False }, Cmd.none, Sub.none )
+
+            else
+                ( { model | loading = False, formError = FormErrorSubmit res.message }, Cmd.none, Sub.none )
+
+        AddConfigResp (Err err) ->
+            ( { model | loading = False, formError = FormErrorHttp err }, Cmd.none, Sub.none )
+
+        DeleteConfigResp (Ok res) ->
+            if res.success then
+                update flags tz (SetViewMode Table) { model | loading = False }
+
+            else
+                ( { model | formError = FormErrorSubmit res.message, loading = False }, Cmd.none, Sub.none )
+
+        DeleteConfigResp (Err err) ->
+            ( { model | formError = FormErrorHttp err, loading = False }, Cmd.none, Sub.none )
+
+
+
+--- view
+
+
+view : Texts -> UiSettings -> Flags -> Model -> Html Msg
+view texts settings flags model =
+    if model.viewMode == Table then
+        viewTable texts model
+
+    else
+        viewForm texts settings flags model
+
+
+viewTable : Texts -> Model -> Html Msg
+viewTable texts model =
+    div [ class "flex flex-col" ]
+        [ MB.view
+            { start =
+                []
+            , end =
+                [ MB.PrimaryButton
+                    { tagger = InitNewConfig
+                    , title = texts.createNewAddonRunConfig
+                    , icon = Just "fa fa-plus"
+                    , label = texts.newAddonRunConfig
+                    }
+                ]
+            , rootClasses = "mb-4"
+            , sticky = True
+            }
+        , div
+            [ class "flex flex-col"
+            ]
+            [ Html.map TableMsg
+                (Comp.AddonRunConfigTable.view texts.addonArchiveTable model.runConfigs)
+            ]
+        , B.loadingDimmer
+            { label = ""
+            , active = model.loading
+            }
+        ]
+
+
+viewForm : Texts -> UiSettings -> Flags -> Model -> Html Msg
+viewForm texts uiSettings _ model =
+    let
+        newConfig =
+            model.formModel.runConfig.id == ""
+
+        isValid =
+            Comp.AddonRunConfigForm.get model.formModel /= Nothing
+    in
+    div []
+        [ Html.form []
+            [ if newConfig then
+                h1 [ class S.header2 ]
+                    [ text texts.createNewAddonRunConfig
+                    ]
+
+              else
+                h1 [ class S.header2 ]
+                    [ text (Comp.AddonRunConfigForm.get model.formModel |> Maybe.map .name |> Maybe.withDefault "Update")
+                    ]
+            , MB.view
+                { start =
+                    [ MB.CustomElement <|
+                        B.primaryButton
+                            { handler = onClick Submit
+                            , title = texts.basics.submitThisForm
+                            , icon = "fa fa-save"
+                            , label = texts.basics.submit
+                            , disabled = not isValid
+                            , attrs = [ href "#" ]
+                            }
+                    , MB.SecondaryButton
+                        { tagger = SetViewMode Table
+                        , title = texts.basics.backToList
+                        , icon = Just "fa fa-arrow-left"
+                        , label = texts.basics.back
+                        }
+                    ]
+                , end =
+                    if not newConfig then
+                        [ MB.DeleteButton
+                            { tagger = RequestDelete
+                            , title = texts.deleteThisAddonRunConfig
+                            , icon = Just "fa fa-trash"
+                            , label = texts.basics.delete
+                            }
+                        ]
+
+                    else
+                        []
+                , rootClasses = "mb-4"
+                , sticky = True
+                }
+            , div
+                [ classList
+                    [ ( "hidden", model.formError == FormErrorNone )
+                    ]
+                , class "my-2"
+                , class S.errorMessage
+                ]
+                [ case model.formError of
+                    FormErrorNone ->
+                        text ""
+
+                    FormErrorHttp err ->
+                        text (texts.httpError err)
+
+                    FormErrorInvalid ->
+                        text texts.correctFormErrors
+
+                    FormErrorSubmit m ->
+                        text m
+                ]
+            , div []
+                [ Html.map FormMsg (Comp.AddonRunConfigForm.view texts.addonArchiveForm uiSettings model.formModel)
+                ]
+            , B.loadingDimmer
+                { active = model.loading
+                , label = texts.basics.loading
+                }
+            , B.contentDimmer
+                (model.deleteConfirm == DeleteConfirmOn)
+                (div [ class "flex flex-col" ]
+                    [ div [ class "text-lg" ]
+                        [ i [ class "fa fa-info-circle mr-2" ] []
+                        , text texts.reallyDeleteAddonRunConfig
+                        ]
+                    , div [ class "mt-4 flex flex-row items-center" ]
+                        [ B.deleteButton
+                            { label = texts.basics.yes
+                            , icon = "fa fa-check"
+                            , disabled = False
+                            , handler = onClick (DeleteConfigNow model.formModel.runConfig.id)
+                            , attrs = [ href "#" ]
+                            }
+                        , B.secondaryButton
+                            { label = texts.basics.no
+                            , icon = "fa fa-times"
+                            , disabled = False
+                            , handler = onClick CancelDelete
+                            , attrs = [ href "#", class "ml-2" ]
+                            }
+                        ]
+                    ]
+                )
+            ]
+        ]
diff --git a/modules/webapp/src/main/elm/Comp/AddonRunConfigTable.elm b/modules/webapp/src/main/elm/Comp/AddonRunConfigTable.elm
new file mode 100644
index 00000000..02febe38
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/AddonRunConfigTable.elm
@@ -0,0 +1,79 @@
+{-
+   Copyright 2020 Eike K. & Contributors
+
+   SPDX-License-Identifier: AGPL-3.0-or-later
+-}
+
+
+module Comp.AddonRunConfigTable exposing (..)
+
+import Api.Model.AddonRunConfig exposing (AddonRunConfig)
+import Comp.Basic as B
+import Html exposing (Html, div, table, tbody, td, text, th, thead, tr)
+import Html.Attributes exposing (class)
+import Messages.Comp.AddonRunConfigTable exposing (Texts)
+import Styles as S
+import Util.Html
+
+
+type Msg
+    = SelectRunConfig AddonRunConfig
+
+
+type TableAction
+    = Selected AddonRunConfig
+
+
+
+--- Update
+
+
+update : Msg -> TableAction
+update msg =
+    case msg of
+        SelectRunConfig cfg ->
+            Selected cfg
+
+
+
+--- View
+
+
+view : Texts -> List AddonRunConfig -> Html Msg
+view texts addons =
+    table [ class S.tableMain ]
+        [ thead []
+            [ tr []
+                [ th [ class "" ] []
+                , th [ class "text-left" ]
+                    [ text texts.basics.name
+                    ]
+                , th [ class "px-2 text-center" ] [ text texts.enabled ]
+                , th [ class "px-2 text-left" ] [ text texts.trigger ]
+                , th [ class "px-2 text-center" ] [ text "# Addons" ]
+                ]
+            ]
+        , tbody []
+            (List.map (renderRunConfigLine texts) addons)
+        ]
+
+
+renderRunConfigLine : Texts -> AddonRunConfig -> Html Msg
+renderRunConfigLine texts cfg =
+    tr
+        [ class S.tableRow
+        ]
+        [ B.editLinkTableCell texts.basics.edit (SelectRunConfig cfg)
+        , td [ class "text-left py-4 md:py-2" ]
+            [ text cfg.name
+            ]
+        , td [ class "w-px whitespace-nowrap px-2 text-center" ]
+            [ Util.Html.checkbox2 cfg.enabled
+            ]
+        , td [ class "px-2 text-left" ]
+            [ text (String.join ", " cfg.trigger)
+            ]
+        , td [ class "px-2 text-center" ]
+            [ text (String.fromInt <| List.length cfg.addons)
+            ]
+        ]
diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/Model.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/Model.elm
index 726166fb..e01db9d7 100644
--- a/modules/webapp/src/main/elm/Comp/ItemDetail/Model.elm
+++ b/modules/webapp/src/main/elm/Comp/ItemDetail/Model.elm
@@ -28,6 +28,8 @@ module Comp.ItemDetail.Model exposing
     , resultModelCmdSub
     )
 
+import Api.Model.AddonRunConfig exposing (AddonRunConfig)
+import Api.Model.AddonRunConfigList exposing (AddonRunConfigList)
 import Api.Model.BasicResult exposing (BasicResult)
 import Api.Model.CustomField exposing (CustomField)
 import Api.Model.EquipmentList exposing (EquipmentList)
@@ -72,6 +74,7 @@ import Set exposing (Set)
 
 type alias Model =
     { item : ItemDetail
+    , runConfigs : List AddonRunConfig
     , visibleAttach : Int
     , attachMenuOpen : Bool
     , menuOpen : Bool
@@ -123,6 +126,9 @@ type alias Model =
     , viewMode : ViewMode
     , showQrModel : ShowQrModel
     , itemLinkModel : Comp.ItemLinkForm.Model
+    , showRunAddon : Bool
+    , addonRunConfigDropdown : Comp.Dropdown.Model AddonRunConfig
+    , addonRunSubmitted : Bool
     }
 
 
@@ -204,6 +210,7 @@ isEditNotes field =
 emptyModel : Model
 emptyModel =
     { item = Api.Model.ItemDetail.empty
+    , runConfigs = []
     , visibleAttach = 0
     , attachMenuOpen = False
     , menuOpen = False
@@ -259,6 +266,9 @@ emptyModel =
     , viewMode = SimpleView
     , showQrModel = initShowQrModel
     , itemLinkModel = Comp.ItemLinkForm.emptyModel
+    , showRunAddon = False
+    , addonRunConfigDropdown = Comp.Dropdown.makeSingle
+    , addonRunSubmitted = False
     }
 
 
@@ -373,6 +383,12 @@ type Msg
     | SetNameMsg Comp.SimpleTextInput.Msg
     | ToggleSelectItem
     | ItemLinkFormMsg Comp.ItemLinkForm.Msg
+    | ToggleShowRunAddon
+    | LoadRunConfigResp (Result Http.Error AddonRunConfigList)
+    | RunAddonMsg (Comp.Dropdown.Msg AddonRunConfig)
+    | RunSelectedAddon
+    | RunAddonResp (Result Http.Error BasicResult)
+    | SetAddonRunSubmitted Bool
 
 
 type SaveNameState
diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/RunAddonForm.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/RunAddonForm.elm
new file mode 100644
index 00000000..80705c5e
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/ItemDetail/RunAddonForm.elm
@@ -0,0 +1,78 @@
+{-
+   Copyright 2020 Eike K. & Contributors
+
+   SPDX-License-Identifier: AGPL-3.0-or-later
+-}
+
+
+module Comp.ItemDetail.RunAddonForm exposing (..)
+
+import Comp.Basic as B
+import Comp.Dropdown
+import Comp.ItemDetail.Model exposing (..)
+import Comp.MenuBar as MB
+import Data.DropdownStyle as DS
+import Data.UiSettings exposing (UiSettings)
+import Html exposing (Html, div, h3, label, text)
+import Html.Attributes exposing (class, classList, title)
+import Html.Events exposing (onClick)
+import Messages.Comp.ItemDetail.RunAddonForm exposing (Texts)
+import Styles as S
+
+
+view : Texts -> UiSettings -> Model -> Html Msg
+view texts uiSettings model =
+    let
+        viewSettings =
+            { makeOption = \cfg -> { text = cfg.name, additional = "" }
+            , placeholder = texts.basics.selectPlaceholder
+            , labelColor = \_ -> \_ -> ""
+            , style = DS.mainStyle
+            }
+
+        runDisabled =
+            Comp.Dropdown.getSelected model.addonRunConfigDropdown
+                |> List.isEmpty
+    in
+    div
+        [ classList [ ( "hidden", not model.showRunAddon ) ]
+        , class "mb-4"
+        ]
+        [ h3 [ class S.header3 ] [ text texts.runAddon ]
+        , div [ class "my-2" ]
+            [ label [ class S.inputLabel ]
+                [ text texts.addonRunConfig
+                ]
+            , Html.map RunAddonMsg (Comp.Dropdown.view2 viewSettings uiSettings model.addonRunConfigDropdown)
+            ]
+        , div [ class "my-2" ]
+            [ MB.view
+                { start =
+                    [ MB.CustomElement <|
+                        B.primaryButton
+                            { label = "Run"
+                            , icon =
+                                if model.addonRunSubmitted then
+                                    "fa fa-check"
+
+                                else
+                                    "fa fa-play"
+                            , disabled = runDisabled
+                            , handler = onClick RunSelectedAddon
+                            , attrs =
+                                [ title texts.runAddonTitle
+                                ]
+                            }
+                    , MB.SecondaryButton
+                        { label = texts.basics.cancel
+                        , icon = Just "fa fa-times"
+                        , tagger = ToggleShowRunAddon
+                        , title = ""
+                        }
+                    ]
+                , end = []
+                , rootClasses = "text-sm mt-1"
+                , sticky = False
+                }
+            ]
+        ]
diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm
index abc9db46..48871ecb 100644
--- a/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm
+++ b/modules/webapp/src/main/elm/Comp/ItemDetail/Update.elm
@@ -56,6 +56,7 @@ import Comp.PersonForm
 import Comp.SentMails
 import Comp.SimpleTextInput
 import Comp.TagDropdown
+import Data.AddonTrigger
 import Data.CustomFieldChange exposing (CustomFieldChange(..))
 import Data.Direction
 import Data.Environment as Env
@@ -75,7 +76,9 @@ import Html5.DragDrop as DD
 import Http
 import Page exposing (Page(..))
 import Ports
+import Process
 import Set exposing (Set)
+import Task
 import Util.File exposing (makeFileId)
 import Util.List
 import Util.Maybe
@@ -121,9 +124,77 @@ update inav env msg model =
                     , Cmd.map ItemMailMsg ic
                     , Cmd.map CustomFieldMsg cc
                     , Api.getSentMails env.flags model.item.id SentMailsResp
+                    , Api.addonRunConfigGet env.flags LoadRunConfigResp
                     ]
                 )
 
+        LoadRunConfigResp (Ok list) ->
+            let
+                existingItem cfg =
+                    cfg.enabled
+                        && (Data.AddonTrigger.fromList cfg.trigger
+                                |> List.any ((==) Data.AddonTrigger.ExistingItem)
+                           )
+
+                configs =
+                    List.filter existingItem list.items
+
+                dropdown =
+                    Comp.Dropdown.makeSingleList { options = configs, selected = Nothing }
+            in
+            resultModel { model | runConfigs = configs, addonRunConfigDropdown = dropdown }
+
+        RunAddonMsg lm ->
+            let
+                ( dd, dc ) =
+                    Comp.Dropdown.update lm model.addonRunConfigDropdown
+            in
+            resultModelCmd ( { model | addonRunConfigDropdown = dd }, Cmd.map RunAddonMsg dc )
+
+        RunSelectedAddon ->
+            let
+                configs =
+                    Comp.Dropdown.getSelected model.addonRunConfigDropdown
+                        |> List.map .id
+
+                payload =
+                    { itemId = model.item.id
+                    , additionalItems = []
+                    , addonRunConfigIds = configs
+                    }
+
+                ( dd, _ ) =
+                    Comp.Dropdown.update (Comp.Dropdown.SetSelection []) model.addonRunConfigDropdown
+            in
+            case configs of
+                [] ->
+                    resultModel model
+
+                _ ->
+                    resultModelCmd
+                        ( { model | addonRunConfigDropdown = dd }
+                        , Api.addonRunExistingItem env.flags payload RunAddonResp
+                        )
+
+        LoadRunConfigResp (Err _) ->
+            resultModel model
+
+        RunAddonResp (Ok res) ->
+            if res.success then
+                resultModelCmd
+                    ( { model | addonRunSubmitted = True }
+                    , Process.sleep 1200 |> Task.perform (\_ -> SetAddonRunSubmitted False)
+                    )
+
+            else
+                resultModel model
+
+        RunAddonResp (Err _) ->
+            resultModel model
+
+        SetAddonRunSubmitted flag ->
+            resultModel { model | addonRunSubmitted = flag }
+
         SetItem item ->
             let
                 res1 =
@@ -1638,6 +1709,9 @@ update inav env msg model =
                 , Sub.map ItemLinkFormMsg ils
                 )
 
+        ToggleShowRunAddon ->
+            resultModel { model | showRunAddon = not model.showRunAddon, mobileItemMenuOpen = False }
+
 
 
 --- Helper
diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/View2.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/View2.elm
index 54a1d3d9..41abc845 100644
--- a/modules/webapp/src/main/elm/Comp/ItemDetail/View2.elm
+++ b/modules/webapp/src/main/elm/Comp/ItemDetail/View2.elm
@@ -22,6 +22,7 @@ import Comp.ItemDetail.Model
         , isShowQrItem
         )
 import Comp.ItemDetail.Notes
+import Comp.ItemDetail.RunAddonForm
 import Comp.ItemDetail.ShowQrCode
 import Comp.ItemDetail.SingleAttachment
 import Comp.ItemLinkForm
@@ -177,6 +178,24 @@ menuBar texts inav env model =
                     ]
                     [ Icons.addFilesIcon2 ""
                     ]
+            , MB.CustomElement <|
+                a
+                    [ classList
+                        [ ( "bg-gray-200 dark:bg-slate-600", model.showRunAddon )
+                        , ( "hidden", not env.flags.config.addonsEnabled || List.isEmpty model.runConfigs )
+                        , ( "hidden md:block", env.flags.config.addonsEnabled && not (List.isEmpty model.runConfigs) )
+                        ]
+                    , if model.showRunAddon then
+                        title texts.close
+
+                      else
+                        title texts.runAddonTitle
+                    , onClick ToggleShowRunAddon
+                    , class S.secondaryBasicButton
+                    , href "#"
+                    ]
+                    [ Icons.addonIcon ""
+                    ]
             , MB.CustomElement <|
                 a
                     [ classList
@@ -248,6 +267,15 @@ menuBar texts inav env model =
                             , onClick AddFilesToggle
                             ]
                       }
+                    , { icon = Icons.addonIcon ""
+                      , label = texts.runAddonLabel
+                      , disabled = False
+                      , attrs =
+                            [ href "#"
+                            , onClick ToggleShowRunAddon
+                            , classList [ ( "hidden", not env.flags.config.addonsEnabled ) ]
+                            ]
+                      }
                     , { icon = Icons.showQrIcon ""
                       , label = texts.showQrCode
                       , disabled = False
@@ -402,6 +430,11 @@ itemActions texts flags settings model classes =
             (S.border ++ " mb-4")
             model
             (Comp.ItemDetail.ShowQrCode.Item model.item.id)
+        , if flags.config.addonsEnabled then
+            Comp.ItemDetail.RunAddonForm.view texts.runAddonForm settings model
+
+          else
+            span [ class "hidden" ] []
         ]
 
 
diff --git a/modules/webapp/src/main/elm/Data/AddonTrigger.elm b/modules/webapp/src/main/elm/Data/AddonTrigger.elm
new file mode 100644
index 00000000..f790d4e0
--- /dev/null
+++ b/modules/webapp/src/main/elm/Data/AddonTrigger.elm
@@ -0,0 +1,59 @@
+{-
+   Copyright 2020 Eike K. & Contributors
+
+   SPDX-License-Identifier: AGPL-3.0-or-later
+-}
+
+
+module Data.AddonTrigger exposing (..)
+
+-- A copy of docspell.addons.AddonTrigger.scala
+
+
+type AddonTrigger
+    = FinalProcessItem
+    | FinalReprocessItem
+    | Scheduled
+    | ExistingItem
+
+
+all : List AddonTrigger
+all =
+    [ FinalProcessItem
+    , FinalReprocessItem
+    , Scheduled
+    , ExistingItem
+    ]
+
+
+asString : AddonTrigger -> String
+asString t =
+    case t of
+        FinalProcessItem ->
+            "final-process-item"
+
+        FinalReprocessItem ->
+            "final-reprocess-item"
+
+        Scheduled ->
+            "scheduled"
+
+        ExistingItem ->
+            "existing-item"
+
+
+fromString : String -> Maybe AddonTrigger
+fromString s =
+    let
+        name =
+            String.toLower s
+
+        x =
+            List.filter (\e -> asString e == name) all
+    in
+    List.head x
+
+
+fromList : List String -> List AddonTrigger
+fromList list =
+    List.filterMap fromString list
diff --git a/modules/webapp/src/main/elm/Data/CalEvent.elm b/modules/webapp/src/main/elm/Data/CalEvent.elm
index 6453f703..cff62da9 100644
--- a/modules/webapp/src/main/elm/Data/CalEvent.elm
+++ b/modules/webapp/src/main/elm/Data/CalEvent.elm
@@ -8,6 +8,7 @@
 module Data.CalEvent exposing
     ( CalEvent
     , everyMonth
+    , everyMonthTz
     , fromEvent
     , makeEvent
     )
@@ -29,7 +30,12 @@ type alias CalEvent =
 
 everyMonth : CalEvent
 everyMonth =
-    CalEvent Nothing "*" "*" "01" "00" "00" Data.TimeZone.utc
+    everyMonthTz Data.TimeZone.utc
+
+
+everyMonthTz : TimeZone -> CalEvent
+everyMonthTz tz =
+    CalEvent Nothing "*" "*" "01" "00" "00" tz
 
 
 makeEvent : CalEvent -> String
diff --git a/modules/webapp/src/main/elm/Data/Flags.elm b/modules/webapp/src/main/elm/Data/Flags.elm
index f91aba31..614b452a 100644
--- a/modules/webapp/src/main/elm/Data/Flags.elm
+++ b/modules/webapp/src/main/elm/Data/Flags.elm
@@ -38,6 +38,7 @@ type alias Config =
     , downloadAllMaxFiles : Int
     , downloadAllMaxSize : Int
     , openIdAuth : List OpenIdAuth
+    , addonsEnabled : Bool
     }
 
 
diff --git a/modules/webapp/src/main/elm/Data/Icons.elm b/modules/webapp/src/main/elm/Data/Icons.elm
index 236390a7..c50cebb5 100644
--- a/modules/webapp/src/main/elm/Data/Icons.elm
+++ b/modules/webapp/src/main/elm/Data/Icons.elm
@@ -8,6 +8,8 @@
 module Data.Icons exposing
     ( addFiles2
     , addFilesIcon2
+    , addonIcon
+    , addonRunConfigIcon
     , concerned
     , concerned2
     , concernedIcon
@@ -76,7 +78,7 @@ module Data.Icons exposing
     )
 
 import Data.CustomFieldType exposing (CustomFieldType)
-import Html exposing (Html, i, img)
+import Html exposing (Html, div, i, img, span)
 import Html.Attributes exposing (class, src)
 import Svg
 import Svg.Attributes as SA
@@ -265,6 +267,24 @@ customFieldIcon2 classes =
     i [ class (customField2 ++ " " ++ classes) ] []
 
 
+addon : String
+addon =
+    "fa fa-puzzle-piece"
+
+
+addonIcon : String -> Html msg
+addonIcon classes =
+    i [ class (addon ++ " " ++ classes) ] []
+
+
+addonRunConfigIcon : String -> Html msg
+addonRunConfigIcon classes =
+    div [ class (classes ++ " inline-block relative margin-auto leading-8") ]
+        [ i [ class "fa fa-puzzle-piece" ] []
+        , i [ class "fa fa-play font-bold absolute text-xs -right-2 top-0" ] []
+        ]
+
+
 search : String
 search =
     "fa fa-search"
diff --git a/modules/webapp/src/main/elm/Data/ServerEvent.elm b/modules/webapp/src/main/elm/Data/ServerEvent.elm
index 194ba0cc..57320996 100644
--- a/modules/webapp/src/main/elm/Data/ServerEvent.elm
+++ b/modules/webapp/src/main/elm/Data/ServerEvent.elm
@@ -5,15 +5,34 @@
 -}
 
 
-module Data.ServerEvent exposing (ServerEvent(..), decode)
+module Data.ServerEvent exposing (AddonInfo, ServerEvent(..), decode)
 
 import Json.Decode as D
+import Json.Decode.Pipeline as P
 
 
 type ServerEvent
     = JobSubmitted String
     | JobDone String
     | JobsWaiting Int
+    | AddonInstalled AddonInfo
+
+
+type alias AddonInfo =
+    { success : Bool
+    , addonId : Maybe String
+    , addonUrl : Maybe String
+    , message : String
+    }
+
+
+addonInfoDecoder : D.Decoder AddonInfo
+addonInfoDecoder =
+    D.succeed AddonInfo
+        |> P.required "success" D.bool
+        |> P.optional "addonId" (D.maybe D.string) Nothing
+        |> P.optional "addonUrl" (D.maybe D.string) Nothing
+        |> P.required "message" D.string
 
 
 decoder : D.Decoder ServerEvent
@@ -43,5 +62,9 @@ decodeTag tag =
             D.field "content" D.int
                 |> D.map JobsWaiting
 
+        "addon-installed" ->
+            D.field "content" addonInfoDecoder
+                |> D.map AddonInstalled
+
         _ ->
             D.fail ("Unknown tag: " ++ tag)
diff --git a/modules/webapp/src/main/elm/Messages/Comp/AddonArchiveForm.elm b/modules/webapp/src/main/elm/Messages/Comp/AddonArchiveForm.elm
new file mode 100644
index 00000000..3bac22c4
--- /dev/null
+++ b/modules/webapp/src/main/elm/Messages/Comp/AddonArchiveForm.elm
@@ -0,0 +1,54 @@
+{-
+   Copyright 2020 Eike K. & Contributors
+
+   SPDX-License-Identifier: AGPL-3.0-or-later
+-}
+
+
+module Messages.Comp.AddonArchiveForm exposing
+    ( Texts
+    , de
+    , fr
+    , gb
+    )
+
+import Messages.Basics
+
+
+type alias Texts =
+    { basics : Messages.Basics.Texts
+    , addonUrl : String
+    , addonUrlPlaceholder : String
+    , installInfoText : String
+    }
+
+
+gb : Texts
+gb =
+    { basics = Messages.Basics.gb
+    , addonUrl = "Addon URL"
+    , addonUrlPlaceholder = "e.g. https://github.com/some-user/project/refs/tags/1.0.zip"
+    , installInfoText = "Only urls to remote addon zip files are supported."
+    }
+
+
+de : Texts
+de =
+    { basics = Messages.Basics.de
+    , addonUrl = "Addon URL"
+    , addonUrlPlaceholder = "z.B. https://github.com/some-user/project/refs/tags/1.0.zip"
+    , installInfoText = "Nur URLs to externen zip Dateien werden unterstützt."
+    }
+
+
+
+-- TODO: translate-fr
+
+
+fr : Texts
+fr =
+    { basics = Messages.Basics.fr
+    , addonUrl = "Addon URL"
+    , addonUrlPlaceholder = "p.e. https://github.com/some-user/project/refs/tags/1.0.zip"
+    , installInfoText = "Only urls to remote addon zip files are supported."
+    }
diff --git a/modules/webapp/src/main/elm/Messages/Comp/AddonArchiveManage.elm b/modules/webapp/src/main/elm/Messages/Comp/AddonArchiveManage.elm
new file mode 100644
index 00000000..5cc6b135
--- /dev/null
+++ b/modules/webapp/src/main/elm/Messages/Comp/AddonArchiveManage.elm
@@ -0,0 +1,86 @@
+{-
+   Copyright 2020 Eike K. & Contributors
+
+   SPDX-License-Identifier: AGPL-3.0-or-later
+-}
+
+
+module Messages.Comp.AddonArchiveManage exposing
+    ( Texts
+    , de
+    , fr
+    , gb
+    )
+
+import Http
+import Messages.Basics
+import Messages.Comp.AddonArchiveForm
+import Messages.Comp.AddonArchiveTable
+import Messages.Comp.HttpError
+
+
+type alias Texts =
+    { basics : Messages.Basics.Texts
+    , addonArchiveTable : Messages.Comp.AddonArchiveTable.Texts
+    , addonArchiveForm : Messages.Comp.AddonArchiveForm.Texts
+    , httpError : Http.Error -> String
+    , newAddonArchive : String
+    , reallyDeleteAddonArchive : String
+    , createNewAddonArchive : String
+    , deleteThisAddonArchive : String
+    , correctFormErrors : String
+    , installNow : String
+    , updateNow : String
+    , description : String
+    }
+
+
+gb : Texts
+gb =
+    { basics = Messages.Basics.gb
+    , addonArchiveTable = Messages.Comp.AddonArchiveTable.gb
+    , addonArchiveForm = Messages.Comp.AddonArchiveForm.gb
+    , httpError = Messages.Comp.HttpError.gb
+    , newAddonArchive = "New Addon"
+    , reallyDeleteAddonArchive = "Really delete this Addon?"
+    , createNewAddonArchive = "Install new Addon"
+    , deleteThisAddonArchive = "Delete this Addon"
+    , correctFormErrors = "Please correct the errors in the form."
+    , installNow = "Install Addon"
+    , updateNow = "Update Addon"
+    , description = "Description"
+    }
+
+
+de : Texts
+de =
+    { basics = Messages.Basics.de
+    , addonArchiveTable = Messages.Comp.AddonArchiveTable.de
+    , addonArchiveForm = Messages.Comp.AddonArchiveForm.de
+    , httpError = Messages.Comp.HttpError.de
+    , newAddonArchive = "Neues Addon"
+    , reallyDeleteAddonArchive = "Dieses Addon wirklich entfernen?"
+    , createNewAddonArchive = "Neues Addon installieren"
+    , deleteThisAddonArchive = "Addon löschen"
+    , correctFormErrors = "Bitte korrigiere die Fehler im Formular."
+    , installNow = "Addon Installieren"
+    , updateNow = "Addon aktualisieren"
+    , description = "Beschreibung"
+    }
+
+
+fr : Texts
+fr =
+    { basics = Messages.Basics.fr
+    , addonArchiveTable = Messages.Comp.AddonArchiveTable.fr
+    , addonArchiveForm = Messages.Comp.AddonArchiveForm.fr
+    , httpError = Messages.Comp.HttpError.fr
+    , newAddonArchive = "Nouveau favori"
+    , reallyDeleteAddonArchive = "Confirmer la suppression de ce favori ?"
+    , createNewAddonArchive = "Créer un nouveau favori"
+    , deleteThisAddonArchive = "Supprimer ce favori"
+    , correctFormErrors = "Veuillez corriger les erreurs du formulaire"
+    , installNow = "Installation de l'addon"
+    , updateNow = "Actualiser l'addon"
+    , description = "Description"
+    }
diff --git a/modules/webapp/src/main/elm/Messages/Comp/AddonArchiveTable.elm b/modules/webapp/src/main/elm/Messages/Comp/AddonArchiveTable.elm
new file mode 100644
index 00000000..47f95eee
--- /dev/null
+++ b/modules/webapp/src/main/elm/Messages/Comp/AddonArchiveTable.elm
@@ -0,0 +1,42 @@
+{-
+   Copyright 2020 Eike K. & Contributors
+
+   SPDX-License-Identifier: AGPL-3.0-or-later
+-}
+
+
+module Messages.Comp.AddonArchiveTable exposing
+    ( Texts
+    , de
+    , fr
+    , gb
+    )
+
+import Messages.Basics
+
+
+type alias Texts =
+    { basics : Messages.Basics.Texts
+    , version : String
+    }
+
+
+gb : Texts
+gb =
+    { basics = Messages.Basics.gb
+    , version = "Version"
+    }
+
+
+de : Texts
+de =
+    { basics = Messages.Basics.de
+    , version = "Version"
+    }
+
+
+fr : Texts
+fr =
+    { basics = Messages.Basics.fr
+    , version = "Version"
+    }
diff --git a/modules/webapp/src/main/elm/Messages/Comp/AddonRunConfigForm.elm b/modules/webapp/src/main/elm/Messages/Comp/AddonRunConfigForm.elm
new file mode 100644
index 00000000..181239f9
--- /dev/null
+++ b/modules/webapp/src/main/elm/Messages/Comp/AddonRunConfigForm.elm
@@ -0,0 +1,108 @@
+{-
+   Copyright 2020 Eike K. & Contributors
+
+   SPDX-License-Identifier: AGPL-3.0-or-later
+-}
+
+
+module Messages.Comp.AddonRunConfigForm exposing
+    ( Texts
+    , de
+    , fr
+    , gb
+    )
+
+import Data.TimeZone exposing (TimeZone)
+import Messages.Basics
+import Messages.Comp.CalEventInput
+
+
+type alias Texts =
+    { basics : Messages.Basics.Texts
+    , calEventInput : Messages.Comp.CalEventInput.Texts
+    , enableDisable : String
+    , chooseName : String
+    , impersonateUser : String
+    , triggerRun : String
+    , schedule : String
+    , addons : String
+    , includedAddons : String
+    , add : String
+    , readMore : String
+    , readLess : String
+    , arguments : String
+    , update : String
+    , argumentsUpdated : String
+    , configureTitle : String
+    , configureLabel : String
+    }
+
+
+gb : TimeZone -> Texts
+gb tz =
+    { basics = Messages.Basics.gb
+    , calEventInput = Messages.Comp.CalEventInput.gb tz
+    , enableDisable = "Enable or disable this run configuration."
+    , chooseName = "Choose a name…"
+    , impersonateUser = "Run on behalf of user"
+    , triggerRun = "Trigger Run"
+    , schedule = "Schedule"
+    , addons = "Addons"
+    , includedAddons = "Included addons"
+    , add = "Add"
+    , readMore = "Read more"
+    , readLess = "Read less"
+    , arguments = "Arguments"
+    , update = "Update"
+    , argumentsUpdated = "Arguments updated"
+    , configureTitle = "Configure this addon"
+    , configureLabel = "Configure"
+    }
+
+
+de : TimeZone -> Texts
+de tz =
+    { basics = Messages.Basics.de
+    , calEventInput = Messages.Comp.CalEventInput.de tz
+    , enableDisable = "Konfiguration aktivieren oder deaktivieren"
+    , chooseName = "Name der Konfiguration…"
+    , impersonateUser = "Als Benutzer ausführen"
+    , triggerRun = "Auslöser"
+    , schedule = "Zeitplan"
+    , addons = "Addons"
+    , includedAddons = "Gewählte Addons"
+    , add = "Hinzufügen"
+    , readMore = "Mehr"
+    , readLess = "Weniger"
+    , arguments = "Argumente"
+    , update = "Aktualisieren"
+    , argumentsUpdated = "Argumente aktualisiert"
+    , configureTitle = "Konfiguriere dieses Addon"
+    , configureLabel = "Konfigurieren"
+    }
+
+
+
+-- TODO: translate-fr
+
+
+fr : TimeZone -> Texts
+fr tz =
+    { basics = Messages.Basics.fr
+    , calEventInput = Messages.Comp.CalEventInput.fr tz
+    , enableDisable = "Activer ou désactiver cette tâche."
+    , chooseName = "Choose a name…"
+    , impersonateUser = "Impersonate user"
+    , triggerRun = "Trigger Run"
+    , schedule = "Programmation"
+    , addons = "Addons"
+    , includedAddons = "Included addons"
+    , add = "Ajouter"
+    , readMore = "Read more"
+    , readLess = "Read less"
+    , arguments = "Arguments"
+    , update = "Update"
+    , argumentsUpdated = "Arguments updated"
+    , configureTitle = "Configure this addon"
+    , configureLabel = "Configure"
+    }
diff --git a/modules/webapp/src/main/elm/Messages/Comp/AddonRunConfigManage.elm b/modules/webapp/src/main/elm/Messages/Comp/AddonRunConfigManage.elm
new file mode 100644
index 00000000..b50a17fc
--- /dev/null
+++ b/modules/webapp/src/main/elm/Messages/Comp/AddonRunConfigManage.elm
@@ -0,0 +1,79 @@
+{-
+   Copyright 2020 Eike K. & Contributors
+
+   SPDX-License-Identifier: AGPL-3.0-or-later
+-}
+
+
+module Messages.Comp.AddonRunConfigManage exposing
+    ( Texts
+    , de
+    , fr
+    , gb
+    )
+
+import Data.TimeZone exposing (TimeZone)
+import Http
+import Messages.Basics
+import Messages.Comp.AddonRunConfigForm
+import Messages.Comp.AddonRunConfigTable
+import Messages.Comp.HttpError
+
+
+type alias Texts =
+    { basics : Messages.Basics.Texts
+    , addonArchiveTable : Messages.Comp.AddonRunConfigTable.Texts
+    , addonArchiveForm : Messages.Comp.AddonRunConfigForm.Texts
+    , httpError : Http.Error -> String
+    , newAddonRunConfig : String
+    , reallyDeleteAddonRunConfig : String
+    , createNewAddonRunConfig : String
+    , deleteThisAddonRunConfig : String
+    , correctFormErrors : String
+    }
+
+
+gb : TimeZone -> Texts
+gb tz =
+    { basics = Messages.Basics.gb
+    , addonArchiveTable = Messages.Comp.AddonRunConfigTable.gb
+    , addonArchiveForm = Messages.Comp.AddonRunConfigForm.gb tz
+    , httpError = Messages.Comp.HttpError.gb
+    , newAddonRunConfig = "New"
+    , reallyDeleteAddonRunConfig = "Really delete this run config?"
+    , createNewAddonRunConfig = "Create a new run configuration"
+    , deleteThisAddonRunConfig = "Delete this run configuration"
+    , correctFormErrors = "Please correct the errors in the form."
+    }
+
+
+de : TimeZone -> Texts
+de tz =
+    { basics = Messages.Basics.de
+    , addonArchiveTable = Messages.Comp.AddonRunConfigTable.de
+    , addonArchiveForm = Messages.Comp.AddonRunConfigForm.de tz
+    , httpError = Messages.Comp.HttpError.de
+    , newAddonRunConfig = "Neu"
+    , reallyDeleteAddonRunConfig = "Dieses Konfiguration wirklich entfernen?"
+    , createNewAddonRunConfig = "Neue Run-Konfiguration erstellen"
+    , deleteThisAddonRunConfig = "Run-Konfiguration löschen"
+    , correctFormErrors = "Bitte korrigiere die Fehler im Formular."
+    }
+
+
+
+--- TODO translate-fr
+
+
+fr : TimeZone -> Texts
+fr tz =
+    { basics = Messages.Basics.fr
+    , addonArchiveTable = Messages.Comp.AddonRunConfigTable.fr
+    , addonArchiveForm = Messages.Comp.AddonRunConfigForm.fr tz
+    , httpError = Messages.Comp.HttpError.fr
+    , newAddonRunConfig = "Nouveau favori"
+    , reallyDeleteAddonRunConfig = "Confirmer la suppression de ce favori ?"
+    , createNewAddonRunConfig = "Créer un nouveau favori"
+    , deleteThisAddonRunConfig = "Supprimer ce favori"
+    , correctFormErrors = "Veuillez corriger les erreurs du formulaire"
+    }
diff --git a/modules/webapp/src/main/elm/Messages/Comp/AddonRunConfigTable.elm b/modules/webapp/src/main/elm/Messages/Comp/AddonRunConfigTable.elm
new file mode 100644
index 00000000..02c4b417
--- /dev/null
+++ b/modules/webapp/src/main/elm/Messages/Comp/AddonRunConfigTable.elm
@@ -0,0 +1,50 @@
+{-
+   Copyright 2020 Eike K. & Contributors
+
+   SPDX-License-Identifier: AGPL-3.0-or-later
+-}
+
+
+module Messages.Comp.AddonRunConfigTable exposing
+    ( Texts
+    , de
+    , fr
+    , gb
+    )
+
+import Messages.Basics
+
+
+type alias Texts =
+    { basics : Messages.Basics.Texts
+    , enabled : String
+    , trigger : String
+    }
+
+
+gb : Texts
+gb =
+    { basics = Messages.Basics.gb
+    , enabled = "Enabled"
+    , trigger = "Triggered"
+    }
+
+
+de : Texts
+de =
+    { basics = Messages.Basics.de
+    , enabled = "Aktive"
+    , trigger = "Auslöser"
+    }
+
+
+
+-- TODO translate-fr
+
+
+fr : Texts
+fr =
+    { basics = Messages.Basics.fr
+    , enabled = "Enabled"
+    , trigger = "Triggered"
+    }
diff --git a/modules/webapp/src/main/elm/Messages/Comp/ItemDetail.elm b/modules/webapp/src/main/elm/Messages/Comp/ItemDetail.elm
index 3ea89606..fca6e904 100644
--- a/modules/webapp/src/main/elm/Messages/Comp/ItemDetail.elm
+++ b/modules/webapp/src/main/elm/Messages/Comp/ItemDetail.elm
@@ -20,6 +20,7 @@ import Messages.Comp.ItemDetail.AddFilesForm
 import Messages.Comp.ItemDetail.ConfirmModal
 import Messages.Comp.ItemDetail.ItemInfoHeader
 import Messages.Comp.ItemDetail.Notes
+import Messages.Comp.ItemDetail.RunAddonForm
 import Messages.Comp.ItemDetail.SingleAttachment
 import Messages.Comp.ItemLinkForm
 import Messages.Comp.ItemMail
@@ -38,6 +39,7 @@ type alias Texts =
     , detailEdit : Messages.Comp.DetailEdit.Texts
     , confirmModal : Messages.Comp.ItemDetail.ConfirmModal.Texts
     , itemLinkForm : Messages.Comp.ItemLinkForm.Texts
+    , runAddonForm : Messages.Comp.ItemDetail.RunAddonForm.Texts
     , httpError : Http.Error -> String
     , key : String
     , backToSearchResults : String
@@ -64,6 +66,8 @@ type alias Texts =
     , selectItem : String
     , deselectItem : String
     , relatedItems : String
+    , runAddonLabel : String
+    , runAddonTitle : String
     }
 
 
@@ -78,6 +82,7 @@ gb tz =
     , detailEdit = Messages.Comp.DetailEdit.gb
     , confirmModal = Messages.Comp.ItemDetail.ConfirmModal.gb
     , itemLinkForm = Messages.Comp.ItemLinkForm.gb tz
+    , runAddonForm = Messages.Comp.ItemDetail.RunAddonForm.gb
     , httpError = Messages.Comp.HttpError.gb
     , key = "Key"
     , backToSearchResults = "Back to search results"
@@ -104,6 +109,8 @@ gb tz =
     , selectItem = "Select this item"
     , deselectItem = "Deselect this item"
     , relatedItems = "Linked items"
+    , runAddonLabel = "Run addon"
+    , runAddonTitle = "Run an addon on this item"
     }
 
 
@@ -118,6 +125,7 @@ de tz =
     , detailEdit = Messages.Comp.DetailEdit.de
     , confirmModal = Messages.Comp.ItemDetail.ConfirmModal.de
     , itemLinkForm = Messages.Comp.ItemLinkForm.de tz
+    , runAddonForm = Messages.Comp.ItemDetail.RunAddonForm.de
     , httpError = Messages.Comp.HttpError.de
     , key = "Taste"
     , backToSearchResults = "Zurück zur Suche"
@@ -144,6 +152,8 @@ de tz =
     , selectItem = "Zur Auswahl hinzufügen"
     , deselectItem = "Aus Auswahl entfernen"
     , relatedItems = "Verknüpfte Dokumente"
+    , runAddonLabel = "Addon ausführen"
+    , runAddonTitle = "Addons für dieses Dokument ausführen"
     }
 
 
@@ -158,6 +168,7 @@ fr tz =
     , detailEdit = Messages.Comp.DetailEdit.fr
     , confirmModal = Messages.Comp.ItemDetail.ConfirmModal.fr
     , itemLinkForm = Messages.Comp.ItemLinkForm.fr tz
+    , runAddonForm = Messages.Comp.ItemDetail.RunAddonForm.fr
     , httpError = Messages.Comp.HttpError.fr
     , key = "Clé"
     , backToSearchResults = "Retour aux résultat de recherche"
@@ -184,4 +195,10 @@ fr tz =
     , selectItem = "Sélectionner  ce document"
     , deselectItem = "Désélectionner  ce document"
     , relatedItems = "Documents associés"
+    , runAddonLabel = "Run addon"
+    , runAddonTitle = "Run an addon on this item"
     }
+
+
+
+-- TODO translate-fr
diff --git a/modules/webapp/src/main/elm/Messages/Comp/ItemDetail/RunAddonForm.elm b/modules/webapp/src/main/elm/Messages/Comp/ItemDetail/RunAddonForm.elm
new file mode 100644
index 00000000..9901c55b
--- /dev/null
+++ b/modules/webapp/src/main/elm/Messages/Comp/ItemDetail/RunAddonForm.elm
@@ -0,0 +1,49 @@
+{-
+   Copyright 2020 Eike K. & Contributors
+
+   SPDX-License-Identifier: AGPL-3.0-or-later
+-}
+
+
+module Messages.Comp.ItemDetail.RunAddonForm exposing (Texts, de, fr, gb)
+
+import Messages.Basics
+
+
+type alias Texts =
+    { basics : Messages.Basics.Texts
+    , runAddon : String
+    , addonRunConfig : String
+    , runAddonTitle : String
+    }
+
+
+gb : Texts
+gb =
+    { basics = Messages.Basics.gb
+    , runAddon = "Run an addon"
+    , addonRunConfig = "Addon run configuration"
+    , runAddonTitle = "Run the selected addon on this item."
+    }
+
+
+de : Texts
+de =
+    { basics = Messages.Basics.de
+    , runAddon = "Addon ausführen"
+    , addonRunConfig = "Addon Konfiguration"
+    , runAddonTitle = "Run the selected addon on this item."
+    }
+
+
+
+-- TODO: translate-fr
+
+
+fr : Texts
+fr =
+    { basics = Messages.Basics.fr
+    , runAddon = "Run an addon"
+    , addonRunConfig = "Addon run configuration"
+    , runAddonTitle = "Run the selected addon on this item."
+    }
diff --git a/modules/webapp/src/main/elm/Messages/Page/ManageData.elm b/modules/webapp/src/main/elm/Messages/Page/ManageData.elm
index 5760f0f6..6ce47932 100644
--- a/modules/webapp/src/main/elm/Messages/Page/ManageData.elm
+++ b/modules/webapp/src/main/elm/Messages/Page/ManageData.elm
@@ -14,6 +14,8 @@ module Messages.Page.ManageData exposing
 
 import Data.TimeZone exposing (TimeZone)
 import Messages.Basics
+import Messages.Comp.AddonArchiveManage
+import Messages.Comp.AddonRunConfigManage
 import Messages.Comp.BookmarkManage
 import Messages.Comp.CustomFieldManage
 import Messages.Comp.EquipmentManage
@@ -32,8 +34,12 @@ type alias Texts =
     , folderManage : Messages.Comp.FolderManage.Texts
     , customFieldManage : Messages.Comp.CustomFieldManage.Texts
     , bookmarkManage : Messages.Comp.BookmarkManage.Texts
+    , addonArchiveManage : Messages.Comp.AddonArchiveManage.Texts
+    , addonRunConfigManage : Messages.Comp.AddonRunConfigManage.Texts
     , manageData : String
     , bookmarks : String
+    , addonArchives : String
+    , addonRunConfigs : String
     }
 
 
@@ -47,8 +53,12 @@ gb tz =
     , folderManage = Messages.Comp.FolderManage.gb tz
     , customFieldManage = Messages.Comp.CustomFieldManage.gb tz
     , bookmarkManage = Messages.Comp.BookmarkManage.gb
+    , addonArchiveManage = Messages.Comp.AddonArchiveManage.gb
+    , addonRunConfigManage = Messages.Comp.AddonRunConfigManage.gb tz
     , manageData = "Manage Data"
     , bookmarks = "Bookmarks"
+    , addonArchives = "Addons"
+    , addonRunConfigs = "Addon Run Configurations"
     }
 
 
@@ -62,8 +72,12 @@ de tz =
     , folderManage = Messages.Comp.FolderManage.de tz
     , customFieldManage = Messages.Comp.CustomFieldManage.de tz
     , bookmarkManage = Messages.Comp.BookmarkManage.de
+    , addonArchiveManage = Messages.Comp.AddonArchiveManage.de
+    , addonRunConfigManage = Messages.Comp.AddonRunConfigManage.de tz
     , manageData = "Daten verwalten"
     , bookmarks = "Bookmarks"
+    , addonArchives = "Addons"
+    , addonRunConfigs = "Addon Run Configurations"
     }
 
 
@@ -77,6 +91,10 @@ fr tz =
     , folderManage = Messages.Comp.FolderManage.fr tz
     , customFieldManage = Messages.Comp.CustomFieldManage.fr tz
     , bookmarkManage = Messages.Comp.BookmarkManage.fr
+    , addonArchiveManage = Messages.Comp.AddonArchiveManage.fr
+    , addonRunConfigManage = Messages.Comp.AddonRunConfigManage.fr tz
     , manageData = "Gestion des métadonnées"
     , bookmarks = "Favoris"
+    , addonArchives = "Addons"
+    , addonRunConfigs = "Addon Run Configurations"
     }
diff --git a/modules/webapp/src/main/elm/Page/ManageData/Data.elm b/modules/webapp/src/main/elm/Page/ManageData/Data.elm
index f56b8702..e0a0fd45 100644
--- a/modules/webapp/src/main/elm/Page/ManageData/Data.elm
+++ b/modules/webapp/src/main/elm/Page/ManageData/Data.elm
@@ -12,6 +12,8 @@ module Page.ManageData.Data exposing
     , init
     )
 
+import Comp.AddonArchiveManage
+import Comp.AddonRunConfigManage
 import Comp.BookmarkManage
 import Comp.CustomFieldManage
 import Comp.EquipmentManage
@@ -31,6 +33,8 @@ type alias Model =
     , folderManageModel : Comp.FolderManage.Model
     , fieldManageModel : Comp.CustomFieldManage.Model
     , bookmarkModel : Comp.BookmarkManage.Model
+    , addonArchiveModel : Comp.AddonArchiveManage.Model
+    , addonRunConfigModel : Comp.AddonRunConfigManage.Model
     }
 
 
@@ -42,6 +46,12 @@ init flags =
 
         ( bm, bc ) =
             Comp.BookmarkManage.init flags
+
+        ( aam, aac ) =
+            Comp.AddonArchiveManage.init flags
+
+        ( arm, arc ) =
+            Comp.AddonRunConfigManage.init flags
     in
     ( { currentTab = Just TagTab
       , tagManageModel = m2
@@ -51,10 +61,14 @@ init flags =
       , folderManageModel = Comp.FolderManage.empty
       , fieldManageModel = Comp.CustomFieldManage.empty
       , bookmarkModel = bm
+      , addonArchiveModel = aam
+      , addonRunConfigModel = arm
       }
     , Cmd.batch
         [ Cmd.map TagManageMsg c2
         , Cmd.map BookmarkMsg bc
+        , Cmd.map AddonArchiveMsg aac
+        , Cmd.map AddonRunConfigMsg arc
         ]
     )
 
@@ -67,6 +81,8 @@ type Tab
     | FolderTab
     | CustomFieldTab
     | BookmarkTab
+    | AddonArchiveTab
+    | AddonRunConfigTab
 
 
 type Msg
@@ -78,3 +94,5 @@ type Msg
     | FolderMsg Comp.FolderManage.Msg
     | CustomFieldMsg Comp.CustomFieldManage.Msg
     | BookmarkMsg Comp.BookmarkManage.Msg
+    | AddonArchiveMsg Comp.AddonArchiveManage.Msg
+    | AddonRunConfigMsg Comp.AddonRunConfigManage.Msg
diff --git a/modules/webapp/src/main/elm/Page/ManageData/Update.elm b/modules/webapp/src/main/elm/Page/ManageData/Update.elm
index b2c61dd3..981f1b38 100644
--- a/modules/webapp/src/main/elm/Page/ManageData/Update.elm
+++ b/modules/webapp/src/main/elm/Page/ManageData/Update.elm
@@ -7,6 +7,8 @@
 
 module Page.ManageData.Update exposing (update)
 
+import Comp.AddonArchiveManage
+import Comp.AddonRunConfigManage
 import Comp.BookmarkManage
 import Comp.CustomFieldManage
 import Comp.EquipmentManage
@@ -15,11 +17,12 @@ import Comp.OrgManage
 import Comp.PersonManage
 import Comp.TagManage
 import Data.Flags exposing (Flags)
+import Data.UiSettings exposing (UiSettings)
 import Page.ManageData.Data exposing (..)
 
 
-update : Flags -> Msg -> Model -> ( Model, Cmd Msg, Sub Msg )
-update flags msg model =
+update : Flags -> UiSettings -> Msg -> Model -> ( Model, Cmd Msg, Sub Msg )
+update flags uiSettings msg model =
     case msg of
         SetTab t ->
             let
@@ -28,16 +31,16 @@ update flags msg model =
             in
             case t of
                 TagTab ->
-                    update flags (TagManageMsg Comp.TagManage.LoadTags) m
+                    update flags uiSettings (TagManageMsg Comp.TagManage.LoadTags) m
 
                 EquipTab ->
-                    update flags (EquipManageMsg Comp.EquipmentManage.LoadEquipments) m
+                    update flags uiSettings (EquipManageMsg Comp.EquipmentManage.LoadEquipments) m
 
                 OrgTab ->
-                    update flags (OrgManageMsg Comp.OrgManage.LoadOrgs) m
+                    update flags uiSettings (OrgManageMsg Comp.OrgManage.LoadOrgs) m
 
                 PersonTab ->
-                    update flags (PersonManageMsg Comp.PersonManage.LoadPersons) m
+                    update flags uiSettings (PersonManageMsg Comp.PersonManage.LoadPersons) m
 
                 FolderTab ->
                     let
@@ -60,6 +63,20 @@ update flags msg model =
                     in
                     ( { m | bookmarkModel = bm }, Cmd.map BookmarkMsg bc, Sub.none )
 
+                AddonArchiveTab ->
+                    let
+                        ( aam, aac ) =
+                            Comp.AddonArchiveManage.init flags
+                    in
+                    ( { m | addonArchiveModel = aam }, Cmd.map AddonArchiveMsg aac, Sub.none )
+
+                AddonRunConfigTab ->
+                    let
+                        ( arm, arc ) =
+                            Comp.AddonRunConfigManage.init flags
+                    in
+                    ( { m | addonRunConfigModel = arm }, Cmd.map AddonRunConfigMsg arc, Sub.none )
+
         TagManageMsg m ->
             let
                 ( m2, c2 ) =
@@ -117,3 +134,23 @@ update flags msg model =
             , Cmd.map BookmarkMsg c2
             , Sub.map BookmarkMsg s2
             )
+
+        AddonArchiveMsg lm ->
+            let
+                ( aam, aac, aas ) =
+                    Comp.AddonArchiveManage.update flags lm model.addonArchiveModel
+            in
+            ( { model | addonArchiveModel = aam }
+            , Cmd.map AddonArchiveMsg aac
+            , Sub.map AddonArchiveMsg aas
+            )
+
+        AddonRunConfigMsg lm ->
+            let
+                ( arm, arc, ars ) =
+                    Comp.AddonRunConfigManage.update flags uiSettings.timeZone lm model.addonRunConfigModel
+            in
+            ( { model | addonRunConfigModel = arm }
+            , Cmd.map AddonRunConfigMsg arc
+            , Sub.map AddonRunConfigMsg ars
+            )
diff --git a/modules/webapp/src/main/elm/Page/ManageData/View2.elm b/modules/webapp/src/main/elm/Page/ManageData/View2.elm
index 2354a7c3..53f814dc 100644
--- a/modules/webapp/src/main/elm/Page/ManageData/View2.elm
+++ b/modules/webapp/src/main/elm/Page/ManageData/View2.elm
@@ -7,6 +7,8 @@
 
 module Page.ManageData.View2 exposing (viewContent, viewSidebar)
 
+import Comp.AddonArchiveManage
+import Comp.AddonRunConfigManage
 import Comp.BookmarkManage
 import Comp.CustomFieldManage
 import Comp.EquipmentManage
@@ -27,7 +29,7 @@ import Styles as S
 
 
 viewSidebar : Texts -> Bool -> Flags -> UiSettings -> Model -> Html Msg
-viewSidebar texts visible _ settings model =
+viewSidebar texts visible flags settings model =
     div
         [ id "sidebar"
         , class S.sidebar
@@ -134,6 +136,32 @@ viewSidebar texts visible _ settings model =
                     [ text texts.bookmarks
                     ]
                 ]
+            , a
+                [ href "#"
+                , onClick (SetTab AddonArchiveTab)
+                , menuEntryActive model AddonArchiveTab
+                , class S.sidebarLink
+                , classList [ ( "hidden", not flags.config.addonsEnabled ) ]
+                ]
+                [ Icons.addonIcon ""
+                , span
+                    [ class "ml-3" ]
+                    [ text texts.addonArchives
+                    ]
+                ]
+            , a
+                [ href "#"
+                , onClick (SetTab AddonRunConfigTab)
+                , menuEntryActive model AddonRunConfigTab
+                , class S.sidebarLink
+                , classList [ ( "hidden", not flags.config.addonsEnabled ) ]
+                ]
+                [ Icons.addonRunConfigIcon ""
+                , span
+                    [ class "ml-3" ]
+                    [ text texts.addonRunConfigs
+                    ]
+                ]
             ]
         ]
 
@@ -166,6 +194,20 @@ viewContent texts flags settings model =
             Just BookmarkTab ->
                 viewBookmarks texts flags settings model
 
+            Just AddonArchiveTab ->
+                if flags.config.addonsEnabled then
+                    viewAddonArchives texts flags settings model
+
+                else
+                    []
+
+            Just AddonRunConfigTab ->
+                if flags.config.addonsEnabled then
+                    viewAddonRunConfigs texts flags settings model
+
+                else
+                    []
+
             Nothing ->
                 []
         )
@@ -306,3 +348,33 @@ viewBookmarks texts flags settings model =
         ]
     , Html.map BookmarkMsg (Comp.BookmarkManage.view texts.bookmarkManage settings flags model.bookmarkModel)
     ]
+
+
+viewAddonArchives : Texts -> Flags -> UiSettings -> Model -> List (Html Msg)
+viewAddonArchives texts flags settings model =
+    [ h2
+        [ class S.header1
+        , class "inline-flex items-center"
+        ]
+        [ Icons.addonIcon ""
+        , div [ class "ml-2" ]
+            [ text texts.addonArchives
+            ]
+        ]
+    , Html.map AddonArchiveMsg (Comp.AddonArchiveManage.view texts.addonArchiveManage settings flags model.addonArchiveModel)
+    ]
+
+
+viewAddonRunConfigs : Texts -> Flags -> UiSettings -> Model -> List (Html Msg)
+viewAddonRunConfigs texts flags settings model =
+    [ h2
+        [ class S.header1
+        , class "inline-flex items-center"
+        ]
+        [ Icons.addonRunConfigIcon "mr-4"
+        , div [ class "ml-2" ]
+            [ text texts.addonRunConfigs
+            ]
+        ]
+    , Html.map AddonRunConfigMsg (Comp.AddonRunConfigManage.view texts.addonRunConfigManage settings flags model.addonRunConfigModel)
+    ]
diff --git a/modules/webapp/src/main/elm/Styles.elm b/modules/webapp/src/main/elm/Styles.elm
index 48a866c4..a9a9304b 100644
--- a/modules/webapp/src/main/elm/Styles.elm
+++ b/modules/webapp/src/main/elm/Styles.elm
@@ -35,6 +35,11 @@ sidebarLink =
     " mb-2 px-4 py-3 flex flex-row hover:bg-blue-100 dark:hover:bg-slate-600 hover:font-bold rounded rounded-lg items-center "
 
 
+successText : String
+successText =
+    " text-green-600  dark:text-lime-500 "
+
+
 successMessage : String
 successMessage =
     " border border-green-600 bg-green-50 text-green-600 dark:border-lime-800 dark:bg-lime-300 dark:text-lime-800 px-4 py-2 rounded "
@@ -281,7 +286,7 @@ link =
 
 inputErrorBorder : String
 inputErrorBorder =
-    " border-red-600 dark:border-orange-600 "
+    " ring dark:ring-0 ring-red-600 dark:border-orange-600 "
 
 
 inputLabel : String
diff --git a/modules/webapp/src/main/elm/Util/List.elm b/modules/webapp/src/main/elm/Util/List.elm
index 0c532bcb..ce7d1250 100644
--- a/modules/webapp/src/main/elm/Util/List.elm
+++ b/modules/webapp/src/main/elm/Util/List.elm
@@ -14,12 +14,42 @@ module Util.List exposing
     , findNext
     , findPrev
     , get
+    , removeByIndex
+    , replaceByIndex
     , sliding
     )
 
 import Html.Attributes exposing (list)
 
 
+removeByIndex : Int -> List a -> List a
+removeByIndex index list =
+    List.indexedMap
+        (\idx ->
+            \e ->
+                if idx == index then
+                    Nothing
+
+                else
+                    Just e
+        )
+        list
+        |> List.filterMap identity
+
+
+replaceByIndex : Int -> a -> List a -> List a
+replaceByIndex index element list =
+    let
+        repl idx e =
+            if idx == index then
+                element
+
+            else
+                e
+    in
+    List.indexedMap repl list
+
+
 changePosition : Int -> Int -> List a -> List a
 changePosition source target list =
     let
diff --git a/modules/webapp/src/main/elm/Util/String.elm b/modules/webapp/src/main/elm/Util/String.elm
index 54cfa9ea..a5972924 100644
--- a/modules/webapp/src/main/elm/Util/String.elm
+++ b/modules/webapp/src/main/elm/Util/String.elm
@@ -9,6 +9,7 @@ module Util.String exposing
     ( appendIfAbsent
     , crazyEncode
     , ellipsis
+    , firstSentenceOrMax
     , isBlank
     , isNothingOrBlank
     , underscoreToSpace
@@ -16,7 +17,6 @@ module Util.String exposing
     )
 
 import Base64
-import Html exposing (strong)
 
 
 crazyEncode : String -> String
@@ -45,6 +45,26 @@ ellipsis len str =
         String.left (len - 1) str ++ "…"
 
 
+firstSentenceOrMax : Int -> String -> Maybe String
+firstSentenceOrMax maxLen str =
+    let
+        idx =
+            String.indexes "." str
+                |> List.head
+                |> Maybe.map ((+) 2)
+                |> Maybe.map (min maxLen)
+                |> Maybe.withDefault maxLen
+
+        len =
+            String.length str
+    in
+    if len <= maxLen then
+        Nothing
+
+    else
+        Just <| String.left (idx - 1) str ++ "…"
+
+
 withDefault : String -> String -> String
 withDefault default str =
     if str == "" then
diff --git a/modules/webapp/src/main/styles/custom-components.css b/modules/webapp/src/main/styles/custom-components.css
index 5daaf40b..afb3edca 100644
--- a/modules/webapp/src/main/styles/custom-components.css
+++ b/modules/webapp/src/main/styles/custom-components.css
@@ -96,4 +96,8 @@
   .markdown-preview a {
       @apply text-blue-400 hover:text-blue-500 dark:text-sky-200 dark:hover:text-sky-100 cursor-pointer;
   }
+
+  .markdown-preview pre {
+      @apply font-mono px-2 py-2 text-sm border dark:border-slate-600 rounded my-2;
+  }
 }