Add routes to link items

This commit is contained in:
eikek 2022-03-14 16:54:39 +01:00
parent 1874ac070f
commit 232baf5858
12 changed files with 449 additions and 3 deletions

View File

@ -50,6 +50,7 @@ trait BackendApp[F[_]] {
def notification: ONotification[F]
def bookmarks: OQueryBookmarks[F]
def fileRepository: OFileRepository[F]
def itemLink: OItemLink[F]
}
object BackendApp {
@ -106,6 +107,7 @@ object BackendApp {
notifyImpl <- ONotification(store, notificationMod)
bookmarksImpl <- OQueryBookmarks(store)
fileRepoImpl <- OFileRepository(store, schedulerModule.jobs, joexImpl)
itemLinkImpl <- Resource.pure(OItemLink(store, itemSearchImpl))
} yield new BackendApp[F] {
val pubSub = pubSubT
val login = loginImpl
@ -134,5 +136,6 @@ object BackendApp {
val notification = notifyImpl
val bookmarks = bookmarksImpl
val fileRepository = fileRepoImpl
val itemLink = itemLinkImpl
}
}

View File

@ -0,0 +1,89 @@
/*
* Copyright 2020 Eike K. & Contributors
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package docspell.backend.ops
import cats.data.NonEmptyList
import cats.effect._
import cats.implicits._
import docspell.backend.ops.OItemLink.LinkResult
import docspell.common.{AccountId, Ident}
import docspell.query.ItemQuery
import docspell.query.ItemQueryDsl._
import docspell.store.qb.Batch
import docspell.store.queries.Query
import docspell.store.records.RItemLink
import docspell.store.{AddResult, Store}
trait OItemLink[F[_]] {
def addAll(cid: Ident, target: Ident, related: NonEmptyList[Ident]): F[LinkResult]
def removeAll(cid: Ident, target: Ident, related: NonEmptyList[Ident]): F[Unit]
def getRelated(
account: AccountId,
item: Ident,
batch: Batch
): F[Vector[OItemSearch.ListItemWithTags]]
}
object OItemLink {
sealed trait LinkResult
object LinkResult {
/** When the target item is in the related list. */
case object LinkTargetItemError extends LinkResult
case object Success extends LinkResult
def linkTargetItemError: LinkResult = LinkTargetItemError
}
def apply[F[_]: Sync](store: Store[F], search: OItemSearch[F]): OItemLink[F] =
new OItemLink[F] {
def getRelated(
accountId: AccountId,
item: Ident,
batch: Batch
): F[Vector[OItemSearch.ListItemWithTags]] =
store
.transact(RItemLink.findLinked(accountId.collective, item))
.map(ids => NonEmptyList.fromList(ids.toList))
.flatMap {
case Some(nel) =>
val expr = Q.itemIdsIn(nel.map(_.id))
val query = Query(
Query
.Fix(accountId, Some(ItemQuery.Expr.ValidItemStates), None),
Query.QueryExpr(expr)
)
search.findItemsWithTags(0)(query, batch)
case None =>
Vector.empty[OItemSearch.ListItemWithTags].pure[F]
}
def addAll(cid: Ident, target: Ident, related: NonEmptyList[Ident]): F[LinkResult] =
if (related.contains_(target)) LinkResult.linkTargetItemError.pure[F]
else related.traverse(addSingle(cid, target, _)).as(LinkResult.Success)
def removeAll(cid: Ident, target: Ident, related: NonEmptyList[Ident]): F[Unit] =
store.transact(RItemLink.deleteAll(cid, target, related)).void
def addSingle(cid: Ident, target: Ident, related: Ident): F[Unit] = {
val exists = RItemLink.exists(cid, target, related)
val insert = RItemLink.insertNew(cid, target, related)
store.add(insert, exists).flatMap {
case AddResult.Success => ().pure[F]
case AddResult.EntityExists(_) => ().pure[F]
case AddResult.Failure(ex) =>
Sync[F].raiseError(ex)
}
}
}
}

View File

@ -78,5 +78,7 @@ object ItemQueryDsl {
def tagsEq(values: NonEmptyList[String]): Expr =
Expr.TagsMatch(TagOperator.AllMatch, values)
def itemIdsIn(values: NonEmptyList[String]): Expr =
Expr.InExpr(Attr.ItemId, values)
}
}

View File

@ -3675,6 +3675,99 @@ paths:
schema:
$ref: "#/components/schemas/BasicResult"
/sec/itemlink/{itemId}:
get:
operationId: "sec-itemlink-get"
tags: [ Item ]
summary: Get related items
description: |
Returns a list of related items for the given one.
security:
- authTokenHeader: []
parameters:
- $ref: "#/components/parameters/itemId"
responses:
422:
description: BadRequest
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/ItemLightGroup"
/sec/itemlink/{itemId}/{id}:
delete:
operationId: "sec-itemlink-delete"
tags: [Item]
summary: Delete an item from the list of related items
description: |
Deletes the item `id` from the list of related items on
`itemId`.
security:
- authTokenHeader: []
parameters:
- $ref: "#/components/parameters/itemId"
- $ref: "#/components/parameters/id"
responses:
422:
description: BadRequest
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/BasicResult"
/sec/itemlink/addAll:
post:
operationId: "sec-itemlink-appendall-post"
tags: [ Item ]
summary: Add more items as related
description: |
Add one or more items to anothers list of related items.
Duplicates are ignored.
security:
- authTokenHeader: []
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/ItemLinkData"
responses:
422:
description: BadRequest
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/BasicResult"
/sec/itemlink/removeAll:
post:
operationId: "sec-itemlink-removeall-post"
tags: [ Item ]
summary: Remove items from the list of related items
description: |
Remove all given items from the list of related items
security:
- authTokenHeader: []
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/ItemLinkData"
responses:
422:
description: BadRequest
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/BasicResult"
/sec/items/merge:
post:
@ -5486,6 +5579,22 @@ paths:
components:
schemas:
ItemLinkData:
description: |
Data for changing the list of related items.
required:
- item
- related
properties:
item:
type: string
format: ident
related:
type: array
items:
type: string
format: ident
FileIntegrityCheckRequest:
description: |
Data for running a file integrity check

View File

@ -128,6 +128,7 @@ final class RestAppImpl[F[_]: Async](
"queue" -> JobQueueRoutes(backend, token),
"item" -> ItemRoutes(config, backend, token),
"items" -> ItemMultiRoutes(config, backend, token),
"itemlink" -> ItemLinkRoutes(token.account, backend.itemLink),
"attachment" -> AttachmentRoutes(backend, token),
"attachments" -> AttachmentMultiRoutes(backend, token),
"upload" -> UploadRoutes.secured(backend, config, token),

View File

@ -188,7 +188,7 @@ trait Conversions {
ItemLightGroup(g._1, g._2.map(mkItemLight).toList)
val gs =
groups.map(mkGroup _).toList.sortWith((g1, g2) => g1.name.compareTo(g2.name) >= 0)
groups.map(mkGroup).toList.sortWith((g1, g2) => g1.name.compareTo(g2.name) >= 0)
ItemLightList(gs)
}
@ -199,7 +199,7 @@ trait Conversions {
ItemLightGroup(g._1, g._2.map(mkItemLightWithTags).toList)
val gs =
groups.map(mkGroup _).toList.sortWith((g1, g2) => g1.name.compareTo(g2.name) >= 0)
groups.map(mkGroup).toList.sortWith((g1, g2) => g1.name.compareTo(g2.name) >= 0)
ItemLightList(gs)
}
@ -210,7 +210,7 @@ trait Conversions {
ItemLightGroup(g._1, g._2.map(mkItemLightWithTags).toList)
val gs =
groups.map(mkGroup _).toList.sortWith((g1, g2) => g1.name.compareTo(g2.name) >= 0)
groups.map(mkGroup).toList.sortWith((g1, g2) => g1.name.compareTo(g2.name) >= 0)
ItemLightList(gs)
}

View File

@ -0,0 +1,82 @@
/*
* Copyright 2020 Eike K. & Contributors
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package docspell.restserver.routes
import cats.data.{NonEmptyList, OptionT}
import cats.effect._
import cats.implicits._
import docspell.backend.ops.OItemLink
import docspell.backend.ops.OItemLink.LinkResult
import docspell.common._
import docspell.joexapi.model.BasicResult
import docspell.restapi.model.{ItemLightGroup, ItemLinkData}
import docspell.restserver.conv.Conversions
import docspell.store.qb.Batch
import org.http4s.HttpRoutes
import org.http4s.circe.CirceEntityCodec._
import org.http4s.dsl.Http4sDsl
class ItemLinkRoutes[F[_]: Async](account: AccountId, backend: OItemLink[F])
extends Http4sDsl[F] {
def get: HttpRoutes[F] =
HttpRoutes.of {
case GET -> Root / Ident(id) =>
for {
results <- backend.getRelated(account, id, Batch.all)
conv = results.map(Conversions.mkItemLightWithTags)
res = ItemLightGroup("related", conv.toList)
resp <- Ok(res)
} yield resp
case DELETE -> Root / Ident(target) / Ident(id) =>
for {
_ <- backend.removeAll(account.collective, target, NonEmptyList.of(id))
resp <- Ok(BasicResult(true, "Related items removed"))
} yield resp
case req @ POST -> Root / "addAll" =>
for {
input <- req.as[ItemLinkData]
related = NonEmptyList.fromList(input.related)
res <- OptionT
.fromOption[F](related)
.semiflatMap(backend.addAll(account.collective, input.item, _))
.value
resp <- Ok(convertResult(res))
} yield resp
case req @ POST -> Root / "removeAll" =>
for {
input <- req.as[ItemLinkData]
related = NonEmptyList.fromList(input.related)
_ <- related
.map(backend.removeAll(account.collective, input.item, _))
.getOrElse(
BadRequest(BasicResult(false, "List of related items must not be empty"))
)
resp <- Ok(BasicResult(true, "Related items removed"))
} yield resp
}
private def convertResult(r: Option[LinkResult]): BasicResult =
r match {
case Some(LinkResult.Success) => BasicResult(true, "Related items added")
case Some(LinkResult.LinkTargetItemError) =>
BasicResult(false, "Items cannot be related to itself.")
case None =>
BasicResult(false, "List of related items must not be empty")
}
}
object ItemLinkRoutes {
def apply[F[_]: Async](account: AccountId, itemLink: OItemLink[F]): HttpRoutes[F] =
new ItemLinkRoutes[F](account, itemLink).get
}

View File

@ -0,0 +1,11 @@
create table "item_link" (
"id" varchar(254) not null primary key,
"cid" varchar(254) not null,
"item1" varchar(254) not null,
"item2" varchar(254) not null,
"created" timestamp not null,
unique ("cid", "item1", "item2"),
foreign key ("cid") references "collective"("cid") on delete cascade,
foreign key ("item1") references "item"("itemid") on delete cascade,
foreign key ("item2") references "item"("itemid") on delete cascade
);

View File

@ -0,0 +1,11 @@
create table `item_link` (
`id` varchar(254) not null primary key,
`cid` varchar(254) not null,
`item1` varchar(254) not null,
`item2` varchar(254) not null,
`created` timestamp not null,
unique (`cid`, `item1`, `item2`),
foreign key (`cid`) references `collective`(`cid`) on delete cascade,
foreign key (`item1`) references `item`(`itemid`) on delete cascade,
foreign key (`item2`) references `item`(`itemid`) on delete cascade
);

View File

@ -0,0 +1,11 @@
create table "item_link" (
"id" varchar(254) not null primary key,
"cid" varchar(254) not null,
"item1" varchar(254) not null,
"item2" varchar(254) not null,
"created" timestamp not null,
unique ("cid", "item1", "item2"),
foreign key ("cid") references "collective"("cid") on delete cascade,
foreign key ("item1") references "item"("itemid") on delete cascade,
foreign key ("item2") references "item"("itemid") on delete cascade
);

View File

@ -27,6 +27,13 @@ object DML extends DoobieMeta {
def insert(table: TableDef, cols: Nel[Column[_]], values: Fragment): ConnectionIO[Int] =
insertFragment(table, cols, List(values)).update.run
def insertSilent(
table: TableDef,
cols: Nel[Column[_]],
values: Fragment
): ConnectionIO[Int] =
insertFragment(table, cols, List(values)).update(LogHandler.nop).run
def insertMany(
table: TableDef,
cols: Nel[Column[_]],

View File

@ -0,0 +1,120 @@
/*
* Copyright 2020 Eike K. & Contributors
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package docspell.store.records
import cats.Order
import cats.data.NonEmptyList
import cats.effect.Sync
import cats.implicits._
import docspell.common._
import docspell.store.qb.DSL._
import docspell.store.qb._
import doobie._
import doobie.implicits._
final case class RItemLink(
id: Ident,
cid: Ident,
item1: Ident,
item2: Ident,
created: Timestamp
)
object RItemLink {
def create[F[_]: Sync](cid: Ident, item1: Ident, item2: Ident): F[RItemLink] =
for {
id <- Ident.randomId[F]
now <- Timestamp.current[F]
} yield RItemLink(id, cid, item1, item2, now)
final case class Table(alias: Option[String]) extends TableDef {
val tableName = "item_link"
val id: Column[Ident] = Column("id", this)
val cid: Column[Ident] = Column("cid", this)
val item1: Column[Ident] = Column("item1", this)
val item2: Column[Ident] = Column("item2", this)
val created: Column[Timestamp] = Column("created", this)
val all: NonEmptyList[Column[_]] =
NonEmptyList.of(id, cid, item1, item2, created)
}
def as(alias: String): Table =
Table(Some(alias))
val T: Table = Table(None)
private def orderIds(item1: Ident, item2: Ident): (Ident, Ident) = {
val i1 = Order[Ident].min(item1, item2)
val i2 = Order[Ident].max(item1, item2)
(i1, i2)
}
def insert(r: RItemLink): ConnectionIO[Int] = {
val (i1, i2) = orderIds(r.item1, r.item2)
DML.insertSilent(T, T.all, sql"${r.id},${r.cid},$i1,$i2,${r.created}")
}
def insertNew(cid: Ident, item1: Ident, item2: Ident): ConnectionIO[Int] =
create[ConnectionIO](cid, item1, item2).flatMap(insert)
def update(r: RItemLink): ConnectionIO[Int] = {
val (i1, i2) = orderIds(r.item1, r.item2)
DML.update(
T,
T.id === r.id && T.cid === r.cid,
DML.set(
T.item1.setTo(i1),
T.item2.setTo(i2)
)
)
}
def exists(cid: Ident, item1: Ident, item2: Ident): ConnectionIO[Boolean] = {
val (i1, i2) = orderIds(item1, item2)
Select(
select(count(T.id)),
from(T),
T.cid === cid && T.item1 === i1 && T.item2 === i2
).build.query[Int].unique.map(_ > 0)
}
def findLinked(cid: Ident, item: Ident): ConnectionIO[Vector[Ident]] =
union(
Select(
select(T.item1),
from(T),
T.cid === cid && T.item2 === item
),
Select(
select(T.item2),
from(T),
T.cid === cid && T.item1 === item
)
).build.query[Ident].to[Vector]
def deleteAll(
cid: Ident,
item: Ident,
related: NonEmptyList[Ident]
): ConnectionIO[Int] =
DML.delete(
T,
T.cid === cid && (
(T.item1 === item && T.item2.in(related)) ||
(T.item2 === item && T.item1.in(related))
)
)
def delete(cid: Ident, item1: Ident, item2: Ident): ConnectionIO[Int] = {
val (i1, i2) = orderIds(item1, item2)
DML.delete(T, T.cid === cid && T.item1 === i1 && T.item2 === i2)
}
}