From 698ff58aa3c8652aa1577b8191c6ac500d96be4c Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Mon, 1 Mar 2021 11:50:07 +0100
Subject: [PATCH] Provide a more convenient interface to search

---
 .../docspell/backend/ops/OSimpleSearch.scala  | 177 +++++++++++++++++-
 .../docspell/common/ItemQueryString.scala     |   6 +
 .../scala/docspell/store/queries/Query.scala  |  30 ++-
 3 files changed, 204 insertions(+), 9 deletions(-)

diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala b/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala
index 0ec0d903..d4a29a7f 100644
--- a/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala
+++ b/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala
@@ -1,13 +1,180 @@
 package docspell.backend.ops
 
-import docspell.backend.ops.OItemSearch.ListItemWithTags
-import docspell.common.ItemQueryString
-import docspell.store.qb.Batch
+import cats.implicits._
 
+import docspell.common._
+import docspell.store.qb.Batch
+import docspell.store.queries.Query
+import docspell.query.{ItemQueryParser, ParseFailure}
+
+import OSimpleSearch._
+import docspell.store.queries.SearchSummary
+import cats.effect.Sync
+
+/** A "porcelain" api on top of OFulltext and OItemSearch. */
 trait OSimpleSearch[F[_]] {
 
-  def searchByString(q: ItemQueryString, batch: Batch): F[Vector[ListItemWithTags]]
+  def search(settings: Settings)(q: Query, fulltextQuery: Option[String]): F[Items]
+  def searchSummary(
+      settings: Settings
+  )(q: Query, fulltextQuery: Option[String]): F[SearchSummary]
+
+  def searchByString(
+      settings: Settings
+  )(fix: Query.Fix, q: ItemQueryString): Either[ParseFailure, F[Items]]
+  def searchSummaryByString(
+      settings: Settings
+  )(fix: Query.Fix, q: ItemQueryString): Either[ParseFailure, F[SearchSummary]]
 
 }
 
-object OSimpleSearch {}
+object OSimpleSearch {
+
+  final case class Settings(
+      batch: Batch,
+      useFTS: Boolean,
+      resolveDetails: Boolean,
+      maxNoteLen: Int
+  )
+  object Settings {
+    def plain(batch: Batch, useFulltext: Boolean, maxNoteLen: Int): Settings =
+      Settings(batch, useFulltext, false, maxNoteLen)
+    def detailed(batch: Batch, useFulltext: Boolean, maxNoteLen: Int): Settings =
+      Settings(batch, useFulltext, true, maxNoteLen)
+  }
+
+  sealed trait Items {
+    def fold[A](
+        f1: Vector[OFulltext.FtsItem] => A,
+        f2: Vector[OFulltext.FtsItemWithTags] => A,
+        f3: Vector[OItemSearch.ListItem] => A,
+        f4: Vector[OItemSearch.ListItemWithTags] => A
+    ): A
+
+  }
+  object Items {
+    def ftsItems(items: Vector[OFulltext.FtsItem]): Items =
+      FtsItems(items)
+
+    case class FtsItems(items: Vector[OFulltext.FtsItem]) extends Items {
+      def fold[A](
+          f1: Vector[OFulltext.FtsItem] => A,
+          f2: Vector[OFulltext.FtsItemWithTags] => A,
+          f3: Vector[OItemSearch.ListItem] => A,
+          f4: Vector[OItemSearch.ListItemWithTags] => A
+      ): A = f1(items)
+
+    }
+
+    def ftsItemsFull(items: Vector[OFulltext.FtsItemWithTags]): Items =
+      FtsItemsFull(items)
+
+    case class FtsItemsFull(items: Vector[OFulltext.FtsItemWithTags]) extends Items {
+      def fold[A](
+          f1: Vector[OFulltext.FtsItem] => A,
+          f2: Vector[OFulltext.FtsItemWithTags] => A,
+          f3: Vector[OItemSearch.ListItem] => A,
+          f4: Vector[OItemSearch.ListItemWithTags] => A
+      ): A = f2(items)
+    }
+
+    def itemsPlain(items: Vector[OItemSearch.ListItem]): Items =
+      ItemsPlain(items)
+
+    case class ItemsPlain(items: Vector[OItemSearch.ListItem]) extends Items {
+      def fold[A](
+          f1: Vector[OFulltext.FtsItem] => A,
+          f2: Vector[OFulltext.FtsItemWithTags] => A,
+          f3: Vector[OItemSearch.ListItem] => A,
+          f4: Vector[OItemSearch.ListItemWithTags] => A
+      ): A = f3(items)
+    }
+
+    def itemsFull(items: Vector[OItemSearch.ListItemWithTags]): Items =
+      ItemsFull(items)
+
+    case class ItemsFull(items: Vector[OItemSearch.ListItemWithTags]) extends Items {
+      def fold[A](
+          f1: Vector[OFulltext.FtsItem] => A,
+          f2: Vector[OFulltext.FtsItemWithTags] => A,
+          f3: Vector[OItemSearch.ListItem] => A,
+          f4: Vector[OItemSearch.ListItemWithTags] => A
+      ): A = f4(items)
+    }
+
+  }
+
+  def apply[F[_]: Sync](fts: OFulltext[F], is: OItemSearch[F]): OSimpleSearch[F] =
+    new Impl(fts, is)
+
+  final class Impl[F[_]: Sync](fts: OFulltext[F], is: OItemSearch[F])
+      extends OSimpleSearch[F] {
+    def searchByString(
+        settings: Settings
+    )(fix: Query.Fix, q: ItemQueryString): Either[ParseFailure, F[Items]] =
+      ItemQueryParser
+        .parse(q.query)
+        .map(iq => Query(fix, Query.QueryExpr(iq)))
+        .map(search(settings)(_, None)) //TODO resolve content:xyz expressions
+
+    def searchSummaryByString(
+        settings: Settings
+    )(fix: Query.Fix, q: ItemQueryString): Either[ParseFailure, F[SearchSummary]] =
+      ItemQueryParser
+        .parse(q.query)
+        .map(iq => Query(fix, Query.QueryExpr(iq)))
+        .map(searchSummary(settings)(_, None)) //TODO resolve content:xyz expressions
+
+    def searchSummary(
+        settings: Settings
+    )(q: Query, fulltextQuery: Option[String]): F[SearchSummary] =
+      fulltextQuery match {
+        case Some(ftq) if settings.useFTS =>
+          if (q.isEmpty)
+            fts.findIndexOnlySummary(q.fix.account, OFulltext.FtsInput(ftq))
+          else
+            fts
+              .findItemsSummary(q, OFulltext.FtsInput(ftq))
+
+        case _ =>
+          is.findItemsSummary(q)
+      }
+
+    def search(settings: Settings)(q: Query, fulltextQuery: Option[String]): F[Items] =
+      // 1. fulltext only   if fulltextQuery.isDefined && q.isEmpty && useFTS
+      // 2. sql+fulltext    if fulltextQuery.isDefined && q.nonEmpty && useFTS
+      // 3. sql-only        else (if fulltextQuery.isEmpty || !useFTS)
+      fulltextQuery match {
+        case Some(ftq) if settings.useFTS =>
+          if (q.isEmpty)
+            fts
+              .findIndexOnly(settings.maxNoteLen)(
+                OFulltext.FtsInput(ftq),
+                q.fix.account,
+                settings.batch
+              )
+              .map(Items.ftsItemsFull)
+          else if (settings.resolveDetails)
+            fts
+              .findItemsWithTags(settings.maxNoteLen)(
+                q,
+                OFulltext.FtsInput(ftq),
+                settings.batch
+              )
+              .map(Items.ftsItemsFull)
+          else
+            fts
+              .findItems(settings.maxNoteLen)(q, OFulltext.FtsInput(ftq), settings.batch)
+              .map(Items.ftsItems)
+
+        case _ =>
+          if (settings.resolveDetails)
+            is.findItemsWithTags(settings.maxNoteLen)(q, settings.batch)
+              .map(Items.itemsFull)
+          else
+            is.findItems(settings.maxNoteLen)(q, settings.batch)
+              .map(Items.itemsPlain)
+      }
+  }
+
+}
diff --git a/modules/common/src/main/scala/docspell/common/ItemQueryString.scala b/modules/common/src/main/scala/docspell/common/ItemQueryString.scala
index 49bc878f..8e6e887e 100644
--- a/modules/common/src/main/scala/docspell/common/ItemQueryString.scala
+++ b/modules/common/src/main/scala/docspell/common/ItemQueryString.scala
@@ -1,3 +1,9 @@
 package docspell.common
 
 case class ItemQueryString(query: String)
+
+object ItemQueryString {
+
+  def apply(qs: Option[String]): ItemQueryString =
+    ItemQueryString(qs.getOrElse(""))
+}
diff --git a/modules/store/src/main/scala/docspell/store/queries/Query.scala b/modules/store/src/main/scala/docspell/store/queries/Query.scala
index 879d15b0..6be04a1e 100644
--- a/modules/store/src/main/scala/docspell/store/queries/Query.scala
+++ b/modules/store/src/main/scala/docspell/store/queries/Query.scala
@@ -14,6 +14,12 @@ case class Query(fix: Query.Fix, cond: Query.QueryCond) {
 
   def withFix(f: Query.Fix => Query.Fix): Query =
     copy(fix = f(fix))
+
+  def isEmpty: Boolean =
+    fix.isEmpty && cond.isEmpty
+
+  def nonEmpty: Boolean =
+    !isEmpty
 }
 
 object Query {
@@ -22,9 +28,18 @@ object Query {
       account: AccountId,
       itemIds: Option[Set[Ident]],
       orderAsc: Option[RItem.Table => Column[_]]
-  )
+  ) {
 
-  sealed trait QueryCond
+    def isEmpty: Boolean =
+      itemIds.isEmpty
+  }
+
+  sealed trait QueryCond {
+    def isEmpty: Boolean
+
+    def nonEmpty: Boolean =
+      !isEmpty
+  }
 
   case class QueryForm(
       name: Option[String],
@@ -47,7 +62,11 @@ object Query {
       itemIds: Option[Set[Ident]],
       customValues: Seq[CustomValue],
       source: Option[String]
-  ) extends QueryCond
+  ) extends QueryCond {
+
+    def isEmpty: Boolean =
+      this == QueryForm.empty
+  }
   object QueryForm {
     val empty =
       QueryForm(
@@ -74,7 +93,10 @@ object Query {
       )
   }
 
-  case class QueryExpr(q: ItemQuery) extends QueryCond
+  case class QueryExpr(q: ItemQuery) extends QueryCond {
+    def isEmpty: Boolean =
+      q.expr == ItemQuery.all.expr
+  }
 
   def empty(account: AccountId): Query =
     Query(Fix(account, None, None), QueryForm.empty)