From 070c2b5e5f8ffb4efa713a9f072c01ca6eba028a Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Thu, 6 Aug 2020 21:43:27 +0200 Subject: [PATCH 1/2] Allow to search by tag categories The server accepts a list of tag categories for inclusion and exclusion. The categories in the include list imply to return items that have at least one tag of each category. The categories in the exclude list imply to return all items that have no tag in any of these categories. --- .../src/main/resources/docspell-openapi.yml | 8 +++ .../restserver/conv/Conversions.scala | 2 + .../scala/docspell/store/impl/Column.scala | 3 + .../scala/docspell/store/queries/QItem.scala | 22 +++---- .../docspell/store/records/TagItemName.scala | 60 +++++++++++++++++++ 5 files changed, 84 insertions(+), 11 deletions(-) create mode 100644 modules/store/src/main/scala/docspell/store/records/TagItemName.scala 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 + } + } +} From a6a6e334d58d411e8cdab50e7291bc7041ee1a29 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Thu, 6 Aug 2020 22:23:35 +0200 Subject: [PATCH 2/2] Search by tag category via web ui --- .../webapp/src/main/elm/Comp/SearchMenu.elm | 44 +++++++++++++++++++ .../src/main/elm/Comp/UiSettingsForm.elm | 5 +-- modules/webapp/src/main/elm/Util/Tag.elm | 24 +++++++++- 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/modules/webapp/src/main/elm/Comp/SearchMenu.elm b/modules/webapp/src/main/elm/Comp/SearchMenu.elm index 10f6843d..ad4fbe5f 100644 --- a/modules/webapp/src/main/elm/Comp/SearchMenu.elm +++ b/modules/webapp/src/main/elm/Comp/SearchMenu.elm @@ -41,6 +41,8 @@ import Util.Update type alias Model = { tagInclModel : Comp.Dropdown.Model Tag , tagExclModel : Comp.Dropdown.Model Tag + , tagCatInclModel : Comp.Dropdown.Model String + , tagCatExclModel : Comp.Dropdown.Model String , directionModel : Comp.Dropdown.Model Direction , orgModel : Comp.Dropdown.Model IdName , corrPersonModel : Comp.Dropdown.Model IdName @@ -68,6 +70,8 @@ init : Model init = { tagInclModel = Util.Tag.makeDropdownModel , tagExclModel = Util.Tag.makeDropdownModel + , tagCatInclModel = Util.Tag.makeCatDropdownModel + , tagCatExclModel = Util.Tag.makeCatDropdownModel , directionModel = Comp.Dropdown.makeSingleList { makeOption = @@ -157,6 +161,8 @@ type Msg | ToggleNameHelp | FolderMsg (Comp.Dropdown.Msg IdName) | GetFolderResp (Result Http.Error FolderList) + | TagCatIncMsg (Comp.Dropdown.Msg String) + | TagCatExcMsg (Comp.Dropdown.Msg String) getDirection : Model -> Maybe Direction @@ -211,6 +217,8 @@ getItemSearch model = model.allNameModel |> Maybe.map amendWildcards , fullText = model.fulltextModel + , tagCategoriesInclude = Comp.Dropdown.getSelected model.tagCatInclModel + , tagCategoriesExclude = Comp.Dropdown.getSelected model.tagCatExclModel } @@ -280,11 +288,17 @@ update flags settings msg model = let tagList = Comp.Dropdown.SetOptions tags.items + + catList = + Util.Tag.getCategories tags.items + |> Comp.Dropdown.SetOptions in noChange <| Util.Update.andThen1 [ update flags settings (TagIncMsg tagList) >> .modelCmd , update flags settings (TagExcMsg tagList) >> .modelCmd + , update flags settings (TagCatIncMsg catList) >> .modelCmd + , update flags settings (TagCatExcMsg catList) >> .modelCmd ] model @@ -551,6 +565,28 @@ update flags settings msg model = ) (isDropdownChangeMsg lm) + TagCatIncMsg m -> + let + ( m2, c2 ) = + Comp.Dropdown.update m model.tagCatInclModel + in + NextState + ( { model | tagCatInclModel = m2 } + , Cmd.map TagCatIncMsg c2 + ) + (isDropdownChangeMsg m) + + TagCatExcMsg m -> + let + ( m2, c2 ) = + Comp.Dropdown.update m model.tagCatExclModel + in + NextState + ( { model | tagCatExclModel = m2 } + , Cmd.map TagCatExcMsg c2 + ) + (isDropdownChangeMsg m) + -- View @@ -645,6 +681,14 @@ view flags settings model = [ label [] [ text "Exclude (or)" ] , Html.map TagExcMsg (Comp.Dropdown.view settings model.tagExclModel) ] + , div [ class "field" ] + [ label [] [ text "Category Include (and)" ] + , Html.map TagCatIncMsg (Comp.Dropdown.view settings model.tagCatInclModel) + ] + , div [ class "field" ] + [ label [] [ text "Category Exclude (or)" ] + , Html.map TagCatExcMsg (Comp.Dropdown.view settings model.tagCatExclModel) + ] , formHeader (Icons.searchIcon "") "Content" , div [ classList diff --git a/modules/webapp/src/main/elm/Comp/UiSettingsForm.elm b/modules/webapp/src/main/elm/Comp/UiSettingsForm.elm index a6f256f6..0970e4c5 100644 --- a/modules/webapp/src/main/elm/Comp/UiSettingsForm.elm +++ b/modules/webapp/src/main/elm/Comp/UiSettingsForm.elm @@ -18,7 +18,7 @@ import Html exposing (..) import Html.Attributes exposing (..) import Html.Events exposing (onCheck) import Http -import Util.List +import Util.Tag type alias Model = @@ -148,8 +148,7 @@ update sett msg model = GetTagsResp (Ok tl) -> let categories = - List.filterMap .category tl.items - |> Util.List.distinct + Util.Tag.getCategories tl.items in ( { model | tagColorModel = diff --git a/modules/webapp/src/main/elm/Util/Tag.elm b/modules/webapp/src/main/elm/Util/Tag.elm index a4bed92a..8e59b36c 100644 --- a/modules/webapp/src/main/elm/Util/Tag.elm +++ b/modules/webapp/src/main/elm/Util/Tag.elm @@ -1,8 +1,13 @@ -module Util.Tag exposing (makeDropdownModel) +module Util.Tag exposing + ( getCategories + , makeCatDropdownModel + , makeDropdownModel + ) import Api.Model.Tag exposing (Tag) import Comp.Dropdown import Data.UiSettings +import Util.List makeDropdownModel : Comp.Dropdown.Model Tag @@ -17,3 +22,20 @@ makeDropdownModel = "basic " ++ Data.UiSettings.tagColorString tag settings , placeholder = "Choose a tag…" } + + +makeCatDropdownModel : Comp.Dropdown.Model String +makeCatDropdownModel = + Comp.Dropdown.makeModel + { multiple = True + , searchable = \n -> n > 5 + , makeOption = \cat -> { value = cat, text = cat, additional = "" } + , labelColor = \_ -> \_ -> "" + , placeholder = "Choose a tag category…" + } + + +getCategories : List Tag -> List String +getCategories tags = + List.filterMap .category tags + |> Util.List.distinct