mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-06-22 02:18:26 +00:00
Implement item merge
This commit is contained in:
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.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
Reference in New Issue
Block a user