diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala index 3668e778..c62cc064 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala @@ -75,6 +75,8 @@ trait OItem[F[_]] { def findByFileSource(checksum: String, sourceId: Ident): F[Vector[RItem]] def deleteAttachment(id: Ident, collective: Ident): F[Int] + + def moveAttachmentBefore(itemId: Ident, source: Ident, target: Ident): F[AddResult] } object OItem { @@ -121,6 +123,16 @@ object OItem { def apply[F[_]: Effect](store: Store[F]): Resource[F, OItem[F]] = Resource.pure[F, OItem[F]](new OItem[F] { + def moveAttachmentBefore( + itemId: Ident, + source: Ident, + target: Ident + ): F[AddResult] = + store + .transact(QItem.moveAttachmentBefore(itemId, source, target)) + .attempt + .map(AddResult.fromUpdate) + def findItem(id: Ident, collective: Ident): F[Option[ItemData]] = store .transact(QItem.findItem(id)) diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 5932a998..4fcc44a0 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -1239,6 +1239,30 @@ paths: application/json: schema: $ref: "#/components/schemas/ItemProposals" + /sec/item/{itemId}/attachment/movebefore: + post: + tags: [ Item ] + summary: Reorder attachments within an item + description: | + Moves the `source` attachment before the `target` attachment, + such that `source` becomes the immediate neighbor of `target` + with a lower position. + security: + - authTokenHeader: [] + parameters: + - $ref: "#/components/parameters/itemId" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/MoveAttachment" + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/BasicResult" /sec/attachment/{id}: delete: @@ -1945,6 +1969,19 @@ paths: components: schemas: + MoveAttachment: + description: | + Data to move an attachment to another position. + required: + - source + - target + properties: + source: + type: string + format: ident + target: + type: string + format: ident ScanMailboxSettingsList: description: | A list of scan-mailbox tasks. diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala index 389c79de..c22d7bca 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala @@ -137,6 +137,14 @@ object ItemRoutes { resp <- Ok(ip) } yield resp + case req @ POST -> Root / Ident(id) / "attachment" / "movebefore" => + for { + data <- req.as[MoveAttachment] + _ <- logger.fdebug(s"Move item (${id.id}) attachment $data") + res <- backend.item.moveAttachmentBefore(id, data.source, data.target) + resp <- Ok(Conversions.basicResult(res, "Attachment moved.")) + } yield resp + case DELETE -> Root / Ident(id) => for { n <- backend.item.deleteItem(id, user.account.collective) diff --git a/modules/store/src/main/scala/docspell/store/impl/Column.scala b/modules/store/src/main/scala/docspell/store/impl/Column.scala index 0cdc0be3..72a69e08 100644 --- a/modules/store/src/main/scala/docspell/store/impl/Column.scala +++ b/modules/store/src/main/scala/docspell/store/impl/Column.scala @@ -72,12 +72,18 @@ case class Column(name: String, ns: String = "", alias: String = "") { def isGt[A: Put](a: A): Fragment = f ++ fr"> $a" + def isGte[A: Put](a: A): Fragment = + f ++ fr">= $a" + def isGt(c: Column): Fragment = f ++ fr">" ++ c.f def isLt[A: Put](a: A): Fragment = f ++ fr"< $a" + def isLte[A: Put](a: A): Fragment = + f ++ fr"<= $a" + def isLt(c: Column): Fragment = f ++ fr"<" ++ c.f @@ -103,4 +109,10 @@ case class Column(name: String, ns: String = "", alias: String = "") { def max: Fragment = fr"MAX(" ++ f ++ fr")" + + def increment[A: Put](a: A): Fragment = + f ++ fr"=" ++ f ++ fr"+ $a" + + def decrement[A: Put](a: A): Fragment = + f ++ fr"=" ++ f ++ fr"- $a" } diff --git a/modules/store/src/main/scala/docspell/store/queries/QItem.scala b/modules/store/src/main/scala/docspell/store/queries/QItem.scala index 619c0dbf..5abb2c92 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala @@ -1,8 +1,9 @@ package docspell.store.queries import bitpeace.FileMeta -import cats.implicits._ import cats.effect.Sync +import cats.data.OptionT +import cats.implicits._ import fs2.Stream import doobie._ import doobie.implicits._ @@ -16,6 +17,44 @@ import org.log4s._ object QItem { private[this] val logger = getLogger + def moveAttachmentBefore( + itemId: Ident, + source: Ident, + target: Ident + ): ConnectionIO[Int] = { + + // rs < rt + def moveBack(rs: RAttachment, rt: RAttachment): ConnectionIO[Int] = + for { + n <- RAttachment.decPositions(itemId, rs.position, rt.position) + k <- RAttachment.updatePosition(rs.id, rt.position) + } yield n + k + + // rs > rt + def moveForward(rs: RAttachment, rt: RAttachment): ConnectionIO[Int] = + for { + n <- RAttachment.incPositions(itemId, rt.position, rs.position) + k <- RAttachment.updatePosition(rs.id, rt.position) + } yield n + k + + (for { + _ <- OptionT.liftF( + if (source == target) + Sync[ConnectionIO].raiseError(new Exception("Attachments are the same!")) + else ().pure[ConnectionIO] + ) + rs <- OptionT(RAttachment.findById(source)).filter(_.itemId == itemId) + rt <- OptionT(RAttachment.findById(target)).filter(_.itemId == itemId) + n <- OptionT.liftF( + if (rs.position == rt.position || rs.position + 1 == rt.position) + 0.pure[ConnectionIO] + else if (rs.position < rt.position) moveBack(rs, rt) + else moveForward(rs, rt) + ) + } yield n).getOrElse(0) + + } + case class ItemData( item: RItem, corrOrg: Option[ROrganization], diff --git a/modules/store/src/main/scala/docspell/store/records/RAttachment.scala b/modules/store/src/main/scala/docspell/store/records/RAttachment.scala index b997bb5e..def33fa6 100644 --- a/modules/store/src/main/scala/docspell/store/records/RAttachment.scala +++ b/modules/store/src/main/scala/docspell/store/records/RAttachment.scala @@ -38,6 +38,20 @@ object RAttachment { fr"${v.id},${v.itemId},${v.fileId.id},${v.position},${v.created},${v.name}" ).update.run + def decPositions(iId: Ident, lowerBound: Int, upperBound: Int): ConnectionIO[Int] = + updateRow( + table, + and(itemId.is(iId), position.isGte(lowerBound), position.isLte(upperBound)), + position.decrement(1) + ).update.run + + def incPositions(iId: Ident, lowerBound: Int, upperBound: Int): ConnectionIO[Int] = + updateRow( + table, + and(itemId.is(iId), position.isGte(lowerBound), position.isLte(upperBound)), + position.increment(1) + ).update.run + def nextPosition(id: Ident): ConnectionIO[Int] = for { max <- selectSimple(position.max, table, itemId.is(id)).query[Option[Int]].unique