mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-04-05 10:59:33 +00:00
Merge pull request #1011 from eikek/feature/414-merge-items
Feature/414 merge items
This commit is contained in:
commit
2252acff12
202
modules/backend/src/main/scala/docspell/backend/item/Merge.scala
Normal file
202
modules/backend/src/main/scala/docspell/backend/item/Merge.scala
Normal file
@ -0,0 +1,202 @@
|
||||
/*
|
||||
* Copyright 2020 Docspell Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.backend.item
|
||||
|
||||
import cats.data.EitherT
|
||||
import cats.data.NonEmptyList
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.backend.fulltext.CreateIndex
|
||||
import docspell.backend.ops.OItem
|
||||
import docspell.common._
|
||||
import docspell.store.Store
|
||||
import docspell.store.queries.QCustomField
|
||||
import docspell.store.queries.QCustomField.FieldValue
|
||||
import docspell.store.records.RAttachment
|
||||
import docspell.store.records.RCustomField
|
||||
import docspell.store.records.RItem
|
||||
import docspell.store.records.RTagItem
|
||||
|
||||
trait Merge[F[_]] {
|
||||
def merge(items: NonEmptyList[Ident], collective: Ident): F[Merge.Result[RItem]]
|
||||
}
|
||||
|
||||
object Merge {
|
||||
|
||||
type Result[A] = Either[Error, A]
|
||||
sealed trait Error
|
||||
object Error {
|
||||
final case object NoItems extends Error
|
||||
def noItems: Error = NoItems
|
||||
|
||||
}
|
||||
|
||||
def apply[F[_]: Async](
|
||||
logger: Logger[F],
|
||||
store: Store[F],
|
||||
itemOps: OItem[F],
|
||||
createIndex: CreateIndex[F]
|
||||
): Merge[F] =
|
||||
new Merge[F] {
|
||||
def merge(givenIds: NonEmptyList[Ident], collective: Ident): F[Result[RItem]] =
|
||||
(for {
|
||||
items <- loadItems(givenIds, collective)
|
||||
ids = items.map(_.id)
|
||||
target = moveMainData(items)
|
||||
_ <- EitherT.right[Error](store.transact(RItem.updateAll(target)))
|
||||
_ <- EitherT.right[Error](moveTags(ids))
|
||||
_ <- EitherT.right[Error](moveCustomFields(ids))
|
||||
_ <- EitherT.right[Error](moveAttachments(ids))
|
||||
_ <- EitherT.right[Error](
|
||||
createIndex
|
||||
.reIndexData(logger, collective.some, NonEmptyList.one(ids.head).some, 50)
|
||||
)
|
||||
_ <- EitherT.right[Error](
|
||||
NonEmptyList.fromList(items.tail.map(_.id)) match {
|
||||
case Some(nel) => itemOps.deleteItemMultiple(nel, collective)
|
||||
case None => 0.pure[F]
|
||||
}
|
||||
)
|
||||
} yield target).value
|
||||
|
||||
def loadItems(
|
||||
items: NonEmptyList[Ident],
|
||||
collective: Ident
|
||||
): EitherT[F, Error, NonEmptyList[RItem]] = {
|
||||
val loaded =
|
||||
store
|
||||
.transact(
|
||||
items.toList.traverse(id => RItem.findByIdAndCollective(id, collective))
|
||||
)
|
||||
.map(_.flatten)
|
||||
.map(NonEmptyList.fromList)
|
||||
EitherT.fromOptionF(loaded, Error.NoItems)
|
||||
}
|
||||
|
||||
def moveAttachments(items: NonEmptyList[Ident]): F[Int] = {
|
||||
val target = items.head
|
||||
for {
|
||||
attachs <- store.transact(items.tail.traverse(id => RAttachment.findByItem(id)))
|
||||
attachFlat = attachs.flatMap(_.toList)
|
||||
n <- attachFlat.traverse(a =>
|
||||
store.transact(RAttachment.updateItemId(a.id, target))
|
||||
)
|
||||
} yield n.sum
|
||||
}
|
||||
|
||||
def moveTags(items: NonEmptyList[Ident]): F[Int] = {
|
||||
val target = items.head
|
||||
items.tail
|
||||
.traverse(id => store.transact(RTagItem.moveTags(id, target)))
|
||||
.map(_.sum)
|
||||
}
|
||||
|
||||
def moveMainData(items: NonEmptyList[RItem]): RItem =
|
||||
items.tail.foldLeft(items.head)(combine)
|
||||
|
||||
def moveCustomFields(items: NonEmptyList[Ident]): F[Unit] =
|
||||
for {
|
||||
values <- store.transact(QCustomField.findAllValues(items))
|
||||
byField = values.groupBy(_.field.name)
|
||||
newValues = mergeFields(items.head, byField)
|
||||
_ <- newValues.traverse(fv =>
|
||||
store.transact(RCustomField.setValue(fv.field, items.head, fv.value))
|
||||
)
|
||||
} yield ()
|
||||
}
|
||||
|
||||
private def mergeFields(
|
||||
targetId: Ident,
|
||||
byField: Map[Ident, List[FieldValue]]
|
||||
): List[FieldValue] =
|
||||
byField
|
||||
.filter(kv => kv._1 != targetId || kv._2.size > 1)
|
||||
.values
|
||||
.flatMap(NonEmptyList.fromList)
|
||||
.map { nel =>
|
||||
if (nel.tail.isEmpty) nel.head
|
||||
else mergeFieldSameName(nel)
|
||||
}
|
||||
.toList
|
||||
|
||||
private def mergeFieldSameName(fields: NonEmptyList[FieldValue]): FieldValue =
|
||||
fields.head.field.ftype match {
|
||||
case CustomFieldType.Bool => fields.head
|
||||
case CustomFieldType.Date => fields.head
|
||||
case CustomFieldType.Money =>
|
||||
val amount =
|
||||
fields.toList
|
||||
.flatMap(fv => CustomFieldType.Money.parseValue(fv.value).toOption)
|
||||
.toList
|
||||
.sum
|
||||
fields.head.copy(value = CustomFieldType.Money.valueString(amount))
|
||||
|
||||
case CustomFieldType.Numeric =>
|
||||
val amount =
|
||||
fields.toList
|
||||
.flatMap(fv => CustomFieldType.Numeric.parseValue(fv.value).toOption)
|
||||
.toList
|
||||
.sum
|
||||
fields.head.copy(value = CustomFieldType.Numeric.valueString(amount))
|
||||
|
||||
case CustomFieldType.Text =>
|
||||
val text = fields.toList
|
||||
.flatMap(fv => CustomFieldType.Text.parseValue(fv.value).toOption)
|
||||
.mkString(", ")
|
||||
fields.head.copy(value = CustomFieldType.Text.valueString(text))
|
||||
}
|
||||
|
||||
private def combine(target: RItem, source: RItem): RItem =
|
||||
MoveProp
|
||||
.all(
|
||||
MoveProp.whenNotExists(_.itemDate)((i, v) => i.copy(itemDate = v)),
|
||||
MoveProp.whenNotExists(_.corrPerson)((i, v) => i.copy(corrPerson = v)),
|
||||
MoveProp.whenNotExists(_.concPerson)((i, v) => i.copy(concPerson = v)),
|
||||
MoveProp.whenNotExists(_.concEquipment)((i, v) => i.copy(concEquipment = v)),
|
||||
MoveProp.whenNotExists(_.dueDate)((i, v) => i.copy(dueDate = v)),
|
||||
MoveProp.whenNotExists(_.folderId)((i, v) => i.copy(folderId = v)),
|
||||
MoveProp.concat(_.notes)((i, v) => i.copy(notes = v))
|
||||
)
|
||||
.move(target, source)
|
||||
|
||||
trait MoveProp {
|
||||
def move(target: RItem, source: RItem): RItem
|
||||
}
|
||||
object MoveProp {
|
||||
def whenNotExists[A](
|
||||
get: RItem => Option[A]
|
||||
)(set: (RItem, Option[A]) => RItem): MoveProp =
|
||||
new MoveProp {
|
||||
def move(target: RItem, source: RItem): RItem =
|
||||
get(target) match {
|
||||
case Some(_) => target
|
||||
case None => set(target, get(source))
|
||||
}
|
||||
}
|
||||
|
||||
def concat(
|
||||
get: RItem => Option[String]
|
||||
)(set: (RItem, Option[String]) => RItem): MoveProp =
|
||||
new MoveProp {
|
||||
def move(target: RItem, source: RItem): RItem =
|
||||
(get(target), get(source)) match {
|
||||
case (Some(st), Some(ss)) => set(target, Some(st + "\n\n" + ss))
|
||||
case (Some(_), None) => target
|
||||
case (None, src) => set(target, src)
|
||||
}
|
||||
}
|
||||
|
||||
def all(props: MoveProp*): MoveProp =
|
||||
new MoveProp {
|
||||
def move(target: RItem, source: RItem): RItem =
|
||||
props.foldLeft(target) { (el, move) =>
|
||||
move.move(el, source)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -13,6 +13,7 @@ import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.backend.ops.OCustomFields.CustomFieldData
|
||||
import docspell.backend.ops.OCustomFields.FieldValue
|
||||
import docspell.backend.ops.OCustomFields.NewCustomField
|
||||
import docspell.backend.ops.OCustomFields.RemoveValue
|
||||
import docspell.backend.ops.OCustomFields.SetValue
|
||||
@ -53,6 +54,9 @@ trait OCustomFields[F[_]] {
|
||||
|
||||
/** Deletes a value for a given field an item. */
|
||||
def deleteValue(in: RemoveValue): F[UpdateResult]
|
||||
|
||||
/** Finds all values to the given items */
|
||||
def findAllValues(itemIds: NonEmptyList[Ident]): F[List[FieldValue]]
|
||||
}
|
||||
|
||||
object OCustomFields {
|
||||
@ -60,6 +64,9 @@ object OCustomFields {
|
||||
type CustomFieldData = QCustomField.CustomFieldData
|
||||
val CustomFieldData = QCustomField.CustomFieldData
|
||||
|
||||
type FieldValue = QCustomField.FieldValue
|
||||
val FieldValue = QCustomField.FieldValue
|
||||
|
||||
case class NewCustomField(
|
||||
name: Ident,
|
||||
label: Option[String],
|
||||
@ -100,6 +107,9 @@ object OCustomFields {
|
||||
|
||||
private[this] val logger = Logger.log4s[ConnectionIO](getLogger)
|
||||
|
||||
def findAllValues(itemIds: NonEmptyList[Ident]): F[List[FieldValue]] =
|
||||
store.transact(QCustomField.findAllValues(itemIds))
|
||||
|
||||
def findAll(coll: Ident, nameQuery: Option[String]): F[Vector[CustomFieldData]] =
|
||||
store.transact(
|
||||
QCustomField.findAllLike(
|
||||
|
@ -12,6 +12,7 @@ import cats.implicits._
|
||||
|
||||
import docspell.backend.JobFactory
|
||||
import docspell.backend.fulltext.CreateIndex
|
||||
import docspell.backend.item.Merge
|
||||
import docspell.common._
|
||||
import docspell.ftsclient.FtsClient
|
||||
import docspell.store.queries.{QAttachment, QItem, QMoveAttachment}
|
||||
@ -206,6 +207,14 @@ trait OItem[F[_]] {
|
||||
storeMode: MakePreviewArgs.StoreMode,
|
||||
notifyJoex: Boolean
|
||||
): F[UpdateResult]
|
||||
|
||||
/** Merges a list of items into one item. The remaining items are deleted.
|
||||
*/
|
||||
def merge(
|
||||
logger: Logger[F],
|
||||
items: NonEmptyList[Ident],
|
||||
collective: Ident
|
||||
): F[UpdateResult]
|
||||
}
|
||||
|
||||
object OItem {
|
||||
@ -223,6 +232,18 @@ object OItem {
|
||||
oequip <- OEquipment(store)
|
||||
logger <- Resource.pure[F, Logger[F]](Logger.log4s(getLogger))
|
||||
oitem <- Resource.pure[F, OItem[F]](new OItem[F] {
|
||||
|
||||
def merge(
|
||||
logger: Logger[F],
|
||||
items: NonEmptyList[Ident],
|
||||
collective: Ident
|
||||
): F[UpdateResult] =
|
||||
Merge(logger, store, this, createIndex).merge(items, collective).attempt.map {
|
||||
case Right(Right(_)) => UpdateResult.success
|
||||
case Right(Left(Merge.Error.NoItems)) => UpdateResult.NotFound
|
||||
case Left(ex) => UpdateResult.failure(ex)
|
||||
}
|
||||
|
||||
def moveAttachmentBefore(
|
||||
itemId: Ident,
|
||||
source: Ident,
|
||||
|
@ -2332,6 +2332,43 @@ paths:
|
||||
$ref: "#/components/schemas/BasicResult"
|
||||
|
||||
|
||||
/sec/items/merge:
|
||||
post:
|
||||
operationId: "sec-items-merge"
|
||||
tags:
|
||||
- Item (Multi Edit)
|
||||
summary: Merge multiple items into one.
|
||||
description: |
|
||||
A list of items is merged into one item by copying all
|
||||
metadata into the first item in the list.
|
||||
|
||||
Metadata is copied into the target item, if there is no value
|
||||
present. So the order of items in the list matters - the first
|
||||
item with a correspondent or folder will win.
|
||||
|
||||
For metadata that allow multiple values, like tags or custom
|
||||
fields the values are combined. Notes are concatenated from
|
||||
all items and custom fields with the same name are added
|
||||
together for money/numeric fields, concatenated for text
|
||||
fields or the first value is used for other field types.
|
||||
|
||||
After a successful merge, the remaining items are deleted from
|
||||
the database (they cannot be restored).
|
||||
security:
|
||||
- authTokenHeader: []
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/IdList"
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicResult"
|
||||
|
||||
/sec/items/deleteAll:
|
||||
post:
|
||||
operationId: "sec-items-delete-all"
|
||||
|
@ -12,7 +12,7 @@ import cats.implicits._
|
||||
import docspell.backend.BackendApp
|
||||
import docspell.backend.auth.AuthToken
|
||||
import docspell.backend.ops.OCustomFields.{RemoveValue, SetValue}
|
||||
import docspell.common.ItemState
|
||||
import docspell.common._
|
||||
import docspell.restapi.model._
|
||||
import docspell.restserver.conv.{Conversions, MultiIdSupport}
|
||||
|
||||
@ -20,8 +20,10 @@ import org.http4s.HttpRoutes
|
||||
import org.http4s.circe.CirceEntityDecoder._
|
||||
import org.http4s.circe.CirceEntityEncoder._
|
||||
import org.http4s.dsl.Http4sDsl
|
||||
import org.log4s.getLogger
|
||||
|
||||
object ItemMultiRoutes extends MultiIdSupport {
|
||||
private[this] val log4sLogger = getLogger
|
||||
|
||||
def apply[F[_]: Async](
|
||||
backend: BackendApp[F],
|
||||
@ -217,6 +219,14 @@ object ItemMultiRoutes extends MultiIdSupport {
|
||||
resp <- Ok(Conversions.basicResult(res, "Custom fields removed."))
|
||||
} yield resp
|
||||
|
||||
case req @ POST -> Root / "merge" =>
|
||||
for {
|
||||
json <- req.as[IdList]
|
||||
items <- readIds[F](json.ids)
|
||||
logger = Logger.log4s(log4sLogger)
|
||||
res <- backend.item.merge(logger, items, user.account.collective)
|
||||
resp <- Ok(Conversions.basicResult(res, "Items merged"))
|
||||
} yield resp
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -6,6 +6,7 @@
|
||||
|
||||
package docspell.store.queries
|
||||
|
||||
import cats.data.{NonEmptyList => Nel}
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.common._
|
||||
@ -19,7 +20,7 @@ object QCustomField {
|
||||
private val f = RCustomField.as("f")
|
||||
private val v = RCustomFieldValue.as("v")
|
||||
|
||||
case class CustomFieldData(field: RCustomField, usageCount: Int)
|
||||
final case class CustomFieldData(field: RCustomField, usageCount: Int)
|
||||
|
||||
def findAllLike(
|
||||
coll: Ident,
|
||||
@ -47,4 +48,24 @@ object QCustomField {
|
||||
GroupBy(f.all)
|
||||
)
|
||||
}
|
||||
|
||||
final case class FieldValue(
|
||||
field: RCustomField,
|
||||
itemId: Ident,
|
||||
value: String
|
||||
)
|
||||
|
||||
def findAllValues(itemIds: Nel[Ident]): ConnectionIO[List[FieldValue]] = {
|
||||
val cf = RCustomField.as("cf")
|
||||
val cv = RCustomFieldValue.as("cv")
|
||||
|
||||
run(
|
||||
select(cf.all, Nel.of(cv.itemId, cv.value)),
|
||||
from(cv).innerJoin(cf, cv.field === cf.id),
|
||||
cv.itemId.in(itemIds)
|
||||
)
|
||||
.query[FieldValue]
|
||||
.to[List]
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -97,6 +97,9 @@ object RAttachment {
|
||||
DML.set(T.fileId.setTo(fId))
|
||||
)
|
||||
|
||||
def updateItemId(attachId: Ident, itemId: Ident): ConnectionIO[Int] =
|
||||
DML.update(T, T.id === attachId, DML.set(T.itemId.setTo(itemId)))
|
||||
|
||||
def updatePosition(attachId: Ident, pos: Int): ConnectionIO[Int] =
|
||||
DML.update(T, T.id === attachId, DML.set(T.position.setTo(pos)))
|
||||
|
||||
|
@ -130,6 +130,30 @@ object RItem {
|
||||
def getCollective(itemId: Ident): ConnectionIO[Option[Ident]] =
|
||||
Select(T.cid.s, from(T), T.id === itemId).build.query[Ident].option
|
||||
|
||||
def updateAll(item: RItem): ConnectionIO[Int] =
|
||||
for {
|
||||
t <- currentTime
|
||||
n <- DML.update(
|
||||
T,
|
||||
T.id === item.id,
|
||||
DML.set(
|
||||
T.name.setTo(item.name),
|
||||
T.itemDate.setTo(item.itemDate),
|
||||
T.source.setTo(item.source),
|
||||
T.incoming.setTo(item.direction),
|
||||
T.corrOrg.setTo(item.corrOrg),
|
||||
T.corrPerson.setTo(item.corrPerson),
|
||||
T.concPerson.setTo(item.concPerson),
|
||||
T.concEquipment.setTo(item.concEquipment),
|
||||
T.inReplyTo.setTo(item.inReplyTo),
|
||||
T.dueDate.setTo(item.dueDate),
|
||||
T.notes.setTo(item.notes),
|
||||
T.folder.setTo(item.folderId),
|
||||
T.updated.setTo(t)
|
||||
)
|
||||
)
|
||||
} yield n
|
||||
|
||||
def updateState(
|
||||
itemId: Ident,
|
||||
itemState: ItemState,
|
||||
|
@ -34,6 +34,22 @@ object RTagItem {
|
||||
def insert(v: RTagItem): ConnectionIO[Int] =
|
||||
DML.insert(T, T.all, fr"${v.tagItemId},${v.itemId},${v.tagId}")
|
||||
|
||||
def moveTags(fromItem: Ident, toItem: Ident): ConnectionIO[Int] =
|
||||
for {
|
||||
both <- intersect(
|
||||
Select(select(T.tagId), from(T), T.itemId === fromItem).distinct,
|
||||
Select(select(T.tagId), from(T), T.itemId === toItem).distinct
|
||||
).build
|
||||
.query[Ident]
|
||||
.to[List]
|
||||
skipIds = NonEmptyList.fromList(both)
|
||||
n <- DML.update(
|
||||
T,
|
||||
T.itemId === fromItem &&? skipIds.map(ids => T.tagId.notIn(ids)),
|
||||
DML.set(T.itemId.setTo(toItem))
|
||||
)
|
||||
} yield n
|
||||
|
||||
def deleteItemTags(item: Ident): ConnectionIO[Int] =
|
||||
DML.delete(T, T.itemId === item)
|
||||
|
||||
|
@ -80,6 +80,7 @@ module Api exposing
|
||||
, login
|
||||
, loginSession
|
||||
, logout
|
||||
, mergeItems
|
||||
, moveAttachmentBefore
|
||||
, newInvite
|
||||
, postCustomField
|
||||
@ -1470,6 +1471,20 @@ getJobQueueStateTask flags =
|
||||
--- Item (Mulit Edit)
|
||||
|
||||
|
||||
mergeItems :
|
||||
Flags
|
||||
-> List String
|
||||
-> (Result Http.Error BasicResult -> msg)
|
||||
-> Cmd msg
|
||||
mergeItems flags items receive =
|
||||
Http2.authPost
|
||||
{ url = flags.config.baseUrl ++ "/api/v1/sec/items/merge"
|
||||
, account = getAccount flags
|
||||
, body = Http.jsonBody (Api.Model.IdList.encode (IdList items))
|
||||
, expect = Http.expectJson receive Api.Model.BasicResult.decoder
|
||||
}
|
||||
|
||||
|
||||
reprocessMultiple :
|
||||
Flags
|
||||
-> Set String
|
||||
|
501
modules/webapp/src/main/elm/Comp/ItemMerge.elm
Normal file
501
modules/webapp/src/main/elm/Comp/ItemMerge.elm
Normal file
@ -0,0 +1,501 @@
|
||||
{-
|
||||
Copyright 2020 Docspell Contributors
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
-}
|
||||
|
||||
|
||||
module Comp.ItemMerge exposing
|
||||
( Model
|
||||
, Msg
|
||||
, Outcome(..)
|
||||
, init
|
||||
, initQuery
|
||||
, update
|
||||
, view
|
||||
)
|
||||
|
||||
import Api
|
||||
import Api.Model.BasicResult exposing (BasicResult)
|
||||
import Api.Model.ItemLight exposing (ItemLight)
|
||||
import Api.Model.ItemLightList exposing (ItemLightList)
|
||||
import Comp.Basic
|
||||
import Comp.MenuBar as MB
|
||||
import Data.Direction
|
||||
import Data.Fields
|
||||
import Data.Flags exposing (Flags)
|
||||
import Data.Icons as Icons
|
||||
import Data.ItemQuery exposing (ItemQuery)
|
||||
import Data.ItemTemplate as IT
|
||||
import Data.SearchMode exposing (SearchMode)
|
||||
import Data.UiSettings exposing (UiSettings)
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (onClick)
|
||||
import Html5.DragDrop as DD
|
||||
import Http
|
||||
import Messages.Comp.ItemMerge exposing (Texts)
|
||||
import Styles as S
|
||||
import Util.CustomField
|
||||
import Util.Item
|
||||
import Util.List
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ items : List ItemLight
|
||||
, showInfoText : Bool
|
||||
, dragDrop : DDModel
|
||||
, formState : FormState
|
||||
}
|
||||
|
||||
|
||||
init : List ItemLight -> Model
|
||||
init items =
|
||||
{ items = items
|
||||
, showInfoText = False
|
||||
, dragDrop = DD.init
|
||||
, formState = FormStateInitial
|
||||
}
|
||||
|
||||
|
||||
initQuery : Flags -> SearchMode -> ItemQuery -> ( Model, Cmd Msg )
|
||||
initQuery flags searchMode query =
|
||||
let
|
||||
itemQuery =
|
||||
{ offset = Just 0
|
||||
, limit = Just 50
|
||||
, withDetails = Just True
|
||||
, searchMode = Just (Data.SearchMode.asString searchMode)
|
||||
, query = Data.ItemQuery.render query
|
||||
}
|
||||
in
|
||||
( init [], Api.itemSearch flags itemQuery ItemResp )
|
||||
|
||||
|
||||
type alias Dropped =
|
||||
{ sourceIdx : Int
|
||||
, targetIdx : Int
|
||||
}
|
||||
|
||||
|
||||
type alias DDModel =
|
||||
DD.Model Int Int
|
||||
|
||||
|
||||
type alias DDMsg =
|
||||
DD.Msg Int Int
|
||||
|
||||
|
||||
type FormState
|
||||
= FormStateInitial
|
||||
| FormStateHttp Http.Error
|
||||
| FormStateMergeSuccessful
|
||||
| FormStateError String
|
||||
| FormStateMergeInProcess
|
||||
|
||||
|
||||
|
||||
--- Update
|
||||
|
||||
|
||||
type Outcome
|
||||
= OutcomeCancel
|
||||
| OutcomeMerged
|
||||
| OutcomeNotYet
|
||||
|
||||
|
||||
type alias UpdateResult =
|
||||
{ model : Model
|
||||
, cmd : Cmd Msg
|
||||
, outcome : Outcome
|
||||
}
|
||||
|
||||
|
||||
notDoneResult : ( Model, Cmd Msg ) -> UpdateResult
|
||||
notDoneResult t =
|
||||
{ model = Tuple.first t
|
||||
, cmd = Tuple.second t
|
||||
, outcome = OutcomeNotYet
|
||||
}
|
||||
|
||||
|
||||
type Msg
|
||||
= ItemResp (Result Http.Error ItemLightList)
|
||||
| ToggleInfoText
|
||||
| DragDrop (DD.Msg Int Int)
|
||||
| SubmitMerge
|
||||
| CancelMerge
|
||||
| MergeResp (Result Http.Error BasicResult)
|
||||
|
||||
|
||||
update : Flags -> Msg -> Model -> UpdateResult
|
||||
update flags msg model =
|
||||
case msg of
|
||||
ItemResp (Ok list) ->
|
||||
notDoneResult ( init (flatten list), Cmd.none )
|
||||
|
||||
ItemResp (Err err) ->
|
||||
notDoneResult ( { model | formState = FormStateHttp err }, Cmd.none )
|
||||
|
||||
MergeResp (Ok result) ->
|
||||
if result.success then
|
||||
{ model = { model | formState = FormStateMergeSuccessful }
|
||||
, cmd = Cmd.none
|
||||
, outcome = OutcomeMerged
|
||||
}
|
||||
|
||||
else
|
||||
{ model = { model | formState = FormStateError result.message }
|
||||
, cmd = Cmd.none
|
||||
, outcome = OutcomeNotYet
|
||||
}
|
||||
|
||||
MergeResp (Err err) ->
|
||||
{ model = { model | formState = FormStateHttp err }
|
||||
, cmd = Cmd.none
|
||||
, outcome = OutcomeNotYet
|
||||
}
|
||||
|
||||
ToggleInfoText ->
|
||||
notDoneResult
|
||||
( { model | showInfoText = not model.showInfoText }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
DragDrop lmsg ->
|
||||
let
|
||||
( m, res ) =
|
||||
DD.update lmsg model.dragDrop
|
||||
|
||||
dropped =
|
||||
Maybe.map (\( idx1, idx2, _ ) -> Dropped idx1 idx2) res
|
||||
|
||||
model_ =
|
||||
{ model | dragDrop = m }
|
||||
in
|
||||
case dropped of
|
||||
Just data ->
|
||||
let
|
||||
items =
|
||||
Util.List.changePosition data.sourceIdx data.targetIdx model.items
|
||||
in
|
||||
notDoneResult ( { model_ | items = items }, Cmd.none )
|
||||
|
||||
Nothing ->
|
||||
notDoneResult ( model_, Cmd.none )
|
||||
|
||||
SubmitMerge ->
|
||||
let
|
||||
ids =
|
||||
List.map .id model.items
|
||||
in
|
||||
notDoneResult
|
||||
( { model | formState = FormStateMergeInProcess }
|
||||
, Api.mergeItems flags ids MergeResp
|
||||
)
|
||||
|
||||
CancelMerge ->
|
||||
{ model = model
|
||||
, cmd = Cmd.none
|
||||
, outcome = OutcomeCancel
|
||||
}
|
||||
|
||||
|
||||
flatten : ItemLightList -> List ItemLight
|
||||
flatten list =
|
||||
list.groups |> List.concatMap .items
|
||||
|
||||
|
||||
|
||||
--- View
|
||||
|
||||
|
||||
view : Texts -> UiSettings -> Model -> Html Msg
|
||||
view texts settings model =
|
||||
div [ class "px-2 mb-4" ]
|
||||
[ h1 [ class S.header1 ]
|
||||
[ text texts.title
|
||||
, a
|
||||
[ class "ml-2"
|
||||
, class S.link
|
||||
, href "#"
|
||||
, onClick ToggleInfoText
|
||||
]
|
||||
[ i [ class "fa fa-info-circle" ] []
|
||||
]
|
||||
]
|
||||
, p
|
||||
[ class S.infoMessage
|
||||
, classList [ ( "hidden", not model.showInfoText ) ]
|
||||
]
|
||||
[ text texts.infoText
|
||||
]
|
||||
, p
|
||||
[ class S.warnMessage
|
||||
, class "mt-2"
|
||||
]
|
||||
[ text texts.deleteWarn
|
||||
]
|
||||
, MB.view <|
|
||||
{ start =
|
||||
[ MB.PrimaryButton
|
||||
{ tagger = SubmitMerge
|
||||
, title = texts.submitMergeTitle
|
||||
, icon = Just "fa fa-less-than"
|
||||
, label = texts.submitMerge
|
||||
}
|
||||
, MB.SecondaryButton
|
||||
{ tagger = CancelMerge
|
||||
, title = texts.cancelMergeTitle
|
||||
, icon = Just "fa fa-times"
|
||||
, label = texts.cancelMerge
|
||||
}
|
||||
]
|
||||
, end = []
|
||||
, rootClasses = "my-4"
|
||||
}
|
||||
, renderFormState texts model
|
||||
, div [ class "flex-col px-2" ]
|
||||
(List.indexedMap (itemCard texts settings model) model.items)
|
||||
]
|
||||
|
||||
|
||||
itemCard : Texts -> UiSettings -> Model -> Int -> ItemLight -> Html Msg
|
||||
itemCard texts settings model index item =
|
||||
let
|
||||
previewUrl =
|
||||
Api.itemBasePreviewURL item.id
|
||||
|
||||
fieldHidden f =
|
||||
Data.UiSettings.fieldHidden settings f
|
||||
|
||||
dirIcon =
|
||||
i
|
||||
[ class (Data.Direction.iconFromMaybe2 item.direction)
|
||||
, class "mr-2 w-4 text-center"
|
||||
, classList [ ( "hidden", fieldHidden Data.Fields.Direction ) ]
|
||||
, IT.render IT.direction (templateCtx texts) item |> title
|
||||
]
|
||||
[]
|
||||
|
||||
titlePattern =
|
||||
settings.cardTitleTemplate.template
|
||||
|
||||
subtitlePattern =
|
||||
settings.cardSubtitleTemplate.template
|
||||
|
||||
dropActive =
|
||||
let
|
||||
currentDrop =
|
||||
getDropId model
|
||||
|
||||
currentDrag =
|
||||
getDragId model
|
||||
in
|
||||
currentDrop == Just index && currentDrag /= Just index && currentDrag /= Just (index - 1)
|
||||
in
|
||||
div
|
||||
([ classList [ ( "pt-12 mx-2", dropActive ) ]
|
||||
]
|
||||
++ droppable DragDrop index
|
||||
)
|
||||
[ div
|
||||
([ class "flex flex-col sm:flex-row rounded"
|
||||
, class "cursor-pointer items-center"
|
||||
, classList
|
||||
[ ( "border-2 border-blue-500 dark:border-blue-500", index == 0 )
|
||||
, ( "bg-blue-100 dark:bg-lightblue-900", index == 0 )
|
||||
, ( "border border-gray-400 dark:border-bluegray-600 dark:hover:border-bluegray-500 bg-white dark:bg-bluegray-700 mt-2", index /= 0 )
|
||||
, ( "bg-yellow-50 dark:bg-lime-900 mt-4", dropActive )
|
||||
]
|
||||
, id ("merge-" ++ item.id)
|
||||
]
|
||||
++ draggable DragDrop index
|
||||
)
|
||||
[ div
|
||||
[ class "mr-2 sm:rounded-l w-16 bg-white"
|
||||
, classList [ ( "hidden", fieldHidden Data.Fields.PreviewImage ) ]
|
||||
]
|
||||
[ img
|
||||
[ class "preview-image mx-auto pt-1"
|
||||
, classList
|
||||
[ ( "sm:rounded-l", True )
|
||||
]
|
||||
, src previewUrl
|
||||
]
|
||||
[]
|
||||
]
|
||||
, div [ class "flex-grow flex flex-col py-1 px-2" ]
|
||||
[ div [ class "flex flex-col sm:flex-row items-center" ]
|
||||
[ div
|
||||
[ class "font-bold text-lg"
|
||||
, classList [ ( "hidden", IT.render titlePattern (templateCtx texts) item == "" ) ]
|
||||
]
|
||||
[ dirIcon
|
||||
, IT.render titlePattern (templateCtx texts) item |> text
|
||||
]
|
||||
, div
|
||||
[ classList
|
||||
[ ( "opacity-75 sm:ml-2", True )
|
||||
, ( "hidden", IT.render subtitlePattern (templateCtx texts) item == "" )
|
||||
]
|
||||
]
|
||||
[ IT.render subtitlePattern (templateCtx texts) item |> text
|
||||
]
|
||||
]
|
||||
, mainData texts settings item
|
||||
, mainTagsAndFields2 settings item
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
mainData : Texts -> UiSettings -> ItemLight -> Html Msg
|
||||
mainData texts settings item =
|
||||
let
|
||||
ctx =
|
||||
templateCtx texts
|
||||
|
||||
corr =
|
||||
IT.render (Util.Item.corrTemplate settings) ctx item
|
||||
|
||||
conc =
|
||||
IT.render (Util.Item.concTemplate settings) ctx item
|
||||
in
|
||||
div [ class "flex flex-row space-x-2" ]
|
||||
[ div
|
||||
[ classList
|
||||
[ ( "hidden", corr == "" )
|
||||
]
|
||||
]
|
||||
[ Icons.correspondentIcon2 "mr-1"
|
||||
, text corr
|
||||
]
|
||||
, div
|
||||
[ classList
|
||||
[ ( "hidden", conc == "" )
|
||||
]
|
||||
, class "ml-2"
|
||||
]
|
||||
[ Icons.concernedIcon2 "mr-1"
|
||||
, text conc
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
mainTagsAndFields2 : UiSettings -> ItemLight -> Html Msg
|
||||
mainTagsAndFields2 settings item =
|
||||
let
|
||||
fieldHidden f =
|
||||
Data.UiSettings.fieldHidden settings f
|
||||
|
||||
hideTags =
|
||||
item.tags == [] || fieldHidden Data.Fields.Tag
|
||||
|
||||
hideFields =
|
||||
item.customfields == [] || fieldHidden Data.Fields.CustomFields
|
||||
|
||||
showTag tag =
|
||||
div
|
||||
[ class "label mt-1 font-semibold"
|
||||
, class (Data.UiSettings.tagColorString2 tag settings)
|
||||
]
|
||||
[ i [ class "fa fa-tag mr-2" ] []
|
||||
, span [] [ text tag.name ]
|
||||
]
|
||||
|
||||
showField fv =
|
||||
Util.CustomField.renderValue2
|
||||
[ ( S.basicLabel, True )
|
||||
, ( "mt-1 font-semibold", True )
|
||||
]
|
||||
Nothing
|
||||
fv
|
||||
|
||||
renderFields =
|
||||
if hideFields then
|
||||
[]
|
||||
|
||||
else
|
||||
List.sortBy Util.CustomField.nameOrLabel item.customfields
|
||||
|> List.map showField
|
||||
|
||||
renderTags =
|
||||
if hideTags then
|
||||
[]
|
||||
|
||||
else
|
||||
List.map showTag item.tags
|
||||
in
|
||||
div
|
||||
[ classList
|
||||
[ ( "flex flex-row items-center flex-wrap text-xs font-medium my-1 space-x-2", True )
|
||||
, ( "hidden", hideTags && hideFields )
|
||||
]
|
||||
]
|
||||
(renderFields ++ renderTags)
|
||||
|
||||
|
||||
renderFormState : Texts -> Model -> Html Msg
|
||||
renderFormState texts model =
|
||||
case model.formState of
|
||||
FormStateInitial ->
|
||||
span [ class "hidden" ] []
|
||||
|
||||
FormStateError msg ->
|
||||
div
|
||||
[ class S.errorMessage
|
||||
, class "my-2"
|
||||
]
|
||||
[ text msg
|
||||
]
|
||||
|
||||
FormStateHttp err ->
|
||||
div
|
||||
[ class S.errorMessage
|
||||
, class "my-2"
|
||||
]
|
||||
[ text (texts.httpError err)
|
||||
]
|
||||
|
||||
FormStateMergeSuccessful ->
|
||||
div
|
||||
[ class S.successMessage
|
||||
, class "my-2"
|
||||
]
|
||||
[ text texts.mergeSuccessful
|
||||
]
|
||||
|
||||
FormStateMergeInProcess ->
|
||||
Comp.Basic.loadingDimmer
|
||||
{ active = True
|
||||
, label = texts.mergeInProcess
|
||||
}
|
||||
|
||||
|
||||
templateCtx : Texts -> IT.TemplateContext
|
||||
templateCtx texts =
|
||||
{ dateFormatLong = texts.formatDateLong
|
||||
, dateFormatShort = texts.formatDateShort
|
||||
, directionLabel = \_ -> ""
|
||||
}
|
||||
|
||||
|
||||
droppable : (DDMsg -> msg) -> Int -> List (Attribute msg)
|
||||
droppable tagger dropId =
|
||||
DD.droppable tagger dropId
|
||||
|
||||
|
||||
draggable : (DDMsg -> msg) -> Int -> List (Attribute msg)
|
||||
draggable tagger itemId =
|
||||
DD.draggable tagger itemId
|
||||
|
||||
|
||||
getDropId : Model -> Maybe Int
|
||||
getDropId model =
|
||||
DD.getDropId model.dragDrop
|
||||
|
||||
|
||||
getDragId : Model -> Maybe Int
|
||||
getDragId model =
|
||||
DD.getDragId model.dragDrop
|
71
modules/webapp/src/main/elm/Messages/Comp/ItemMerge.elm
Normal file
71
modules/webapp/src/main/elm/Messages/Comp/ItemMerge.elm
Normal file
@ -0,0 +1,71 @@
|
||||
{-
|
||||
Copyright 2020 Docspell Contributors
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
-}
|
||||
|
||||
|
||||
module Messages.Comp.ItemMerge exposing
|
||||
( Texts
|
||||
, de
|
||||
, gb
|
||||
)
|
||||
|
||||
import Http
|
||||
import Messages.Basics
|
||||
import Messages.Comp.HttpError
|
||||
import Messages.DateFormat
|
||||
import Messages.UiLanguage
|
||||
|
||||
|
||||
type alias Texts =
|
||||
{ basics : Messages.Basics.Texts
|
||||
, httpError : Http.Error -> String
|
||||
, title : String
|
||||
, infoText : String
|
||||
, deleteWarn : String
|
||||
, formatDateLong : Int -> String
|
||||
, formatDateShort : Int -> String
|
||||
, submitMerge : String
|
||||
, cancelMerge : String
|
||||
, submitMergeTitle : String
|
||||
, cancelMergeTitle : String
|
||||
, mergeSuccessful : String
|
||||
, mergeInProcess : String
|
||||
}
|
||||
|
||||
|
||||
gb : Texts
|
||||
gb =
|
||||
{ basics = Messages.Basics.gb
|
||||
, httpError = Messages.Comp.HttpError.gb
|
||||
, title = "Merge Items"
|
||||
, infoText = "When merging items the first item in the list acts as the target. Every other items metadata is copied into the target item. If the property is a single value (like correspondent), it is only set if not already present. Tags, custom fields and attachments are added. The items can be reordered using drag&drop."
|
||||
, deleteWarn = "Note that all items but the first one is deleted after a successful merge!"
|
||||
, formatDateLong = Messages.DateFormat.formatDateLong Messages.UiLanguage.English
|
||||
, formatDateShort = Messages.DateFormat.formatDateShort Messages.UiLanguage.English
|
||||
, submitMerge = "Merge"
|
||||
, submitMergeTitle = "Merge the documents now"
|
||||
, cancelMerge = "Cancel"
|
||||
, cancelMergeTitle = "Back to select view"
|
||||
, mergeSuccessful = "Items merged successfully"
|
||||
, mergeInProcess = "Items are merged …"
|
||||
}
|
||||
|
||||
|
||||
de : Texts
|
||||
de =
|
||||
{ basics = Messages.Basics.de
|
||||
, httpError = Messages.Comp.HttpError.de
|
||||
, title = "Dokumente zusammenführen"
|
||||
, infoText = "Beim Zusammenführen der Dokumente, wird das erste in der Liste als Zieldokument verwendet. Die Metadaten der anderen Dokumente werden der Reihe nach auf des Zieldokument geschrieben. Metadaten die nur einen Wert haben, werden nur gesetzt falls noch kein Wert existiert. Tags, Benutzerfelder und Anhänge werden zu dem Zieldokument hinzugefügt. Die Einträge können mit Drag&Drop umgeordnet werden."
|
||||
, deleteWarn = "Bitte beachte, dass nach erfolgreicher Zusammenführung alle anderen Dokumente gelöscht werden!"
|
||||
, formatDateLong = Messages.DateFormat.formatDateLong Messages.UiLanguage.German
|
||||
, formatDateShort = Messages.DateFormat.formatDateShort Messages.UiLanguage.German
|
||||
, submitMerge = "Zusammenführen"
|
||||
, submitMergeTitle = "Dokumente jetzt zusammenführen"
|
||||
, cancelMerge = "Abbrechen"
|
||||
, cancelMergeTitle = "Zurück zur Auswahl"
|
||||
, mergeSuccessful = "Die Dokumente wurden erfolgreich zusammengeführt."
|
||||
, mergeInProcess = "Dokumente werden zusammengeführt…"
|
||||
}
|
@ -13,6 +13,7 @@ module Messages.Page.Home exposing
|
||||
|
||||
import Messages.Basics
|
||||
import Messages.Comp.ItemCardList
|
||||
import Messages.Comp.ItemMerge
|
||||
import Messages.Comp.SearchStatsView
|
||||
import Messages.Page.HomeSideMenu
|
||||
|
||||
@ -22,6 +23,7 @@ type alias Texts =
|
||||
, itemCardList : Messages.Comp.ItemCardList.Texts
|
||||
, searchStatsView : Messages.Comp.SearchStatsView.Texts
|
||||
, sideMenu : Messages.Page.HomeSideMenu.Texts
|
||||
, itemMerge : Messages.Comp.ItemMerge.Texts
|
||||
, contentSearch : String
|
||||
, searchInNames : String
|
||||
, selectModeTitle : String
|
||||
@ -39,6 +41,7 @@ type alias Texts =
|
||||
, selectNone : String
|
||||
, resetSearchForm : String
|
||||
, exitSelectMode : String
|
||||
, mergeItemsTitle : Int -> String
|
||||
}
|
||||
|
||||
|
||||
@ -48,6 +51,7 @@ gb =
|
||||
, itemCardList = Messages.Comp.ItemCardList.gb
|
||||
, searchStatsView = Messages.Comp.SearchStatsView.gb
|
||||
, sideMenu = Messages.Page.HomeSideMenu.gb
|
||||
, itemMerge = Messages.Comp.ItemMerge.gb
|
||||
, contentSearch = "Content search…"
|
||||
, searchInNames = "Search in names…"
|
||||
, selectModeTitle = "Select Mode"
|
||||
@ -65,6 +69,7 @@ gb =
|
||||
, selectNone = "Select none"
|
||||
, resetSearchForm = "Reset search form"
|
||||
, exitSelectMode = "Exit Select Mode"
|
||||
, mergeItemsTitle = \n -> "Merge " ++ String.fromInt n ++ " selected items"
|
||||
}
|
||||
|
||||
|
||||
@ -74,6 +79,7 @@ de =
|
||||
, itemCardList = Messages.Comp.ItemCardList.de
|
||||
, searchStatsView = Messages.Comp.SearchStatsView.de
|
||||
, sideMenu = Messages.Page.HomeSideMenu.de
|
||||
, itemMerge = Messages.Comp.ItemMerge.de
|
||||
, contentSearch = "Volltextsuche…"
|
||||
, searchInNames = "Suche in Namen…"
|
||||
, selectModeTitle = "Auswahlmodus"
|
||||
@ -91,4 +97,5 @@ de =
|
||||
, selectNone = "Wähle alle Dokumente ab"
|
||||
, resetSearchForm = "Suchformular zurücksetzen"
|
||||
, exitSelectMode = "Auswahlmodus verlassen"
|
||||
, mergeItemsTitle = \n -> String.fromInt n ++ " gewählte Dokumente zusammenführen"
|
||||
}
|
||||
|
@ -26,12 +26,14 @@ module Page.Home.Data exposing
|
||||
|
||||
import Api
|
||||
import Api.Model.BasicResult exposing (BasicResult)
|
||||
import Api.Model.ItemLight exposing (ItemLight)
|
||||
import Api.Model.ItemLightList exposing (ItemLightList)
|
||||
import Api.Model.SearchStats exposing (SearchStats)
|
||||
import Browser.Dom as Dom
|
||||
import Comp.ItemCardList
|
||||
import Comp.ItemDetail.FormChange exposing (FormChange)
|
||||
import Comp.ItemDetail.MultiEditMenu exposing (SaveNameState(..))
|
||||
import Comp.ItemMerge
|
||||
import Comp.LinkTarget exposing (LinkTarget)
|
||||
import Comp.PowerSearchInput
|
||||
import Comp.SearchMenu
|
||||
@ -76,6 +78,7 @@ type alias SelectViewModel =
|
||||
, action : SelectActionMode
|
||||
, confirmModal : Maybe ConfirmModalValue
|
||||
, editModel : Comp.ItemDetail.MultiEditMenu.Model
|
||||
, mergeModel : Comp.ItemMerge.Model
|
||||
, saveNameState : SaveNameState
|
||||
, saveCustomFieldState : Set String
|
||||
}
|
||||
@ -87,6 +90,7 @@ initSelectViewModel =
|
||||
, action = NoneAction
|
||||
, confirmModal = Nothing
|
||||
, editModel = Comp.ItemDetail.MultiEditMenu.init
|
||||
, mergeModel = Comp.ItemMerge.init []
|
||||
, saveNameState = SaveSuccess
|
||||
, saveCustomFieldState = Set.empty
|
||||
}
|
||||
@ -205,6 +209,8 @@ type Msg
|
||||
| ReprocessSelectedConfirmed
|
||||
| ClientSettingsSaveResp UiSettings (Result Http.Error BasicResult)
|
||||
| RemoveItem String
|
||||
| MergeSelectedItems
|
||||
| MergeItemsMsg Comp.ItemMerge.Msg
|
||||
|
||||
|
||||
type SearchType
|
||||
@ -218,6 +224,7 @@ type SelectActionMode
|
||||
| EditSelected
|
||||
| ReprocessSelected
|
||||
| RestoreSelected
|
||||
| MergeSelected
|
||||
|
||||
|
||||
type alias SearchParam =
|
||||
|
@ -16,6 +16,7 @@ import Browser.Navigation as Nav
|
||||
import Comp.ItemCardList
|
||||
import Comp.ItemDetail.FormChange exposing (FormChange(..))
|
||||
import Comp.ItemDetail.MultiEditMenu exposing (SaveNameState(..))
|
||||
import Comp.ItemMerge
|
||||
import Comp.LinkTarget exposing (LinkTarget)
|
||||
import Comp.PowerSearchInput
|
||||
import Comp.SearchMenu
|
||||
@ -361,6 +362,7 @@ update mId key flags settings msg model =
|
||||
|
||||
_ ->
|
||||
noSub ( model, Cmd.none )
|
||||
|
||||
RestoreSelectedConfirmed ->
|
||||
case model.viewMode of
|
||||
SelectView svm ->
|
||||
@ -383,7 +385,6 @@ update mId key flags settings msg model =
|
||||
_ ->
|
||||
noSub ( model, Cmd.none )
|
||||
|
||||
|
||||
DeleteAllResp (Ok res) ->
|
||||
if res.success then
|
||||
let
|
||||
@ -535,6 +536,90 @@ update mId key flags settings msg model =
|
||||
_ ->
|
||||
noSub ( model, Cmd.none )
|
||||
|
||||
MergeSelectedItems ->
|
||||
case model.viewMode of
|
||||
SelectView svm ->
|
||||
if svm.action == MergeSelected then
|
||||
noSub
|
||||
( { model
|
||||
| viewMode =
|
||||
SelectView
|
||||
{ svm
|
||||
| action = NoneAction
|
||||
, mergeModel = Comp.ItemMerge.init []
|
||||
}
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
else if svm.ids == Set.empty then
|
||||
noSub ( model, Cmd.none )
|
||||
|
||||
else
|
||||
let
|
||||
( mm, mc ) =
|
||||
Comp.ItemMerge.initQuery
|
||||
flags
|
||||
model.searchMenuModel.searchMode
|
||||
(Q.ItemIdIn (Set.toList svm.ids))
|
||||
in
|
||||
noSub
|
||||
( { model
|
||||
| viewMode =
|
||||
SelectView
|
||||
{ svm
|
||||
| action = MergeSelected
|
||||
, mergeModel = mm
|
||||
}
|
||||
}
|
||||
, Cmd.map MergeItemsMsg mc
|
||||
)
|
||||
|
||||
_ ->
|
||||
noSub ( model, Cmd.none )
|
||||
|
||||
MergeItemsMsg lmsg ->
|
||||
case model.viewMode of
|
||||
SelectView svm ->
|
||||
let
|
||||
result =
|
||||
Comp.ItemMerge.update flags lmsg svm.mergeModel
|
||||
|
||||
nextView =
|
||||
case result.outcome of
|
||||
Comp.ItemMerge.OutcomeCancel ->
|
||||
SelectView { svm | action = NoneAction }
|
||||
|
||||
Comp.ItemMerge.OutcomeNotYet ->
|
||||
SelectView { svm | mergeModel = result.model }
|
||||
|
||||
Comp.ItemMerge.OutcomeMerged ->
|
||||
if settings.searchMenuVisible then
|
||||
SearchView
|
||||
|
||||
else
|
||||
SimpleView
|
||||
|
||||
model_ =
|
||||
{ model | viewMode = nextView }
|
||||
in
|
||||
if result.outcome == Comp.ItemMerge.OutcomeMerged then
|
||||
update mId
|
||||
key
|
||||
flags
|
||||
settings
|
||||
(DoSearch model.searchTypeDropdownValue)
|
||||
model_
|
||||
|
||||
else
|
||||
noSub
|
||||
( model_
|
||||
, Cmd.map MergeItemsMsg result.cmd
|
||||
)
|
||||
|
||||
_ ->
|
||||
noSub ( model, Cmd.none )
|
||||
|
||||
EditMenuMsg lmsg ->
|
||||
case model.viewMode of
|
||||
SelectView svm ->
|
||||
|
@ -10,6 +10,7 @@ module Page.Home.View2 exposing (viewContent, viewSidebar)
|
||||
import Comp.Basic as B
|
||||
import Comp.ConfirmModal
|
||||
import Comp.ItemCardList
|
||||
import Comp.ItemMerge
|
||||
import Comp.MenuBar as MB
|
||||
import Comp.PowerSearchInput
|
||||
import Comp.SearchMenu
|
||||
@ -50,7 +51,11 @@ viewContent texts flags settings model =
|
||||
]
|
||||
(searchStats texts flags settings model
|
||||
++ itemsBar texts flags settings model
|
||||
++ itemCardList texts flags settings model
|
||||
++ [ div [ class "sm:relative" ]
|
||||
(itemMergeView texts settings model
|
||||
++ itemCardList texts flags settings model
|
||||
)
|
||||
]
|
||||
++ confirmModal texts model
|
||||
)
|
||||
|
||||
@ -59,6 +64,28 @@ viewContent texts flags settings model =
|
||||
--- Helpers
|
||||
|
||||
|
||||
itemMergeView : Texts -> UiSettings -> Model -> List (Html Msg)
|
||||
itemMergeView texts settings model =
|
||||
case model.viewMode of
|
||||
SelectView svm ->
|
||||
case svm.action of
|
||||
MergeSelected ->
|
||||
[ div
|
||||
[ class S.dimmerMerge
|
||||
, class "mt-10 sm:mt-0"
|
||||
]
|
||||
[ Html.map MergeItemsMsg
|
||||
(Comp.ItemMerge.view texts.itemMerge settings svm.mergeModel)
|
||||
]
|
||||
]
|
||||
|
||||
_ ->
|
||||
[]
|
||||
|
||||
_ ->
|
||||
[]
|
||||
|
||||
|
||||
confirmModal : Texts -> Model -> List (Html Msg)
|
||||
confirmModal texts model =
|
||||
let
|
||||
@ -251,6 +278,7 @@ editMenuBar texts model svm =
|
||||
, inputClass =
|
||||
[ ( btnStyle, True )
|
||||
, ( "bg-gray-200 dark:bg-bluegray-600", svm.action == EditSelected )
|
||||
, ( "hidden", model.searchMenuModel.searchMode == Data.SearchMode.Trashed )
|
||||
]
|
||||
}
|
||||
, MB.CustomButton
|
||||
@ -261,6 +289,7 @@ editMenuBar texts model svm =
|
||||
, inputClass =
|
||||
[ ( btnStyle, True )
|
||||
, ( "bg-gray-200 dark:bg-bluegray-600", svm.action == ReprocessSelected )
|
||||
, ( "hidden", model.searchMenuModel.searchMode == Data.SearchMode.Trashed )
|
||||
]
|
||||
}
|
||||
, MB.CustomButton
|
||||
@ -285,6 +314,17 @@ editMenuBar texts model svm =
|
||||
, ( "hidden", model.searchMenuModel.searchMode == Data.SearchMode.Normal )
|
||||
]
|
||||
}
|
||||
, MB.CustomButton
|
||||
{ tagger = MergeSelectedItems
|
||||
, label = ""
|
||||
, icon = Just "fa fa-less-than"
|
||||
, title = texts.mergeItemsTitle selectCount
|
||||
, inputClass =
|
||||
[ ( btnStyle, True )
|
||||
, ( "bg-gray-200 dark:bg-bluegray-600", svm.action == MergeSelected )
|
||||
, ( "hidden", model.searchMenuModel.searchMode == Data.SearchMode.Trashed )
|
||||
]
|
||||
}
|
||||
]
|
||||
, end =
|
||||
[ MB.CustomButton
|
||||
|
@ -343,6 +343,11 @@ dimmerCard =
|
||||
" absolute top-0 left-0 w-full h-full bg-black bg-opacity-60 dark:bg-lightblue-900 dark:bg-opacity-60 z-30 flex flex-col items-center justify-center px-4 py-2 "
|
||||
|
||||
|
||||
dimmerMerge : String
|
||||
dimmerMerge =
|
||||
" absolute top-0 left-0 w-full h-full bg-white bg-opacity-100 dark:bg-bluegray-800 dark:bg-opacity-100 z-40 flex flex-col"
|
||||
|
||||
|
||||
tableMain : String
|
||||
tableMain =
|
||||
"border-collapse table w-full"
|
||||
|
62
modules/webapp/src/main/elm/Util/Item.elm
Normal file
62
modules/webapp/src/main/elm/Util/Item.elm
Normal file
@ -0,0 +1,62 @@
|
||||
{-
|
||||
Copyright 2020 Docspell Contributors
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
-}
|
||||
|
||||
|
||||
module Util.Item exposing
|
||||
( concTemplate
|
||||
, corrTemplate
|
||||
)
|
||||
|
||||
import Api.Model.ItemLight exposing (ItemLight)
|
||||
import Data.Fields
|
||||
import Data.ItemTemplate as IT exposing (ItemTemplate)
|
||||
import Data.UiSettings exposing (UiSettings)
|
||||
|
||||
|
||||
corrTemplate : UiSettings -> ItemTemplate
|
||||
corrTemplate settings =
|
||||
let
|
||||
fieldHidden f =
|
||||
Data.UiSettings.fieldHidden settings f
|
||||
|
||||
hiddenTuple =
|
||||
( fieldHidden Data.Fields.CorrOrg, fieldHidden Data.Fields.CorrPerson )
|
||||
in
|
||||
case hiddenTuple of
|
||||
( True, True ) ->
|
||||
IT.empty
|
||||
|
||||
( True, False ) ->
|
||||
IT.corrPerson
|
||||
|
||||
( False, True ) ->
|
||||
IT.corrOrg
|
||||
|
||||
( False, False ) ->
|
||||
IT.correspondent
|
||||
|
||||
|
||||
concTemplate : UiSettings -> ItemTemplate
|
||||
concTemplate settings =
|
||||
let
|
||||
fieldHidden f =
|
||||
Data.UiSettings.fieldHidden settings f
|
||||
|
||||
hiddenTuple =
|
||||
( fieldHidden Data.Fields.ConcPerson, fieldHidden Data.Fields.ConcEquip )
|
||||
in
|
||||
case hiddenTuple of
|
||||
( True, True ) ->
|
||||
IT.empty
|
||||
|
||||
( True, False ) ->
|
||||
IT.concEquip
|
||||
|
||||
( False, True ) ->
|
||||
IT.concPerson
|
||||
|
||||
( False, False ) ->
|
||||
IT.concerning
|
@ -6,7 +6,8 @@
|
||||
|
||||
|
||||
module Util.List exposing
|
||||
( distinct
|
||||
( changePosition
|
||||
, distinct
|
||||
, dropRight
|
||||
, find
|
||||
, findIndexed
|
||||
@ -16,6 +17,47 @@ module Util.List exposing
|
||||
, sliding
|
||||
)
|
||||
|
||||
import Html.Attributes exposing (list)
|
||||
|
||||
|
||||
changePosition : Int -> Int -> List a -> List a
|
||||
changePosition source target list =
|
||||
let
|
||||
len =
|
||||
List.length list
|
||||
|
||||
noChange =
|
||||
source == target || source + 1 == target
|
||||
|
||||
outOfBounds n =
|
||||
n < 0 || n >= len
|
||||
|
||||
concat el acc =
|
||||
let
|
||||
idx =
|
||||
Tuple.first el
|
||||
|
||||
ela =
|
||||
Tuple.second el
|
||||
in
|
||||
if idx == source then
|
||||
( target, ela ) :: acc
|
||||
|
||||
else if idx >= target then
|
||||
( idx + 1, ela ) :: acc
|
||||
|
||||
else
|
||||
( idx, ela ) :: acc
|
||||
in
|
||||
if noChange || outOfBounds source || outOfBounds target then
|
||||
list
|
||||
|
||||
else
|
||||
List.indexedMap Tuple.pair list
|
||||
|> List.foldl concat []
|
||||
|> List.sortBy Tuple.first
|
||||
|> List.map Tuple.second
|
||||
|
||||
|
||||
get : List a -> Int -> Maybe a
|
||||
get list index =
|
||||
|
@ -45,6 +45,7 @@ description = "A list of features and limitations."
|
||||
- [Read your mailboxes](@/docs/webapp/scanmailbox.md) via IMAP to
|
||||
import mails into docspell
|
||||
- [Edit multiple items](@/docs/webapp/multiedit.md) at once
|
||||
- [Merge](@/docs/webapp/merge.md) multiple items
|
||||
- REST server and document processing are separate applications which
|
||||
can be scaled-out independently
|
||||
- Everything stored in a SQL database: PostgreSQL, MariaDB or H2
|
||||
|
BIN
website/site/content/docs/webapp/merge-01.png
Normal file
BIN
website/site/content/docs/webapp/merge-01.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 158 KiB |
BIN
website/site/content/docs/webapp/merge-02.png
Normal file
BIN
website/site/content/docs/webapp/merge-02.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 122 KiB |
BIN
website/site/content/docs/webapp/merge-03.png
Normal file
BIN
website/site/content/docs/webapp/merge-03.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 132 KiB |
BIN
website/site/content/docs/webapp/merge-04.png
Normal file
BIN
website/site/content/docs/webapp/merge-04.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 112 KiB |
70
website/site/content/docs/webapp/merge.md
Normal file
70
website/site/content/docs/webapp/merge.md
Normal file
@ -0,0 +1,70 @@
|
||||
+++
|
||||
title = "Merge Items"
|
||||
weight = 100
|
||||
[extra]
|
||||
mktoc = true
|
||||
+++
|
||||
|
||||
Merging multiple items into one lets you transfer metadata and
|
||||
attachments from multiple items into a single one. The items that have
|
||||
been merged are removed afterwards.
|
||||
|
||||
# Usage
|
||||
## Select items to merge
|
||||
|
||||
Multiple items can be merged where all metadata is copied into the
|
||||
target item. This can be done by selecting multiple items to merge via
|
||||
the multi selection tool as described
|
||||
[here](@/docs/webapp/multiedit.md#toggle-selection-mode).
|
||||
|
||||
Then select some items (at least 2) and click the merge tool button.
|
||||
|
||||
{{ figure(file="merge-01.png") }}
|
||||
|
||||
|
||||
## Edit order of items
|
||||
|
||||
This opens the merge view, where you can change the order of the
|
||||
selected items.
|
||||
|
||||
{{ figure(file="merge-02.png") }}
|
||||
|
||||
The order of this list can matter when merging (see below). You can
|
||||
move items via drag and drop:
|
||||
|
||||
{{ figure(file="merge-03.png") }}
|
||||
|
||||
|
||||
## Click merge
|
||||
|
||||
Once you clicke the *Merge* button, the items are merged and you will
|
||||
be taken to the search view.
|
||||
|
||||
{{ figure(file="merge-04.png") }}
|
||||
|
||||
As you can see, tags are all combined. Custom fields of same name are
|
||||
also merged, where possible. For text fields, the values are
|
||||
concatenated with a comma as separator. Money and numeric fields are
|
||||
simply added together. Also it shows that there are now two
|
||||
attachments in the item.
|
||||
|
||||
|
||||
# How it works
|
||||
|
||||
Since the metadata of all items are merged into one, the order matters
|
||||
for fields that allow only one value (for example correspondents,
|
||||
concerning person/equipment, folder and dates). For these fields, the
|
||||
value of the first item in the list is used. The exception is the item
|
||||
notes: they are all concatenated with some newlines in between.
|
||||
|
||||
All properties that allow multiple values (like tags and the
|
||||
attachments, of course) are simply moved to the target item. Custom
|
||||
fields are merged depending on their type. Fields of type money and
|
||||
numeric are added together such that the final item contains the sum
|
||||
of all values. Text fields are concatenated using a comma as
|
||||
separator. Other fields (boolean and date) are again chosen from the
|
||||
first item that has a value.
|
||||
|
||||
After merging, the other items are removed from the database (they
|
||||
cannot be restored). This reason is that many data is moved into the
|
||||
target item and so the remaining items are actually empty.
|
Loading…
x
Reference in New Issue
Block a user