Implement item merge

This commit is contained in:
eikek
2021-08-16 11:55:47 +02:00
parent 22d331f082
commit 85085ec173
9 changed files with 324 additions and 2 deletions

View 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.setDeletedState(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
.map(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)
}
}
}
}

View File

@ -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(

View File

@ -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,