diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index c5451378..96f66c07 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -3752,6 +3752,14 @@ components: items: type: string format: ident + tagCategoriesInclude: + type: array + items: + type: string + tagCategoriesExclude: + type: array + items: + type: string inbox: type: boolean offset: diff --git a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala index 5cf79d9b..ef732d30 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala @@ -124,6 +124,8 @@ trait Conversions { m.folder, m.tagsInclude.map(Ident.unsafe), m.tagsExclude.map(Ident.unsafe), + m.tagCategoriesInclude, + m.tagCategoriesExclude, m.dateFrom, m.dateUntil, m.dueDateFrom, 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 de495170..578dd213 100644 --- a/modules/store/src/main/scala/docspell/store/impl/Column.scala +++ b/modules/store/src/main/scala/docspell/store/impl/Column.scala @@ -26,6 +26,9 @@ case class Column(name: String, ns: String = "", alias: String = "") { def is[A: Put](value: A): Fragment = f ++ fr" = $value" + def lowerIs[A: Put](value: A): Fragment = + fr"lower(" ++ f ++ fr") = $value" + def is[A: Put](ov: Option[A]): Fragment = ov match { case Some(v) => f ++ fr" = $v" 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 1ce0e976..1240d4a7 100644 --- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala +++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala @@ -172,6 +172,8 @@ object QItem { folder: Option[Ident], tagsInclude: List[Ident], tagsExclude: List[Ident], + tagCategoryIncl: List[String], + tagCategoryExcl: List[String], dateFrom: Option[Timestamp], dateTo: Option[Timestamp], dueDateFrom: Option[Timestamp], @@ -195,6 +197,8 @@ object QItem { None, Nil, Nil, + Nil, + Nil, None, None, None, @@ -323,25 +327,21 @@ object QItem { val EC = REquipment.Columns // inclusive tags are AND-ed - val tagSelectsIncl = q.tagsInclude + val tagSelectsIncl = (q.tagsInclude .map(tid => selectSimple( List(RTagItem.Columns.itemId), RTagItem.table, RTagItem.Columns.tagId.is(tid) ) - ) + ) ++ q.tagCategoryIncl.map(cat => + TagItemName.itemsInCategory(NonEmptyList.of(cat)) + )) .map(f => sql"(" ++ f ++ sql") ") // exclusive tags are OR-ed val tagSelectsExcl = - if (q.tagsExclude.isEmpty) Fragment.empty - else - selectSimple( - List(RTagItem.Columns.itemId), - RTagItem.table, - RTagItem.Columns.tagId.isOneOf(q.tagsExclude) - ) + TagItemName.itemsWithTagOrCategory(q.tagsExclude, q.tagCategoryExcl) val iFolder = IC.folder.prefix("i") val name = q.name.map(_.toLowerCase).map(queryWildcard) @@ -370,11 +370,11 @@ object QItem { RPerson.Columns.pid.prefix("p1").isOrDiscard(q.concPerson), REquipment.Columns.eid.prefix("e1").isOrDiscard(q.concEquip), RFolder.Columns.id.prefix("f1").isOrDiscard(q.folder), - if (q.tagsInclude.isEmpty) Fragment.empty + if (q.tagsInclude.isEmpty && q.tagCategoryIncl.isEmpty) Fragment.empty else IC.id.prefix("i") ++ sql" IN (" ++ tagSelectsIncl .reduce(_ ++ fr"INTERSECT" ++ _) ++ sql")", - if (q.tagsExclude.isEmpty) Fragment.empty + if (q.tagsExclude.isEmpty && q.tagCategoryExcl.isEmpty) Fragment.empty else IC.id.prefix("i").f ++ sql" NOT IN (" ++ tagSelectsExcl ++ sql")", q.dateFrom .map(d => diff --git a/modules/store/src/main/scala/docspell/store/records/TagItemName.scala b/modules/store/src/main/scala/docspell/store/records/TagItemName.scala new file mode 100644 index 00000000..05689ffd --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/TagItemName.scala @@ -0,0 +1,60 @@ +package docspell.store.records + +import docspell.common._ +import docspell.store.impl.Implicits._ +import cats.data.NonEmptyList +import doobie._ +import doobie.implicits._ + +/** A helper class combining information from `RTag` and `RTagItem`. + * This is not a "record", there is no corresponding table. + */ +case class TagItemName( + tagId: Ident, + collective: Ident, + name: String, + category: Option[String], + tagItemId: Ident, + itemId: Ident +) + +object TagItemName { + + def itemsInCategory(cats: NonEmptyList[String]): Fragment = { + val catsLower = cats.map(_.toLowerCase) + val tiItem = RTagItem.Columns.itemId.prefix("ti") + val tiTag = RTagItem.Columns.tagId.prefix("ti") + val tCat = RTag.Columns.category.prefix("t") + val tId = RTag.Columns.tid.prefix("t") + + val from = RTag.table ++ fr"t INNER JOIN" ++ + RTagItem.table ++ fr"ti ON" ++ tiTag.is(tId) + + if (cats.tail.isEmpty) + selectSimple(List(tiItem), from, tCat.lowerIs(catsLower.head)) + else + selectSimple(List(tiItem), from, tCat.isLowerIn(catsLower)) + } + + def itemsWithTagOrCategory(tags: List[Ident], cats: List[String]): Fragment = { + val catsLower = cats.map(_.toLowerCase) + val tiItem = RTagItem.Columns.itemId.prefix("ti") + val tiTag = RTagItem.Columns.tagId.prefix("ti") + val tCat = RTag.Columns.category.prefix("t") + val tId = RTag.Columns.tid.prefix("t") + + val from = RTag.table ++ fr"t INNER JOIN" ++ + RTagItem.table ++ fr"ti ON" ++ tiTag.is(tId) + + (NonEmptyList.fromList(tags), NonEmptyList.fromList(catsLower)) match { + case (Some(tagNel), Some(catNel)) => + selectSimple(List(tiItem), from, or(tId.isIn(tagNel), tCat.isLowerIn(catNel))) + case (Some(tagNel), None) => + selectSimple(List(tiItem), from, tId.isIn(tagNel)) + case (None, Some(catNel)) => + selectSimple(List(tiItem), from, tCat.isLowerIn(catNel)) + case (None, None) => + Fragment.empty + } + } +}