From 74a79a79d99880d21625ec6e63f0b8bf7319bcca Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Sun, 21 Feb 2021 12:04:48 +0100
Subject: [PATCH 01/33] Initial project setup

---
 build.sbt                                     | 25 +++++++++++++++++--
 .../src/main/scala/docspell/query/Query.scala |  3 +++
 .../scala/docspell/query/QueryParser.scala    | 13 ++++++++++
 project/Dependencies.scala                    | 11 ++++++++
 project/plugins.sbt                           |  2 ++
 5 files changed, 52 insertions(+), 2 deletions(-)
 create mode 100644 modules/query/src/main/scala/docspell/query/Query.scala
 create mode 100644 modules/query/src/main/scala/docspell/query/QueryParser.scala

diff --git a/build.sbt b/build.sbt
index 2c9db4f1..3063a246 100644
--- a/build.sbt
+++ b/build.sbt
@@ -264,6 +264,24 @@ ${lines.map(_._1).mkString(",\n")}
   )
   .dependsOn(common)
 
+val query =
+  crossProject(JSPlatform, JVMPlatform)
+    .crossType(CrossType.Pure)
+    .in(file("modules/query"))
+    .settings(sharedSettings)
+    .settings(testSettings)
+    .settings(
+      name := "docspell-query",
+      libraryDependencies +=
+        Dependencies.catsParseJS.value
+    )
+    .jvmSettings(
+      libraryDependencies +=
+        Dependencies.scalaJsStubs
+    )
+val queryJVM = query.jvm
+val queryJS  = query.js
+
 val store = project
   .in(file("modules/store"))
   .disablePlugins(RevolverPlugin)
@@ -284,7 +302,7 @@ val store = project
         Dependencies.calevCore ++
         Dependencies.calevFs2
   )
-  .dependsOn(common)
+  .dependsOn(common, queryJVM)
 
 val extract = project
   .in(file("modules/extract"))
@@ -425,6 +443,7 @@ val webapp = project
     openapiSpec := (restapi / Compile / resourceDirectory).value / "docspell-openapi.yml",
     openapiElmConfig := ElmConfig().withJson(ElmJson.decodePipeline)
   )
+  .dependsOn(queryJS)
 
 // --- Application(s)
 
@@ -594,7 +613,9 @@ val root = project
     backend,
     webapp,
     restapi,
-    restserver
+    restserver,
+    queryJVM,
+    queryJS
   )
 
 // --- Helpers
diff --git a/modules/query/src/main/scala/docspell/query/Query.scala b/modules/query/src/main/scala/docspell/query/Query.scala
new file mode 100644
index 00000000..5f6f3b0f
--- /dev/null
+++ b/modules/query/src/main/scala/docspell/query/Query.scala
@@ -0,0 +1,3 @@
+package docspell.query
+
+case class Query(raw: String)
diff --git a/modules/query/src/main/scala/docspell/query/QueryParser.scala b/modules/query/src/main/scala/docspell/query/QueryParser.scala
new file mode 100644
index 00000000..0f6278d9
--- /dev/null
+++ b/modules/query/src/main/scala/docspell/query/QueryParser.scala
@@ -0,0 +1,13 @@
+package docspell.query
+
+import scala.scalajs.js.annotation._
+
+@JSExportTopLevel("DsQueryParser")
+object QueryParser {
+
+  @JSExport
+  def parse(input: String): Either[String, Query] = {
+    Right(Query("parsed: " + input))
+
+  }
+}
diff --git a/project/Dependencies.scala b/project/Dependencies.scala
index a512e9ba..0ce02040 100644
--- a/project/Dependencies.scala
+++ b/project/Dependencies.scala
@@ -1,6 +1,7 @@
 package docspell.build
 
 import sbt._
+import org.portablescala.sbtplatformdeps.PlatformDepsPlugin.autoImport._
 
 object Dependencies {
 
@@ -8,6 +9,7 @@ object Dependencies {
   val BetterMonadicForVersion = "0.3.1"
   val BitpeaceVersion         = "0.6.0"
   val CalevVersion            = "0.4.1"
+  val CatsParseVersion        = "0.3.1"
   val CirceVersion            = "0.13.0"
   val ClipboardJsVersion      = "2.0.6"
   val DoobieVersion           = "0.10.0"
@@ -41,6 +43,15 @@ object Dependencies {
   val JQueryVersion           = "3.5.1"
   val ViewerJSVersion         = "0.5.8"
 
+  val catsParse = Seq(
+    "org.typelevel" %% "cats-parse" % CatsParseVersion
+  )
+  val catsParseJS =
+    Def.setting("org.typelevel" %%% "cats-parse" % CatsParseVersion)
+
+  val scalaJsStubs =
+    "org.scala-js" %% "scalajs-stubs" % "1.0.0" % "provided"
+
   val kittens = Seq(
     "org.typelevel" %% "kittens" % KittensVersion
   )
diff --git a/project/plugins.sbt b/project/plugins.sbt
index 958f4d6d..a3b25d78 100644
--- a/project/plugins.sbt
+++ b/project/plugins.sbt
@@ -7,5 +7,7 @@ addSbtPlugin("com.typesafe.sbt" % "sbt-ghpages" % "0.6.3")
 addSbtPlugin("com.typesafe.sbt" % "sbt-git" % "1.0.0")
 addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.8.0")
 addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1")
+addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.0.0")
+addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.5.0")
 addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.2")
 addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.9.5")

From be5c7ffb8891100e1e340112bec192458e701993 Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Tue, 23 Feb 2021 01:24:24 +0100
Subject: [PATCH 02/33] First draft of ast and parser

---
 build.sbt                                     |  10 +-
 .../src/main/scala/docspell/query/Date.scala  |  14 ++
 .../main/scala/docspell/query/ItemQuery.scala |  97 +++++++++
 .../docspell/query/ItemQueryParser.scala      |  18 ++
 .../src/main/scala/docspell/query/Query.scala |   3 -
 .../scala/docspell/query/QueryParser.scala    |  13 --
 .../docspell/query/internal/AttrParser.scala  |  85 ++++++++
 .../docspell/query/internal/BasicParser.scala |  51 +++++
 .../docspell/query/internal/DateParser.scala  |  41 ++++
 .../docspell/query/internal/ExprParser.scala  |  30 +++
 .../query/internal/OperatorParser.scala       |  36 ++++
 .../query/internal/SimpleExprParser.scala     |  66 ++++++
 .../docspell/query/internal/StringUtil.scala  | 177 ++++++++++++++++
 .../scala/docspell/query/AttrParserTest.scala |  41 ++++
 .../docspell/query/BasicParserTest.scala      |  35 ++++
 .../scala/docspell/query/DateParserTest.scala |  36 ++++
 .../scala/docspell/query/ExprParserTest.scala |  48 +++++
 .../docspell/query/OperatorParserTest.scala   |  23 +++
 .../docspell/query/SimpleExprParserTest.scala | 154 ++++++++++++++
 .../main/scala/docspell/store/qb/Column.scala |   3 +
 .../main/scala/docspell/store/qb/DSL.scala    |   4 +-
 .../qb/generator/ItemQueryGenerator.scala     | 195 ++++++++++++++++++
 .../docspell/store/qb/generator/Tables.scala  |  14 ++
 .../docspell/store/records/TagItemName.scala  |  18 ++
 project/Dependencies.scala                    |   2 +
 25 files changed, 1190 insertions(+), 24 deletions(-)
 create mode 100644 modules/query/src/main/scala/docspell/query/Date.scala
 create mode 100644 modules/query/src/main/scala/docspell/query/ItemQuery.scala
 create mode 100644 modules/query/src/main/scala/docspell/query/ItemQueryParser.scala
 delete mode 100644 modules/query/src/main/scala/docspell/query/Query.scala
 delete mode 100644 modules/query/src/main/scala/docspell/query/QueryParser.scala
 create mode 100644 modules/query/src/main/scala/docspell/query/internal/AttrParser.scala
 create mode 100644 modules/query/src/main/scala/docspell/query/internal/BasicParser.scala
 create mode 100644 modules/query/src/main/scala/docspell/query/internal/DateParser.scala
 create mode 100644 modules/query/src/main/scala/docspell/query/internal/ExprParser.scala
 create mode 100644 modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala
 create mode 100644 modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala
 create mode 100644 modules/query/src/main/scala/docspell/query/internal/StringUtil.scala
 create mode 100644 modules/query/src/test/scala/docspell/query/AttrParserTest.scala
 create mode 100644 modules/query/src/test/scala/docspell/query/BasicParserTest.scala
 create mode 100644 modules/query/src/test/scala/docspell/query/DateParserTest.scala
 create mode 100644 modules/query/src/test/scala/docspell/query/ExprParserTest.scala
 create mode 100644 modules/query/src/test/scala/docspell/query/OperatorParserTest.scala
 create mode 100644 modules/query/src/test/scala/docspell/query/SimpleExprParserTest.scala
 create mode 100644 modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
 create mode 100644 modules/store/src/main/scala/docspell/store/qb/generator/Tables.scala

diff --git a/build.sbt b/build.sbt
index 3063a246..7cb00d86 100644
--- a/build.sbt
+++ b/build.sbt
@@ -279,8 +279,6 @@ val query =
       libraryDependencies +=
         Dependencies.scalaJsStubs
     )
-val queryJVM = query.jvm
-val queryJS  = query.js
 
 val store = project
   .in(file("modules/store"))
@@ -302,7 +300,7 @@ val store = project
         Dependencies.calevCore ++
         Dependencies.calevFs2
   )
-  .dependsOn(common, queryJVM)
+  .dependsOn(common, query.jvm)
 
 val extract = project
   .in(file("modules/extract"))
@@ -443,7 +441,7 @@ val webapp = project
     openapiSpec := (restapi / Compile / resourceDirectory).value / "docspell-openapi.yml",
     openapiElmConfig := ElmConfig().withJson(ElmJson.decodePipeline)
   )
-  .dependsOn(queryJS)
+  .dependsOn(query.js)
 
 // --- Application(s)
 
@@ -614,8 +612,8 @@ val root = project
     webapp,
     restapi,
     restserver,
-    queryJVM,
-    queryJS
+    query.jvm,
+    query.js
   )
 
 // --- Helpers
diff --git a/modules/query/src/main/scala/docspell/query/Date.scala b/modules/query/src/main/scala/docspell/query/Date.scala
new file mode 100644
index 00000000..30e9dabb
--- /dev/null
+++ b/modules/query/src/main/scala/docspell/query/Date.scala
@@ -0,0 +1,14 @@
+package docspell.query
+
+sealed trait Date
+object Date {
+  def apply(y: Int, m: Int, d: Int): Date =
+    Local(y, m, d)
+
+  def apply(ms: Long): Date =
+    Millis(ms)
+
+  final case class Local(year: Int, month: Int, day: Int) extends Date
+
+  final case class Millis(ms: Long) extends Date
+}
diff --git a/modules/query/src/main/scala/docspell/query/ItemQuery.scala b/modules/query/src/main/scala/docspell/query/ItemQuery.scala
new file mode 100644
index 00000000..2a6759a2
--- /dev/null
+++ b/modules/query/src/main/scala/docspell/query/ItemQuery.scala
@@ -0,0 +1,97 @@
+package docspell.query
+
+import cats.data.{NonEmptyList => Nel}
+import docspell.query.ItemQuery.Attr.{DateAttr, StringAttr}
+
+/** A query evaluates to `true` or `false` given enough details about
+  * an item.
+  *
+  * It may consist of (field,op,value) tuples that specify some checks
+  * against a specific field of an item using some operator or a
+  * combination thereof.
+  */
+final case class ItemQuery(expr: ItemQuery.Expr, raw: Option[String])
+
+object ItemQuery {
+
+  sealed trait Operator
+  object Operator {
+    case object Eq   extends Operator
+    case object Like extends Operator
+    case object Gt   extends Operator
+    case object Lt   extends Operator
+    case object Gte  extends Operator
+    case object Lte  extends Operator
+  }
+
+  sealed trait TagOperator
+  object TagOperator {
+    case object AllMatch extends TagOperator
+    case object AnyMatch extends TagOperator
+  }
+
+  sealed trait Attr
+  object Attr {
+    sealed trait StringAttr extends Attr
+    sealed trait DateAttr   extends Attr
+
+    case object ItemName   extends StringAttr
+    case object ItemSource extends StringAttr
+    case object ItemId     extends StringAttr
+    case object Date       extends DateAttr
+    case object DueDate    extends DateAttr
+
+    object Correspondent {
+      case object OrgId      extends StringAttr
+      case object OrgName    extends StringAttr
+      case object PersonId   extends StringAttr
+      case object PersonName extends StringAttr
+    }
+
+    object Concerning {
+      case object PersonId   extends StringAttr
+      case object PersonName extends StringAttr
+      case object EquipId    extends StringAttr
+      case object EquipName  extends StringAttr
+    }
+
+    object Folder {
+      case object FolderId   extends StringAttr
+      case object FolderName extends StringAttr
+    }
+  }
+
+  sealed trait Property
+  object Property {
+    final case class StringProperty(attr: StringAttr, value: String) extends Property
+    final case class DateProperty(attr: DateAttr, value: Date)       extends Property
+
+  }
+
+  sealed trait Expr {
+    def negate: Expr =
+      Expr.NotExpr(this)
+  }
+
+  object Expr {
+    case class AndExpr(expr: Nel[Expr]) extends Expr
+    case class OrExpr(expr: Nel[Expr])  extends Expr
+    case class NotExpr(expr: Expr) extends Expr {
+      override def negate: Expr =
+        expr
+    }
+
+    case class SimpleExpr(op: Operator, prop: Property)      extends Expr
+    case class Exists(field: Attr)                           extends Expr
+    case class InExpr(attr: StringAttr, values: Nel[String]) extends Expr
+
+    case class TagIdsMatch(op: TagOperator, tags: Nel[String])      extends Expr
+    case class TagsMatch(op: TagOperator, tags: Nel[String])        extends Expr
+    case class TagCategoryMatch(op: TagOperator, cats: Nel[String]) extends Expr
+
+    case class CustomFieldMatch(name: String, op: Operator, value: String) extends Expr
+
+    case class Fulltext(query: String) extends Expr
+  }
+
+}
diff --git a/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala b/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala
new file mode 100644
index 00000000..23b0c297
--- /dev/null
+++ b/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala
@@ -0,0 +1,18 @@
+package docspell.query
+
+import docspell.query.internal.ExprParser
+
+import scala.scalajs.js.annotation._
+
+@JSExportTopLevel("DsItemQueryParser")
+object ItemQueryParser {
+
+  @JSExport
+  def parse(input: String): Either[String, ItemQuery] =
+    ExprParser.exprParser
+      .parseAll(input.trim)
+      .left
+      .map(_.toString)
+      .map(expr => ItemQuery(expr, Some(input.trim)))
+
+}
diff --git a/modules/query/src/main/scala/docspell/query/Query.scala b/modules/query/src/main/scala/docspell/query/Query.scala
deleted file mode 100644
index 5f6f3b0f..00000000
--- a/modules/query/src/main/scala/docspell/query/Query.scala
+++ /dev/null
@@ -1,3 +0,0 @@
-package docspell.query
-
-case class Query(raw: String)
diff --git a/modules/query/src/main/scala/docspell/query/QueryParser.scala b/modules/query/src/main/scala/docspell/query/QueryParser.scala
deleted file mode 100644
index 0f6278d9..00000000
--- a/modules/query/src/main/scala/docspell/query/QueryParser.scala
+++ /dev/null
@@ -1,13 +0,0 @@
-package docspell.query
-
-import scala.scalajs.js.annotation._
-
-@JSExportTopLevel("DsQueryParser")
-object QueryParser {
-
-  @JSExport
-  def parse(input: String): Either[String, Query] = {
-    Right(Query("parsed: " + input))
-
-  }
-}
diff --git a/modules/query/src/main/scala/docspell/query/internal/AttrParser.scala b/modules/query/src/main/scala/docspell/query/internal/AttrParser.scala
new file mode 100644
index 00000000..6cd1c8b3
--- /dev/null
+++ b/modules/query/src/main/scala/docspell/query/internal/AttrParser.scala
@@ -0,0 +1,85 @@
+package docspell.query.internal
+
+import cats.parse.{Parser => P}
+import docspell.query.ItemQuery.Attr
+
+object AttrParser {
+
+  val name: P[Attr.StringAttr] =
+    P.ignoreCase("name").map(_ => Attr.ItemName)
+
+  val source: P[Attr.StringAttr] =
+    P.ignoreCase("source").map(_ => Attr.ItemSource)
+
+  val id: P[Attr.StringAttr] =
+    P.ignoreCase("id").map(_ => Attr.ItemId)
+
+  val date: P[Attr.DateAttr] =
+    P.ignoreCase("date").map(_ => Attr.Date)
+
+  val dueDate: P[Attr.DateAttr] =
+    P.stringIn(List("dueDate", "due", "due-date")).map(_ => Attr.DueDate)
+
+  val corrOrgId: P[Attr.StringAttr] =
+    P.stringIn(List("correspondent.org.id", "corr.org.id"))
+      .map(_ => Attr.Correspondent.OrgId)
+
+  val corrOrgName: P[Attr.StringAttr] =
+    P.stringIn(List("correspondent.org.name", "corr.org.name"))
+      .map(_ => Attr.Correspondent.OrgName)
+
+  val corrPersId: P[Attr.StringAttr] =
+    P.stringIn(List("correspondent.person.id", "corr.pers.id"))
+      .map(_ => Attr.Correspondent.PersonId)
+
+  val corrPersName: P[Attr.StringAttr] =
+    P.stringIn(List("correspondent.person.name", "corr.pers.name"))
+      .map(_ => Attr.Correspondent.PersonName)
+
+  val concPersId: P[Attr.StringAttr] =
+    P.stringIn(List("concerning.person.id", "conc.pers.id"))
+      .map(_ => Attr.Concerning.PersonId)
+
+  val concPersName: P[Attr.StringAttr] =
+    P.stringIn(List("concerning.person.name", "conc.pers.name"))
+      .map(_ => Attr.Concerning.PersonName)
+
+  val concEquipId: P[Attr.StringAttr] =
+    P.stringIn(List("concerning.equip.id", "conc.equip.id"))
+      .map(_ => Attr.Concerning.EquipId)
+
+  val concEquipName: P[Attr.StringAttr] =
+    P.stringIn(List("concerning.equip.name", "conc.equip.name"))
+      .map(_ => Attr.Concerning.EquipName)
+
+  val folderId: P[Attr.StringAttr] =
+    P.ignoreCase("folder.id").map(_ => Attr.Folder.FolderId)
+
+  val folderName: P[Attr.StringAttr] =
+    P.ignoreCase("folder").map(_ => Attr.Folder.FolderName)
+
+  val dateAttr: P[Attr.DateAttr] =
+    P.oneOf(List(date, dueDate))
+
+  val stringAttr: P[Attr.StringAttr] =
+    P.oneOf(
+      List(
+        name,
+        source,
+        id,
+        corrOrgId,
+        corrOrgName,
+        corrPersId,
+        corrPersName,
+        concPersId,
+        concPersName,
+        concEquipId,
+        concEquipName,
+        folderId,
+        folderName
+      )
+    )
+
+  val anyAttr: P[Attr] =
+    P.oneOf(List(dateAttr, stringAttr))
+}
diff --git a/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala b/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala
new file mode 100644
index 00000000..36694b10
--- /dev/null
+++ b/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala
@@ -0,0 +1,51 @@
+package docspell.query.internal
+
+import cats.data.{NonEmptyList => Nel}
+import cats.parse.{Parser0, Parser => P}
+
+object BasicParser {
+  private[this] val whitespace: P[Unit] = P.charIn(" \t\r\n").void
+
+  val ws0: Parser0[Unit] = whitespace.rep0.void
+  val ws1: P[Unit]       = whitespace.rep(1).void
+
+  private[this] val listSep: P[Unit] =
+    P.char(',').surroundedBy(BasicParser.ws0).void
+
+  def rep[A](pa: P[A]): P[Nel[A]] =
+    pa.repSep(listSep)
+
+  private[this] val basicString: P[String] =
+    P.charsWhile(c =>
+      c > ' ' && c != '"' && c != '\\' && c != ',' && c != '[' && c != ']'
+    )
+
+  private[this] val identChars: Set[Char] =
+    (('A' to 'Z') ++ ('a' to 'z') ++ ('0' to '9') ++ "-_.").toSet
+
+  val parenAnd: P[Unit] =
+    P.stringIn(List("(&", "(and")).void.surroundedBy(ws0)
+
+  val parenClose: P[Unit] =
+    P.char(')').surroundedBy(ws0)
+
+  val parenOr: P[Unit] =
+    P.stringIn(List("(|", "(or")).void.surroundedBy(ws0)
+
+  val identParser: P[String] =
+    P.charsWhile(identChars.contains)
+
+  val singleString: P[String] =
+    basicString.backtrack.orElse(StringUtil.quoted('"'))
+
+  val stringListValue: P[Nel[String]] = rep(singleString).with1
+    .between(P.char('['), P.char(']'))
+    .backtrack
+    .orElse(rep(singleString))
+
+  val stringOrMore: P[Nel[String]] =
+    stringListValue.backtrack.orElse(
+      singleString.map(v => Nel.of(v))
+    )
+
+}
diff --git a/modules/query/src/main/scala/docspell/query/internal/DateParser.scala b/modules/query/src/main/scala/docspell/query/internal/DateParser.scala
new file mode 100644
index 00000000..43ae6221
--- /dev/null
+++ b/modules/query/src/main/scala/docspell/query/internal/DateParser.scala
@@ -0,0 +1,41 @@
+package docspell.query.internal
+
+import cats.implicits._
+import cats.parse.{Numbers, Parser => P}
+import docspell.query.Date
+
+object DateParser {
+  private[this] val longParser: P[Long] =
+    Numbers.bigInt.map(_.longValue)
+
+  private[this] val digits4: P[Int] =
+    Numbers.digit
+      .repExactlyAs[String](4)
+      .map(_.toInt)
+  private[this] val digits2: P[Int] =
+    Numbers.digit
+      .repExactlyAs[String](2)
+      .map(_.toInt)
+
+  private[this] val month: P[Int] =
+    digits2.filter(n => n >= 1 && n <= 12)
+
+  private[this] val day: P[Int] =
+    digits2.filter(n => n >= 1 && n <= 31)
+
+  private val dateSep: P[Unit] =
+    P.anyChar.void
+
+  val localDateFromString: P[Date] =
+    ((digits4 <* dateSep) ~ (month <* dateSep) ~ day).mapFilter {
+      case ((year, month), day) =>
+        Either.catchNonFatal(Date(year, month, day)).toOption
+    }
+
+  val dateFromMillis: P[Date] =
+    longParser.map(Date.apply)
+
+  val localDate: P[Date] =
+    localDateFromString.backtrack.orElse(dateFromMillis)
+
+}
diff --git a/modules/query/src/main/scala/docspell/query/internal/ExprParser.scala b/modules/query/src/main/scala/docspell/query/internal/ExprParser.scala
new file mode 100644
index 00000000..7c7a6d6a
--- /dev/null
+++ b/modules/query/src/main/scala/docspell/query/internal/ExprParser.scala
@@ -0,0 +1,30 @@
+package docspell.query.internal
+
+import cats.parse.{Parser => P}
+import docspell.query.ItemQuery._
+
+object ExprParser {
+
+  def and(inner: P[Expr]): P[Expr.AndExpr] =
+    inner
+      .repSep(BasicParser.ws1)
+      .between(BasicParser.parenAnd, BasicParser.parenClose)
+      .map(Expr.AndExpr.apply)
+
+  def or(inner: P[Expr]): P[Expr.OrExpr] =
+    inner
+      .repSep(BasicParser.ws1)
+      .between(BasicParser.parenOr, BasicParser.parenClose)
+      .map(Expr.OrExpr.apply)
+
+  def not(inner: P[Expr]): P[Expr.NotExpr] =
+    (P.char('!') *> inner).map(Expr.NotExpr.apply)
+
+  val exprParser: P[Expr] =
+    P.recursive[Expr] { recurse =>
+      val andP = and(recurse)
+      val orP  = or(recurse)
+      val notP = not(recurse)
+      P.oneOf(SimpleExprParser.simpleExpr :: andP :: orP :: notP :: Nil)
+    }
+}
diff --git a/modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala b/modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala
new file mode 100644
index 00000000..76a14e60
--- /dev/null
+++ b/modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala
@@ -0,0 +1,36 @@
+package docspell.query.internal
+
+import cats.parse.{Parser => P}
+import docspell.query.ItemQuery._
+
+object OperatorParser {
+  private[this] val Eq: P[Operator] =
+    P.char('=').void.map(_ => Operator.Eq)
+
+  private[this] val Like: P[Operator] =
+    P.char(':').void.map(_ => Operator.Like)
+
+  private[this] val Gt: P[Operator] =
+    P.char('>').void.map(_ => Operator.Gt)
+
+  private[this] val Lt: P[Operator] =
+    P.char('<').void.map(_ => Operator.Lt)
+
+  private[this] val Gte: P[Operator] =
+    P.string(">=").map(_ => Operator.Gte)
+
+  private[this] val Lte: P[Operator] =
+    P.string("<=").map(_ => Operator.Lte)
+
+  val op: P[Operator] =
+    P.oneOf(List(Like, Eq, Gte, Lte, Gt, Lt))
+
+  private[this] val anyOp: P[TagOperator] =
+    P.char(':').map(_ => TagOperator.AnyMatch)
+
+  private[this] val allOp: P[TagOperator] =
+    P.char('=').map(_ => TagOperator.AllMatch)
+
+  val tagOp: P[TagOperator] =
+    P.oneOf(List(anyOp, allOp))
+}
diff --git a/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala b/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala
new file mode 100644
index 00000000..5865ad80
--- /dev/null
+++ b/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala
@@ -0,0 +1,66 @@
+package docspell.query.internal
+
+import cats.parse.{Parser => P}
+import docspell.query.ItemQuery.Expr.CustomFieldMatch
+import docspell.query.ItemQuery._
+
+object SimpleExprParser {
+
+  private[this] val op: P[Operator] =
+    OperatorParser.op.surroundedBy(BasicParser.ws0)
+
+  val stringExpr: P[Expr.SimpleExpr] =
+    (AttrParser.stringAttr ~ op ~ BasicParser.singleString).map {
+      case ((attr, op), value) =>
+        Expr.SimpleExpr(op, Property.StringProperty(attr, value))
+    }
+
+  val dateExpr: P[Expr.SimpleExpr] =
+    (AttrParser.dateAttr ~ op ~ DateParser.localDate).map { case ((attr, op), value) =>
+      Expr.SimpleExpr(op, Property.DateProperty(attr, value))
+    }
+
+  val existsExpr: P[Expr.Exists] =
+    (P.ignoreCase("exists:") *> AttrParser.anyAttr).map(attr => Expr.Exists(attr))
+
+  val fulltextExpr: P[Expr.Fulltext] =
+    (P.ignoreCase("content:") *> BasicParser.singleString).map(q => Expr.Fulltext(q))
+
+  val tagIdExpr: P[Expr.TagIdsMatch] =
+    (P.ignoreCase("tag.id") *> OperatorParser.tagOp ~ BasicParser.stringOrMore).map {
+      case (op, values) =>
+        Expr.TagIdsMatch(op, values)
+    }
+
+  val tagExpr: P[Expr.TagsMatch] =
+    (P.ignoreCase("tag") *> OperatorParser.tagOp ~ BasicParser.stringOrMore).map {
+      case (op, values) =>
+        Expr.TagsMatch(op, values)
+    }
+
+  val catExpr: P[Expr.TagCategoryMatch] =
+    (P.ignoreCase("cat") *> OperatorParser.tagOp ~ BasicParser.stringOrMore).map {
+      case (op, values) =>
+        Expr.TagCategoryMatch(op, values)
+    }
+
+  val customFieldExpr: P[Expr.CustomFieldMatch] =
+    (P.string("f:") *> BasicParser.identParser ~ op ~ BasicParser.singleString).map {
+      case ((name, op), value) =>
+        CustomFieldMatch(name, op, value)
+    }
+
+  val simpleExpr: P[Expr] =
+    P.oneOf(
+      List(
+        dateExpr,
+        stringExpr,
+        existsExpr,
+        fulltextExpr,
+        tagIdExpr,
+        tagExpr,
+        catExpr,
+        customFieldExpr
+      )
+    )
+}
diff --git a/modules/query/src/main/scala/docspell/query/internal/StringUtil.scala b/modules/query/src/main/scala/docspell/query/internal/StringUtil.scala
new file mode 100644
index 00000000..28a24872
--- /dev/null
+++ b/modules/query/src/main/scala/docspell/query/internal/StringUtil.scala
@@ -0,0 +1,177 @@
+/*
+ * Copyright (c) 2021 Typelevel
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to
+ * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+ * the Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package docspell.query.internal
+
+// modified, from
+// https://github.com/typelevel/cats-parse/blob/e7a58ef15925358fbe7a4c0c1a204296e366a06c/bench/src/main/scala/cats/parse/bench/self.scala
+import cats.parse.{Parser0 => P0, Parser => P}
+
+object StringUtil {
+
+  def quoted(q: Char): P[String] =
+    Util.escapedString(q)
+
+  private object Util extends GenericStringUtil {
+    lazy val decodeTable: Map[Char, Char] =
+      Map(
+        ('\\', '\\'),
+        ('\'', '\''),
+        ('\"', '\"'),
+        ('n', '\n'),
+        ('r', '\r'),
+        ('t', '\t')
+      )
+  }
+  abstract private class GenericStringUtil {
+    protected def decodeTable: Map[Char, Char]
+
+    private val encodeTable = decodeTable.iterator.map { case (v, k) =>
+      (k, s"\\$v")
+    }.toMap
+
+    private val nonPrintEscape: Array[String] =
+      (0 until 32).map { c =>
+        val strHex = c.toHexString
+        val strPad = List.fill(4 - strHex.length)('0').mkString
+        s"\\u$strPad$strHex"
+      }.toArray
+
+    val escapedToken: P[Unit] = {
+      val escapes = P.charIn(decodeTable.keys.toSeq)
+
+      val oct  = P.charIn('0' to '7')
+      val octP = P.char('o') ~ oct ~ oct
+
+      val hex  = P.charIn(('0' to '9') ++ ('a' to 'f') ++ ('A' to 'F'))
+      val hex2 = hex ~ hex
+      val hexP = P.char('x') ~ hex2
+
+      val hex4 = hex2 ~ hex2
+      val u4   = P.char('u') ~ hex4
+      val hex8 = hex4 ~ hex4
+      val u8   = P.char('U') ~ hex8
+
+      val after = P.oneOf[Any](escapes :: octP :: hexP :: u4 :: u8 :: Nil)
+      (P.char('\\') ~ after).void
+    }
+
+    /** String content without the delimiter
+      */
+    def undelimitedString(endP: P[Unit]): P[String] =
+      escapedToken.backtrack
+        .orElse((!endP).with1 ~ P.anyChar)
+        .rep
+        .string
+        .flatMap { str =>
+          unescape(str) match {
+            case Right(str1) => P.pure(str1)
+            case Left(_)     => P.fail
+          }
+        }
+
+    private val simpleString: P0[String] =
+      P.charsWhile0(c => c >= ' ' && c != '"' && c != '\\')
+
+    def escapedString(q: Char): P[String] = {
+      val end: P[Unit] = P.char(q)
+      end *> ((simpleString <* end).backtrack
+        .orElse(undelimitedString(end) <* end))
+    }
+
+    def escape(quoteChar: Char, str: String): String = {
+      // We can ignore escaping the opposite character used for the string
+      // x isn't escaped anyway and is kind of a hack here
+      val ignoreEscape =
+        if (quoteChar == '\'') '"' else if (quoteChar == '"') '\'' else 'x'
+      str.flatMap { c =>
+        if (c == ignoreEscape) c.toString
+        else
+          encodeTable.get(c) match {
+            case None =>
+              if (c < ' ') nonPrintEscape(c.toInt)
+              else c.toString
+            case Some(esc) => esc
+          }
+      }
+    }
+
+    def unescape(str: String): Either[Int, String] = {
+      val sb = new java.lang.StringBuilder
+      def decodeNum(idx: Int, size: Int, base: Int): Int = {
+        val end = idx + size
+        if (end <= str.length) {
+          val intStr = str.substring(idx, end)
+          val asInt =
+            try Integer.parseInt(intStr, base)
+            catch { case _: NumberFormatException => ~idx }
+          sb.append(asInt.toChar)
+          end
+        } else ~(str.length)
+      }
+      @annotation.tailrec
+      def loop(idx: Int): Int =
+        if (idx >= str.length) {
+          // done
+          idx
+        } else if (idx < 0) {
+          // error from decodeNum
+          idx
+        } else {
+          val c0 = str.charAt(idx)
+          if (c0 != '\\') {
+            sb.append(c0)
+            loop(idx + 1)
+          } else {
+            // str(idx) == \
+            val nextIdx = idx + 1
+            if (nextIdx >= str.length) {
+              // error we expect there to be a character after \
+              ~idx
+            } else {
+              val c = str.charAt(nextIdx)
+              decodeTable.get(c) match {
+                case Some(d) =>
+                  sb.append(d)
+                  loop(idx + 2)
+                case None =>
+                  c match {
+                    case 'o'   => loop(decodeNum(idx + 2, 2, 8))
+                    case 'x'   => loop(decodeNum(idx + 2, 2, 16))
+                    case 'u'   => loop(decodeNum(idx + 2, 4, 16))
+                    case 'U'   => loop(decodeNum(idx + 2, 8, 16))
+                    case other =>
+                      // \c is interpreted as just \c, if the character isn't escaped
+                      sb.append('\\')
+                      sb.append(other)
+                      loop(idx + 2)
+                  }
+              }
+            }
+          }
+        }
+
+      val res = loop(0)
+      if (res < 0) Left(~res)
+      else Right(sb.toString)
+    }
+  }
+
+}
diff --git a/modules/query/src/test/scala/docspell/query/AttrParserTest.scala b/modules/query/src/test/scala/docspell/query/AttrParserTest.scala
new file mode 100644
index 00000000..b79c1103
--- /dev/null
+++ b/modules/query/src/test/scala/docspell/query/AttrParserTest.scala
@@ -0,0 +1,41 @@
+package docspell.query
+
+import docspell.query.ItemQuery.Attr
+import docspell.query.internal.AttrParser
+import minitest._
+
+object AttrParserTest extends SimpleTestSuite {
+
+  test("string attributes") {
+    val p = AttrParser.stringAttr
+    assertEquals(p.parseAll("name"), Right(Attr.ItemName))
+    assertEquals(p.parseAll("source"), Right(Attr.ItemSource))
+    assertEquals(p.parseAll("id"), Right(Attr.ItemId))
+    assertEquals(p.parseAll("corr.org.id"), Right(Attr.Correspondent.OrgId))
+    assertEquals(p.parseAll("corr.org.name"), Right(Attr.Correspondent.OrgName))
+    assertEquals(p.parseAll("correspondent.org.name"), Right(Attr.Correspondent.OrgName))
+    assertEquals(p.parseAll("conc.pers.id"), Right(Attr.Concerning.PersonId))
+    assertEquals(p.parseAll("conc.pers.name"), Right(Attr.Concerning.PersonName))
+    assertEquals(p.parseAll("concerning.person.name"), Right(Attr.Concerning.PersonName))
+    assertEquals(p.parseAll("folder"), Right(Attr.Folder.FolderName))
+    assertEquals(p.parseAll("folder.id"), Right(Attr.Folder.FolderId))
+  }
+
+  test("date attributes") {
+    val p = AttrParser.dateAttr
+    assertEquals(p.parseAll("date"), Right(Attr.Date))
+    assertEquals(p.parseAll("dueDate"), Right(Attr.DueDate))
+    assertEquals(p.parseAll("due"), Right(Attr.DueDate))
+  }
+
+  test("all attributes parser") {
+    val p = AttrParser.anyAttr
+    assertEquals(p.parseAll("date"), Right(Attr.Date))
+    assertEquals(p.parseAll("dueDate"), Right(Attr.DueDate))
+    assertEquals(p.parseAll("name"), Right(Attr.ItemName))
+    assertEquals(p.parseAll("source"), Right(Attr.ItemSource))
+    assertEquals(p.parseAll("id"), Right(Attr.ItemId))
+    assertEquals(p.parseAll("corr.org.id"), Right(Attr.Correspondent.OrgId))
+    assertEquals(p.parseAll("corr.org.name"), Right(Attr.Correspondent.OrgName))
+  }
+}
diff --git a/modules/query/src/test/scala/docspell/query/BasicParserTest.scala b/modules/query/src/test/scala/docspell/query/BasicParserTest.scala
new file mode 100644
index 00000000..80a06d18
--- /dev/null
+++ b/modules/query/src/test/scala/docspell/query/BasicParserTest.scala
@@ -0,0 +1,35 @@
+package docspell.query
+
+import minitest._
+import cats.data.{NonEmptyList => Nel}
+import docspell.query.internal.BasicParser
+
+object BasicParserTest extends SimpleTestSuite {
+  test("single string values") {
+    val p = BasicParser.singleString
+    assertEquals(p.parseAll("abcde"), Right("abcde"))
+    assert(p.parseAll("ab cd").isLeft)
+    assertEquals(p.parseAll(""""ab cd""""), Right("ab cd"))
+    assertEquals(p.parseAll(""""and \"this\" is""""), Right("""and "this" is"""))
+  }
+
+  test("string list values") {
+    val p = BasicParser.stringListValue
+    assertEquals(p.parseAll("[ab,cd]"), Right(Nel.of("ab", "cd")))
+    assertEquals(p.parseAll("[\"ab 12\",cd]"), Right(Nel.of("ab 12", "cd")))
+    assertEquals(
+      p.parseAll("[\"ab, 12\",cd]"),
+      Right(Nel.of("ab, 12", "cd"))
+    )
+    assertEquals(p.parseAll("ab,cd,123"), Right(Nel.of("ab", "cd", "123")))
+    assertEquals(p.parseAll("a,b"), Right(Nel.of("a", "b")))
+    assert(p.parseAll("[a,b").isLeft)
+  }
+
+  test("stringvalue") {
+    val p = BasicParser.stringOrMore
+    assertEquals(p.parseAll("abcde"), Right(Nel.of("abcde")))
+    assertEquals(p.parseAll(""""a,b,c""""), Right(Nel.of("a,b,c")))
+    assertEquals(p.parseAll("[a,b,c]"), Right(Nel.of("a", "b", "c")))
+  }
+}
diff --git a/modules/query/src/test/scala/docspell/query/DateParserTest.scala b/modules/query/src/test/scala/docspell/query/DateParserTest.scala
new file mode 100644
index 00000000..ca909a97
--- /dev/null
+++ b/modules/query/src/test/scala/docspell/query/DateParserTest.scala
@@ -0,0 +1,36 @@
+package docspell.query
+
+import docspell.query.internal.DateParser
+import minitest._
+
+object DateParserTest extends SimpleTestSuite {
+
+  def ld(year: Int, m: Int, d: Int): Date =
+    Date(year, m, d)
+
+  test("local date string") {
+    val p = DateParser.localDateFromString
+    assertEquals(p.parseAll("2021-02-22"), Right(ld(2021, 2, 22)))
+    assertEquals(p.parseAll("1999-11-11"), Right(ld(1999, 11, 11)))
+    assertEquals(p.parseAll("2032-01-21"), Right(ld(2032, 1, 21)))
+    assert(p.parseAll("0-0-0").isLeft)
+    assert(p.parseAll("2021-02-30").isRight)
+  }
+
+  test("local date millis") {
+    val p = DateParser.dateFromMillis
+    assertEquals(p.parseAll("0"), Right(Date(0)))
+    assertEquals(
+      p.parseAll("1600000065463"),
+      Right(Date(1600000065463L))
+    )
+  }
+
+  test("local date") {
+    val p = DateParser.localDate
+    assertEquals(p.parseAll("2021-02-22"), Right(ld(2021, 2, 22)))
+    assertEquals(p.parseAll("1999-11-11"), Right(ld(1999, 11, 11)))
+    assertEquals(p.parseAll("0"), Right(Date(0)))
+    assertEquals(p.parseAll("1600000065463"), Right(Date(1600000065463L)))
+  }
+}
diff --git a/modules/query/src/test/scala/docspell/query/ExprParserTest.scala b/modules/query/src/test/scala/docspell/query/ExprParserTest.scala
new file mode 100644
index 00000000..304fd6d0
--- /dev/null
+++ b/modules/query/src/test/scala/docspell/query/ExprParserTest.scala
@@ -0,0 +1,48 @@
+package docspell.query
+
+import docspell.query.ItemQuery._
+import docspell.query.SimpleExprParserTest.stringExpr
+import docspell.query.internal.ExprParser
+import minitest._
+import cats.data.{NonEmptyList => Nel}
+
+object ExprParserTest extends SimpleTestSuite {
+
+  test("simple expr") {
+    val p = ExprParser.exprParser
+    assertEquals(
+      p.parseAll("name:hello"),
+      Right(stringExpr(Operator.Like, Attr.ItemName, "hello"))
+    )
+  }
+
+  test("and") {
+    val p = ExprParser.exprParser
+    assertEquals(
+      p.parseAll("(& name:hello source=webapp )"),
+      Right(
+        Expr.AndExpr(
+          Nel.of(
+            stringExpr(Operator.Like, Attr.ItemName, "hello"),
+            stringExpr(Operator.Eq, Attr.ItemSource, "webapp")
+          )
+        )
+      )
+    )
+  }
+
+  test("or") {
+    val p = ExprParser.exprParser
+    assertEquals(
+      p.parseAll("(| name:hello source=webapp )"),
+      Right(
+        Expr.OrExpr(
+          Nel.of(
+            stringExpr(Operator.Like, Attr.ItemName, "hello"),
+            stringExpr(Operator.Eq, Attr.ItemSource, "webapp")
+          )
+        )
+      )
+    )
+  }
+}
diff --git a/modules/query/src/test/scala/docspell/query/OperatorParserTest.scala b/modules/query/src/test/scala/docspell/query/OperatorParserTest.scala
new file mode 100644
index 00000000..94e9ea35
--- /dev/null
+++ b/modules/query/src/test/scala/docspell/query/OperatorParserTest.scala
@@ -0,0 +1,23 @@
+package docspell.query
+
+import minitest._
+import docspell.query.ItemQuery.{Operator, TagOperator}
+import docspell.query.internal.OperatorParser
+
+object OperatorParserTest extends SimpleTestSuite {
+  test("operator values") {
+    val p = OperatorParser.op
+    assertEquals(p.parseAll("="), Right(Operator.Eq))
+    assertEquals(p.parseAll(":"), Right(Operator.Like))
+    assertEquals(p.parseAll("<"), Right(Operator.Lt))
+    assertEquals(p.parseAll(">"), Right(Operator.Gt))
+    assertEquals(p.parseAll("<="), Right(Operator.Lte))
+    assertEquals(p.parseAll(">="), Right(Operator.Gte))
+  }
+
+  test("tag operators") {
+    val p = OperatorParser.tagOp
+    assertEquals(p.parseAll(":"), Right(TagOperator.AnyMatch))
+    assertEquals(p.parseAll("="), Right(TagOperator.AllMatch))
+  }
+}
diff --git a/modules/query/src/test/scala/docspell/query/SimpleExprParserTest.scala b/modules/query/src/test/scala/docspell/query/SimpleExprParserTest.scala
new file mode 100644
index 00000000..298e9c59
--- /dev/null
+++ b/modules/query/src/test/scala/docspell/query/SimpleExprParserTest.scala
@@ -0,0 +1,154 @@
+package docspell.query
+
+import cats.data.{NonEmptyList => Nel}
+import docspell.query.ItemQuery._
+import docspell.query.internal.SimpleExprParser
+import minitest._
+
+object SimpleExprParserTest extends SimpleTestSuite {
+
+  test("string expr") {
+    val p = SimpleExprParser.stringExpr
+    assertEquals(
+      p.parseAll("name:hello"),
+      Right(stringExpr(Operator.Like, Attr.ItemName, "hello"))
+    )
+    assertEquals(
+      p.parseAll("name:  hello"),
+      Right(stringExpr(Operator.Like, Attr.ItemName, "hello"))
+    )
+    assertEquals(
+      p.parseAll("name:\"hello world\""),
+      Right(stringExpr(Operator.Like, Attr.ItemName, "hello world"))
+    )
+    assertEquals(
+      p.parseAll("name : \"hello world\""),
+      Right(stringExpr(Operator.Like, Attr.ItemName, "hello world"))
+    )
+    assertEquals(
+      p.parseAll("conc.pers.id=Aaiet-aied"),
+      Right(stringExpr(Operator.Eq, Attr.Concerning.PersonId, "Aaiet-aied"))
+    )
+  }
+
+  test("date expr") {
+    val p = SimpleExprParser.dateExpr
+    assertEquals(
+      p.parseAll("due:2021-03-14"),
+      Right(dateExpr(Operator.Like, Attr.DueDate, ld(2021, 3, 14)))
+    )
+    assertEquals(
+      p.parseAll("due<2021-03-14"),
+      Right(dateExpr(Operator.Lt, Attr.DueDate, ld(2021, 3, 14)))
+    )
+  }
+
+  test("exists expr") {
+    val p = SimpleExprParser.existsExpr
+    assertEquals(p.parseAll("exists:name"), Right(Expr.Exists(Attr.ItemName)))
+    assert(p.parseAll("exists:blabla").isLeft)
+    assertEquals(
+      p.parseAll("exists:conc.pers.id"),
+      Right(Expr.Exists(Attr.Concerning.PersonId))
+    )
+  }
+
+  test("fulltext expr") {
+    val p = SimpleExprParser.fulltextExpr
+    assertEquals(p.parseAll("content:test"), Right(Expr.Fulltext("test")))
+    assertEquals(
+      p.parseAll("content:\"hello world\""),
+      Right(Expr.Fulltext("hello world"))
+    )
+  }
+
+  test("category expr") {
+    val p = SimpleExprParser.catExpr
+    assertEquals(
+      p.parseAll("cat:expense,doctype"),
+      Right(Expr.TagCategoryMatch(TagOperator.AnyMatch, Nel.of("expense", "doctype")))
+    )
+  }
+
+  test("custom field") {
+    val p = SimpleExprParser.customFieldExpr
+    assertEquals(
+      p.parseAll("f:usd=26.66"),
+      Right(Expr.CustomFieldMatch("usd", Operator.Eq, "26.66"))
+    )
+  }
+
+  test("tag id expr") {
+    val p = SimpleExprParser.tagIdExpr
+    assertEquals(
+      p.parseAll("tag.id:a,b,c"),
+      Right(Expr.TagIdsMatch(TagOperator.AnyMatch, Nel.of("a", "b", "c")))
+    )
+    assertEquals(
+      p.parseAll("tag.id:a"),
+      Right(Expr.TagIdsMatch(TagOperator.AnyMatch, Nel.of("a")))
+    )
+    assertEquals(
+      p.parseAll("tag.id=a,b,c"),
+      Right(Expr.TagIdsMatch(TagOperator.AllMatch, Nel.of("a", "b", "c")))
+    )
+    assertEquals(
+      p.parseAll("tag.id=a"),
+      Right(Expr.TagIdsMatch(TagOperator.AllMatch, Nel.of("a")))
+    )
+    assertEquals(
+      p.parseAll("tag.id=a,\"x y\""),
+      Right(Expr.TagIdsMatch(TagOperator.AllMatch, Nel.of("a", "x y")))
+    )
+  }
+
+  test("simple expr") {
+    val p = SimpleExprParser.simpleExpr
+    assertEquals(
+      p.parseAll("name:hello"),
+      Right(stringExpr(Operator.Like, Attr.ItemName, "hello"))
+    )
+    assertEquals(
+      p.parseAll("name:hello"),
+      Right(stringExpr(Operator.Like, Attr.ItemName, "hello"))
+    )
+    assertEquals(
+      p.parseAll("due:2021-03-14"),
+      Right(dateExpr(Operator.Like, Attr.DueDate, ld(2021, 3, 14)))
+    )
+    assertEquals(
+      p.parseAll("due<2021-03-14"),
+      Right(dateExpr(Operator.Lt, Attr.DueDate, ld(2021, 3, 14)))
+    )
+    assertEquals(
+      p.parseAll("exists:conc.pers.id"),
+      Right(Expr.Exists(Attr.Concerning.PersonId))
+    )
+    assertEquals(p.parseAll("content:test"), Right(Expr.Fulltext("test")))
+    assertEquals(
+      p.parseAll("tag.id:a"),
+      Right(Expr.TagIdsMatch(TagOperator.AnyMatch, Nel.of("a")))
+    )
+    assertEquals(
+      p.parseAll("tag.id=a,b,c"),
+      Right(Expr.TagIdsMatch(TagOperator.AllMatch, Nel.of("a", "b", "c")))
+    )
+    assertEquals(
+      p.parseAll("cat:expense,doctype"),
+      Right(Expr.TagCategoryMatch(TagOperator.AnyMatch, Nel.of("expense", "doctype")))
+    )
+    assertEquals(
+      p.parseAll("f:usd=26.66"),
+      Right(Expr.CustomFieldMatch("usd", Operator.Eq, "26.66"))
+    )
+  }
+
+  def ld(y: Int, m: Int, d: Int) =
+    DateParserTest.ld(y, m, d)
+
+  def stringExpr(op: Operator, name: Attr.StringAttr, value: String): Expr.SimpleExpr =
+    Expr.SimpleExpr(op, Property.StringProperty(name, value))
+
+  def dateExpr(op: Operator, name: Attr.DateAttr, value: Date): Expr.SimpleExpr =
+    Expr.SimpleExpr(op, Property.DateProperty(name, value))
+}
diff --git a/modules/store/src/main/scala/docspell/store/qb/Column.scala b/modules/store/src/main/scala/docspell/store/qb/Column.scala
index 3e59a62c..e5e13749 100644
--- a/modules/store/src/main/scala/docspell/store/qb/Column.scala
+++ b/modules/store/src/main/scala/docspell/store/qb/Column.scala
@@ -3,6 +3,9 @@ package docspell.store.qb
 case class Column[A](name: String, table: TableDef) {
   def inTable(t: TableDef): Column[A] =
     copy(table = t)
+
+  def cast[B]: Column[B] =
+    this.asInstanceOf[Column[B]]
 }
 
 object Column {}
diff --git a/modules/store/src/main/scala/docspell/store/qb/DSL.scala b/modules/store/src/main/scala/docspell/store/qb/DSL.scala
index e90439bf..fba05543 100644
--- a/modules/store/src/main/scala/docspell/store/qb/DSL.scala
+++ b/modules/store/src/main/scala/docspell/store/qb/DSL.scala
@@ -174,13 +174,13 @@ trait DSL extends DoobieMeta {
       Condition.CompareVal(col, Operator.LowerEq, value)
 
     def ====(value: String): Condition =
-      Condition.CompareVal(col.asInstanceOf[Column[String]], Operator.Eq, value)
+      Condition.CompareVal(col.cast[String], Operator.Eq, value)
 
     def like(value: A)(implicit P: Put[A]): Condition =
       Condition.CompareVal(col, Operator.LowerLike, value)
 
     def likes(value: String): Condition =
-      Condition.CompareVal(col.asInstanceOf[Column[String]], Operator.LowerLike, value)
+      Condition.CompareVal(col.cast[String], Operator.LowerLike, value)
 
     def <=(value: A)(implicit P: Put[A]): Condition =
       Condition.CompareVal(col, Operator.Lte, value)
diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
new file mode 100644
index 00000000..b43764c4
--- /dev/null
+++ b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
@@ -0,0 +1,195 @@
+package docspell.store.qb.generator
+
+import cats.data.NonEmptyList
+import docspell.common._
+import docspell.query.ItemQuery
+import docspell.query.ItemQuery.Attr._
+import docspell.query.ItemQuery.Property.{DateProperty, StringProperty}
+import docspell.query.ItemQuery.{Attr, Expr, Operator, TagOperator}
+import docspell.store.qb.{Operator => QOp, _}
+import docspell.store.qb.DSL._
+import docspell.store.records.{RCustomField, RCustomFieldValue, TagItemName}
+import doobie.util.Put
+
+object ItemQueryGenerator {
+
+  def apply(tables: Tables, coll: Ident)(q: ItemQuery)(implicit
+      PT: Put[Timestamp]
+  ): Condition =
+    fromExpr(tables, coll)(q.expr)
+
+  final def fromExpr(tables: Tables, coll: Ident)(
+      expr: Expr
+  )(implicit PT: Put[Timestamp]): Condition =
+    expr match {
+      case Expr.AndExpr(inner) =>
+        Condition.And(inner.map(fromExpr(tables, coll)))
+
+      case Expr.OrExpr(inner) =>
+        Condition.Or(inner.map(fromExpr(tables, coll)))
+
+      case Expr.NotExpr(inner) =>
+        inner match {
+          case Expr.Exists(notExists) =>
+            anyColumn(tables)(notExists).isNull
+
+          case Expr.TagIdsMatch(op, tags) =>
+            val ids = tags.toList.flatMap(s => Ident.fromString(s).toOption)
+            NonEmptyList
+              .fromList(ids)
+              .map { nel =>
+                op match {
+                  case TagOperator.AnyMatch =>
+                    tables.item.id.notIn(TagItemName.itemsWithEitherTag(nel))
+                  case TagOperator.AllMatch =>
+                    tables.item.id.notIn(TagItemName.itemsWithAllTags(nel))
+                }
+              }
+              .getOrElse(Condition.unit)
+          case Expr.TagsMatch(op, tags) =>
+            op match {
+              case TagOperator.AllMatch =>
+                tables.item.id.notIn(TagItemName.itemsWithAllTagNameOrIds(tags))
+
+              case TagOperator.AnyMatch =>
+                tables.item.id.notIn(TagItemName.itemsWithEitherTagNameOrIds(tags))
+            }
+
+          case Expr.TagCategoryMatch(op, cats) =>
+            op match {
+              case TagOperator.AllMatch =>
+                tables.item.id.notIn(TagItemName.itemsInAllCategories(cats))
+
+              case TagOperator.AnyMatch =>
+                tables.item.id.notIn(TagItemName.itemsInEitherCategory(cats))
+            }
+
+          case Expr.Fulltext(_) =>
+            Condition.unit
+
+          case _ =>
+            Condition.Not(fromExpr(tables, coll)(inner))
+        }
+
+      case Expr.Exists(field) =>
+        anyColumn(tables)(field).isNotNull
+
+      case Expr.SimpleExpr(op, StringProperty(attr, value)) =>
+        val col = stringColumn(tables)(attr)
+        op match {
+          case Operator.Like =>
+            Condition.CompareVal(col, makeOp(op), value.toLowerCase)
+          case _ =>
+            Condition.CompareVal(col, makeOp(op), value)
+        }
+
+      case Expr.SimpleExpr(op, DateProperty(attr, value)) =>
+        val dt  = Timestamp.atUtc(value.atStartOfDay())
+        val col = timestampColumn(tables)(attr)
+        Condition.CompareVal(col, makeOp(op), dt)
+
+      case Expr.InExpr(attr, values) =>
+        val col = stringColumn(tables)(attr)
+        if (values.tail.isEmpty) col === values.head
+        else col.in(values)
+
+      case Expr.TagIdsMatch(op, tags) =>
+        val ids = tags.toList.flatMap(s => Ident.fromString(s).toOption)
+        NonEmptyList
+          .fromList(ids)
+          .map { nel =>
+            op match {
+              case TagOperator.AnyMatch =>
+                tables.item.id.in(TagItemName.itemsWithEitherTag(nel))
+              case TagOperator.AllMatch =>
+                tables.item.id.in(TagItemName.itemsWithAllTags(nel))
+            }
+          }
+          .getOrElse(Condition.unit)
+
+      case Expr.TagsMatch(op, tags) =>
+        op match {
+          case TagOperator.AllMatch =>
+            tables.item.id.in(TagItemName.itemsWithAllTagNameOrIds(tags))
+
+          case TagOperator.AnyMatch =>
+            tables.item.id.in(TagItemName.itemsWithEitherTagNameOrIds(tags))
+        }
+
+      case Expr.TagCategoryMatch(op, cats) =>
+        op match {
+          case TagOperator.AllMatch =>
+            tables.item.id.in(TagItemName.itemsInAllCategories(cats))
+
+          case TagOperator.AnyMatch =>
+            tables.item.id.in(TagItemName.itemsInEitherCategory(cats))
+        }
+
+      case Expr.CustomFieldMatch(field, op, value) =>
+        tables.item.id.in(itemsWithCustomField(coll, field, makeOp(op), value))
+
+      case Expr.Fulltext(_) =>
+        // not supported here
+        Condition.unit
+    }
+
+  private def anyColumn(tables: Tables)(attr: Attr): Column[_] =
+    attr match {
+      case s: StringAttr =>
+        stringColumn(tables)(s)
+      case t: DateAttr =>
+        timestampColumn(tables)(t)
+    }
+
+  private def timestampColumn(tables: Tables)(attr: DateAttr) =
+    attr match {
+      case Attr.Date =>
+        tables.item.itemDate
+      case Attr.DueDate =>
+        tables.item.dueDate
+    }
+
+  private def stringColumn(tables: Tables)(attr: StringAttr): Column[String] =
+    attr match {
+      case Attr.ItemId                   => tables.item.id.cast[String]
+      case Attr.ItemName                 => tables.item.name
+      case Attr.ItemSource               => tables.item.source
+      case Attr.Correspondent.OrgId      => tables.corrOrg.oid.cast[String]
+      case Attr.Correspondent.OrgName    => tables.corrOrg.name
+      case Attr.Correspondent.PersonId   => tables.corrPers.pid.cast[String]
+      case Attr.Correspondent.PersonName => tables.corrPers.name
+      case Attr.Concerning.PersonId      => tables.concPers.pid.cast[String]
+      case Attr.Concerning.PersonName    => tables.concPers.name
+      case Attr.Concerning.EquipId       => tables.concEquip.eid.cast[String]
+      case Attr.Concerning.EquipName     => tables.concEquip.name
+      case Attr.Folder.FolderId          => tables.folder.id.cast[String]
+      case Attr.Folder.FolderName        => tables.folder.name
+    }
+
+  private def makeOp(operator: Operator): QOp =
+    operator match {
+      case Operator.Eq =>
+        QOp.Eq
+      case Operator.Like =>
+        QOp.LowerLike
+      case Operator.Gt =>
+        QOp.Gt
+      case Operator.Lt =>
+        QOp.Lt
+      case Operator.Gte =>
+        QOp.Gte
+      case Operator.Lte =>
+        QOp.Lte
+    }
+
+  def itemsWithCustomField(coll: Ident, field: String, op: QOp, value: String): Select = {
+    val cf  = RCustomField.as("cf")
+    val cfv = RCustomFieldValue.as("cfv")
+    val v   = if (op == QOp.LowerLike) value.toLowerCase else value
+    Select(
+      select(cfv.itemId),
+      from(cfv).innerJoin(cf, cf.id === cfv.field),
+      cf.cid === coll && cf.name ==== field && Condition.CompareVal(cfv.value, op, v)
+    )
+  }
+}
diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/Tables.scala b/modules/store/src/main/scala/docspell/store/qb/generator/Tables.scala
new file mode 100644
index 00000000..966b129d
--- /dev/null
+++ b/modules/store/src/main/scala/docspell/store/qb/generator/Tables.scala
@@ -0,0 +1,14 @@
+package docspell.store.qb.generator
+
+import docspell.store.records._
+
+final case class Tables(
+    item: RItem.Table,
+    corrOrg: ROrganization.Table,
+    corrPers: RPerson.Table,
+    concPers: RPerson.Table,
+    concEquip: REquipment.Table,
+    folder: RFolder.Table,
+    attach: RAttachment.Table,
+    meta: RAttachmentMeta.Table
+)
diff --git a/modules/store/src/main/scala/docspell/store/records/TagItemName.scala b/modules/store/src/main/scala/docspell/store/records/TagItemName.scala
index 71d261bf..012dad4c 100644
--- a/modules/store/src/main/scala/docspell/store/records/TagItemName.scala
+++ b/modules/store/src/main/scala/docspell/store/records/TagItemName.scala
@@ -42,9 +42,27 @@ object TagItemName {
   def itemsWithEitherTag(tags: NonEmptyList[Ident]): Select =
     Select(ti.itemId.s, from(ti), orTags(tags)).distinct
 
+  def itemsWithEitherTagNameOrIds(tags: NonEmptyList[String]): Select =
+    Select(
+      ti.itemId.s,
+      from(ti).innerJoin(t, t.tid === ti.tagId),
+      ti.tagId.cast[String].in(tags) || t.name.inLower(tags.map(_.toLowerCase))
+    ).distinct
+
   def itemsWithAllTags(tags: NonEmptyList[Ident]): Select =
     intersect(tags.map(tid => Select(ti.itemId.s, from(ti), ti.tagId === tid).distinct))
 
+  def itemsWithAllTagNameOrIds(tags: NonEmptyList[String]): Select =
+    intersect(
+      tags.map(tag =>
+        Select(
+          ti.itemId.s,
+          from(ti).innerJoin(t, t.tid === ti.tagId),
+          ti.tagId ==== tag || t.name.lowerEq(tag.toLowerCase)
+        ).distinct
+      )
+    )
+
   def itemsWithEitherTagOrCategory(
       tags: NonEmptyList[Ident],
       cats: NonEmptyList[String]
diff --git a/project/Dependencies.scala b/project/Dependencies.scala
index 0ce02040..dac2bb0e 100644
--- a/project/Dependencies.scala
+++ b/project/Dependencies.scala
@@ -52,6 +52,8 @@ object Dependencies {
   val scalaJsStubs =
     "org.scala-js" %% "scalajs-stubs" % "1.0.0" % "provided"
 
+  val catsJS = Def.setting("org.typelevel" %%% "cats-core" % "2.4.2")
+
   val kittens = Seq(
     "org.typelevel" %% "kittens" % KittensVersion
   )

From c3cdec416c90ebc3e08ed8f97b27ebfe2dc113c5 Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Wed, 24 Feb 2021 00:22:45 +0100
Subject: [PATCH 03/33] Sketching some basic tests

---
 build.sbt                                     |  6 +-
 .../docspell/query/ItemQueryParser.scala      |  2 +
 .../qb/generator/ItemQueryGenerator.scala     | 27 +++++---
 .../scala/docspell/store/StoreFixture.scala   | 67 +++++++++++++++++++
 .../generator/ItemQueryGeneratorTest.scala    | 63 +++++++++++++++++
 5 files changed, 153 insertions(+), 12 deletions(-)
 create mode 100644 modules/store/src/test/scala/docspell/store/StoreFixture.scala
 create mode 100644 modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala

diff --git a/build.sbt b/build.sbt
index 7cb00d86..8b31ed2f 100644
--- a/build.sbt
+++ b/build.sbt
@@ -47,7 +47,8 @@ val sharedSettings = Seq(
 
 val testSettings = Seq(
   testFrameworks += new TestFramework("minitest.runner.Framework"),
-  libraryDependencies ++= Dependencies.miniTest ++ Dependencies.logging.map(_ % Test)
+  libraryDependencies ++= Dependencies.miniTest ++ Dependencies.logging.map(_ % Test),
+  Test / fork := true
 )
 
 lazy val noPublish = Seq(
@@ -275,6 +276,9 @@ val query =
       libraryDependencies +=
         Dependencies.catsParseJS.value
     )
+    .jsSettings(
+      Test / fork := false
+    )
     .jvmSettings(
       libraryDependencies +=
         Dependencies.scalaJsStubs
diff --git a/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala b/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala
index 23b0c297..7fc3a87e 100644
--- a/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala
+++ b/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala
@@ -15,4 +15,6 @@ object ItemQueryParser {
       .map(_.toString)
       .map(expr => ItemQuery(expr, Some(input.trim)))
 
+  def parseUnsafe(input: String): ItemQuery =
+    parse(input).fold(sys.error, identity)
 }
diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
index b43764c4..2f745dd8 100644
--- a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
+++ b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
@@ -1,11 +1,11 @@
 package docspell.store.qb.generator
 
+import java.time.{Instant, LocalDate}
+
 import cats.data.NonEmptyList
 import docspell.common._
-import docspell.query.ItemQuery
-import docspell.query.ItemQuery.Attr._
-import docspell.query.ItemQuery.Property.{DateProperty, StringProperty}
-import docspell.query.ItemQuery.{Attr, Expr, Operator, TagOperator}
+import docspell.query.{Date, ItemQuery}
+import docspell.query.ItemQuery._
 import docspell.store.qb.{Operator => QOp, _}
 import docspell.store.qb.DSL._
 import docspell.store.records.{RCustomField, RCustomFieldValue, TagItemName}
@@ -74,7 +74,7 @@ object ItemQueryGenerator {
       case Expr.Exists(field) =>
         anyColumn(tables)(field).isNotNull
 
-      case Expr.SimpleExpr(op, StringProperty(attr, value)) =>
+      case Expr.SimpleExpr(op, Property.StringProperty(attr, value)) =>
         val col = stringColumn(tables)(attr)
         op match {
           case Operator.Like =>
@@ -83,8 +83,13 @@ object ItemQueryGenerator {
             Condition.CompareVal(col, makeOp(op), value)
         }
 
-      case Expr.SimpleExpr(op, DateProperty(attr, value)) =>
-        val dt  = Timestamp.atUtc(value.atStartOfDay())
+      case Expr.SimpleExpr(op, Property.DateProperty(attr, value)) =>
+        val dt = value match {
+          case Date.Local(year, month, day) =>
+            Timestamp.atUtc(LocalDate.of(year, month, day).atStartOfDay())
+          case Date.Millis(ms) =>
+            Timestamp(Instant.ofEpochMilli(ms))
+        }
         val col = timestampColumn(tables)(attr)
         Condition.CompareVal(col, makeOp(op), dt)
 
@@ -135,13 +140,13 @@ object ItemQueryGenerator {
 
   private def anyColumn(tables: Tables)(attr: Attr): Column[_] =
     attr match {
-      case s: StringAttr =>
+      case s: Attr.StringAttr =>
         stringColumn(tables)(s)
-      case t: DateAttr =>
+      case t: Attr.DateAttr =>
         timestampColumn(tables)(t)
     }
 
-  private def timestampColumn(tables: Tables)(attr: DateAttr) =
+  private def timestampColumn(tables: Tables)(attr: Attr.DateAttr) =
     attr match {
       case Attr.Date =>
         tables.item.itemDate
@@ -149,7 +154,7 @@ object ItemQueryGenerator {
         tables.item.dueDate
     }
 
-  private def stringColumn(tables: Tables)(attr: StringAttr): Column[String] =
+  private def stringColumn(tables: Tables)(attr: Attr.StringAttr): Column[String] =
     attr match {
       case Attr.ItemId                   => tables.item.id.cast[String]
       case Attr.ItemName                 => tables.item.name
diff --git a/modules/store/src/test/scala/docspell/store/StoreFixture.scala b/modules/store/src/test/scala/docspell/store/StoreFixture.scala
new file mode 100644
index 00000000..839eb242
--- /dev/null
+++ b/modules/store/src/test/scala/docspell/store/StoreFixture.scala
@@ -0,0 +1,67 @@
+package docspell.store
+
+import cats.effect._
+import docspell.common.LenientUri
+import docspell.store.impl.StoreImpl
+import doobie._
+import org.h2.jdbcx.JdbcConnectionPool
+
+import scala.concurrent.ExecutionContext
+
+trait StoreFixture {
+  def withStore(db: String)(code: Store[IO] => IO[Unit]): Unit = {
+    //StoreFixture.store(StoreFixture.memoryDB(db)).use(code).unsafeRunSync()
+    val jdbc  = StoreFixture.memoryDB(db)
+    val xa    = StoreFixture.globalXA(jdbc)
+    val store = new StoreImpl[IO](jdbc, xa)
+    store.migrate.unsafeRunSync()
+    code(store).unsafeRunSync()
+  }
+
+  def withXA(db: String)(code: Transactor[IO] => IO[Unit]): Unit =
+    StoreFixture.makeXA(StoreFixture.memoryDB(db)).use(code).unsafeRunSync()
+
+}
+
+object StoreFixture {
+  implicit def contextShift: ContextShift[IO] =
+    IO.contextShift(ExecutionContext.global)
+
+  def memoryDB(dbname: String): JdbcConfig =
+    JdbcConfig(
+      LenientUri.unsafe(
+        s"jdbc:h2:mem:$dbname;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1"
+      ),
+      "sa",
+      ""
+    )
+
+  def globalXA(jdbc: JdbcConfig): Transactor[IO] =
+    Transactor.fromDriverManager(
+      "org.h2.Driver",
+      jdbc.url.asString,
+      jdbc.user,
+      jdbc.password
+    )
+
+  def makeXA(jdbc: JdbcConfig): Resource[IO, Transactor[IO]] = {
+    def jdbcConnPool =
+      JdbcConnectionPool.create(jdbc.url.asString, jdbc.user, jdbc.password)
+
+    val makePool = Resource.make(IO(jdbcConnPool))(cp => IO(cp.dispose()))
+
+    for {
+      ec      <- ExecutionContexts.cachedThreadPool[IO]
+      blocker <- Blocker[IO]
+      pool    <- makePool
+      xa = Transactor.fromDataSource[IO].apply(pool, ec, blocker)
+    } yield xa
+  }
+
+  def store(jdbc: JdbcConfig): Resource[IO, Store[IO]] =
+    for {
+      xa <- makeXA(jdbc)
+      store = new StoreImpl[IO](jdbc, xa)
+      _ <- Resource.liftF(store.migrate)
+    } yield store
+}
diff --git a/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala b/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala
new file mode 100644
index 00000000..7bbfcb52
--- /dev/null
+++ b/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala
@@ -0,0 +1,63 @@
+package docspell.store.generator
+
+import java.time.LocalDate
+
+import docspell.store.records._
+import minitest._
+import docspell.common._
+import docspell.query.ItemQueryParser
+import docspell.store.qb.DSL._
+import docspell.store.qb.generator.{ItemQueryGenerator, Tables}
+
+object ItemQueryGeneratorTest extends SimpleTestSuite {
+  import docspell.store.impl.DoobieMeta._
+
+  val tables = Tables(
+    RItem.as("i"),
+    ROrganization.as("co"),
+    RPerson.as("cp"),
+    RPerson.as("np"),
+    REquipment.as("ne"),
+    RFolder.as("f"),
+    RAttachment.as("a"),
+    RAttachmentMeta.as("m")
+  )
+
+  test("migration") {
+    val q = ItemQueryParser
+      .parseUnsafe("(& name:hello date>=2020-02-01 (| source=expense folder=test ))")
+    val cond = ItemQueryGenerator(tables, Ident.unsafe("coll"))(q)
+    val expect =
+      tables.item.name.like("hello") && tables.item.itemDate >= Timestamp.atUtc(
+        LocalDate.of(2020, 2, 1).atStartOfDay()
+      ) && (tables.item.source === "expense" || tables.folder.name === "test")
+
+    assertEquals(cond, expect)
+  }
+
+//  test("migration2") {
+//    withStore("db2") { store =>
+//      val c = RCollective(
+//        Ident.unsafe("coll1"),
+//        CollectiveState.Active,
+//        Language.German,
+//        true,
+//        Timestamp.Epoch
+//      )
+//      val e =
+//        REquipment(
+//          Ident.unsafe("equip"),
+//          Ident.unsafe("coll1"),
+//          "name",
+//          Timestamp.Epoch,
+//          Timestamp.Epoch,
+//          None
+//        )
+//
+//      for {
+//        _ <- store.transact(RCollective.insert(c))
+//        _ <- store.transact(REquipment.insert(e)).map(_ => ())
+//      } yield ()
+//    }
+//  }
+}

From 186014a1c62337370d884a3046c2e3a609f0ea3c Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Wed, 24 Feb 2021 21:57:41 +0100
Subject: [PATCH 04/33] Refactor search to separate between a base query and
 user query

The `findBase` is adding only strictly required conditions. Everything
else comes from the user.
---
 .../docspell/backend/ops/OFulltext.scala      |   8 +-
 .../docspell/backend/ops/OItemSearch.scala    |   4 +-
 .../joex/notify/NotifyDueItemsTask.scala      |  17 +--
 .../restserver/conv/Conversions.scala         |  53 ++++-----
 .../scala/docspell/store/queries/QItem.scala  |  48 ++++----
 .../scala/docspell/store/queries/Query.scala  | 109 ++++++++++--------
 .../generator/ItemQueryGeneratorTest.scala    |   2 +-
 7 files changed, 129 insertions(+), 112 deletions(-)

diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OFulltext.scala b/modules/backend/src/main/scala/docspell/backend/ops/OFulltext.scala
index 52e23571..c5ede0e0 100644
--- a/modules/backend/src/main/scala/docspell/backend/ops/OFulltext.scala
+++ b/modules/backend/src/main/scala/docspell/backend/ops/OFulltext.scala
@@ -164,7 +164,7 @@ object OFulltext {
             .flatMap(r => Stream.emits(r.results.map(_.itemId)))
             .compile
             .to(Set)
-          q = Query.empty(account).copy(itemIds = itemIds.some)
+          q = Query.empty(account).withCond(_.copy(itemIds = itemIds.some))
           res <- store.transact(QItem.searchStats(q))
         } yield res
       }
@@ -208,7 +208,7 @@ object OFulltext {
           search <- itemSearch.findItems(0)(q, Batch.all)
           fq = FtsQuery(
             ftsQ.query,
-            q.account.collective,
+            q.fix.account.collective,
             search.map(_.id).toSet,
             Set.empty,
             500,
@@ -220,7 +220,7 @@ object OFulltext {
             .flatMap(r => Stream.emits(r.results.map(_.itemId)))
             .compile
             .to(Set)
-          qnext = q.copy(itemIds = items.some)
+          qnext = q.withCond(_.copy(itemIds = items.some))
           res <- store.transact(QItem.searchStats(qnext))
         } yield res
 
@@ -253,7 +253,7 @@ object OFulltext {
         val sqlResult = search(q, batch)
         val fq = FtsQuery(
           ftsQ.query,
-          q.account.collective,
+          q.fix.account.collective,
           Set.empty,
           Set.empty,
           0,
diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala b/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala
index 46ec929d..724ee18e 100644
--- a/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala
+++ b/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala
@@ -138,7 +138,9 @@ object OItemSearch {
         val search = QItem.findItems(q, maxNoteLen: Int, batch)
         store
           .transact(
-            QItem.findItemsWithTags(q.account.collective, search).take(batch.limit.toLong)
+            QItem
+              .findItemsWithTags(q.fix.account.collective, search)
+              .take(batch.limit.toLong)
           )
           .compile
           .toVector
diff --git a/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala b/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala
index e31b6fd7..43b4523a 100644
--- a/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala
+++ b/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala
@@ -72,13 +72,16 @@ object NotifyDueItemsTask {
       q =
         Query
           .empty(ctx.args.account)
-          .copy(
-            states = ItemState.validStates.toList,
-            tagsInclude = ctx.args.tagsInclude,
-            tagsExclude = ctx.args.tagsExclude,
-            dueDateFrom = ctx.args.daysBack.map(back => now - Duration.days(back.toLong)),
-            dueDateTo = Some(now + Duration.days(ctx.args.remindDays.toLong)),
-            orderAsc = Some(_.dueDate)
+          .withOrder(orderAsc = _.dueDate)
+          .withCond(
+            _.copy(
+              states = ItemState.validStates.toList,
+              tagsInclude = ctx.args.tagsInclude,
+              tagsExclude = ctx.args.tagsExclude,
+              dueDateFrom =
+                ctx.args.daysBack.map(back => now - Duration.days(back.toLong)),
+              dueDateTo = Some(now + Duration.days(ctx.args.remindDays.toLong))
+            )
           )
       res <-
         ctx.store
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 4aebc8c8..ecd4cfc0 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala
@@ -145,31 +145,32 @@ trait Conversions {
 
   def mkQuery(m: ItemSearch, account: AccountId): OItemSearch.Query =
     OItemSearch.Query(
-      account,
-      m.name,
-      if (m.inbox) Seq(ItemState.Created)
-      else ItemState.validStates.toList,
-      m.direction,
-      m.corrPerson,
-      m.corrOrg,
-      m.concPerson,
-      m.concEquip,
-      m.folder,
-      m.tagsInclude.map(Ident.unsafe),
-      m.tagsExclude.map(Ident.unsafe),
-      m.tagCategoriesInclude,
-      m.tagCategoriesExclude,
-      m.dateFrom,
-      m.dateUntil,
-      m.dueDateFrom,
-      m.dueDateUntil,
-      m.allNames,
-      m.itemSubset
-        .map(_.ids.flatMap(i => Ident.fromString(i).toOption).toSet)
-        .filter(_.nonEmpty),
-      m.customValues.map(mkCustomValue),
-      m.source,
-      None
+      OItemSearch.Query.Fix(account, None),
+      OItemSearch.Query.QueryCond(
+        m.name,
+        if (m.inbox) Seq(ItemState.Created)
+        else ItemState.validStates.toList,
+        m.direction,
+        m.corrPerson,
+        m.corrOrg,
+        m.concPerson,
+        m.concEquip,
+        m.folder,
+        m.tagsInclude.map(Ident.unsafe),
+        m.tagsExclude.map(Ident.unsafe),
+        m.tagCategoriesInclude,
+        m.tagCategoriesExclude,
+        m.dateFrom,
+        m.dateUntil,
+        m.dueDateFrom,
+        m.dueDateUntil,
+        m.allNames,
+        m.itemSubset
+          .map(_.ids.flatMap(i => Ident.fromString(i).toOption).toSet)
+          .filter(_.nonEmpty),
+        m.customValues.map(mkCustomValue),
+        m.source
+      )
     )
 
   def mkCustomValue(v: CustomFieldValue): OItemSearch.CustomValue =
@@ -182,7 +183,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)
   }
 
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 751d5706..3d3d348b 100644
--- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala
+++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala
@@ -117,7 +117,7 @@ object QItem {
       .map(nel => intersect(nel.map(singleSelect)))
   }
 
-  private def findItemsBase(q: Query, noteMaxLen: Int): Select = {
+  private def findItemsBase(q: Query.Fix, noteMaxLen: Int): Select = {
     object Attachs extends TableDef {
       val tableName = "attachs"
       val aliasName = "cta"
@@ -128,7 +128,7 @@ object QItem {
 
     val coll = q.account.collective
 
-    val baseSelect = Select(
+    Select(
       select(
         i.id.s,
         i.name.s,
@@ -172,27 +172,23 @@ object QItem {
         .leftJoin(pers1, pers1.pid === i.concPerson && pers1.cid === coll)
         .leftJoin(equip, equip.eid === i.concEquipment && equip.cid === coll),
       where(
-        i.cid === coll &&? Nel.fromList(q.states.toList).map(nel => i.state.in(nel)) &&
-          or(i.folder.isNull, i.folder.in(QFolder.findMemberFolderIds(q.account)))
+        i.cid === coll && or(
+          i.folder.isNull,
+          i.folder.in(QFolder.findMemberFolderIds(q.account))
+        )
       )
     ).distinct.orderBy(
       q.orderAsc
         .map(of => OrderBy.asc(coalesce(of(i).s, i.created.s).s))
         .getOrElse(OrderBy.desc(coalesce(i.itemDate.s, i.created.s).s))
     )
-
-    findCustomFieldValuesForColl(coll, q.customValues) match {
-      case Some(itemIds) =>
-        baseSelect.changeWhere(c => c && i.id.in(itemIds))
-      case None =>
-        baseSelect
-    }
   }
 
-  def queryCondition(q: Query): Condition =
+  def queryCondition(coll: Ident, q: Query.QueryCond): Condition =
     Condition.unit &&?
       q.direction.map(d => i.incoming === d) &&?
       q.name.map(n => i.name.like(QueryWildcard.lower(n))) &&?
+      Nel.fromList(q.states.toList).map(nel => i.state.in(nel)) &&?
       q.allNames
         .map(QueryWildcard.lower)
         .map(n =>
@@ -221,15 +217,17 @@ object QItem {
         .map(subsel => i.id.in(subsel)) &&?
       TagItemName
         .itemsWithEitherTagOrCategory(q.tagsExclude, q.tagCategoryExcl)
-        .map(subsel => i.id.notIn(subsel))
+        .map(subsel => i.id.notIn(subsel)) &&?
+      findCustomFieldValuesForColl(coll, q.customValues)
+        .map(itemIds => i.id.in(itemIds))
 
   def findItems(
       q: Query,
       maxNoteLen: Int,
       batch: Batch
   ): Stream[ConnectionIO, ListItem] = {
-    val sql = findItemsBase(q, maxNoteLen)
-      .changeWhere(c => c && queryCondition(q))
+    val sql = findItemsBase(q.fix, maxNoteLen)
+      .changeWhere(c => c && queryCondition(q.fix.account.collective, q.cond))
       .limit(batch)
       .build
     logger.trace(s"List $batch items: $sql")
@@ -251,10 +249,10 @@ object QItem {
         .innerJoin(i, i.id === ti.itemId)
 
     val tagCloud =
-      findItemsBase(q, 0).unwrap
+      findItemsBase(q.fix, 0).unwrap
         .withSelect(select(tag.all).append(count(i.id).as("num")))
         .changeFrom(_.prepend(tagFrom))
-        .changeWhere(c => c && queryCondition(q))
+        .changeWhere(c => c && queryCondition(q.fix.account.collective, q.cond))
         .groupBy(tag.tid)
         .build
         .query[TagCount]
@@ -264,24 +262,24 @@ object QItem {
     // are not included they are fetched separately
     for {
       existing <- tagCloud
-      other    <- RTag.findOthers(q.account.collective, existing.map(_.tag.tagId))
+      other    <- RTag.findOthers(q.fix.account.collective, existing.map(_.tag.tagId))
     } yield existing ++ other.map(TagCount(_, 0))
   }
 
   def searchCountSummary(q: Query): ConnectionIO[Int] =
-    findItemsBase(q, 0).unwrap
+    findItemsBase(q.fix, 0).unwrap
       .withSelect(Nel.of(count(i.id).as("num")))
-      .changeWhere(c => c && queryCondition(q))
+      .changeWhere(c => c && queryCondition(q.fix.account.collective, q.cond))
       .build
       .query[Int]
       .unique
 
   def searchFolderSummary(q: Query): ConnectionIO[List[FolderCount]] = {
     val fu = RUser.as("fu")
-    findItemsBase(q, 0).unwrap
+    findItemsBase(q.fix, 0).unwrap
       .withSelect(select(f.id, f.name, f.owner, fu.login).append(count(i.id).as("num")))
       .changeFrom(_.innerJoin(fu, fu.uid === f.owner))
-      .changeWhere(c => c && queryCondition(q))
+      .changeWhere(c => c && queryCondition(q.fix.account.collective, q.cond))
       .groupBy(f.id, f.name, f.owner, fu.login)
       .build
       .query[FolderCount]
@@ -295,9 +293,9 @@ object QItem {
         .innerJoin(i, i.id === cv.itemId)
 
     val base =
-      findItemsBase(q, 0).unwrap
+      findItemsBase(q.fix, 0).unwrap
         .changeFrom(_.prepend(fieldJoin))
-        .changeWhere(c => c && queryCondition(q))
+        .changeWhere(c => c && queryCondition(q.fix.account.collective, q.cond))
         .groupBy(GroupBy(cf.all))
 
     val basicFields = Nel.of(
@@ -374,7 +372,7 @@ object QItem {
           )
         )
 
-      val from = findItemsBase(q, maxNoteLen)
+      val from = findItemsBase(q.fix, maxNoteLen)
         .appendCte(cte)
         .appendSelect(Tids.weight.s)
         .changeFrom(_.innerJoin(Tids, Tids.itemId === i.id))
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 0d68bdef..083884d6 100644
--- a/modules/store/src/main/scala/docspell/store/queries/Query.scala
+++ b/modules/store/src/main/scala/docspell/store/queries/Query.scala
@@ -1,57 +1,70 @@
 package docspell.store.queries
 
 import docspell.common._
+import docspell.store.qb.Column
 import docspell.store.records.RItem
 
-case class Query(
-    account: AccountId,
-    name: Option[String],
-    states: Seq[ItemState],
-    direction: Option[Direction],
-    corrPerson: Option[Ident],
-    corrOrg: Option[Ident],
-    concPerson: Option[Ident],
-    concEquip: Option[Ident],
-    folder: Option[Ident],
-    tagsInclude: List[Ident],
-    tagsExclude: List[Ident],
-    tagCategoryIncl: List[String],
-    tagCategoryExcl: List[String],
-    dateFrom: Option[Timestamp],
-    dateTo: Option[Timestamp],
-    dueDateFrom: Option[Timestamp],
-    dueDateTo: Option[Timestamp],
-    allNames: Option[String],
-    itemIds: Option[Set[Ident]],
-    customValues: Seq[CustomValue],
-    source: Option[String],
-    orderAsc: Option[RItem.Table => docspell.store.qb.Column[_]]
-)
+case class Query(fix: Query.Fix, cond: Query.QueryCond) {
+  def withCond(f: Query.QueryCond => Query.QueryCond): Query =
+    copy(cond = f(cond))
+
+  def withOrder(orderAsc: RItem.Table => Column[_]): Query =
+    copy(fix = fix.copy(orderAsc = Some(orderAsc)))
+}
 
 object Query {
+
+  case class Fix(account: AccountId, orderAsc: Option[RItem.Table => Column[_]])
+
+  case class QueryCond(
+      name: Option[String],
+      states: Seq[ItemState],
+      direction: Option[Direction],
+      corrPerson: Option[Ident],
+      corrOrg: Option[Ident],
+      concPerson: Option[Ident],
+      concEquip: Option[Ident],
+      folder: Option[Ident],
+      tagsInclude: List[Ident],
+      tagsExclude: List[Ident],
+      tagCategoryIncl: List[String],
+      tagCategoryExcl: List[String],
+      dateFrom: Option[Timestamp],
+      dateTo: Option[Timestamp],
+      dueDateFrom: Option[Timestamp],
+      dueDateTo: Option[Timestamp],
+      allNames: Option[String],
+      itemIds: Option[Set[Ident]],
+      customValues: Seq[CustomValue],
+      source: Option[String]
+  )
+  object QueryCond {
+    val empty =
+      QueryCond(
+        None,
+        Seq.empty,
+        None,
+        None,
+        None,
+        None,
+        None,
+        None,
+        Nil,
+        Nil,
+        Nil,
+        Nil,
+        None,
+        None,
+        None,
+        None,
+        None,
+        None,
+        Seq.empty,
+        None
+      )
+  }
+
   def empty(account: AccountId): Query =
-    Query(
-      account,
-      None,
-      Seq.empty,
-      None,
-      None,
-      None,
-      None,
-      None,
-      None,
-      Nil,
-      Nil,
-      Nil,
-      Nil,
-      None,
-      None,
-      None,
-      None,
-      None,
-      None,
-      Seq.empty,
-      None,
-      None
-    )
+    Query(Fix(account, None), QueryCond.empty)
+
 }
diff --git a/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala b/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala
index 7bbfcb52..4bb5c57f 100644
--- a/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala
+++ b/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala
@@ -23,7 +23,7 @@ object ItemQueryGeneratorTest extends SimpleTestSuite {
     RAttachmentMeta.as("m")
   )
 
-  test("migration") {
+  test("basic test") {
     val q = ItemQueryParser
       .parseUnsafe("(& name:hello date>=2020-02-01 (| source=expense folder=test ))")
     val cond = ItemQueryGenerator(tables, Ident.unsafe("coll"))(q)

From e9ed998e3a184dc3232171176dcc6c1b06d58853 Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Thu, 25 Feb 2021 22:40:17 +0100
Subject: [PATCH 05/33] Basic poc to search via custom query

---
 .../docspell/backend/ops/OFulltext.scala      |  4 +--
 .../docspell/backend/ops/OSimpleSearch.scala  | 13 ++++++++
 .../docspell/common/ItemQueryString.scala     |  3 ++
 .../joex/notify/NotifyDueItemsTask.scala      |  4 +--
 .../docspell/query/ItemQueryParser.scala      |  2 +-
 .../restserver/conv/Conversions.scala         |  4 +--
 .../restserver/http4s/QueryParam.scala        |  4 +++
 .../restserver/routes/ItemRoutes.scala        | 32 +++++++++++++++++--
 .../scala/docspell/store/queries/QItem.scala  | 28 ++++++++++++----
 .../scala/docspell/store/queries/Query.scala  | 26 +++++++++++----
 10 files changed, 97 insertions(+), 23 deletions(-)
 create mode 100644 modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala
 create mode 100644 modules/common/src/main/scala/docspell/common/ItemQueryString.scala

diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OFulltext.scala b/modules/backend/src/main/scala/docspell/backend/ops/OFulltext.scala
index c5ede0e0..52197e17 100644
--- a/modules/backend/src/main/scala/docspell/backend/ops/OFulltext.scala
+++ b/modules/backend/src/main/scala/docspell/backend/ops/OFulltext.scala
@@ -164,7 +164,7 @@ object OFulltext {
             .flatMap(r => Stream.emits(r.results.map(_.itemId)))
             .compile
             .to(Set)
-          q = Query.empty(account).withCond(_.copy(itemIds = itemIds.some))
+          q = Query.empty(account).withFix(_.copy(itemIds = itemIds.some))
           res <- store.transact(QItem.searchStats(q))
         } yield res
       }
@@ -220,7 +220,7 @@ object OFulltext {
             .flatMap(r => Stream.emits(r.results.map(_.itemId)))
             .compile
             .to(Set)
-          qnext = q.withCond(_.copy(itemIds = items.some))
+          qnext = q.withFix(_.copy(itemIds = items.some))
           res <- store.transact(QItem.searchStats(qnext))
         } yield res
 
diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala b/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala
new file mode 100644
index 00000000..0ec0d903
--- /dev/null
+++ b/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala
@@ -0,0 +1,13 @@
+package docspell.backend.ops
+
+import docspell.backend.ops.OItemSearch.ListItemWithTags
+import docspell.common.ItemQueryString
+import docspell.store.qb.Batch
+
+trait OSimpleSearch[F[_]] {
+
+  def searchByString(q: ItemQueryString, batch: Batch): F[Vector[ListItemWithTags]]
+
+}
+
+object OSimpleSearch {}
diff --git a/modules/common/src/main/scala/docspell/common/ItemQueryString.scala b/modules/common/src/main/scala/docspell/common/ItemQueryString.scala
new file mode 100644
index 00000000..49bc878f
--- /dev/null
+++ b/modules/common/src/main/scala/docspell/common/ItemQueryString.scala
@@ -0,0 +1,3 @@
+package docspell.common
+
+case class ItemQueryString(query: String)
diff --git a/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala b/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala
index 43b4523a..1000d630 100644
--- a/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala
+++ b/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala
@@ -73,8 +73,8 @@ object NotifyDueItemsTask {
         Query
           .empty(ctx.args.account)
           .withOrder(orderAsc = _.dueDate)
-          .withCond(
-            _.copy(
+          .withCond(_ =>
+            Query.QueryForm.empty.copy(
               states = ItemState.validStates.toList,
               tagsInclude = ctx.args.tagsInclude,
               tagsExclude = ctx.args.tagsExclude,
diff --git a/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala b/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala
index 7fc3a87e..985c5be7 100644
--- a/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala
+++ b/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala
@@ -12,7 +12,7 @@ object ItemQueryParser {
     ExprParser.exprParser
       .parseAll(input.trim)
       .left
-      .map(_.toString)
+      .map(pe => s"Error parsing: '${input.trim}': $pe")
       .map(expr => ItemQuery(expr, Some(input.trim)))
 
   def parseUnsafe(input: String): ItemQuery =
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 ecd4cfc0..a6516bc0 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala
@@ -145,8 +145,8 @@ trait Conversions {
 
   def mkQuery(m: ItemSearch, account: AccountId): OItemSearch.Query =
     OItemSearch.Query(
-      OItemSearch.Query.Fix(account, None),
-      OItemSearch.Query.QueryCond(
+      OItemSearch.Query.Fix(account, None, None),
+      OItemSearch.Query.QueryForm(
         m.name,
         if (m.inbox) Seq(ItemState.Created)
         else ItemState.validStates.toList,
diff --git a/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala b/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala
index aa846c7e..de4c7090 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala
@@ -25,5 +25,9 @@ object QueryParam {
 
   object QueryOpt extends OptionalQueryParamDecoderMatcher[QueryString]("q")
 
+  object Query  extends OptionalQueryParamDecoderMatcher[String]("q")
+  object Limit  extends OptionalQueryParamDecoderMatcher[Int]("limit")
+  object Offset extends OptionalQueryParamDecoderMatcher[Int]("offset")
+
   object WithFallback extends OptionalQueryParamDecoderMatcher[Boolean]("withFallback")
 }
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 e0b0c5b8..729f2f88 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala
@@ -4,21 +4,20 @@ import cats.Monoid
 import cats.data.NonEmptyList
 import cats.effect._
 import cats.implicits._
-
 import docspell.backend.BackendApp
 import docspell.backend.auth.AuthToken
 import docspell.backend.ops.OCustomFields.{RemoveValue, SetValue}
 import docspell.backend.ops.OFulltext
-import docspell.backend.ops.OItemSearch.Batch
+import docspell.backend.ops.OItemSearch.{Batch, Query}
 import docspell.common._
 import docspell.common.syntax.all._
+import docspell.query.ItemQueryParser
 import docspell.restapi.model._
 import docspell.restserver.Config
 import docspell.restserver.conv.Conversions
 import docspell.restserver.http4s.BinaryUtil
 import docspell.restserver.http4s.Responses
 import docspell.restserver.http4s.{QueryParam => QP}
-
 import org.http4s.HttpRoutes
 import org.http4s.circe.CirceEntityDecoder._
 import org.http4s.circe.CirceEntityEncoder._
@@ -46,6 +45,33 @@ object ItemRoutes {
           resp <- Ok(Conversions.basicResult(res, "Task submitted"))
         } yield resp
 
+      case GET -> Root / "search" :? QP.Query(q) :? QP.Limit(limit) :? QP.Offset(
+            offset
+          ) =>
+        val query =
+          q.map(ItemQueryParser.parse) match {
+            case Some(Right(q)) =>
+              Right(Query(Query.Fix(user.account, None, None), Query.QueryExpr(q)))
+            case Some(Left(err)) =>
+              Left(err)
+            case None =>
+              Right(Query(Query.Fix(user.account, None, None), Query.QueryForm.empty))
+          }
+        val li = limit.getOrElse(cfg.maxItemPageSize)
+        val of = offset.getOrElse(0)
+        query match {
+          case Left(err) =>
+            BadRequest(BasicResult(false, err))
+          case Right(sq) =>
+            for {
+              items <- backend.itemSearch.findItems(cfg.maxNoteLength)(
+                sq,
+                Batch(of, li).restrictLimitTo(cfg.maxItemPageSize)
+              )
+              ok <- Ok(Conversions.mkItemList(items))
+            } yield ok
+        }
+
       case req @ POST -> Root / "search" =>
         for {
           mask <- req.as[ItemSearch]
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 3d3d348b..aa07a4cc 100644
--- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala
+++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala
@@ -5,14 +5,14 @@ import cats.effect.Sync
 import cats.effect.concurrent.Ref
 import cats.implicits._
 import fs2.Stream
-
 import docspell.common.syntax.all._
 import docspell.common.{IdRef, _}
+import docspell.query.ItemQuery
 import docspell.store.Store
 import docspell.store.qb.DSL._
 import docspell.store.qb._
+import docspell.store.qb.generator.{ItemQueryGenerator, Tables}
 import docspell.store.records._
-
 import doobie.implicits._
 import doobie.{Query => _, _}
 import org.log4s.getLogger
@@ -172,10 +172,13 @@ object QItem {
         .leftJoin(pers1, pers1.pid === i.concPerson && pers1.cid === coll)
         .leftJoin(equip, equip.eid === i.concEquipment && equip.cid === coll),
       where(
-        i.cid === coll && or(
-          i.folder.isNull,
-          i.folder.in(QFolder.findMemberFolderIds(q.account))
+        i.cid === coll &&? q.itemIds.map(s =>
+          Nel.fromList(s.toList).map(nel => i.id.in(nel)).getOrElse(i.id.isNull)
         )
+          && or(
+            i.folder.isNull,
+            i.folder.in(QFolder.findMemberFolderIds(q.account))
+          )
       )
     ).distinct.orderBy(
       q.orderAsc
@@ -184,7 +187,7 @@ object QItem {
     )
   }
 
-  def queryCondition(coll: Ident, q: Query.QueryCond): Condition =
+  def queryCondFromForm(coll: Ident, q: Query.QueryForm): Condition =
     Condition.unit &&?
       q.direction.map(d => i.incoming === d) &&?
       q.name.map(n => i.name.like(QueryWildcard.lower(n))) &&?
@@ -221,6 +224,19 @@ object QItem {
       findCustomFieldValuesForColl(coll, q.customValues)
         .map(itemIds => i.id.in(itemIds))
 
+  def queryCondFromExpr(coll: Ident, q: ItemQuery): Condition = {
+    val tables = Tables(i, org, pers0, pers1, equip, f, a, m)
+    ItemQueryGenerator.fromExpr(tables, coll)(q.expr)
+  }
+
+  def queryCondition(coll: Ident, cond: Query.QueryCond): Condition =
+    cond match {
+      case fm: Query.QueryForm =>
+        queryCondFromForm(coll, fm)
+      case expr: Query.QueryExpr =>
+        queryCondFromExpr(coll, expr.q)
+    }
+
   def findItems(
       q: Query,
       maxNoteLen: Int,
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 083884d6..879d15b0 100644
--- a/modules/store/src/main/scala/docspell/store/queries/Query.scala
+++ b/modules/store/src/main/scala/docspell/store/queries/Query.scala
@@ -1,6 +1,7 @@
 package docspell.store.queries
 
 import docspell.common._
+import docspell.query.ItemQuery
 import docspell.store.qb.Column
 import docspell.store.records.RItem
 
@@ -9,14 +10,23 @@ case class Query(fix: Query.Fix, cond: Query.QueryCond) {
     copy(cond = f(cond))
 
   def withOrder(orderAsc: RItem.Table => Column[_]): Query =
-    copy(fix = fix.copy(orderAsc = Some(orderAsc)))
+    withFix(_.copy(orderAsc = Some(orderAsc)))
+
+  def withFix(f: Query.Fix => Query.Fix): Query =
+    copy(fix = f(fix))
 }
 
 object Query {
 
-  case class Fix(account: AccountId, orderAsc: Option[RItem.Table => Column[_]])
+  case class Fix(
+      account: AccountId,
+      itemIds: Option[Set[Ident]],
+      orderAsc: Option[RItem.Table => Column[_]]
+  )
 
-  case class QueryCond(
+  sealed trait QueryCond
+
+  case class QueryForm(
       name: Option[String],
       states: Seq[ItemState],
       direction: Option[Direction],
@@ -37,10 +47,10 @@ object Query {
       itemIds: Option[Set[Ident]],
       customValues: Seq[CustomValue],
       source: Option[String]
-  )
-  object QueryCond {
+  ) extends QueryCond
+  object QueryForm {
     val empty =
-      QueryCond(
+      QueryForm(
         None,
         Seq.empty,
         None,
@@ -64,7 +74,9 @@ object Query {
       )
   }
 
+  case class QueryExpr(q: ItemQuery) extends QueryCond
+
   def empty(account: AccountId): Query =
-    Query(Fix(account, None), QueryCond.empty)
+    Query(Fix(account, None, None), QueryForm.empty)
 
 }

From a80d73d5d204d7895cfe524db0be053262a05661 Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Thu, 25 Feb 2021 22:42:47 +0100
Subject: [PATCH 06/33] Optimize imports

---
 modules/query/src/main/scala/docspell/query/ItemQuery.scala | 1 +
 .../src/main/scala/docspell/query/ItemQueryParser.scala     | 4 ++--
 .../src/main/scala/docspell/query/internal/AttrParser.scala | 1 +
 .../main/scala/docspell/query/internal/BasicParser.scala    | 2 +-
 .../src/main/scala/docspell/query/internal/DateParser.scala | 1 +
 .../src/main/scala/docspell/query/internal/ExprParser.scala | 1 +
 .../main/scala/docspell/query/internal/OperatorParser.scala | 1 +
 .../scala/docspell/query/internal/SimpleExprParser.scala    | 1 +
 .../src/main/scala/docspell/query/internal/StringUtil.scala | 2 +-
 .../main/scala/docspell/restserver/routes/ItemRoutes.scala  | 2 ++
 .../docspell/store/qb/generator/ItemQueryGenerator.scala    | 6 ++++--
 .../store/src/main/scala/docspell/store/queries/QItem.scala | 2 ++
 12 files changed, 18 insertions(+), 6 deletions(-)

diff --git a/modules/query/src/main/scala/docspell/query/ItemQuery.scala b/modules/query/src/main/scala/docspell/query/ItemQuery.scala
index 2a6759a2..435d2da2 100644
--- a/modules/query/src/main/scala/docspell/query/ItemQuery.scala
+++ b/modules/query/src/main/scala/docspell/query/ItemQuery.scala
@@ -1,6 +1,7 @@
 package docspell.query
 
 import cats.data.{NonEmptyList => Nel}
+
 import docspell.query.ItemQuery.Attr.{DateAttr, StringAttr}
 
 /** A query evaluates to `true` or `false` given enough details about
diff --git a/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala b/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala
index 985c5be7..c2b9ffbe 100644
--- a/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala
+++ b/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala
@@ -1,9 +1,9 @@
 package docspell.query
 
-import docspell.query.internal.ExprParser
-
 import scala.scalajs.js.annotation._
 
+import docspell.query.internal.ExprParser
+
 @JSExportTopLevel("DsItemQueryParser")
 object ItemQueryParser {
 
diff --git a/modules/query/src/main/scala/docspell/query/internal/AttrParser.scala b/modules/query/src/main/scala/docspell/query/internal/AttrParser.scala
index 6cd1c8b3..f9520a61 100644
--- a/modules/query/src/main/scala/docspell/query/internal/AttrParser.scala
+++ b/modules/query/src/main/scala/docspell/query/internal/AttrParser.scala
@@ -1,6 +1,7 @@
 package docspell.query.internal
 
 import cats.parse.{Parser => P}
+
 import docspell.query.ItemQuery.Attr
 
 object AttrParser {
diff --git a/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala b/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala
index 36694b10..a3e13742 100644
--- a/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala
+++ b/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala
@@ -1,7 +1,7 @@
 package docspell.query.internal
 
 import cats.data.{NonEmptyList => Nel}
-import cats.parse.{Parser0, Parser => P}
+import cats.parse.{Parser => P, Parser0}
 
 object BasicParser {
   private[this] val whitespace: P[Unit] = P.charIn(" \t\r\n").void
diff --git a/modules/query/src/main/scala/docspell/query/internal/DateParser.scala b/modules/query/src/main/scala/docspell/query/internal/DateParser.scala
index 43ae6221..49cc0b58 100644
--- a/modules/query/src/main/scala/docspell/query/internal/DateParser.scala
+++ b/modules/query/src/main/scala/docspell/query/internal/DateParser.scala
@@ -2,6 +2,7 @@ package docspell.query.internal
 
 import cats.implicits._
 import cats.parse.{Numbers, Parser => P}
+
 import docspell.query.Date
 
 object DateParser {
diff --git a/modules/query/src/main/scala/docspell/query/internal/ExprParser.scala b/modules/query/src/main/scala/docspell/query/internal/ExprParser.scala
index 7c7a6d6a..d9c7d313 100644
--- a/modules/query/src/main/scala/docspell/query/internal/ExprParser.scala
+++ b/modules/query/src/main/scala/docspell/query/internal/ExprParser.scala
@@ -1,6 +1,7 @@
 package docspell.query.internal
 
 import cats.parse.{Parser => P}
+
 import docspell.query.ItemQuery._
 
 object ExprParser {
diff --git a/modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala b/modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala
index 76a14e60..d9d2944d 100644
--- a/modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala
+++ b/modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala
@@ -1,6 +1,7 @@
 package docspell.query.internal
 
 import cats.parse.{Parser => P}
+
 import docspell.query.ItemQuery._
 
 object OperatorParser {
diff --git a/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala b/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala
index 5865ad80..d10fc231 100644
--- a/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala
+++ b/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala
@@ -1,6 +1,7 @@
 package docspell.query.internal
 
 import cats.parse.{Parser => P}
+
 import docspell.query.ItemQuery.Expr.CustomFieldMatch
 import docspell.query.ItemQuery._
 
diff --git a/modules/query/src/main/scala/docspell/query/internal/StringUtil.scala b/modules/query/src/main/scala/docspell/query/internal/StringUtil.scala
index 28a24872..fb81ce14 100644
--- a/modules/query/src/main/scala/docspell/query/internal/StringUtil.scala
+++ b/modules/query/src/main/scala/docspell/query/internal/StringUtil.scala
@@ -22,7 +22,7 @@ package docspell.query.internal
 
 // modified, from
 // https://github.com/typelevel/cats-parse/blob/e7a58ef15925358fbe7a4c0c1a204296e366a06c/bench/src/main/scala/cats/parse/bench/self.scala
-import cats.parse.{Parser0 => P0, Parser => P}
+import cats.parse.{Parser => P, Parser0 => P0}
 
 object StringUtil {
 
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 729f2f88..836927d0 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala
@@ -4,6 +4,7 @@ import cats.Monoid
 import cats.data.NonEmptyList
 import cats.effect._
 import cats.implicits._
+
 import docspell.backend.BackendApp
 import docspell.backend.auth.AuthToken
 import docspell.backend.ops.OCustomFields.{RemoveValue, SetValue}
@@ -18,6 +19,7 @@ import docspell.restserver.conv.Conversions
 import docspell.restserver.http4s.BinaryUtil
 import docspell.restserver.http4s.Responses
 import docspell.restserver.http4s.{QueryParam => QP}
+
 import org.http4s.HttpRoutes
 import org.http4s.circe.CirceEntityDecoder._
 import org.http4s.circe.CirceEntityEncoder._
diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
index 2f745dd8..561ad816 100644
--- a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
+++ b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
@@ -3,12 +3,14 @@ package docspell.store.qb.generator
 import java.time.{Instant, LocalDate}
 
 import cats.data.NonEmptyList
+
 import docspell.common._
-import docspell.query.{Date, ItemQuery}
 import docspell.query.ItemQuery._
-import docspell.store.qb.{Operator => QOp, _}
+import docspell.query.{Date, ItemQuery}
 import docspell.store.qb.DSL._
+import docspell.store.qb.{Operator => QOp, _}
 import docspell.store.records.{RCustomField, RCustomFieldValue, TagItemName}
+
 import doobie.util.Put
 
 object ItemQueryGenerator {
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 aa07a4cc..7461cc8b 100644
--- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala
+++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala
@@ -5,6 +5,7 @@ import cats.effect.Sync
 import cats.effect.concurrent.Ref
 import cats.implicits._
 import fs2.Stream
+
 import docspell.common.syntax.all._
 import docspell.common.{IdRef, _}
 import docspell.query.ItemQuery
@@ -13,6 +14,7 @@ import docspell.store.qb.DSL._
 import docspell.store.qb._
 import docspell.store.qb.generator.{ItemQueryGenerator, Tables}
 import docspell.store.records._
+
 import doobie.implicits._
 import doobie.{Query => _, _}
 import org.log4s.getLogger

From af73b59ec2ec21f6346cf2a8e0dab5f21cc4d8da Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Sat, 27 Feb 2021 18:06:59 +0100
Subject: [PATCH 07/33] Parser improvements

- default expressions into a and node
- fix parsing string lists that end in whitespace
- fix package names of internal classes
---
 build.sbt                                     |  2 +
 .../main/scala/docspell/query/ItemQuery.scala | 26 ++++++----
 .../docspell/query/ItemQueryParser.scala      | 15 ++++--
 .../docspell/query/internal/BasicParser.scala | 20 ++------
 .../docspell/query/internal/DateParser.scala  |  4 ++
 .../docspell/query/internal/ExprParser.scala  | 10 +++-
 .../docspell/query/internal/ExprUtil.scala    | 50 +++++++++++++++++++
 .../query/internal/OperatorParser.scala       |  5 +-
 .../query/internal/SimpleExprParser.scala     | 26 +++++++---
 .../query/{ => internal}/AttrParserTest.scala |  2 +-
 .../{ => internal}/BasicParserTest.scala      | 13 ++---
 .../query/{ => internal}/DateParserTest.scala |  4 +-
 .../query/{ => internal}/ExprParserTest.scala | 29 +++++++++--
 .../query/internal/ItemQueryParserTest.scala  | 43 ++++++++++++++++
 .../{ => internal}/OperatorParserTest.scala   |  3 +-
 .../{ => internal}/SimpleExprParserTest.scala | 13 ++++-
 .../restserver/routes/ItemRoutes.scala        |  8 ++-
 .../qb/generator/ItemQueryGenerator.scala     | 26 +++++++---
 18 files changed, 229 insertions(+), 70 deletions(-)
 create mode 100644 modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala
 rename modules/query/src/test/scala/docspell/query/{ => internal}/AttrParserTest.scala (98%)
 rename modules/query/src/test/scala/docspell/query/{ => internal}/BasicParserTest.scala (69%)
 rename modules/query/src/test/scala/docspell/query/{ => internal}/DateParserTest.scala (94%)
 rename modules/query/src/test/scala/docspell/query/{ => internal}/ExprParserTest.scala (62%)
 create mode 100644 modules/query/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala
 rename modules/query/src/test/scala/docspell/query/{ => internal}/OperatorParserTest.scala (89%)
 rename modules/query/src/test/scala/docspell/query/{ => internal}/SimpleExprParserTest.scala (91%)

diff --git a/build.sbt b/build.sbt
index 8b31ed2f..351f40d7 100644
--- a/build.sbt
+++ b/build.sbt
@@ -269,6 +269,7 @@ val query =
   crossProject(JSPlatform, JVMPlatform)
     .crossType(CrossType.Pure)
     .in(file("modules/query"))
+    .disablePlugins(RevolverPlugin)
     .settings(sharedSettings)
     .settings(testSettings)
     .settings(
@@ -596,6 +597,7 @@ val website = project
 
 val root = project
   .in(file("."))
+  .disablePlugins(RevolverPlugin)
   .settings(sharedSettings)
   .settings(noPublish)
   .settings(
diff --git a/modules/query/src/main/scala/docspell/query/ItemQuery.scala b/modules/query/src/main/scala/docspell/query/ItemQuery.scala
index 435d2da2..46b9e051 100644
--- a/modules/query/src/main/scala/docspell/query/ItemQuery.scala
+++ b/modules/query/src/main/scala/docspell/query/ItemQuery.scala
@@ -14,10 +14,12 @@ import docspell.query.ItemQuery.Attr.{DateAttr, StringAttr}
 final case class ItemQuery(expr: ItemQuery.Expr, raw: Option[String])
 
 object ItemQuery {
+  val all = ItemQuery(Expr.Exists(Attr.ItemId), Some(""))
 
   sealed trait Operator
   object Operator {
     case object Eq   extends Operator
+    case object Neq  extends Operator
     case object Like extends Operator
     case object Gt   extends Operator
     case object Lt   extends Operator
@@ -75,24 +77,26 @@ object ItemQuery {
   }
 
   object Expr {
-    case class AndExpr(expr: Nel[Expr]) extends Expr
-    case class OrExpr(expr: Nel[Expr])  extends Expr
-    case class NotExpr(expr: Expr) extends Expr {
+    final case class AndExpr(expr: Nel[Expr]) extends Expr
+    final case class OrExpr(expr: Nel[Expr])  extends Expr
+    final case class NotExpr(expr: Expr) extends Expr {
       override def negate: Expr =
         expr
     }
 
-    case class SimpleExpr(op: Operator, prop: Property)      extends Expr
-    case class Exists(field: Attr)                           extends Expr
-    case class InExpr(attr: StringAttr, values: Nel[String]) extends Expr
+    final case class SimpleExpr(op: Operator, prop: Property)      extends Expr
+    final case class Exists(field: Attr)                           extends Expr
+    final case class InExpr(attr: StringAttr, values: Nel[String]) extends Expr
+    final case class InDateExpr(attr: DateAttr, values: Nel[Date]) extends Expr
 
-    case class TagIdsMatch(op: TagOperator, tags: Nel[String])      extends Expr
-    case class TagsMatch(op: TagOperator, tags: Nel[String])        extends Expr
-    case class TagCategoryMatch(op: TagOperator, cats: Nel[String]) extends Expr
+    final case class TagIdsMatch(op: TagOperator, tags: Nel[String])      extends Expr
+    final case class TagsMatch(op: TagOperator, tags: Nel[String])        extends Expr
+    final case class TagCategoryMatch(op: TagOperator, cats: Nel[String]) extends Expr
 
-    case class CustomFieldMatch(name: String, op: Operator, value: String) extends Expr
+    final case class CustomFieldMatch(name: String, op: Operator, value: String)
+        extends Expr
 
-    case class Fulltext(query: String) extends Expr
+    final case class Fulltext(query: String) extends Expr
   }
 
 }
diff --git a/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala b/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala
index c2b9ffbe..0a7b8d81 100644
--- a/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala
+++ b/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala
@@ -3,17 +3,22 @@ package docspell.query
 import scala.scalajs.js.annotation._
 
 import docspell.query.internal.ExprParser
+import docspell.query.internal.ExprUtil
 
 @JSExportTopLevel("DsItemQueryParser")
 object ItemQueryParser {
 
   @JSExport
   def parse(input: String): Either[String, ItemQuery] =
-    ExprParser.exprParser
-      .parseAll(input.trim)
-      .left
-      .map(pe => s"Error parsing: '${input.trim}': $pe")
-      .map(expr => ItemQuery(expr, Some(input.trim)))
+    if (input.isEmpty) Right(ItemQuery.all)
+    else {
+      val in = if (input.charAt(0) == '(') input else s"(& $input )"
+      ExprParser
+        .parseQuery(in)
+        .left
+        .map(pe => s"Error parsing: '$input': $pe")
+        .map(q => q.copy(expr = ExprUtil.reduce(q.expr)))
+    }
 
   def parseUnsafe(input: String): ItemQuery =
     parse(input).fold(sys.error, identity)
diff --git a/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala b/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala
index a3e13742..ca2c3462 100644
--- a/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala
+++ b/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala
@@ -7,17 +7,14 @@ object BasicParser {
   private[this] val whitespace: P[Unit] = P.charIn(" \t\r\n").void
 
   val ws0: Parser0[Unit] = whitespace.rep0.void
-  val ws1: P[Unit]       = whitespace.rep(1).void
+  val ws1: P[Unit]       = whitespace.rep.void
 
-  private[this] val listSep: P[Unit] =
-    P.char(',').surroundedBy(BasicParser.ws0).void
-
-  def rep[A](pa: P[A]): P[Nel[A]] =
-    pa.repSep(listSep)
+  val stringListSep: P[Unit] =
+    (ws0.with1.soft ~ P.char(',') ~ ws0).void
 
   private[this] val basicString: P[String] =
     P.charsWhile(c =>
-      c > ' ' && c != '"' && c != '\\' && c != ',' && c != '[' && c != ']'
+      c > ' ' && c != '"' && c != '\\' && c != ',' && c != '[' && c != ']' && c != '(' && c != ')'
     )
 
   private[this] val identChars: Set[Char] =
@@ -38,14 +35,7 @@ object BasicParser {
   val singleString: P[String] =
     basicString.backtrack.orElse(StringUtil.quoted('"'))
 
-  val stringListValue: P[Nel[String]] = rep(singleString).with1
-    .between(P.char('['), P.char(']'))
-    .backtrack
-    .orElse(rep(singleString))
-
   val stringOrMore: P[Nel[String]] =
-    stringListValue.backtrack.orElse(
-      singleString.map(v => Nel.of(v))
-    )
+    singleString.repSep(stringListSep)
 
 }
diff --git a/modules/query/src/main/scala/docspell/query/internal/DateParser.scala b/modules/query/src/main/scala/docspell/query/internal/DateParser.scala
index 49cc0b58..25d1dc1c 100644
--- a/modules/query/src/main/scala/docspell/query/internal/DateParser.scala
+++ b/modules/query/src/main/scala/docspell/query/internal/DateParser.scala
@@ -1,5 +1,6 @@
 package docspell.query.internal
 
+import cats.data.NonEmptyList
 import cats.implicits._
 import cats.parse.{Numbers, Parser => P}
 
@@ -39,4 +40,7 @@ object DateParser {
   val localDate: P[Date] =
     localDateFromString.backtrack.orElse(dateFromMillis)
 
+  val localDateOrMore: P[NonEmptyList[Date]] =
+    localDate.repSep(BasicParser.stringListSep)
+
 }
diff --git a/modules/query/src/main/scala/docspell/query/internal/ExprParser.scala b/modules/query/src/main/scala/docspell/query/internal/ExprParser.scala
index d9c7d313..693c087d 100644
--- a/modules/query/src/main/scala/docspell/query/internal/ExprParser.scala
+++ b/modules/query/src/main/scala/docspell/query/internal/ExprParser.scala
@@ -2,6 +2,7 @@ package docspell.query.internal
 
 import cats.parse.{Parser => P}
 
+import docspell.query.ItemQuery
 import docspell.query.ItemQuery._
 
 object ExprParser {
@@ -18,8 +19,8 @@ object ExprParser {
       .between(BasicParser.parenOr, BasicParser.parenClose)
       .map(Expr.OrExpr.apply)
 
-  def not(inner: P[Expr]): P[Expr.NotExpr] =
-    (P.char('!') *> inner).map(Expr.NotExpr.apply)
+  def not(inner: P[Expr]): P[Expr] =
+    (P.char('!') *> inner).map(_.negate)
 
   val exprParser: P[Expr] =
     P.recursive[Expr] { recurse =>
@@ -28,4 +29,9 @@ object ExprParser {
       val notP = not(recurse)
       P.oneOf(SimpleExprParser.simpleExpr :: andP :: orP :: notP :: Nil)
     }
+
+  def parseQuery(input: String): Either[P.Error, ItemQuery] = {
+    val p = BasicParser.ws0 *> exprParser <* (BasicParser.ws0 ~ P.end)
+    p.parseAll(input).map(expr => ItemQuery(expr, Some(input)))
+  }
 }
diff --git a/modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala b/modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala
new file mode 100644
index 00000000..082f2b28
--- /dev/null
+++ b/modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala
@@ -0,0 +1,50 @@
+package docspell.query.internal
+
+import docspell.query.ItemQuery.Expr._
+import docspell.query.ItemQuery._
+
+object ExprUtil {
+
+  /** Does some basic transformation, like unfolding deeply nested and
+    * trees containing one value etc.
+    */
+  def reduce(expr: Expr): Expr =
+    expr match {
+      case AndExpr(inner) =>
+        if (inner.tail.isEmpty) reduce(inner.head)
+        else AndExpr(inner.map(reduce))
+
+      case OrExpr(inner) =>
+        if (inner.tail.isEmpty) reduce(inner.head)
+        else OrExpr(inner.map(reduce))
+
+      case NotExpr(inner) =>
+        inner match {
+          case NotExpr(inner2) =>
+            reduce(inner2)
+          case _ =>
+            expr
+        }
+
+      case InExpr(_, _) =>
+        expr
+
+      case InDateExpr(_, _) =>
+        expr
+
+      case TagsMatch(_, _) =>
+        expr
+      case TagIdsMatch(_, _) =>
+        expr
+      case Exists(_) =>
+        expr
+      case Fulltext(_) =>
+        expr
+      case SimpleExpr(_, _) =>
+        expr
+      case TagCategoryMatch(_, _) =>
+        expr
+      case CustomFieldMatch(_, _, _) =>
+        expr
+    }
+}
diff --git a/modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala b/modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala
index d9d2944d..f130ed8d 100644
--- a/modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala
+++ b/modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala
@@ -8,6 +8,9 @@ object OperatorParser {
   private[this] val Eq: P[Operator] =
     P.char('=').void.map(_ => Operator.Eq)
 
+  private[this] val Neq: P[Operator] =
+    P.string("!=").void.map(_ => Operator.Neq)
+
   private[this] val Like: P[Operator] =
     P.char(':').void.map(_ => Operator.Like)
 
@@ -24,7 +27,7 @@ object OperatorParser {
     P.string("<=").map(_ => Operator.Lte)
 
   val op: P[Operator] =
-    P.oneOf(List(Like, Eq, Gte, Lte, Gt, Lt))
+    P.oneOf(List(Like, Eq, Neq, Gte, Lte, Gt, Lt))
 
   private[this] val anyOp: P[TagOperator] =
     P.char(':').map(_ => TagOperator.AnyMatch)
diff --git a/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala b/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala
index d10fc231..3b23ceea 100644
--- a/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala
+++ b/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala
@@ -10,15 +10,29 @@ object SimpleExprParser {
   private[this] val op: P[Operator] =
     OperatorParser.op.surroundedBy(BasicParser.ws0)
 
-  val stringExpr: P[Expr.SimpleExpr] =
-    (AttrParser.stringAttr ~ op ~ BasicParser.singleString).map {
-      case ((attr, op), value) =>
+  private[this] val inOp: P[Unit] =
+    P.string("~=").surroundedBy(BasicParser.ws0)
+
+  private[this] val inOrOpStr =
+    P.eitherOr(op ~ BasicParser.singleString, inOp *> BasicParser.stringOrMore)
+
+  private[this] val inOrOpDate =
+    P.eitherOr(op ~ DateParser.localDate, inOp *> DateParser.localDateOrMore)
+
+  val stringExpr: P[Expr] =
+    (AttrParser.stringAttr ~ inOrOpStr).map {
+      case (attr, Right((op, value))) =>
         Expr.SimpleExpr(op, Property.StringProperty(attr, value))
+      case (attr, Left(values)) =>
+        Expr.InExpr(attr, values)
     }
 
-  val dateExpr: P[Expr.SimpleExpr] =
-    (AttrParser.dateAttr ~ op ~ DateParser.localDate).map { case ((attr, op), value) =>
-      Expr.SimpleExpr(op, Property.DateProperty(attr, value))
+  val dateExpr: P[Expr] =
+    (AttrParser.dateAttr ~ inOrOpDate).map {
+      case (attr, Right((op, value))) =>
+        Expr.SimpleExpr(op, Property.DateProperty(attr, value))
+      case (attr, Left(values)) =>
+        Expr.InDateExpr(attr, values)
     }
 
   val existsExpr: P[Expr.Exists] =
diff --git a/modules/query/src/test/scala/docspell/query/AttrParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/AttrParserTest.scala
similarity index 98%
rename from modules/query/src/test/scala/docspell/query/AttrParserTest.scala
rename to modules/query/src/test/scala/docspell/query/internal/AttrParserTest.scala
index b79c1103..0634c83e 100644
--- a/modules/query/src/test/scala/docspell/query/AttrParserTest.scala
+++ b/modules/query/src/test/scala/docspell/query/internal/AttrParserTest.scala
@@ -1,4 +1,4 @@
-package docspell.query
+package docspell.query.internal
 
 import docspell.query.ItemQuery.Attr
 import docspell.query.internal.AttrParser
diff --git a/modules/query/src/test/scala/docspell/query/BasicParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/BasicParserTest.scala
similarity index 69%
rename from modules/query/src/test/scala/docspell/query/BasicParserTest.scala
rename to modules/query/src/test/scala/docspell/query/internal/BasicParserTest.scala
index 80a06d18..c272298a 100644
--- a/modules/query/src/test/scala/docspell/query/BasicParserTest.scala
+++ b/modules/query/src/test/scala/docspell/query/internal/BasicParserTest.scala
@@ -1,4 +1,4 @@
-package docspell.query
+package docspell.query.internal
 
 import minitest._
 import cats.data.{NonEmptyList => Nel}
@@ -14,13 +14,7 @@ object BasicParserTest extends SimpleTestSuite {
   }
 
   test("string list values") {
-    val p = BasicParser.stringListValue
-    assertEquals(p.parseAll("[ab,cd]"), Right(Nel.of("ab", "cd")))
-    assertEquals(p.parseAll("[\"ab 12\",cd]"), Right(Nel.of("ab 12", "cd")))
-    assertEquals(
-      p.parseAll("[\"ab, 12\",cd]"),
-      Right(Nel.of("ab, 12", "cd"))
-    )
+    val p = BasicParser.stringOrMore
     assertEquals(p.parseAll("ab,cd,123"), Right(Nel.of("ab", "cd", "123")))
     assertEquals(p.parseAll("a,b"), Right(Nel.of("a", "b")))
     assert(p.parseAll("[a,b").isLeft)
@@ -30,6 +24,7 @@ object BasicParserTest extends SimpleTestSuite {
     val p = BasicParser.stringOrMore
     assertEquals(p.parseAll("abcde"), Right(Nel.of("abcde")))
     assertEquals(p.parseAll(""""a,b,c""""), Right(Nel.of("a,b,c")))
-    assertEquals(p.parseAll("[a,b,c]"), Right(Nel.of("a", "b", "c")))
+
+    assertEquals(p.parse("a, b, c "), Right((" ", Nel.of("a", "b", "c"))))
   }
 }
diff --git a/modules/query/src/test/scala/docspell/query/DateParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/DateParserTest.scala
similarity index 94%
rename from modules/query/src/test/scala/docspell/query/DateParserTest.scala
rename to modules/query/src/test/scala/docspell/query/internal/DateParserTest.scala
index ca909a97..281fc470 100644
--- a/modules/query/src/test/scala/docspell/query/DateParserTest.scala
+++ b/modules/query/src/test/scala/docspell/query/internal/DateParserTest.scala
@@ -1,7 +1,7 @@
-package docspell.query
+package docspell.query.internal
 
-import docspell.query.internal.DateParser
 import minitest._
+import docspell.query.Date
 
 object DateParserTest extends SimpleTestSuite {
 
diff --git a/modules/query/src/test/scala/docspell/query/ExprParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/ExprParserTest.scala
similarity index 62%
rename from modules/query/src/test/scala/docspell/query/ExprParserTest.scala
rename to modules/query/src/test/scala/docspell/query/internal/ExprParserTest.scala
index 304fd6d0..f918e361 100644
--- a/modules/query/src/test/scala/docspell/query/ExprParserTest.scala
+++ b/modules/query/src/test/scala/docspell/query/internal/ExprParserTest.scala
@@ -1,8 +1,7 @@
-package docspell.query
+package docspell.query.internal
 
 import docspell.query.ItemQuery._
-import docspell.query.SimpleExprParserTest.stringExpr
-import docspell.query.internal.ExprParser
+import docspell.query.internal.SimpleExprParserTest.stringExpr
 import minitest._
 import cats.data.{NonEmptyList => Nel}
 
@@ -45,4 +44,28 @@ object ExprParserTest extends SimpleTestSuite {
       )
     )
   }
+
+  test("tag list inside and/or") {
+    val p = ExprParser.exprParser
+    assertEquals(
+      p.parseAll("(& tag:a,b,c)"),
+      Right(
+        Expr.AndExpr(
+          Nel.of(
+            Expr.TagsMatch(TagOperator.AnyMatch, Nel.of("a", "b", "c"))
+          )
+        )
+      )
+    )
+    assertEquals(
+      p.parseAll("(& tag:a,b,c )"),
+      Right(
+        Expr.AndExpr(
+          Nel.of(
+            Expr.TagsMatch(TagOperator.AnyMatch, Nel.of("a", "b", "c"))
+          )
+        )
+      )
+    )
+  }
 }
diff --git a/modules/query/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala
new file mode 100644
index 00000000..4074748c
--- /dev/null
+++ b/modules/query/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala
@@ -0,0 +1,43 @@
+package docspell.query.internal
+
+import minitest._
+import docspell.query.ItemQueryParser
+import docspell.query.ItemQuery
+
+object ItemQueryParserTest extends SimpleTestSuite {
+
+  test("reduce ands") {
+    val q    = ItemQueryParser.parseUnsafe("(&(&(&(& name:hello))))")
+    val expr = ExprUtil.reduce(q.expr)
+    assertEquals(expr, ItemQueryParser.parseUnsafe("name:hello").expr)
+  }
+
+  test("reduce ors") {
+    val q    = ItemQueryParser.parseUnsafe("(|(|(|(| name:hello))))")
+    val expr = ExprUtil.reduce(q.expr)
+    assertEquals(expr, ItemQueryParser.parseUnsafe("name:hello").expr)
+  }
+
+  test("reduce and/or") {
+    val q    = ItemQueryParser.parseUnsafe("(|(&(&(| name:hello))))")
+    val expr = ExprUtil.reduce(q.expr)
+    assertEquals(expr, ItemQueryParser.parseUnsafe("name:hello").expr)
+  }
+
+  test("reduce inner and/or") {
+    val q    = ItemQueryParser.parseUnsafe("(& name:hello (| name:world))")
+    val expr = ExprUtil.reduce(q.expr)
+    assertEquals(expr, ItemQueryParser.parseUnsafe("(& name:hello name:world)").expr)
+  }
+
+  test("omit and-parens around root structure") {
+    val q      = ItemQueryParser.parseUnsafe("name:hello date>2020-02-02")
+    val expect = ItemQueryParser.parseUnsafe("(& name:hello date>2020-02-02 )")
+    assertEquals(expect, q)
+  }
+
+  test("return all if query is empty") {
+    val q = ItemQueryParser.parseUnsafe("")
+    assertEquals(ItemQuery.all, q)
+  }
+}
diff --git a/modules/query/src/test/scala/docspell/query/OperatorParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/OperatorParserTest.scala
similarity index 89%
rename from modules/query/src/test/scala/docspell/query/OperatorParserTest.scala
rename to modules/query/src/test/scala/docspell/query/internal/OperatorParserTest.scala
index 94e9ea35..b451289c 100644
--- a/modules/query/src/test/scala/docspell/query/OperatorParserTest.scala
+++ b/modules/query/src/test/scala/docspell/query/internal/OperatorParserTest.scala
@@ -1,4 +1,4 @@
-package docspell.query
+package docspell.query.internal
 
 import minitest._
 import docspell.query.ItemQuery.{Operator, TagOperator}
@@ -8,6 +8,7 @@ object OperatorParserTest extends SimpleTestSuite {
   test("operator values") {
     val p = OperatorParser.op
     assertEquals(p.parseAll("="), Right(Operator.Eq))
+    assertEquals(p.parseAll("!="), Right(Operator.Neq))
     assertEquals(p.parseAll(":"), Right(Operator.Like))
     assertEquals(p.parseAll("<"), Right(Operator.Lt))
     assertEquals(p.parseAll(">"), Right(Operator.Gt))
diff --git a/modules/query/src/test/scala/docspell/query/SimpleExprParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala
similarity index 91%
rename from modules/query/src/test/scala/docspell/query/SimpleExprParserTest.scala
rename to modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala
index 298e9c59..f3ee0ae5 100644
--- a/modules/query/src/test/scala/docspell/query/SimpleExprParserTest.scala
+++ b/modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala
@@ -1,9 +1,9 @@
-package docspell.query
+package docspell.query.internal
 
 import cats.data.{NonEmptyList => Nel}
 import docspell.query.ItemQuery._
-import docspell.query.internal.SimpleExprParser
 import minitest._
+import docspell.query.Date
 
 object SimpleExprParserTest extends SimpleTestSuite {
 
@@ -29,6 +29,11 @@ object SimpleExprParserTest extends SimpleTestSuite {
       p.parseAll("conc.pers.id=Aaiet-aied"),
       Right(stringExpr(Operator.Eq, Attr.Concerning.PersonId, "Aaiet-aied"))
     )
+    assert(p.parseAll("conc.pers.id=Aaiet,aied").isLeft)
+    assertEquals(
+      p.parseAll("name~=hello,world"),
+      Right(Expr.InExpr(Attr.ItemName, Nel.of("hello", "world")))
+    )
   }
 
   test("date expr") {
@@ -41,6 +46,10 @@ object SimpleExprParserTest extends SimpleTestSuite {
       p.parseAll("due<2021-03-14"),
       Right(dateExpr(Operator.Lt, Attr.DueDate, ld(2021, 3, 14)))
     )
+    assertEquals(
+      p.parseAll("due~=2021-03-14,2021-03-13"),
+      Right(Expr.InDateExpr(Attr.DueDate, Nel.of(ld(2021, 3, 14), ld(2021, 3, 13))))
+    )
   }
 
   test("exists expr") {
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 836927d0..510273a0 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala
@@ -51,13 +51,11 @@ object ItemRoutes {
             offset
           ) =>
         val query =
-          q.map(ItemQueryParser.parse) match {
-            case Some(Right(q)) =>
+          ItemQueryParser.parse(q.getOrElse("")) match {
+            case Right(q) =>
               Right(Query(Query.Fix(user.account, None, None), Query.QueryExpr(q)))
-            case Some(Left(err)) =>
+            case Left(err) =>
               Left(err)
-            case None =>
-              Right(Query(Query.Fix(user.account, None, None), Query.QueryForm.empty))
           }
         val li = limit.getOrElse(cfg.maxItemPageSize)
         val of = offset.getOrElse(0)
diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
index 561ad816..2e2b3233 100644
--- a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
+++ b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
@@ -12,6 +12,7 @@ import docspell.store.qb.{Operator => QOp, _}
 import docspell.store.records.{RCustomField, RCustomFieldValue, TagItemName}
 
 import doobie.util.Put
+import docspell.store.queries.QueryWildcard
 
 object ItemQueryGenerator {
 
@@ -80,18 +81,13 @@ object ItemQueryGenerator {
         val col = stringColumn(tables)(attr)
         op match {
           case Operator.Like =>
-            Condition.CompareVal(col, makeOp(op), value.toLowerCase)
+            Condition.CompareVal(col, makeOp(op), QueryWildcard.lower(value))
           case _ =>
             Condition.CompareVal(col, makeOp(op), value)
         }
 
       case Expr.SimpleExpr(op, Property.DateProperty(attr, value)) =>
-        val dt = value match {
-          case Date.Local(year, month, day) =>
-            Timestamp.atUtc(LocalDate.of(year, month, day).atStartOfDay())
-          case Date.Millis(ms) =>
-            Timestamp(Instant.ofEpochMilli(ms))
-        }
+        val dt  = dateToTimestamp(value)
         val col = timestampColumn(tables)(attr)
         Condition.CompareVal(col, makeOp(op), dt)
 
@@ -100,6 +96,12 @@ object ItemQueryGenerator {
         if (values.tail.isEmpty) col === values.head
         else col.in(values)
 
+      case Expr.InDateExpr(attr, values) =>
+        val col = timestampColumn(tables)(attr)
+        val dts = values.map(dateToTimestamp)
+        if (values.tail.isEmpty) col === dts.head
+        else col.in(dts)
+
       case Expr.TagIdsMatch(op, tags) =>
         val ids = tags.toList.flatMap(s => Ident.fromString(s).toOption)
         NonEmptyList
@@ -140,6 +142,14 @@ object ItemQueryGenerator {
         Condition.unit
     }
 
+  private def dateToTimestamp(date: Date): Timestamp =
+    date match {
+      case Date.Local(year, month, day) =>
+        Timestamp.atUtc(LocalDate.of(year, month, day).atStartOfDay())
+      case Date.Millis(ms) =>
+        Timestamp(Instant.ofEpochMilli(ms))
+    }
+
   private def anyColumn(tables: Tables)(attr: Attr): Column[_] =
     attr match {
       case s: Attr.StringAttr =>
@@ -177,6 +187,8 @@ object ItemQueryGenerator {
     operator match {
       case Operator.Eq =>
         QOp.Eq
+      case Operator.Neq =>
+        QOp.Neq
       case Operator.Like =>
         QOp.LowerLike
       case Operator.Gt =>

From 9013d9264ea013f30bd84616b40ce4a92b4e2e43 Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Sun, 28 Feb 2021 16:11:25 +0100
Subject: [PATCH 08/33] Add more convenient date parsers and some basic macros

---
 build.sbt                                     |  4 +-
 .../docspell/backend/ops/OFulltext.scala      |  6 +-
 .../docspell/backend/ops/OItemSearch.scala    | 44 ++++++----
 .../joex/notify/NotifyDueItemsTask.scala      |  6 +-
 .../src/main/scala/docspell/query/Date.scala  | 32 +++++--
 .../main/scala/docspell/query/ItemQuery.scala | 23 +++++
 .../docspell/query/ItemQueryParser.scala      |  2 +-
 .../docspell/query/internal/AttrParser.scala  | 34 ++++----
 .../docspell/query/internal/BasicParser.scala |  6 ++
 .../docspell/query/internal/DateParser.scala  | 85 ++++++++++++++++---
 .../docspell/query/internal/ExprParser.scala  |  9 +-
 .../docspell/query/internal/ExprUtil.scala    | 10 +++
 .../docspell/query/internal/MacroParser.scala | 65 ++++++++++++++
 .../query/internal/OperatorParser.scala       | 18 ++--
 .../query/internal/SimpleExprParser.scala     | 12 ++-
 .../query/internal/DateParserTest.scala       | 51 +++++++++--
 .../query/internal/MacroParserTest.scala      | 19 +++++
 .../query/internal/SimpleExprParserTest.scala | 27 +++++-
 .../restserver/src/main/resources/logback.xml |  1 +
 .../qb/generator/ItemQueryGenerator.scala     | 62 ++++++++++----
 .../scala/docspell/store/queries/QItem.scala  | 39 +++++----
 .../generator/ItemQueryGeneratorTest.scala    | 28 +-----
 project/Dependencies.scala                    |  4 +
 23 files changed, 445 insertions(+), 142 deletions(-)
 create mode 100644 modules/query/src/main/scala/docspell/query/internal/MacroParser.scala
 create mode 100644 modules/query/src/test/scala/docspell/query/internal/MacroParserTest.scala

diff --git a/build.sbt b/build.sbt
index 351f40d7..884234f3 100644
--- a/build.sbt
+++ b/build.sbt
@@ -275,7 +275,9 @@ val query =
     .settings(
       name := "docspell-query",
       libraryDependencies +=
-        Dependencies.catsParseJS.value
+        Dependencies.catsParseJS.value,
+      libraryDependencies +=
+        Dependencies.scalaJavaTime.value
     )
     .jsSettings(
       Test / fork := false
diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OFulltext.scala b/modules/backend/src/main/scala/docspell/backend/ops/OFulltext.scala
index 52197e17..0dd2348c 100644
--- a/modules/backend/src/main/scala/docspell/backend/ops/OFulltext.scala
+++ b/modules/backend/src/main/scala/docspell/backend/ops/OFulltext.scala
@@ -159,13 +159,14 @@ object OFulltext {
 
         for {
           folder <- store.transact(QFolder.getMemberFolders(account))
+          now    <- Timestamp.current[F]
           itemIds <- fts
             .searchAll(fq.withFolders(folder))
             .flatMap(r => Stream.emits(r.results.map(_.itemId)))
             .compile
             .to(Set)
           q = Query.empty(account).withFix(_.copy(itemIds = itemIds.some))
-          res <- store.transact(QItem.searchStats(q))
+          res <- store.transact(QItem.searchStats(now.toUtcDate)(q))
         } yield res
       }
 
@@ -221,7 +222,8 @@ object OFulltext {
             .compile
             .to(Set)
           qnext = q.withFix(_.copy(itemIds = items.some))
-          res <- store.transact(QItem.searchStats(qnext))
+          now <- Timestamp.current[F]
+          res <- store.transact(QItem.searchStats(now.toUtcDate)(qnext))
         } yield res
 
       // Helper
diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala b/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala
index 724ee18e..a74e451a 100644
--- a/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala
+++ b/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala
@@ -127,27 +127,39 @@ object OItemSearch {
           .map(opt => opt.flatMap(_.filterCollective(collective)))
 
       def findItems(maxNoteLen: Int)(q: Query, batch: Batch): F[Vector[ListItem]] =
-        store
-          .transact(QItem.findItems(q, maxNoteLen, batch).take(batch.limit.toLong))
-          .compile
-          .toVector
+        Timestamp
+          .current[F]
+          .map(_.toUtcDate)
+          .flatMap { today =>
+            store
+              .transact(
+                QItem.findItems(q, today, maxNoteLen, batch).take(batch.limit.toLong)
+              )
+              .compile
+              .toVector
+          }
 
       def findItemsWithTags(
           maxNoteLen: Int
-      )(q: Query, batch: Batch): F[Vector[ListItemWithTags]] = {
-        val search = QItem.findItems(q, maxNoteLen: Int, batch)
-        store
-          .transact(
-            QItem
-              .findItemsWithTags(q.fix.account.collective, search)
-              .take(batch.limit.toLong)
-          )
-          .compile
-          .toVector
-      }
+      )(q: Query, batch: Batch): F[Vector[ListItemWithTags]] =
+        for {
+          now <- Timestamp.current[F]
+          search = QItem.findItems(q, now.toUtcDate, maxNoteLen: Int, batch)
+          res <- store
+            .transact(
+              QItem
+                .findItemsWithTags(q.fix.account.collective, search)
+                .take(batch.limit.toLong)
+            )
+            .compile
+            .toVector
+        } yield res
 
       def findItemsSummary(q: Query): F[SearchSummary] =
-        store.transact(QItem.searchStats(q))
+        Timestamp
+          .current[F]
+          .map(_.toUtcDate)
+          .flatMap(today => store.transact(QItem.searchStats(today)(q)))
 
       def findAttachment(id: Ident, collective: Ident): F[Option[AttachmentData[F]]] =
         store
diff --git a/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala b/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala
index 1000d630..4ce26507 100644
--- a/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala
+++ b/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala
@@ -85,7 +85,11 @@ object NotifyDueItemsTask {
           )
       res <-
         ctx.store
-          .transact(QItem.findItems(q, 0, Batch.limit(maxItems)).take(maxItems.toLong))
+          .transact(
+            QItem
+              .findItems(q, now.toUtcDate, 0, Batch.limit(maxItems))
+              .take(maxItems.toLong)
+          )
           .compile
           .toVector
     } yield res
diff --git a/modules/query/src/main/scala/docspell/query/Date.scala b/modules/query/src/main/scala/docspell/query/Date.scala
index 30e9dabb..21ce9b35 100644
--- a/modules/query/src/main/scala/docspell/query/Date.scala
+++ b/modules/query/src/main/scala/docspell/query/Date.scala
@@ -1,14 +1,32 @@
 package docspell.query
 
-sealed trait Date
-object Date {
-  def apply(y: Int, m: Int, d: Int): Date =
-    Local(y, m, d)
+import java.time.LocalDate
+import java.time.Period
 
-  def apply(ms: Long): Date =
+import cats.implicits._
+
+sealed trait Date
+
+object Date {
+  def apply(y: Int, m: Int, d: Int): Either[Throwable, DateLiteral] =
+    Either.catchNonFatal(Local(LocalDate.of(y, m, d)))
+
+  def apply(ms: Long): DateLiteral =
     Millis(ms)
 
-  final case class Local(year: Int, month: Int, day: Int) extends Date
+  sealed trait DateLiteral extends Date
 
-  final case class Millis(ms: Long) extends Date
+  final case class Local(date: LocalDate) extends DateLiteral
+
+  final case class Millis(ms: Long) extends DateLiteral
+
+  case object Today extends DateLiteral
+
+  sealed trait CalcDirection
+  object CalcDirection {
+    case object Plus  extends CalcDirection
+    case object Minus extends CalcDirection
+  }
+
+  case class Calc(date: DateLiteral, calc: CalcDirection, period: Period) extends Date
 }
diff --git a/modules/query/src/main/scala/docspell/query/ItemQuery.scala b/modules/query/src/main/scala/docspell/query/ItemQuery.scala
index 46b9e051..ec824ecb 100644
--- a/modules/query/src/main/scala/docspell/query/ItemQuery.scala
+++ b/modules/query/src/main/scala/docspell/query/ItemQuery.scala
@@ -40,6 +40,7 @@ object ItemQuery {
 
     case object ItemName   extends StringAttr
     case object ItemSource extends StringAttr
+    case object ItemNotes  extends StringAttr
     case object ItemId     extends StringAttr
     case object Date       extends DateAttr
     case object DueDate    extends DateAttr
@@ -69,6 +70,11 @@ object ItemQuery {
     final case class StringProperty(attr: StringAttr, value: String) extends Property
     final case class DateProperty(attr: DateAttr, value: Date)       extends Property
 
+    def apply(sa: StringAttr, value: String): Property =
+      StringProperty(sa, value)
+
+    def apply(da: DateAttr, value: Date): Property =
+      DateProperty(da, value)
   }
 
   sealed trait Expr {
@@ -88,6 +94,8 @@ object ItemQuery {
     final case class Exists(field: Attr)                           extends Expr
     final case class InExpr(attr: StringAttr, values: Nel[String]) extends Expr
     final case class InDateExpr(attr: DateAttr, values: Nel[Date]) extends Expr
+    final case class InboxExpr(inbox: Boolean)                     extends Expr
+    final case class DirectionExpr(incoming: Boolean)              extends Expr
 
     final case class TagIdsMatch(op: TagOperator, tags: Nel[String])      extends Expr
     final case class TagsMatch(op: TagOperator, tags: Nel[String])        extends Expr
@@ -97,6 +105,21 @@ object ItemQuery {
         extends Expr
 
     final case class Fulltext(query: String) extends Expr
+
+    def or(expr0: Expr, exprs: Expr*): OrExpr =
+      OrExpr(Nel.of(expr0, exprs: _*))
+
+    def and(expr0: Expr, exprs: Expr*): AndExpr =
+      AndExpr(Nel.of(expr0, exprs: _*))
+
+    def string(op: Operator, attr: StringAttr, value: String): SimpleExpr =
+      SimpleExpr(op, Property(attr, value))
+
+    def like(attr: StringAttr, value: String): SimpleExpr =
+      string(Operator.Like, attr, value)
+
+    def date(op: Operator, attr: DateAttr, value: Date): SimpleExpr =
+      SimpleExpr(op, Property(attr, value))
   }
 
 }
diff --git a/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala b/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala
index 0a7b8d81..cea6c09e 100644
--- a/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala
+++ b/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala
@@ -16,7 +16,7 @@ object ItemQueryParser {
       ExprParser
         .parseQuery(in)
         .left
-        .map(pe => s"Error parsing: '$input': $pe")
+        .map(pe => s"Error parsing: '$input': $pe") //TODO
         .map(q => q.copy(expr = ExprUtil.reduce(q.expr)))
     }
 
diff --git a/modules/query/src/main/scala/docspell/query/internal/AttrParser.scala b/modules/query/src/main/scala/docspell/query/internal/AttrParser.scala
index f9520a61..d5289c67 100644
--- a/modules/query/src/main/scala/docspell/query/internal/AttrParser.scala
+++ b/modules/query/src/main/scala/docspell/query/internal/AttrParser.scala
@@ -7,57 +7,60 @@ import docspell.query.ItemQuery.Attr
 object AttrParser {
 
   val name: P[Attr.StringAttr] =
-    P.ignoreCase("name").map(_ => Attr.ItemName)
+    P.ignoreCase("name").as(Attr.ItemName)
 
   val source: P[Attr.StringAttr] =
-    P.ignoreCase("source").map(_ => Attr.ItemSource)
+    P.ignoreCase("source").as(Attr.ItemSource)
 
   val id: P[Attr.StringAttr] =
-    P.ignoreCase("id").map(_ => Attr.ItemId)
+    P.ignoreCase("id").as(Attr.ItemId)
 
   val date: P[Attr.DateAttr] =
-    P.ignoreCase("date").map(_ => Attr.Date)
+    P.ignoreCase("date").as(Attr.Date)
+
+  val notes: P[Attr.StringAttr] =
+    P.ignoreCase("notes").as(Attr.ItemNotes)
 
   val dueDate: P[Attr.DateAttr] =
-    P.stringIn(List("dueDate", "due", "due-date")).map(_ => Attr.DueDate)
+    P.stringIn(List("dueDate", "due", "due-date")).as(Attr.DueDate)
 
   val corrOrgId: P[Attr.StringAttr] =
     P.stringIn(List("correspondent.org.id", "corr.org.id"))
-      .map(_ => Attr.Correspondent.OrgId)
+      .as(Attr.Correspondent.OrgId)
 
   val corrOrgName: P[Attr.StringAttr] =
     P.stringIn(List("correspondent.org.name", "corr.org.name"))
-      .map(_ => Attr.Correspondent.OrgName)
+      .as(Attr.Correspondent.OrgName)
 
   val corrPersId: P[Attr.StringAttr] =
     P.stringIn(List("correspondent.person.id", "corr.pers.id"))
-      .map(_ => Attr.Correspondent.PersonId)
+      .as(Attr.Correspondent.PersonId)
 
   val corrPersName: P[Attr.StringAttr] =
     P.stringIn(List("correspondent.person.name", "corr.pers.name"))
-      .map(_ => Attr.Correspondent.PersonName)
+      .as(Attr.Correspondent.PersonName)
 
   val concPersId: P[Attr.StringAttr] =
     P.stringIn(List("concerning.person.id", "conc.pers.id"))
-      .map(_ => Attr.Concerning.PersonId)
+      .as(Attr.Concerning.PersonId)
 
   val concPersName: P[Attr.StringAttr] =
     P.stringIn(List("concerning.person.name", "conc.pers.name"))
-      .map(_ => Attr.Concerning.PersonName)
+      .as(Attr.Concerning.PersonName)
 
   val concEquipId: P[Attr.StringAttr] =
     P.stringIn(List("concerning.equip.id", "conc.equip.id"))
-      .map(_ => Attr.Concerning.EquipId)
+      .as(Attr.Concerning.EquipId)
 
   val concEquipName: P[Attr.StringAttr] =
     P.stringIn(List("concerning.equip.name", "conc.equip.name"))
-      .map(_ => Attr.Concerning.EquipName)
+      .as(Attr.Concerning.EquipName)
 
   val folderId: P[Attr.StringAttr] =
-    P.ignoreCase("folder.id").map(_ => Attr.Folder.FolderId)
+    P.ignoreCase("folder.id").as(Attr.Folder.FolderId)
 
   val folderName: P[Attr.StringAttr] =
-    P.ignoreCase("folder").map(_ => Attr.Folder.FolderName)
+    P.ignoreCase("folder").as(Attr.Folder.FolderName)
 
   val dateAttr: P[Attr.DateAttr] =
     P.oneOf(List(date, dueDate))
@@ -68,6 +71,7 @@ object AttrParser {
         name,
         source,
         id,
+        notes,
         corrOrgId,
         corrOrgName,
         corrPersId,
diff --git a/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala b/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala
index ca2c3462..c4edd070 100644
--- a/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala
+++ b/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala
@@ -38,4 +38,10 @@ object BasicParser {
   val stringOrMore: P[Nel[String]] =
     singleString.repSep(stringListSep)
 
+  val bool: P[Boolean] = {
+    val trueP  = P.stringIn(List("yes", "true", "Yes", "True")).as(true)
+    val falseP = P.stringIn(List("no", "false", "No", "False")).as(false)
+    trueP | falseP
+  }
+
 }
diff --git a/modules/query/src/main/scala/docspell/query/internal/DateParser.scala b/modules/query/src/main/scala/docspell/query/internal/DateParser.scala
index 25d1dc1c..33e0d556 100644
--- a/modules/query/src/main/scala/docspell/query/internal/DateParser.scala
+++ b/modules/query/src/main/scala/docspell/query/internal/DateParser.scala
@@ -1,7 +1,8 @@
 package docspell.query.internal
 
-import cats.data.NonEmptyList
-import cats.implicits._
+import java.time.Period
+
+import cats.data.{NonEmptyList => Nel}
 import cats.parse.{Numbers, Parser => P}
 
 import docspell.query.Date
@@ -26,21 +27,79 @@ object DateParser {
     digits2.filter(n => n >= 1 && n <= 31)
 
   private val dateSep: P[Unit] =
-    P.anyChar.void
+    P.charIn('-', '/').void
 
-  val localDateFromString: P[Date] =
-    ((digits4 <* dateSep) ~ (month <* dateSep) ~ day).mapFilter {
-      case ((year, month), day) =>
-        Either.catchNonFatal(Date(year, month, day)).toOption
+  private val dateString: P[((Int, Option[Int]), Option[Int])] =
+    digits4 ~ (dateSep *> month).? ~ (dateSep *> day).?
+
+  private[internal] val dateFromString: P[Date.DateLiteral] =
+    dateString.mapFilter { case ((year, month), day) =>
+      Date(year, month.getOrElse(1), day.getOrElse(1)).toOption
     }
 
-  val dateFromMillis: P[Date] =
-    longParser.map(Date.apply)
+  private[internal] val dateFromMillis: P[Date.DateLiteral] =
+    P.string("ms") *> longParser.map(Date.apply)
 
-  val localDate: P[Date] =
-    localDateFromString.backtrack.orElse(dateFromMillis)
+  private val dateFromToday: P[Date.DateLiteral] =
+    P.string("today").as(Date.Today)
 
-  val localDateOrMore: P[NonEmptyList[Date]] =
-    localDate.repSep(BasicParser.stringListSep)
+  val dateLiteral: P[Date.DateLiteral] =
+    P.oneOf(List(dateFromString, dateFromToday, dateFromMillis))
 
+  // val dateLiteralOrMore: P[NonEmptyList[Date.DateLiteral]] =
+  //   dateLiteral.repSep(BasicParser.stringListSep)
+
+  val dateCalcDirection: P[Date.CalcDirection] =
+    P.oneOf(
+      List(
+        P.char('+').as(Date.CalcDirection.Plus),
+        P.char('-').as(Date.CalcDirection.Minus)
+      )
+    )
+
+  def periodPart(unitSuffix: Char, f: Int => Period): P[Period] =
+    ((Numbers.nonZeroDigit ~ Numbers.digits0).void.string.soft <* P.ignoreCaseChar(
+      unitSuffix
+    ))
+      .map(n => f(n.toInt))
+
+  private[this] val periodMonths: P[Period] =
+    periodPart('m', n => Period.ofMonths(n))
+
+  private[this] val periodDays: P[Period] =
+    periodPart('d', n => Period.ofDays(n))
+
+  val period: P[Period] =
+    periodDays.eitherOr(periodMonths).map(_.fold(identity, identity))
+
+  val periods: P[Period] =
+    period.rep.map(_.reduceLeft((p0, p1) => p0.plus(p1)))
+
+  val dateRange: P[(Date, Date)] =
+    ((dateLiteral <* P.char(';')) ~ dateCalcDirection.eitherOr(P.char('/')) ~ period)
+      .map { case ((date, calc), period) =>
+        calc match {
+          case Right(Date.CalcDirection.Plus) =>
+            (date, Date.Calc(date, Date.CalcDirection.Plus, period))
+          case Right(Date.CalcDirection.Minus) =>
+            (Date.Calc(date, Date.CalcDirection.Minus, period), date)
+          case Left(_) =>
+            (
+              Date.Calc(date, Date.CalcDirection.Minus, period),
+              Date.Calc(date, Date.CalcDirection.Plus, period)
+            )
+        }
+      }
+
+  val date: P[Date] =
+    (dateLiteral ~ (P.char(';') *> dateCalcDirection ~ period).?).map {
+      case (date, Some((c, p))) =>
+        Date.Calc(date, c, p)
+
+      case (date, None) =>
+        date
+    }
+
+  val dateOrMore: P[Nel[Date]] =
+    date.repSep(BasicParser.stringListSep)
 }
diff --git a/modules/query/src/main/scala/docspell/query/internal/ExprParser.scala b/modules/query/src/main/scala/docspell/query/internal/ExprParser.scala
index 693c087d..329ec030 100644
--- a/modules/query/src/main/scala/docspell/query/internal/ExprParser.scala
+++ b/modules/query/src/main/scala/docspell/query/internal/ExprParser.scala
@@ -24,10 +24,11 @@ object ExprParser {
 
   val exprParser: P[Expr] =
     P.recursive[Expr] { recurse =>
-      val andP = and(recurse)
-      val orP  = or(recurse)
-      val notP = not(recurse)
-      P.oneOf(SimpleExprParser.simpleExpr :: andP :: orP :: notP :: Nil)
+      val andP   = and(recurse)
+      val orP    = or(recurse)
+      val notP   = not(recurse)
+      val macros = MacroParser.all
+      P.oneOf(SimpleExprParser.simpleExpr :: macros :: andP :: orP :: notP :: Nil)
     }
 
   def parseQuery(input: String): Either[P.Error, ItemQuery] = {
diff --git a/modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala b/modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala
index 082f2b28..75007d11 100644
--- a/modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala
+++ b/modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala
@@ -22,10 +22,20 @@ object ExprUtil {
         inner match {
           case NotExpr(inner2) =>
             reduce(inner2)
+          case InboxExpr(flag) =>
+            InboxExpr(!flag)
+          case DirectionExpr(flag) =>
+            DirectionExpr(!flag)
           case _ =>
             expr
         }
 
+      case DirectionExpr(_) =>
+        expr
+
+      case InboxExpr(_) =>
+        expr
+
       case InExpr(_, _) =>
         expr
 
diff --git a/modules/query/src/main/scala/docspell/query/internal/MacroParser.scala b/modules/query/src/main/scala/docspell/query/internal/MacroParser.scala
new file mode 100644
index 00000000..2a2c2d41
--- /dev/null
+++ b/modules/query/src/main/scala/docspell/query/internal/MacroParser.scala
@@ -0,0 +1,65 @@
+package docspell.query.internal
+
+import cats.parse.{Parser => P}
+
+import docspell.query.ItemQuery._
+
+object MacroParser {
+  private[this] val macroDef: P[String] =
+    P.char('$') *> BasicParser.identParser <* P.char(':')
+
+  def parser[A](macros: Map[String, P[A]]): P[A] = {
+    val p: P[P[A]] = macroDef.map { name =>
+      macros
+        .get(name)
+        .getOrElse(P.failWith(s"Unknown macro: $name"))
+    }
+
+    val px = (p ~ P.index ~ BasicParser.singleString).map { case ((pexpr, index), str) =>
+      pexpr
+        .parseAll(str)
+        .left
+        .map(err => err.copy(failedAtOffset = err.failedAtOffset + index))
+    }
+
+    P.select(px)(P.Fail)
+  }
+
+  // --- definitions of available macros
+
+  /** Expands in an OR expression that matches name fields of item and
+    * correspondent/concerning metadata.
+    */
+  val names: P[Expr] =
+    P.string(P.anyChar.rep.void).map { input =>
+      Expr.or(
+        Expr.like(Attr.ItemName, input),
+        Expr.like(Attr.ItemNotes, input),
+        Expr.like(Attr.Correspondent.OrgName, input),
+        Expr.like(Attr.Correspondent.PersonName, input),
+        Expr.like(Attr.Concerning.PersonName, input),
+        Expr.like(Attr.Concerning.EquipName, input)
+      )
+    }
+
+  def dateRange(attr: Attr.DateAttr): P[Expr] =
+    DateParser.dateRange.map { case (left, right) =>
+      Expr.and(
+        Expr.date(Operator.Gte, attr, left),
+        Expr.date(Operator.Lte, attr, right)
+      )
+    }
+
+  // --- all macro parser
+
+  val allMacros: Map[String, P[Expr]] =
+    Map(
+      "names"  -> names,
+      "datein" -> dateRange(Attr.Date),
+      "duein"  -> dateRange(Attr.DueDate)
+    )
+
+  val all: P[Expr] =
+    parser(allMacros)
+
+}
diff --git a/modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala b/modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala
index f130ed8d..de93fb4f 100644
--- a/modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala
+++ b/modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala
@@ -6,34 +6,34 @@ import docspell.query.ItemQuery._
 
 object OperatorParser {
   private[this] val Eq: P[Operator] =
-    P.char('=').void.map(_ => Operator.Eq)
+    P.char('=').as(Operator.Eq)
 
   private[this] val Neq: P[Operator] =
-    P.string("!=").void.map(_ => Operator.Neq)
+    P.string("!=").as(Operator.Neq)
 
   private[this] val Like: P[Operator] =
-    P.char(':').void.map(_ => Operator.Like)
+    P.char(':').as(Operator.Like)
 
   private[this] val Gt: P[Operator] =
-    P.char('>').void.map(_ => Operator.Gt)
+    P.char('>').as(Operator.Gt)
 
   private[this] val Lt: P[Operator] =
-    P.char('<').void.map(_ => Operator.Lt)
+    P.char('<').as(Operator.Lt)
 
   private[this] val Gte: P[Operator] =
-    P.string(">=").map(_ => Operator.Gte)
+    P.string(">=").as(Operator.Gte)
 
   private[this] val Lte: P[Operator] =
-    P.string("<=").map(_ => Operator.Lte)
+    P.string("<=").as(Operator.Lte)
 
   val op: P[Operator] =
     P.oneOf(List(Like, Eq, Neq, Gte, Lte, Gt, Lt))
 
   private[this] val anyOp: P[TagOperator] =
-    P.char(':').map(_ => TagOperator.AnyMatch)
+    P.char(':').as(TagOperator.AnyMatch)
 
   private[this] val allOp: P[TagOperator] =
-    P.char('=').map(_ => TagOperator.AllMatch)
+    P.char('=').as(TagOperator.AllMatch)
 
   val tagOp: P[TagOperator] =
     P.oneOf(List(anyOp, allOp))
diff --git a/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala b/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala
index 3b23ceea..f76fe947 100644
--- a/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala
+++ b/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala
@@ -17,7 +17,7 @@ object SimpleExprParser {
     P.eitherOr(op ~ BasicParser.singleString, inOp *> BasicParser.stringOrMore)
 
   private[this] val inOrOpDate =
-    P.eitherOr(op ~ DateParser.localDate, inOp *> DateParser.localDateOrMore)
+    P.eitherOr(op ~ DateParser.date, inOp *> DateParser.dateOrMore)
 
   val stringExpr: P[Expr] =
     (AttrParser.stringAttr ~ inOrOpStr).map {
@@ -65,6 +65,12 @@ object SimpleExprParser {
         CustomFieldMatch(name, op, value)
     }
 
+  val inboxExpr: P[Expr.InboxExpr] =
+    (P.string("inbox:") *> BasicParser.bool).map(Expr.InboxExpr.apply)
+
+  val dirExpr: P[Expr.DirectionExpr] =
+    (P.string("incoming:") *> BasicParser.bool).map(Expr.DirectionExpr.apply)
+
   val simpleExpr: P[Expr] =
     P.oneOf(
       List(
@@ -75,7 +81,9 @@ object SimpleExprParser {
         tagIdExpr,
         tagExpr,
         catExpr,
-        customFieldExpr
+        customFieldExpr,
+        inboxExpr,
+        dirExpr
       )
     )
 }
diff --git a/modules/query/src/test/scala/docspell/query/internal/DateParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/DateParserTest.scala
index 281fc470..ff538363 100644
--- a/modules/query/src/test/scala/docspell/query/internal/DateParserTest.scala
+++ b/modules/query/src/test/scala/docspell/query/internal/DateParserTest.scala
@@ -2,35 +2,68 @@ package docspell.query.internal
 
 import minitest._
 import docspell.query.Date
+import java.time.Period
 
 object DateParserTest extends SimpleTestSuite {
 
-  def ld(year: Int, m: Int, d: Int): Date =
-    Date(year, m, d)
+  def ld(year: Int, m: Int, d: Int): Date.DateLiteral =
+    Date(year, m, d).fold(throw _, identity)
+
+  def ldPlus(year: Int, m: Int, d: Int, p: Period): Date.Calc =
+    Date.Calc(ld(year, m, d), Date.CalcDirection.Plus, p)
+
+  def ldMinus(year: Int, m: Int, d: Int, p: Period): Date.Calc =
+    Date.Calc(ld(year, m, d), Date.CalcDirection.Minus, p)
 
   test("local date string") {
-    val p = DateParser.localDateFromString
+    val p = DateParser.dateFromString
     assertEquals(p.parseAll("2021-02-22"), Right(ld(2021, 2, 22)))
     assertEquals(p.parseAll("1999-11-11"), Right(ld(1999, 11, 11)))
     assertEquals(p.parseAll("2032-01-21"), Right(ld(2032, 1, 21)))
     assert(p.parseAll("0-0-0").isLeft)
-    assert(p.parseAll("2021-02-30").isRight)
+    assert(p.parseAll("2021-02-30").isLeft)
   }
 
   test("local date millis") {
     val p = DateParser.dateFromMillis
-    assertEquals(p.parseAll("0"), Right(Date(0)))
+    assertEquals(p.parseAll("ms0"), Right(Date(0)))
     assertEquals(
-      p.parseAll("1600000065463"),
+      p.parseAll("ms1600000065463"),
       Right(Date(1600000065463L))
     )
   }
 
   test("local date") {
-    val p = DateParser.localDate
+    val p = DateParser.date
     assertEquals(p.parseAll("2021-02-22"), Right(ld(2021, 2, 22)))
     assertEquals(p.parseAll("1999-11-11"), Right(ld(1999, 11, 11)))
-    assertEquals(p.parseAll("0"), Right(Date(0)))
-    assertEquals(p.parseAll("1600000065463"), Right(Date(1600000065463L)))
+    assertEquals(p.parseAll("ms0"), Right(Date(0)))
+    assertEquals(p.parseAll("ms1600000065463"), Right(Date(1600000065463L)))
+  }
+
+  test("local partial date") {
+    val p = DateParser.date
+    assertEquals(p.parseAll("2021-04"), Right(ld(2021, 4, 1)))
+    assertEquals(p.parseAll("2021-12"), Right(ld(2021, 12, 1)))
+    assert(p.parseAll("2021-13").isLeft)
+    assert(p.parseAll("2021-28").isLeft)
+    assertEquals(p.parseAll("2021"), Right(ld(2021, 1, 1)))
+  }
+
+  test("date calcs") {
+    val p = DateParser.date
+    assertEquals(p.parseAll("2020-02;+2d"), Right(ldPlus(2020, 2, 1, Period.ofDays(2))))
+    assertEquals(
+      p.parseAll("today;-2m"),
+      Right(Date.Calc(Date.Today, Date.CalcDirection.Minus, Period.ofMonths(2)))
+    )
+  }
+
+  test("period") {
+    val p = DateParser.periods
+    assertEquals(p.parseAll("15d"), Right(Period.ofDays(15)))
+    assertEquals(p.parseAll("15m"), Right(Period.ofMonths(15)))
+    assertEquals(p.parseAll("15d10m"), Right(Period.ofMonths(10).plus(Period.ofDays(15))))
+    assertEquals(p.parseAll("10m15d"), Right(Period.ofMonths(10).plus(Period.ofDays(15))))
   }
 }
diff --git a/modules/query/src/test/scala/docspell/query/internal/MacroParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/MacroParserTest.scala
new file mode 100644
index 00000000..0836884a
--- /dev/null
+++ b/modules/query/src/test/scala/docspell/query/internal/MacroParserTest.scala
@@ -0,0 +1,19 @@
+package docspell.query.internal
+
+import minitest._
+import cats.parse.{Parser => P}
+
+object MacroParserTest extends SimpleTestSuite {
+
+  test("fail with unkown macro names") {
+    val p = MacroParser.parser(Map.empty)
+    assert(p.parseAll("$bla:blup").isLeft) // TODO check error message
+  }
+
+  test("select correct parser") {
+    val p =
+      MacroParser.parser[Int](Map("one" -> P.anyChar.as(1), "two" -> P.anyChar.as(2)))
+    assertEquals(p.parseAll("$one:y"), Right(1))
+    assertEquals(p.parseAll("$two:y"), Right(2))
+  }
+}
diff --git a/modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala
index f3ee0ae5..9f79aa51 100644
--- a/modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala
+++ b/modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala
@@ -4,6 +4,7 @@ import cats.data.{NonEmptyList => Nel}
 import docspell.query.ItemQuery._
 import minitest._
 import docspell.query.Date
+import java.time.Period
 
 object SimpleExprParserTest extends SimpleTestSuite {
 
@@ -39,8 +40,8 @@ object SimpleExprParserTest extends SimpleTestSuite {
   test("date expr") {
     val p = SimpleExprParser.dateExpr
     assertEquals(
-      p.parseAll("due:2021-03-14"),
-      Right(dateExpr(Operator.Like, Attr.DueDate, ld(2021, 3, 14)))
+      p.parseAll("date:2021-03-14"),
+      Right(dateExpr(Operator.Like, Attr.Date, ld(2021, 3, 14)))
     )
     assertEquals(
       p.parseAll("due<2021-03-14"),
@@ -50,6 +51,28 @@ object SimpleExprParserTest extends SimpleTestSuite {
       p.parseAll("due~=2021-03-14,2021-03-13"),
       Right(Expr.InDateExpr(Attr.DueDate, Nel.of(ld(2021, 3, 14), ld(2021, 3, 13))))
     )
+    assertEquals(
+      p.parseAll("due>2021"),
+      Right(dateExpr(Operator.Gt, Attr.DueDate, ld(2021, 1, 1)))
+    )
+    assertEquals(
+      p.parseAll("date<2021-01"),
+      Right(dateExpr(Operator.Lt, Attr.Date, ld(2021, 1, 1)))
+    )
+    assertEquals(
+      p.parseAll("date<today"),
+      Right(dateExpr(Operator.Lt, Attr.Date, Date.Today))
+    )
+    assertEquals(
+      p.parseAll("date>today;-2m"),
+      Right(
+        dateExpr(
+          Operator.Gt,
+          Attr.Date,
+          Date.Calc(Date.Today, Date.CalcDirection.Minus, Period.ofMonths(2))
+        )
+      )
+    )
   }
 
   test("exists expr") {
diff --git a/modules/restserver/src/main/resources/logback.xml b/modules/restserver/src/main/resources/logback.xml
index f9b2d921..d972abcb 100644
--- a/modules/restserver/src/main/resources/logback.xml
+++ b/modules/restserver/src/main/resources/logback.xml
@@ -9,6 +9,7 @@
 
   <logger name="docspell" level="debug" />
   <logger name="emil" level="debug"/>
+  <logger name="docspell.store.queries.QItem" level="trace"/>
 
   <root level="INFO">
     <appender-ref ref="STDOUT" />
diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
index 2e2b3233..173e8399 100644
--- a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
+++ b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
@@ -1,6 +1,7 @@
 package docspell.store.qb.generator
 
-import java.time.{Instant, LocalDate}
+import java.time.Instant
+import java.time.LocalDate
 
 import cats.data.NonEmptyList
 
@@ -9,27 +10,27 @@ import docspell.query.ItemQuery._
 import docspell.query.{Date, ItemQuery}
 import docspell.store.qb.DSL._
 import docspell.store.qb.{Operator => QOp, _}
+import docspell.store.queries.QueryWildcard
 import docspell.store.records.{RCustomField, RCustomFieldValue, TagItemName}
 
 import doobie.util.Put
-import docspell.store.queries.QueryWildcard
 
 object ItemQueryGenerator {
 
-  def apply(tables: Tables, coll: Ident)(q: ItemQuery)(implicit
+  def apply(today: LocalDate, tables: Tables, coll: Ident)(q: ItemQuery)(implicit
       PT: Put[Timestamp]
   ): Condition =
-    fromExpr(tables, coll)(q.expr)
+    fromExpr(today, tables, coll)(q.expr)
 
-  final def fromExpr(tables: Tables, coll: Ident)(
+  final def fromExpr(today: LocalDate, tables: Tables, coll: Ident)(
       expr: Expr
   )(implicit PT: Put[Timestamp]): Condition =
     expr match {
       case Expr.AndExpr(inner) =>
-        Condition.And(inner.map(fromExpr(tables, coll)))
+        Condition.And(inner.map(fromExpr(today, tables, coll)))
 
       case Expr.OrExpr(inner) =>
-        Condition.Or(inner.map(fromExpr(tables, coll)))
+        Condition.Or(inner.map(fromExpr(today, tables, coll)))
 
       case Expr.NotExpr(inner) =>
         inner match {
@@ -71,7 +72,7 @@ object ItemQueryGenerator {
             Condition.unit
 
           case _ =>
-            Condition.Not(fromExpr(tables, coll)(inner))
+            Condition.Not(fromExpr(today, tables, coll)(inner))
         }
 
       case Expr.Exists(field) =>
@@ -87,9 +88,10 @@ object ItemQueryGenerator {
         }
 
       case Expr.SimpleExpr(op, Property.DateProperty(attr, value)) =>
-        val dt  = dateToTimestamp(value)
-        val col = timestampColumn(tables)(attr)
-        Condition.CompareVal(col, makeOp(op), dt)
+        val dt       = dateToTimestamp(today)(value)
+        val col      = timestampColumn(tables)(attr)
+        val noLikeOp = if (op == Operator.Like) Operator.Eq else op
+        Condition.CompareVal(col, makeOp(noLikeOp), dt)
 
       case Expr.InExpr(attr, values) =>
         val col = stringColumn(tables)(attr)
@@ -98,10 +100,18 @@ object ItemQueryGenerator {
 
       case Expr.InDateExpr(attr, values) =>
         val col = timestampColumn(tables)(attr)
-        val dts = values.map(dateToTimestamp)
+        val dts = values.map(dateToTimestamp(today))
         if (values.tail.isEmpty) col === dts.head
         else col.in(dts)
 
+      case Expr.DirectionExpr(incoming) =>
+        if (incoming) tables.item.incoming === Direction.Incoming
+        else tables.item.incoming === Direction.Outgoing
+
+      case Expr.InboxExpr(flag) =>
+        if (flag) tables.item.state === ItemState.created
+        else tables.item.state === ItemState.confirmed
+
       case Expr.TagIdsMatch(op, tags) =>
         val ids = tags.toList.flatMap(s => Ident.fromString(s).toOption)
         NonEmptyList
@@ -142,12 +152,31 @@ object ItemQueryGenerator {
         Condition.unit
     }
 
-  private def dateToTimestamp(date: Date): Timestamp =
+  private def dateToTimestamp(today: LocalDate)(date: Date): Timestamp =
     date match {
-      case Date.Local(year, month, day) =>
-        Timestamp.atUtc(LocalDate.of(year, month, day).atStartOfDay())
+      case d: Date.DateLiteral =>
+        val ld = dateLiteralToDate(today)(d)
+        println(s">>>> date= $ld")
+        Timestamp.atUtc(ld.atStartOfDay)
+      case Date.Calc(date, c, period) =>
+        val ld = c match {
+          case Date.CalcDirection.Plus =>
+            dateLiteralToDate(today)(date).plus(period)
+          case Date.CalcDirection.Minus =>
+            dateLiteralToDate(today)(date).minus(period)
+        }
+        println(s">>>> date= $ld")
+        Timestamp.atUtc(ld.atStartOfDay())
+    }
+
+  private def dateLiteralToDate(today: LocalDate)(dateLit: Date.DateLiteral): LocalDate =
+    dateLit match {
+      case Date.Local(date) =>
+        date
       case Date.Millis(ms) =>
-        Timestamp(Instant.ofEpochMilli(ms))
+        Instant.ofEpochMilli(ms).atZone(Timestamp.UTC).toLocalDate()
+      case Date.Today =>
+        today
     }
 
   private def anyColumn(tables: Tables)(attr: Attr): Column[_] =
@@ -171,6 +200,7 @@ object ItemQueryGenerator {
       case Attr.ItemId                   => tables.item.id.cast[String]
       case Attr.ItemName                 => tables.item.name
       case Attr.ItemSource               => tables.item.source
+      case Attr.ItemNotes                => tables.item.notes
       case Attr.Correspondent.OrgId      => tables.corrOrg.oid.cast[String]
       case Attr.Correspondent.OrgName    => tables.corrOrg.name
       case Attr.Correspondent.PersonId   => tables.corrPers.pid.cast[String]
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 7461cc8b..a79db262 100644
--- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala
+++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala
@@ -1,5 +1,7 @@
 package docspell.store.queries
 
+import java.time.LocalDate
+
 import cats.data.{NonEmptyList => Nel}
 import cats.effect.Sync
 import cats.effect.concurrent.Ref
@@ -226,41 +228,42 @@ object QItem {
       findCustomFieldValuesForColl(coll, q.customValues)
         .map(itemIds => i.id.in(itemIds))
 
-  def queryCondFromExpr(coll: Ident, q: ItemQuery): Condition = {
+  def queryCondFromExpr(today: LocalDate, coll: Ident, q: ItemQuery): Condition = {
     val tables = Tables(i, org, pers0, pers1, equip, f, a, m)
-    ItemQueryGenerator.fromExpr(tables, coll)(q.expr)
+    ItemQueryGenerator.fromExpr(today, tables, coll)(q.expr)
   }
 
-  def queryCondition(coll: Ident, cond: Query.QueryCond): Condition =
+  def queryCondition(today: LocalDate, coll: Ident, cond: Query.QueryCond): Condition =
     cond match {
       case fm: Query.QueryForm =>
         queryCondFromForm(coll, fm)
       case expr: Query.QueryExpr =>
-        queryCondFromExpr(coll, expr.q)
+        queryCondFromExpr(today, coll, expr.q)
     }
 
   def findItems(
       q: Query,
+      today: LocalDate,
       maxNoteLen: Int,
       batch: Batch
   ): Stream[ConnectionIO, ListItem] = {
     val sql = findItemsBase(q.fix, maxNoteLen)
-      .changeWhere(c => c && queryCondition(q.fix.account.collective, q.cond))
+      .changeWhere(c => c && queryCondition(today, q.fix.account.collective, q.cond))
       .limit(batch)
       .build
     logger.trace(s"List $batch items: $sql")
     sql.query[ListItem].stream
   }
 
-  def searchStats(q: Query): ConnectionIO[SearchSummary] =
+  def searchStats(today: LocalDate)(q: Query): ConnectionIO[SearchSummary] =
     for {
-      count   <- searchCountSummary(q)
-      tags    <- searchTagSummary(q)
-      fields  <- searchFieldSummary(q)
-      folders <- searchFolderSummary(q)
+      count   <- searchCountSummary(today)(q)
+      tags    <- searchTagSummary(today)(q)
+      fields  <- searchFieldSummary(today)(q)
+      folders <- searchFolderSummary(today)(q)
     } yield SearchSummary(count, tags, fields, folders)
 
-  def searchTagSummary(q: Query): ConnectionIO[List[TagCount]] = {
+  def searchTagSummary(today: LocalDate)(q: Query): ConnectionIO[List[TagCount]] = {
     val tagFrom =
       from(ti)
         .innerJoin(tag, tag.tid === ti.tagId)
@@ -270,7 +273,7 @@ object QItem {
       findItemsBase(q.fix, 0).unwrap
         .withSelect(select(tag.all).append(count(i.id).as("num")))
         .changeFrom(_.prepend(tagFrom))
-        .changeWhere(c => c && queryCondition(q.fix.account.collective, q.cond))
+        .changeWhere(c => c && queryCondition(today, q.fix.account.collective, q.cond))
         .groupBy(tag.tid)
         .build
         .query[TagCount]
@@ -284,27 +287,27 @@ object QItem {
     } yield existing ++ other.map(TagCount(_, 0))
   }
 
-  def searchCountSummary(q: Query): ConnectionIO[Int] =
+  def searchCountSummary(today: LocalDate)(q: Query): ConnectionIO[Int] =
     findItemsBase(q.fix, 0).unwrap
       .withSelect(Nel.of(count(i.id).as("num")))
-      .changeWhere(c => c && queryCondition(q.fix.account.collective, q.cond))
+      .changeWhere(c => c && queryCondition(today, q.fix.account.collective, q.cond))
       .build
       .query[Int]
       .unique
 
-  def searchFolderSummary(q: Query): ConnectionIO[List[FolderCount]] = {
+  def searchFolderSummary(today: LocalDate)(q: Query): ConnectionIO[List[FolderCount]] = {
     val fu = RUser.as("fu")
     findItemsBase(q.fix, 0).unwrap
       .withSelect(select(f.id, f.name, f.owner, fu.login).append(count(i.id).as("num")))
       .changeFrom(_.innerJoin(fu, fu.uid === f.owner))
-      .changeWhere(c => c && queryCondition(q.fix.account.collective, q.cond))
+      .changeWhere(c => c && queryCondition(today, q.fix.account.collective, q.cond))
       .groupBy(f.id, f.name, f.owner, fu.login)
       .build
       .query[FolderCount]
       .to[List]
   }
 
-  def searchFieldSummary(q: Query): ConnectionIO[List[FieldStats]] = {
+  def searchFieldSummary(today: LocalDate)(q: Query): ConnectionIO[List[FieldStats]] = {
     val fieldJoin =
       from(cv)
         .innerJoin(cf, cf.id === cv.field)
@@ -313,7 +316,7 @@ object QItem {
     val base =
       findItemsBase(q.fix, 0).unwrap
         .changeFrom(_.prepend(fieldJoin))
-        .changeWhere(c => c && queryCondition(q.fix.account.collective, q.cond))
+        .changeWhere(c => c && queryCondition(today, q.fix.account.collective, q.cond))
         .groupBy(GroupBy(cf.all))
 
     val basicFields = Nel.of(
diff --git a/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala b/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala
index 4bb5c57f..e2358f94 100644
--- a/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala
+++ b/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala
@@ -22,11 +22,12 @@ object ItemQueryGeneratorTest extends SimpleTestSuite {
     RAttachment.as("a"),
     RAttachmentMeta.as("m")
   )
+  val now: LocalDate = LocalDate.of(2021, 2, 25)
 
   test("basic test") {
     val q = ItemQueryParser
       .parseUnsafe("(& name:hello date>=2020-02-01 (| source=expense folder=test ))")
-    val cond = ItemQueryGenerator(tables, Ident.unsafe("coll"))(q)
+    val cond = ItemQueryGenerator(now, tables, Ident.unsafe("coll"))(q)
     val expect =
       tables.item.name.like("hello") && tables.item.itemDate >= Timestamp.atUtc(
         LocalDate.of(2020, 2, 1).atStartOfDay()
@@ -35,29 +36,4 @@ object ItemQueryGeneratorTest extends SimpleTestSuite {
     assertEquals(cond, expect)
   }
 
-//  test("migration2") {
-//    withStore("db2") { store =>
-//      val c = RCollective(
-//        Ident.unsafe("coll1"),
-//        CollectiveState.Active,
-//        Language.German,
-//        true,
-//        Timestamp.Epoch
-//      )
-//      val e =
-//        REquipment(
-//          Ident.unsafe("equip"),
-//          Ident.unsafe("coll1"),
-//          "name",
-//          Timestamp.Epoch,
-//          Timestamp.Epoch,
-//          None
-//        )
-//
-//      for {
-//        _ <- store.transact(RCollective.insert(c))
-//        _ <- store.transact(REquipment.insert(e)).map(_ => ())
-//      } yield ()
-//    }
-//  }
 }
diff --git a/project/Dependencies.scala b/project/Dependencies.scala
index dac2bb0e..d737e1c9 100644
--- a/project/Dependencies.scala
+++ b/project/Dependencies.scala
@@ -33,6 +33,7 @@ object Dependencies {
   val PoiVersion              = "4.1.2"
   val PostgresVersion         = "42.2.19"
   val PureConfigVersion       = "0.14.1"
+  val ScalaJavaTimeVersion    = "2.2.0"
   val Slf4jVersion            = "1.7.30"
   val StanfordNlpVersion      = "4.2.0"
   val TikaVersion             = "1.25"
@@ -54,6 +55,9 @@ object Dependencies {
 
   val catsJS = Def.setting("org.typelevel" %%% "cats-core" % "2.4.2")
 
+  val scalaJavaTime =
+    Def.setting("io.github.cquiroz" %%% "scala-java-time" % ScalaJavaTimeVersion)
+
   val kittens = Seq(
     "org.typelevel" %% "kittens" % KittensVersion
   )

From d737da768e80379f3d64853f0ea096d2f869f619 Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Sun, 28 Feb 2021 21:15:21 +0100
Subject: [PATCH 09/33] Move to munit in query module

---
 build.sbt                                     |  8 ++++++-
 .../query/internal/AttrParserTest.scala       |  4 ++--
 .../query/internal/BasicParserTest.scala      |  4 ++--
 .../query/internal/DateParserTest.scala       | 13 ++--------
 .../query/internal/ExprParserTest.scala       |  5 ++--
 .../query/internal/ItemQueryParserTest.scala  |  4 ++--
 .../query/internal/MacroParserTest.scala      |  4 ++--
 .../query/internal/OperatorParserTest.scala   |  4 ++--
 .../query/internal/SimpleExprParserTest.scala | 12 ++--------
 .../docspell/query/internal/ValueHelper.scala | 24 +++++++++++++++++++
 .../qb/generator/ItemQueryGenerator.scala     |  2 --
 .../generator/ItemQueryGeneratorTest.scala    | 11 +++++----
 project/Dependencies.scala                    |  6 +++++
 13 files changed, 60 insertions(+), 41 deletions(-)
 create mode 100644 modules/query/src/test/scala/docspell/query/internal/ValueHelper.scala

diff --git a/build.sbt b/build.sbt
index 884234f3..1c4c170b 100644
--- a/build.sbt
+++ b/build.sbt
@@ -51,6 +51,11 @@ val testSettings = Seq(
   Test / fork := true
 )
 
+val testSettingsMUnit = Seq(
+  libraryDependencies ++= Dependencies.munit.map(_ % Test),
+  testFrameworks += new TestFramework("munit.Framework")
+)
+
 lazy val noPublish = Seq(
   publish := {},
   publishLocal := {},
@@ -267,11 +272,12 @@ ${lines.map(_._1).mkString(",\n")}
 
 val query =
   crossProject(JSPlatform, JVMPlatform)
+    .withoutSuffixFor(JVMPlatform)
     .crossType(CrossType.Pure)
     .in(file("modules/query"))
     .disablePlugins(RevolverPlugin)
     .settings(sharedSettings)
-    .settings(testSettings)
+    .settings(testSettingsMUnit)
     .settings(
       name := "docspell-query",
       libraryDependencies +=
diff --git a/modules/query/src/test/scala/docspell/query/internal/AttrParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/AttrParserTest.scala
index 0634c83e..4c6dce3c 100644
--- a/modules/query/src/test/scala/docspell/query/internal/AttrParserTest.scala
+++ b/modules/query/src/test/scala/docspell/query/internal/AttrParserTest.scala
@@ -2,9 +2,9 @@ package docspell.query.internal
 
 import docspell.query.ItemQuery.Attr
 import docspell.query.internal.AttrParser
-import minitest._
+import munit._
 
-object AttrParserTest extends SimpleTestSuite {
+class AttrParserTest extends FunSuite {
 
   test("string attributes") {
     val p = AttrParser.stringAttr
diff --git a/modules/query/src/test/scala/docspell/query/internal/BasicParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/BasicParserTest.scala
index c272298a..e397ce9b 100644
--- a/modules/query/src/test/scala/docspell/query/internal/BasicParserTest.scala
+++ b/modules/query/src/test/scala/docspell/query/internal/BasicParserTest.scala
@@ -1,10 +1,10 @@
 package docspell.query.internal
 
-import minitest._
+import munit._
 import cats.data.{NonEmptyList => Nel}
 import docspell.query.internal.BasicParser
 
-object BasicParserTest extends SimpleTestSuite {
+class BasicParserTest extends FunSuite {
   test("single string values") {
     val p = BasicParser.singleString
     assertEquals(p.parseAll("abcde"), Right("abcde"))
diff --git a/modules/query/src/test/scala/docspell/query/internal/DateParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/DateParserTest.scala
index ff538363..ec836ced 100644
--- a/modules/query/src/test/scala/docspell/query/internal/DateParserTest.scala
+++ b/modules/query/src/test/scala/docspell/query/internal/DateParserTest.scala
@@ -1,19 +1,10 @@
 package docspell.query.internal
 
-import minitest._
+import munit._
 import docspell.query.Date
 import java.time.Period
 
-object DateParserTest extends SimpleTestSuite {
-
-  def ld(year: Int, m: Int, d: Int): Date.DateLiteral =
-    Date(year, m, d).fold(throw _, identity)
-
-  def ldPlus(year: Int, m: Int, d: Int, p: Period): Date.Calc =
-    Date.Calc(ld(year, m, d), Date.CalcDirection.Plus, p)
-
-  def ldMinus(year: Int, m: Int, d: Int, p: Period): Date.Calc =
-    Date.Calc(ld(year, m, d), Date.CalcDirection.Minus, p)
+class DateParserTest extends FunSuite with ValueHelper {
 
   test("local date string") {
     val p = DateParser.dateFromString
diff --git a/modules/query/src/test/scala/docspell/query/internal/ExprParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/ExprParserTest.scala
index f918e361..6f0fe07f 100644
--- a/modules/query/src/test/scala/docspell/query/internal/ExprParserTest.scala
+++ b/modules/query/src/test/scala/docspell/query/internal/ExprParserTest.scala
@@ -1,11 +1,10 @@
 package docspell.query.internal
 
 import docspell.query.ItemQuery._
-import docspell.query.internal.SimpleExprParserTest.stringExpr
-import minitest._
+import munit._
 import cats.data.{NonEmptyList => Nel}
 
-object ExprParserTest extends SimpleTestSuite {
+class ExprParserTest extends FunSuite with ValueHelper {
 
   test("simple expr") {
     val p = ExprParser.exprParser
diff --git a/modules/query/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala
index 4074748c..61dfdf86 100644
--- a/modules/query/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala
+++ b/modules/query/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala
@@ -1,10 +1,10 @@
 package docspell.query.internal
 
-import minitest._
+import munit._
 import docspell.query.ItemQueryParser
 import docspell.query.ItemQuery
 
-object ItemQueryParserTest extends SimpleTestSuite {
+class ItemQueryParserTest extends FunSuite {
 
   test("reduce ands") {
     val q    = ItemQueryParser.parseUnsafe("(&(&(&(& name:hello))))")
diff --git a/modules/query/src/test/scala/docspell/query/internal/MacroParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/MacroParserTest.scala
index 0836884a..045d1120 100644
--- a/modules/query/src/test/scala/docspell/query/internal/MacroParserTest.scala
+++ b/modules/query/src/test/scala/docspell/query/internal/MacroParserTest.scala
@@ -1,9 +1,9 @@
 package docspell.query.internal
 
-import minitest._
+import munit._
 import cats.parse.{Parser => P}
 
-object MacroParserTest extends SimpleTestSuite {
+class MacroParserTest extends FunSuite {
 
   test("fail with unkown macro names") {
     val p = MacroParser.parser(Map.empty)
diff --git a/modules/query/src/test/scala/docspell/query/internal/OperatorParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/OperatorParserTest.scala
index b451289c..1a5a8af0 100644
--- a/modules/query/src/test/scala/docspell/query/internal/OperatorParserTest.scala
+++ b/modules/query/src/test/scala/docspell/query/internal/OperatorParserTest.scala
@@ -1,10 +1,10 @@
 package docspell.query.internal
 
-import minitest._
+import munit._
 import docspell.query.ItemQuery.{Operator, TagOperator}
 import docspell.query.internal.OperatorParser
 
-object OperatorParserTest extends SimpleTestSuite {
+class OperatorParserTest extends FunSuite {
   test("operator values") {
     val p = OperatorParser.op
     assertEquals(p.parseAll("="), Right(Operator.Eq))
diff --git a/modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala
index 9f79aa51..32c152a0 100644
--- a/modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala
+++ b/modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala
@@ -2,11 +2,11 @@ package docspell.query.internal
 
 import cats.data.{NonEmptyList => Nel}
 import docspell.query.ItemQuery._
-import minitest._
+import munit._
 import docspell.query.Date
 import java.time.Period
 
-object SimpleExprParserTest extends SimpleTestSuite {
+class SimpleExprParserTest extends FunSuite with ValueHelper {
 
   test("string expr") {
     val p = SimpleExprParser.stringExpr
@@ -175,12 +175,4 @@ object SimpleExprParserTest extends SimpleTestSuite {
     )
   }
 
-  def ld(y: Int, m: Int, d: Int) =
-    DateParserTest.ld(y, m, d)
-
-  def stringExpr(op: Operator, name: Attr.StringAttr, value: String): Expr.SimpleExpr =
-    Expr.SimpleExpr(op, Property.StringProperty(name, value))
-
-  def dateExpr(op: Operator, name: Attr.DateAttr, value: Date): Expr.SimpleExpr =
-    Expr.SimpleExpr(op, Property.DateProperty(name, value))
 }
diff --git a/modules/query/src/test/scala/docspell/query/internal/ValueHelper.scala b/modules/query/src/test/scala/docspell/query/internal/ValueHelper.scala
new file mode 100644
index 00000000..f2729f18
--- /dev/null
+++ b/modules/query/src/test/scala/docspell/query/internal/ValueHelper.scala
@@ -0,0 +1,24 @@
+package docspell.query.internal
+
+import docspell.query.Date
+import docspell.query.ItemQuery._
+import java.time.Period
+
+trait ValueHelper {
+
+  def ld(year: Int, m: Int, d: Int): Date.DateLiteral =
+    Date(year, m, d).fold(throw _, identity)
+
+  def ldPlus(year: Int, m: Int, d: Int, p: Period): Date.Calc =
+    Date.Calc(ld(year, m, d), Date.CalcDirection.Plus, p)
+
+  def ldMinus(year: Int, m: Int, d: Int, p: Period): Date.Calc =
+    Date.Calc(ld(year, m, d), Date.CalcDirection.Minus, p)
+
+  def stringExpr(op: Operator, name: Attr.StringAttr, value: String): Expr.SimpleExpr =
+    Expr.SimpleExpr(op, Property.StringProperty(name, value))
+
+  def dateExpr(op: Operator, name: Attr.DateAttr, value: Date): Expr.SimpleExpr =
+    Expr.SimpleExpr(op, Property.DateProperty(name, value))
+
+}
diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
index 173e8399..d02e9660 100644
--- a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
+++ b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
@@ -156,7 +156,6 @@ object ItemQueryGenerator {
     date match {
       case d: Date.DateLiteral =>
         val ld = dateLiteralToDate(today)(d)
-        println(s">>>> date= $ld")
         Timestamp.atUtc(ld.atStartOfDay)
       case Date.Calc(date, c, period) =>
         val ld = c match {
@@ -165,7 +164,6 @@ object ItemQueryGenerator {
           case Date.CalcDirection.Minus =>
             dateLiteralToDate(today)(date).minus(period)
         }
-        println(s">>>> date= $ld")
         Timestamp.atUtc(ld.atStartOfDay())
     }
 
diff --git a/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala b/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala
index e2358f94..3d9e5b2e 100644
--- a/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala
+++ b/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala
@@ -24,14 +24,17 @@ object ItemQueryGeneratorTest extends SimpleTestSuite {
   )
   val now: LocalDate = LocalDate.of(2021, 2, 25)
 
+  def mkTimestamp(year: Int, month: Int, day: Int): Timestamp =
+    Timestamp.atUtc(LocalDate.of(year, month, day).atStartOfDay())
+
   test("basic test") {
     val q = ItemQueryParser
-      .parseUnsafe("(& name:hello date>=2020-02-01 (| source=expense folder=test ))")
+      .parseUnsafe("(& name:hello date>=2020-02-01 (| source:expense* folder=test ))")
     val cond = ItemQueryGenerator(now, tables, Ident.unsafe("coll"))(q)
     val expect =
-      tables.item.name.like("hello") && tables.item.itemDate >= Timestamp.atUtc(
-        LocalDate.of(2020, 2, 1).atStartOfDay()
-      ) && (tables.item.source === "expense" || tables.folder.name === "test")
+      tables.item.name.like("hello") &&
+        tables.item.itemDate >= mkTimestamp(2020, 2, 1) &&
+        (tables.item.source.like("expense%") || tables.folder.name === "test")
 
     assertEquals(cond, expect)
   }
diff --git a/project/Dependencies.scala b/project/Dependencies.scala
index d737e1c9..8197c338 100644
--- a/project/Dependencies.scala
+++ b/project/Dependencies.scala
@@ -28,6 +28,7 @@ object Dependencies {
   val LogbackVersion          = "1.2.3"
   val MariaDbVersion          = "2.7.2"
   val MiniTestVersion         = "2.9.3"
+  val MUnitVersion            = "0.7.22"
   val OrganizeImportsVersion  = "0.5.0"
   val PdfboxVersion           = "2.0.22"
   val PoiVersion              = "4.1.2"
@@ -271,6 +272,11 @@ object Dependencies {
     "io.monix" %% "minitest-laws" % MiniTestVersion
   ).map(_ % Test)
 
+  val munit = Seq(
+    "org.scalameta" %% "munit" % MUnitVersion,
+    "org.scalameta" %% "munit-scalacheck" % MUnitVersion
+  )
+
   val kindProjectorPlugin = "org.typelevel" %% "kind-projector"     % KindProjectorVersion
   val betterMonadicFor    = "com.olegpy"    %% "better-monadic-for" % BetterMonadicForVersion
 

From e079ec198788bf76007691255e4b4395f737d09e Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Sun, 28 Feb 2021 22:36:48 +0100
Subject: [PATCH 10/33] Provide custom error structure for parse failures

---
 .../docspell/query/ItemQueryParser.scala      |  7 +-
 .../scala/docspell/query/ParseFailure.scala   | 65 +++++++++++++++++++
 .../restserver/routes/ItemRoutes.scala        |  2 +-
 3 files changed, 70 insertions(+), 4 deletions(-)
 create mode 100644 modules/query/src/main/scala/docspell/query/ParseFailure.scala

diff --git a/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala b/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala
index cea6c09e..cf9b491c 100644
--- a/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala
+++ b/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala
@@ -9,17 +9,18 @@ import docspell.query.internal.ExprUtil
 object ItemQueryParser {
 
   @JSExport
-  def parse(input: String): Either[String, ItemQuery] =
+  def parse(input: String): Either[ParseFailure, ItemQuery] =
     if (input.isEmpty) Right(ItemQuery.all)
     else {
       val in = if (input.charAt(0) == '(') input else s"(& $input )"
       ExprParser
         .parseQuery(in)
         .left
-        .map(pe => s"Error parsing: '$input': $pe") //TODO
+        .map(ParseFailure.fromError(in))
         .map(q => q.copy(expr = ExprUtil.reduce(q.expr)))
     }
 
   def parseUnsafe(input: String): ItemQuery =
-    parse(input).fold(sys.error, identity)
+    parse(input).fold(m => sys.error(m.render), identity)
+
 }
diff --git a/modules/query/src/main/scala/docspell/query/ParseFailure.scala b/modules/query/src/main/scala/docspell/query/ParseFailure.scala
new file mode 100644
index 00000000..05235c03
--- /dev/null
+++ b/modules/query/src/main/scala/docspell/query/ParseFailure.scala
@@ -0,0 +1,65 @@
+package docspell.query
+
+import cats.data.{NonEmptyList => Nel}
+import cats.parse.Parser
+import cats.parse.Parser.Expectation.EndOfString
+import cats.parse.Parser.Expectation.ExpectedFailureAt
+import cats.parse.Parser.Expectation.Fail
+import cats.parse.Parser.Expectation.FailWith
+import cats.parse.Parser.Expectation.InRange
+import cats.parse.Parser.Expectation.Length
+import cats.parse.Parser.Expectation.OneOfStr
+import cats.parse.Parser.Expectation.StartOfString
+
+final case class ParseFailure(
+    input: String,
+    failedAt: Int,
+    messages: Nel[ParseFailure.Message]
+) {
+
+  def render: String = {
+    val items = messages.map(_.msg).toList.mkString(", ")
+    s"Failed to read input near $failedAt: $input\nDetails: $items"
+  }
+}
+
+object ParseFailure {
+
+  final case class Message(offset: Int, msg: String)
+
+  private[query] def fromError(input: String)(pe: Parser.Error): ParseFailure =
+    ParseFailure(
+      input,
+      pe.failedAtOffset,
+      Parser.Expectation.unify(pe.expected).map(expectationToMsg)
+    )
+
+  private[query] def expectationToMsg(e: Parser.Expectation): Message =
+    e match {
+      case StartOfString(offset) =>
+        Message(offset, "Expected start of string")
+
+      case FailWith(offset, message) =>
+        Message(offset, message)
+
+      case InRange(offset, lower, upper) =>
+        if (lower == upper) Message(offset, s"Expected character: $lower")
+        else Message(offset, s"Expected character from range: [$lower .. $upper]")
+
+      case Length(offset, expected, actual) =>
+        Message(offset, s"Expected input of length $expected, but got $actual")
+
+      case ExpectedFailureAt(offset, matched) =>
+        Message(offset, s"Expected failing, but matched '$matched'")
+
+      case EndOfString(offset, length) =>
+        Message(offset, s"Expected end of string at length: $length")
+
+      case Fail(offset) =>
+        Message(offset, s"Failed to parse near $offset")
+
+      case OneOfStr(offset, strs) =>
+        val options = strs.mkString(", ")
+        Message(offset, s"Expected one of the following strings: $options")
+    }
+}
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 510273a0..e93e4e95 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala
@@ -61,7 +61,7 @@ object ItemRoutes {
         val of = offset.getOrElse(0)
         query match {
           case Left(err) =>
-            BadRequest(BasicResult(false, err))
+            BadRequest(BasicResult(false, err.render))
           case Right(sq) =>
             for {
               items <- backend.itemSearch.findItems(cfg.maxNoteLength)(

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 11/33] 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)

From dadab0d30868b53e39ce6d75c6557d8021ac4838 Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Mon, 1 Mar 2021 12:37:25 +0100
Subject: [PATCH 12/33] Implement search by query in endpoints

---
 .../scala/docspell/backend/BackendApp.scala   |   3 +
 .../docspell/backend/ops/OSimpleSearch.scala  |  25 ++-
 .../src/main/resources/docspell-openapi.yml   | 152 +++++++++++++++---
 .../restserver/http4s/QueryParam.scala        |   7 +-
 .../restserver/routes/ItemRoutes.scala        | 125 ++++++++++----
 modules/webapp/src/main/elm/Api.elm           |  10 +-
 6 files changed, 249 insertions(+), 73 deletions(-)

diff --git a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala
index 81328296..80df397c 100644
--- a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala
+++ b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala
@@ -37,6 +37,7 @@ trait BackendApp[F[_]] {
   def userTask: OUserTask[F]
   def folder: OFolder[F]
   def customFields: OCustomFields[F]
+  def simpleSearch: OSimpleSearch[F]
 }
 
 object BackendApp {
@@ -71,6 +72,7 @@ object BackendApp {
       userTaskImpl     <- OUserTask(utStore, queue, joexImpl)
       folderImpl       <- OFolder(store)
       customFieldsImpl <- OCustomFields(store)
+      simpleSearchImpl = OSimpleSearch(fulltextImpl, itemSearchImpl)
     } yield new BackendApp[F] {
       val login        = loginImpl
       val signup       = signupImpl
@@ -90,6 +92,7 @@ object BackendApp {
       val userTask     = userTaskImpl
       val folder       = folderImpl
       val customFields = customFieldsImpl
+      val simpleSearch = simpleSearchImpl
     }
 
   def apply[F[_]: ConcurrentEffect: ContextShift](
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 d4a29a7f..8eb91c7f 100644
--- a/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala
+++ b/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala
@@ -1,29 +1,28 @@
 package docspell.backend.ops
 
+import cats.effect.Sync
 import cats.implicits._
 
+import docspell.backend.ops.OSimpleSearch._
 import docspell.common._
+import docspell.query.{ItemQueryParser, ParseFailure}
 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 search(settings: Settings)(q: Query, fulltextQuery: Option[String]): F[Items]
   def searchSummary(
-      settings: Settings
+      useFTS: Boolean
   )(q: Query, fulltextQuery: Option[String]): F[SearchSummary]
 
   def searchByString(
       settings: Settings
   )(fix: Query.Fix, q: ItemQueryString): Either[ParseFailure, F[Items]]
   def searchSummaryByString(
-      settings: Settings
+      useFTS: Boolean
   )(fix: Query.Fix, q: ItemQueryString): Either[ParseFailure, F[SearchSummary]]
 
 }
@@ -36,12 +35,6 @@ object OSimpleSearch {
       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](
@@ -118,18 +111,18 @@ object OSimpleSearch {
         .map(search(settings)(_, None)) //TODO resolve content:xyz expressions
 
     def searchSummaryByString(
-        settings: Settings
+        useFTS: Boolean
     )(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
+        .map(searchSummary(useFTS)(_, None)) //TODO resolve content:xyz expressions
 
     def searchSummary(
-        settings: Settings
+        useFTS: Boolean
     )(q: Query, fulltextQuery: Option[String]): F[SearchSummary] =
       fulltextQuery match {
-        case Some(ftq) if settings.useFTS =>
+        case Some(ftq) if useFTS =>
           if (q.isEmpty)
             fts.findIndexOnlySummary(q.fix.account, OFulltext.FtsInput(ftq))
           else
diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml
index 8623ade1..a56e911c 100644
--- a/modules/restapi/src/main/resources/docspell-openapi.yml
+++ b/modules/restapi/src/main/resources/docspell-openapi.yml
@@ -1310,16 +1310,16 @@ paths:
                 $ref: "#/components/schemas/BasicResult"
 
 
-  /sec/item/search:
+  /sec/item/searchForm:
     post:
-      tags: [ Item ]
+      tags: [ Item Search ]
       summary: Search for items.
       description: |
         Search for items given a search form. The results are grouped
         by month and are sorted by item date (newest first). Tags and
         attachments are *not* resolved. The results will always
         contain an empty list for item tags and attachments. Use
-        `/searchWithTags` to also retrieve all tags and a list of
+        `/searchFormWithTags` to also retrieve all tags and a list of
         attachments of an item.
 
         The `fulltext` field can be used to restrict the results by
@@ -1328,6 +1328,8 @@ paths:
         The customfields used in the search query are allowed to be
         specified by either field id or field name. The values may
         contain the wildcard `*` at beginning or end.
+
+        **NOTE** This is deprecated in favor for using a search query.
       security:
         - authTokenHeader: []
       requestBody:
@@ -1342,9 +1344,9 @@ paths:
             application/json:
               schema:
                 $ref: "#/components/schemas/ItemLightList"
-  /sec/item/searchWithTags:
+  /sec/item/searchFormWithTags:
     post:
-      tags: [ Item ]
+      tags: [ Item Search ]
       summary: Search for items.
       description: |
         Search for items given a search form. The results are grouped
@@ -1355,6 +1357,8 @@ paths:
 
         The `fulltext` field can be used to restrict the results by
         using full-text search in the documents contents.
+
+        **NOTE** This is deprecated in favor for using search query.
       security:
         - authTokenHeader: []
       requestBody:
@@ -1369,9 +1373,60 @@ paths:
             application/json:
               schema:
                 $ref: "#/components/schemas/ItemLightList"
+
+  /sec/item/search:
+    get:
+      tags: [ Item Search ]
+      summary: Search for items.
+      description: |
+        Search for items given a search query. The results are grouped
+        by month and are sorted by item date (newest first). Tags and
+        attachments are *not* resolved. The results will always
+        contain an empty list for item tags and attachments. Set
+        `withDetails` to `true` for retrieving all tags and a list of
+        attachments of an item.
+      security:
+        - authTokenHeader: []
+      parameters:
+        - $ref: "#/components/parameters/q"
+        - $ref: "#/components/parameters/limit"
+        - $ref: "#/components/parameters/offset"
+        - $ref: "#/components/parameters/withDetails"
+      responses:
+        200:
+          description: Ok
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/ItemLightList"
+    post:
+      tags: [ Item Search ]
+      summary: Search for items.
+      description: |
+        Search for items given a search query. The results are grouped
+        by month and are sorted by item date (newest first). Tags and
+        attachments are *not* resolved. The results will always
+        contain an empty list for item tags and attachments. Use
+        `withDetails` to also retrieve all tags and a list of
+        attachments of an item.
+      security:
+        - authTokenHeader: []
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: "#/components/schemas/ItemQuery"
+      responses:
+        200:
+          description: Ok
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/ItemLightList"
+
   /sec/item/searchIndex:
     post:
-      tags: [ Item ]
+      tags: [ Item Search ]
       summary: Search for items using full-text search only.
       description: |
         Search for items by only using the full-text search index.
@@ -1391,7 +1446,7 @@ paths:
         content:
           application/json:
             schema:
-              $ref: "#/components/schemas/ItemFtsSearch"
+              $ref: "#/components/schemas/ItemQuery"
       responses:
         200:
           description: Ok
@@ -1400,12 +1455,14 @@ paths:
               schema:
                 $ref: "#/components/schemas/ItemLightList"
 
-  /sec/item/searchStats:
+  /sec/item/searchFormStats:
     post:
-      tags: [ Item ]
+      tags: [ Item Search ]
       summary: Get basic statistics about the data of a search.
       description: |
         Takes a search query and returns a summary about the results.
+
+        **NOTE** This is deprecated in favor of using a search query.
       security:
         - authTokenHeader: []
       requestBody:
@@ -1420,6 +1477,44 @@ paths:
             application/json:
               schema:
                 $ref: "#/components/schemas/SearchStats"
+  /sec/item/searchStats:
+    post:
+      tags: [ Item Search ]
+      summary: Get basic statistics about search results.
+      description: |
+        Instead of returning the results of a query, uses it to return
+        a summary.
+      security:
+        - authTokenHeader: []
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: "#/components/schemas/ItemQuery"
+      responses:
+        200:
+          description: Ok
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/SearchStats"
+    get:
+      tags: [ Item Search ]
+      summary: Get basic statistics about search results.
+      description: |
+        Instead of returning the results of a query, uses it to return
+        a summary.
+      security:
+        - authTokenHeader: []
+      parameters:
+        - $ref: "#/components/parameters/q"
+      responses:
+        200:
+          description: Ok
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/SearchStats"
 
   /sec/item/{id}:
     get:
@@ -3777,22 +3872,12 @@ components:
           type: array
           items:
             $ref: "#/components/schemas/IdName"
-    FolderMember:
+    ItemQuery:
       description: |
-        Information to add or remove a folder member.
-      required:
-        - userId
-      properties:
-        userId:
-          type: string
-          format: ident
-    ItemFtsSearch:
-      description: |
-        Query description for a full-text only search.
+        Query description for a search. Is used for fulltext-only
+        searches and combined searches.
       required:
         - query
-        - offset
-        - limit
       properties:
         offset:
           type: integer
@@ -3804,6 +3889,9 @@ components:
             The maximum number of results to return. Note that this
             limit is a soft limit, there is some hard limit on the
             server, too.
+        withDetails:
+          type: boolean
+          default: false
         query:
           type: string
           description: |
@@ -5547,6 +5635,26 @@ components:
       required: false
       schema:
         type: string
+    limit:
+      name: limit
+      in: query
+      description: A limit for a search query
+      schema:
+        type: integer
+        format: int32
+    offset:
+      name: offset
+      in: query
+      description: A offset into the results for a search query
+      schema:
+        type: integer
+        format: int32
+    withDetails:
+      name: withDetails
+      in: query
+      description: Whether to return details to each item.
+      schema:
+        type: boolean
     name:
       name: name
       in: path
diff --git a/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala b/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala
index de4c7090..6907c2c4 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/http4s/QueryParam.scala
@@ -25,9 +25,10 @@ object QueryParam {
 
   object QueryOpt extends OptionalQueryParamDecoderMatcher[QueryString]("q")
 
-  object Query  extends OptionalQueryParamDecoderMatcher[String]("q")
-  object Limit  extends OptionalQueryParamDecoderMatcher[Int]("limit")
-  object Offset extends OptionalQueryParamDecoderMatcher[Int]("offset")
+  object Query       extends OptionalQueryParamDecoderMatcher[String]("q")
+  object Limit       extends OptionalQueryParamDecoderMatcher[Int]("limit")
+  object Offset      extends OptionalQueryParamDecoderMatcher[Int]("offset")
+  object WithDetails extends OptionalQueryParamDecoderMatcher[Boolean]("withDetails")
 
   object WithFallback extends OptionalQueryParamDecoderMatcher[Boolean]("withFallback")
 }
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 e93e4e95..9a0fd9cc 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala
@@ -10,9 +10,9 @@ import docspell.backend.auth.AuthToken
 import docspell.backend.ops.OCustomFields.{RemoveValue, SetValue}
 import docspell.backend.ops.OFulltext
 import docspell.backend.ops.OItemSearch.{Batch, Query}
+import docspell.backend.ops.OSimpleSearch
 import docspell.common._
 import docspell.common.syntax.all._
-import docspell.query.ItemQueryParser
 import docspell.restapi.model._
 import docspell.restserver.Config
 import docspell.restserver.conv.Conversions
@@ -49,30 +49,96 @@ object ItemRoutes {
 
       case GET -> Root / "search" :? QP.Query(q) :? QP.Limit(limit) :? QP.Offset(
             offset
-          ) =>
-        val query =
-          ItemQueryParser.parse(q.getOrElse("")) match {
-            case Right(q) =>
-              Right(Query(Query.Fix(user.account, None, None), Query.QueryExpr(q)))
-            case Left(err) =>
-              Left(err)
-          }
-        val li = limit.getOrElse(cfg.maxItemPageSize)
-        val of = offset.getOrElse(0)
-        query match {
-          case Left(err) =>
-            BadRequest(BasicResult(false, err.render))
-          case Right(sq) =>
-            for {
-              items <- backend.itemSearch.findItems(cfg.maxNoteLength)(
-                sq,
-                Batch(of, li).restrictLimitTo(cfg.maxItemPageSize)
+          ) :? QP.WithDetails(detailFlag) =>
+        val batch = Batch(offset.getOrElse(0), limit.getOrElse(cfg.maxItemPageSize))
+          .restrictLimitTo(cfg.maxItemPageSize)
+        val itemQuery = ItemQueryString(q)
+        val settings = OSimpleSearch.Settings(
+          batch,
+          cfg.fullTextSearch.enabled,
+          detailFlag.getOrElse(false),
+          cfg.maxNoteLength
+        )
+        val fixQuery = Query.Fix(user.account, None, None)
+        backend.simpleSearch.searchByString(settings)(fixQuery, itemQuery) match {
+          case Right(results) =>
+            val items = results.map(
+              _.fold(
+                Conversions.mkItemListFts,
+                Conversions.mkItemListWithTagsFts,
+                Conversions.mkItemList,
+                Conversions.mkItemListWithTags
               )
-              ok <- Ok(Conversions.mkItemList(items))
-            } yield ok
+            )
+            Ok(items)
+          case Left(fail) =>
+            BadRequest(BasicResult(false, fail.render))
+        }
+
+      case GET -> Root / "searchStats" :? QP.Query(q) =>
+        val itemQuery = ItemQueryString(q)
+        val fixQuery  = Query.Fix(user.account, None, None)
+        backend.simpleSearch
+          .searchSummaryByString(cfg.fullTextSearch.enabled)(fixQuery, itemQuery) match {
+          case Right(summary) =>
+            summary.flatMap(s => Ok(Conversions.mkSearchStats(s)))
+          case Left(fail) =>
+            BadRequest(BasicResult(false, fail.render))
         }
 
       case req @ POST -> Root / "search" =>
+        for {
+          userQuery <- req.as[ItemQuery]
+          batch = Batch(
+            userQuery.offset.getOrElse(0),
+            userQuery.limit.getOrElse(cfg.maxItemPageSize)
+          ).restrictLimitTo(
+            cfg.maxItemPageSize
+          )
+          itemQuery = ItemQueryString(userQuery.query)
+          settings = OSimpleSearch.Settings(
+            batch,
+            cfg.fullTextSearch.enabled,
+            userQuery.withDetails.getOrElse(false),
+            cfg.maxNoteLength
+          )
+          fixQuery = Query.Fix(user.account, None, None)
+          resp <- backend.simpleSearch
+            .searchByString(settings)(fixQuery, itemQuery) match {
+            case Right(results) =>
+              val items = results.map(
+                _.fold(
+                  Conversions.mkItemListFts,
+                  Conversions.mkItemListWithTagsFts,
+                  Conversions.mkItemList,
+                  Conversions.mkItemListWithTags
+                )
+              )
+              Ok(items)
+            case Left(fail) =>
+              BadRequest(BasicResult(false, fail.render))
+          }
+        } yield resp
+
+      case req @ POST -> Root / "searchStats" =>
+        for {
+          userQuery <- req.as[ItemQuery]
+          itemQuery = ItemQueryString(userQuery.query)
+          fixQuery  = Query.Fix(user.account, None, None)
+          resp <- backend.simpleSearch
+            .searchSummaryByString(cfg.fullTextSearch.enabled)(
+              fixQuery,
+              itemQuery
+            ) match {
+            case Right(summary) =>
+              summary.flatMap(s => Ok(Conversions.mkSearchStats(s)))
+            case Left(fail) =>
+              BadRequest(BasicResult(false, fail.render))
+          }
+        } yield resp
+
+      //DEPRECATED
+      case req @ POST -> Root / "searchForm" =>
         for {
           mask <- req.as[ItemSearch]
           _    <- logger.ftrace(s"Got search mask: $mask")
@@ -111,7 +177,8 @@ object ItemRoutes {
           }
         } yield resp
 
-      case req @ POST -> Root / "searchWithTags" =>
+      //DEPRECATED
+      case req @ POST -> Root / "searchFormWithTags" =>
         for {
           mask <- req.as[ItemSearch]
           _    <- logger.ftrace(s"Got search mask: $mask")
@@ -151,7 +218,7 @@ object ItemRoutes {
 
       case req @ POST -> Root / "searchIndex" =>
         for {
-          mask <- req.as[ItemFtsSearch]
+          mask <- req.as[ItemQuery]
           resp <- mask.query match {
             case q if q.length > 1 =>
               val ftsIn = OFulltext.FtsInput(q)
@@ -159,7 +226,10 @@ object ItemRoutes {
                 items <- backend.fulltext.findIndexOnly(cfg.maxNoteLength)(
                   ftsIn,
                   user.account,
-                  Batch(mask.offset, mask.limit).restrictLimitTo(cfg.maxItemPageSize)
+                  Batch(
+                    mask.offset.getOrElse(0),
+                    mask.limit.getOrElse(cfg.maxItemPageSize)
+                  ).restrictLimitTo(cfg.maxItemPageSize)
                 )
                 ok <- Ok(Conversions.mkItemListWithTagsFtsPlain(items))
               } yield ok
@@ -169,7 +239,8 @@ object ItemRoutes {
           }
         } yield resp
 
-      case req @ POST -> Root / "searchStats" =>
+      //DEPRECATED
+      case req @ POST -> Root / "searchFormStats" =>
         for {
           mask <- req.as[ItemSearch]
           query = Conversions.mkQuery(mask, user.account)
@@ -479,12 +550,12 @@ object ItemRoutes {
     private val itemSearchMonoid: Monoid[ItemSearch] =
       cats.derived.semiauto.monoid
 
-    def unapply(m: ItemSearch): Option[ItemFtsSearch] =
+    def unapply(m: ItemSearch): Option[ItemQuery] =
       m.fullText match {
         case Some(fq) =>
           val me = m.copy(fullText = None, offset = 0, limit = 0)
           if (itemSearchMonoid.empty == me)
-            Some(ItemFtsSearch(m.offset, m.limit, fq))
+            Some(ItemQuery(m.offset.some, m.limit.some, Some(false), fq))
           else None
         case _ =>
           None
diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm
index efc1d551..1064da36 100644
--- a/modules/webapp/src/main/elm/Api.elm
+++ b/modules/webapp/src/main/elm/Api.elm
@@ -156,10 +156,10 @@ import Api.Model.ImapSettings exposing (ImapSettings)
 import Api.Model.ImapSettingsList exposing (ImapSettingsList)
 import Api.Model.InviteResult exposing (InviteResult)
 import Api.Model.ItemDetail exposing (ItemDetail)
-import Api.Model.ItemFtsSearch exposing (ItemFtsSearch)
 import Api.Model.ItemInsights exposing (ItemInsights)
 import Api.Model.ItemLightList exposing (ItemLightList)
 import Api.Model.ItemProposals exposing (ItemProposals)
+import Api.Model.ItemQuery exposing (ItemQuery)
 import Api.Model.ItemSearch exposing (ItemSearch)
 import Api.Model.ItemUploadMeta exposing (ItemUploadMeta)
 import Api.Model.ItemsAndDate exposing (ItemsAndDate)
@@ -1684,14 +1684,14 @@ moveAttachmentBefore flags itemId data receive =
 
 itemIndexSearch :
     Flags
-    -> ItemFtsSearch
+    -> ItemQuery
     -> (Result Http.Error ItemLightList -> msg)
     -> Cmd msg
 itemIndexSearch flags query receive =
     Http2.authPost
         { url = flags.config.baseUrl ++ "/api/v1/sec/item/searchIndex"
         , account = getAccount flags
-        , body = Http.jsonBody (Api.Model.ItemFtsSearch.encode query)
+        , body = Http.jsonBody (Api.Model.ItemQuery.encode query)
         , expect = Http.expectJson receive Api.Model.ItemLightList.decoder
         }
 
@@ -1699,7 +1699,7 @@ itemIndexSearch flags query receive =
 itemSearch : Flags -> ItemSearch -> (Result Http.Error ItemLightList -> msg) -> Cmd msg
 itemSearch flags search receive =
     Http2.authPost
-        { url = flags.config.baseUrl ++ "/api/v1/sec/item/searchWithTags"
+        { url = flags.config.baseUrl ++ "/api/v1/sec/item/searchFormWithTags"
         , account = getAccount flags
         , body = Http.jsonBody (Api.Model.ItemSearch.encode search)
         , expect = Http.expectJson receive Api.Model.ItemLightList.decoder
@@ -1709,7 +1709,7 @@ itemSearch flags search receive =
 itemSearchStats : Flags -> ItemSearch -> (Result Http.Error SearchStats -> msg) -> Cmd msg
 itemSearchStats flags search receive =
     Http2.authPost
-        { url = flags.config.baseUrl ++ "/api/v1/sec/item/searchStats"
+        { url = flags.config.baseUrl ++ "/api/v1/sec/item/searchFormStats"
         , account = getAccount flags
         , body = Http.jsonBody (Api.Model.ItemSearch.encode search)
         , expect = Http.expectJson receive Api.Model.SearchStats.decoder

From 18992ee3741bcfca7159d8015b3e8883680a3a3e Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Mon, 1 Mar 2021 14:12:32 +0100
Subject: [PATCH 13/33] Deprecate search endpoints

---
 modules/restapi/src/main/resources/docspell-openapi.yml | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml
index a56e911c..951f2395 100644
--- a/modules/restapi/src/main/resources/docspell-openapi.yml
+++ b/modules/restapi/src/main/resources/docspell-openapi.yml
@@ -1314,6 +1314,7 @@ paths:
     post:
       tags: [ Item Search ]
       summary: Search for items.
+      deprecated: true
       description: |
         Search for items given a search form. The results are grouped
         by month and are sorted by item date (newest first). Tags and
@@ -1348,6 +1349,7 @@ paths:
     post:
       tags: [ Item Search ]
       summary: Search for items.
+      deprecated: true
       description: |
         Search for items given a search form. The results are grouped
         by month by default. For each item, its tags and attachments
@@ -1459,6 +1461,7 @@ paths:
     post:
       tags: [ Item Search ]
       summary: Get basic statistics about the data of a search.
+      deprecated: true
       description: |
         Takes a search query and returns a summary about the results.
 

From f8307f77c63633539f99285835721bf31c203c82 Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Mon, 1 Mar 2021 16:51:36 +0100
Subject: [PATCH 14/33] Search by field id or name

---
 .../docspell/store/qb/generator/ItemQueryGenerator.scala    | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
index d02e9660..54632acc 100644
--- a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
+++ b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
@@ -236,7 +236,11 @@ object ItemQueryGenerator {
     Select(
       select(cfv.itemId),
       from(cfv).innerJoin(cf, cf.id === cfv.field),
-      cf.cid === coll && cf.name ==== field && Condition.CompareVal(cfv.value, op, v)
+      cf.cid === coll && (cf.name ==== field || cf.id ==== field) && Condition.CompareVal(
+        cfv.value,
+        op,
+        v
+      )
     )
   }
 }

From 889e4f4fb0dfab9d1f0ea6824b76a72ff4fd3ba6 Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Mon, 1 Mar 2021 17:01:18 +0100
Subject: [PATCH 15/33] SearchMenu uses query string instead of json form

---
 modules/webapp/src/main/elm/Api.elm           |  12 +-
 .../webapp/src/main/elm/Comp/SearchMenu.elm   | 102 +++++----
 .../src/main/elm/Data/CustomFieldChange.elm   |   1 -
 .../webapp/src/main/elm/Data/ItemQuery.elm    | 202 ++++++++++++++++++
 .../webapp/src/main/elm/Page/Home/Data.elm    |   8 +-
 .../webapp/src/main/elm/Page/Home/Update.elm  |  14 +-
 .../webapp/src/main/elm/Page/Home/View.elm    |  61 ++----
 7 files changed, 305 insertions(+), 95 deletions(-)
 create mode 100644 modules/webapp/src/main/elm/Data/ItemQuery.elm

diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm
index 1064da36..30202fd6 100644
--- a/modules/webapp/src/main/elm/Api.elm
+++ b/modules/webapp/src/main/elm/Api.elm
@@ -1696,22 +1696,22 @@ itemIndexSearch flags query receive =
         }
 
 
-itemSearch : Flags -> ItemSearch -> (Result Http.Error ItemLightList -> msg) -> Cmd msg
+itemSearch : Flags -> ItemQuery -> (Result Http.Error ItemLightList -> msg) -> Cmd msg
 itemSearch flags search receive =
     Http2.authPost
-        { url = flags.config.baseUrl ++ "/api/v1/sec/item/searchFormWithTags"
+        { url = flags.config.baseUrl ++ "/api/v1/sec/item/search"
         , account = getAccount flags
-        , body = Http.jsonBody (Api.Model.ItemSearch.encode search)
+        , body = Http.jsonBody (Api.Model.ItemQuery.encode search)
         , expect = Http.expectJson receive Api.Model.ItemLightList.decoder
         }
 
 
-itemSearchStats : Flags -> ItemSearch -> (Result Http.Error SearchStats -> msg) -> Cmd msg
+itemSearchStats : Flags -> ItemQuery -> (Result Http.Error SearchStats -> msg) -> Cmd msg
 itemSearchStats flags search receive =
     Http2.authPost
-        { url = flags.config.baseUrl ++ "/api/v1/sec/item/searchFormStats"
+        { url = flags.config.baseUrl ++ "/api/v1/sec/item/searchStats"
         , account = getAccount flags
-        , body = Http.jsonBody (Api.Model.ItemSearch.encode search)
+        , body = Http.jsonBody (Api.Model.ItemQuery.encode search)
         , expect = Http.expectJson receive Api.Model.SearchStats.decoder
         }
 
diff --git a/modules/webapp/src/main/elm/Comp/SearchMenu.elm b/modules/webapp/src/main/elm/Comp/SearchMenu.elm
index 5a88fee6..1cc34890 100644
--- a/modules/webapp/src/main/elm/Comp/SearchMenu.elm
+++ b/modules/webapp/src/main/elm/Comp/SearchMenu.elm
@@ -3,7 +3,7 @@ module Comp.SearchMenu exposing
     , Msg(..)
     , NextState
     , TextSearchModel
-    , getItemSearch
+    , getItemQuery
     , init
     , isFulltextSearch
     , isNamesSearch
@@ -21,7 +21,7 @@ import Api.Model.EquipmentList exposing (EquipmentList)
 import Api.Model.FolderStats exposing (FolderStats)
 import Api.Model.IdName exposing (IdName)
 import Api.Model.ItemFieldValue exposing (ItemFieldValue)
-import Api.Model.ItemSearch exposing (ItemSearch)
+import Api.Model.ItemQuery exposing (ItemQuery)
 import Api.Model.PersonList exposing (PersonList)
 import Api.Model.ReferenceList exposing (ReferenceList)
 import Api.Model.SearchStats exposing (SearchStats)
@@ -38,6 +38,7 @@ import Data.DropdownStyle as DS
 import Data.Fields
 import Data.Flags exposing (Flags)
 import Data.Icons as Icons
+import Data.ItemQuery as Q exposing (ItemQuery)
 import Data.PersonUse
 import Data.UiSettings exposing (UiSettings)
 import DatePicker exposing (DatePicker)
@@ -234,11 +235,21 @@ getDirection model =
             Nothing
 
 
-getItemSearch : Model -> ItemSearch
-getItemSearch model =
+getItemQuery : Model -> Maybe ItemQuery
+getItemQuery model =
     let
-        e =
-            Api.Model.ItemSearch.empty
+        when flag body =
+            if flag then
+                Just body
+
+            else
+                Nothing
+
+        whenNot flag body =
+            when (not flag) body
+
+        whenNotEmpty list f =
+            whenNot (List.isEmpty list) (f list)
 
         amendWildcards s =
             if String.startsWith "\"" s && String.endsWith "\"" s then
@@ -254,35 +265,52 @@ getItemSearch model =
         textSearch =
             textSearchValue model.textSearchModel
     in
-    { e
-        | tagsInclude = model.tagSelection.includeTags |> List.map .tag |> List.map .id
-        , tagsExclude = model.tagSelection.excludeTags |> List.map .tag |> List.map .id
-        , corrPerson = Comp.Dropdown.getSelected model.corrPersonModel |> List.map .id |> List.head
-        , corrOrg = Comp.Dropdown.getSelected model.orgModel |> List.map .id |> List.head
-        , concPerson = Comp.Dropdown.getSelected model.concPersonModel |> List.map .id |> List.head
-        , concEquip = Comp.Dropdown.getSelected model.concEquipmentModel |> List.map .id |> List.head
-        , folder = model.selectedFolder |> Maybe.map .id
-        , direction =
-            Comp.Dropdown.getSelected model.directionModel
-                |> List.head
-                |> Maybe.map Data.Direction.toString
-        , inbox = model.inboxCheckbox
-        , dateFrom = model.fromDate
-        , dateUntil = model.untilDate
-        , dueDateFrom = model.fromDueDate
-        , dueDateUntil = model.untilDueDate
-        , name =
-            model.nameModel
-                |> Maybe.map amendWildcards
-        , allNames =
-            textSearch.nameSearch
-                |> Maybe.map amendWildcards
-        , fullText = textSearch.fullText
-        , tagCategoriesInclude = model.tagSelection.includeCats |> List.map .name
-        , tagCategoriesExclude = model.tagSelection.excludeCats |> List.map .name
-        , customValues = Data.CustomFieldChange.toFieldValues model.customValues
-        , source = model.sourceModel
-    }
+    Q.and
+        [ when model.inboxCheckbox (Q.Inbox True)
+        , whenNotEmpty (model.tagSelection.includeTags |> List.map (.tag >> .id))
+            (Q.TagIds Q.AllMatch)
+        , whenNotEmpty (model.tagSelection.excludeTags |> List.map (.tag >> .id))
+            (\ids -> Q.Not (Q.TagIds Q.AnyMatch ids))
+        , whenNotEmpty (model.tagSelection.includeCats |> List.map .name)
+            (Q.CatNames Q.AllMatch)
+        , whenNotEmpty (model.tagSelection.excludeCats |> List.map .name)
+            (\ids -> Q.Not <| Q.CatNames Q.AnyMatch ids)
+        , model.selectedFolder |> Maybe.map .id |> Maybe.map (Q.FolderId Q.Eq)
+        , Comp.Dropdown.getSelected model.orgModel
+            |> List.map .id
+            |> List.head
+            |> Maybe.map (Q.CorrOrgId Q.Eq)
+        , Comp.Dropdown.getSelected model.corrPersonModel
+            |> List.map .id
+            |> List.head
+            |> Maybe.map (Q.CorrPersId Q.Eq)
+        , Comp.Dropdown.getSelected model.concPersonModel
+            |> List.map .id
+            |> List.head
+            |> Maybe.map (Q.ConcPersId Q.Eq)
+        , Comp.Dropdown.getSelected model.concEquipmentModel
+            |> List.map .id
+            |> List.head
+            |> Maybe.map (Q.ConcEquipId Q.Eq)
+        , whenNotEmpty (Data.CustomFieldChange.toFieldValues model.customValues)
+            (List.map (Q.CustomField Q.Like) >> Q.And)
+        , Maybe.map (Q.DateMs Q.Gte) model.fromDate
+        , Maybe.map (Q.DateMs Q.Lte) model.untilDate
+        , Maybe.map (Q.DueDateMs Q.Gte) model.fromDueDate
+        , Maybe.map (Q.DueDateMs Q.Lte) model.untilDueDate
+        , Maybe.map (Q.Source Q.Like) model.sourceModel
+        , model.nameModel
+            |> Maybe.map amendWildcards
+            |> Maybe.map (Q.ItemName Q.Like)
+        , textSearch.nameSearch
+            |> Maybe.map amendWildcards
+            |> Maybe.map Q.AllNames
+        , Comp.Dropdown.getSelected model.directionModel
+            |> List.head
+            |> Maybe.map Q.Dir
+        , textSearch.fullText
+            |> Maybe.map Q.Contents
+        ]
 
 
 resetModel : Model -> Model
@@ -437,7 +465,7 @@ updateDrop ddm flags settings msg model =
             { model = mdp
             , cmd =
                 Cmd.batch
-                    [ Api.itemSearchStats flags Api.Model.ItemSearch.empty GetAllTagsResp
+                    [ Api.itemSearchStats flags Api.Model.ItemQuery.empty GetAllTagsResp
                     , Api.getOrgLight flags GetOrgResp
                     , Api.getEquipments flags "" GetEquipResp
                     , Api.getPersons flags "" GetPersonResp
@@ -450,7 +478,7 @@ updateDrop ddm flags settings msg model =
 
         ResetForm ->
             { model = resetModel model
-            , cmd = Api.itemSearchStats flags Api.Model.ItemSearch.empty GetAllTagsResp
+            , cmd = Api.itemSearchStats flags Api.Model.ItemQuery.empty GetAllTagsResp
             , stateChange = True
             , dragDrop = DD.DragDropData ddm Nothing
             }
diff --git a/modules/webapp/src/main/elm/Data/CustomFieldChange.elm b/modules/webapp/src/main/elm/Data/CustomFieldChange.elm
index 04f69b1c..2ee75c02 100644
--- a/modules/webapp/src/main/elm/Data/CustomFieldChange.elm
+++ b/modules/webapp/src/main/elm/Data/CustomFieldChange.elm
@@ -10,7 +10,6 @@ module Data.CustomFieldChange exposing
 
 import Api.Model.CustomField exposing (CustomField)
 import Api.Model.CustomFieldValue exposing (CustomFieldValue)
-import Api.Model.ItemFieldValue exposing (ItemFieldValue)
 import Dict exposing (Dict)
 
 
diff --git a/modules/webapp/src/main/elm/Data/ItemQuery.elm b/modules/webapp/src/main/elm/Data/ItemQuery.elm
new file mode 100644
index 00000000..ab886b3f
--- /dev/null
+++ b/modules/webapp/src/main/elm/Data/ItemQuery.elm
@@ -0,0 +1,202 @@
+module Data.ItemQuery exposing
+    ( AttrMatch(..)
+    , ItemQuery(..)
+    , TagMatch(..)
+    , and
+    , render
+    , renderMaybe
+    , request
+    )
+
+{-| Models the query language for the purpose of generating a query string.
+-}
+
+import Api.Model.CustomFieldValue exposing (CustomFieldValue)
+import Api.Model.ItemQuery as RQ
+import Data.Direction exposing (Direction)
+
+
+type TagMatch
+    = AnyMatch
+    | AllMatch
+
+
+type AttrMatch
+    = Eq
+    | Neq
+    | Lt
+    | Gt
+    | Lte
+    | Gte
+    | Like
+
+
+type ItemQuery
+    = Inbox Bool
+    | And (List ItemQuery)
+    | Or (List ItemQuery)
+    | Not ItemQuery
+    | TagIds TagMatch (List String)
+    | CatNames TagMatch (List String)
+    | FolderId AttrMatch String
+    | CorrOrgId AttrMatch String
+    | CorrPersId AttrMatch String
+    | ConcPersId AttrMatch String
+    | ConcEquipId AttrMatch String
+    | CustomField AttrMatch CustomFieldValue
+    | DateMs AttrMatch Int
+    | DueDateMs AttrMatch Int
+    | Source AttrMatch String
+    | Dir Direction
+    | ItemIdIn (List String)
+    | ItemName AttrMatch String
+    | AllNames String
+    | Contents String
+
+
+and : List (Maybe ItemQuery) -> Maybe ItemQuery
+and list =
+    case List.filterMap identity list of
+        [] ->
+            Nothing
+
+        es ->
+            Just (And es)
+
+
+request : Maybe ItemQuery -> RQ.ItemQuery
+request mq =
+    { offset = Nothing
+    , limit = Nothing
+    , withDetails = Just True
+    , query = renderMaybe mq
+    }
+
+
+renderMaybe : Maybe ItemQuery -> String
+renderMaybe mq =
+    Maybe.map render mq
+        |> Maybe.withDefault ""
+
+
+render : ItemQuery -> String
+render q =
+    let
+        boolStr flag =
+            if flag then
+                "yes"
+
+            else
+                "no"
+
+        between left right str =
+            left ++ str ++ right
+
+        surround lr str =
+            between lr lr str
+
+        tagMatchStr tm =
+            case tm of
+                AnyMatch ->
+                    ":"
+
+                AllMatch ->
+                    "="
+
+        quoteStr =
+            --TODO escape quotes
+            surround "\""
+    in
+    case q of
+        And inner ->
+            List.map render inner
+                |> String.join " "
+                |> between "(& " " )"
+
+        Or inner ->
+            List.map render inner
+                |> String.join " "
+                |> between "(| " " )"
+
+        Not inner ->
+            "!" ++ render inner
+
+        Inbox flag ->
+            "inbox:" ++ boolStr flag
+
+        TagIds m ids ->
+            List.map quoteStr ids
+                |> String.join ","
+                |> between ("tag.id" ++ tagMatchStr m) ""
+
+        CatNames m ids ->
+            List.map quoteStr ids
+                |> String.join ","
+                |> between ("cat" ++ tagMatchStr m) ""
+
+        FolderId m id ->
+            "folder.id" ++ attrMatch m ++ quoteStr id
+
+        CorrOrgId m id ->
+            "correspondent.org.id" ++ attrMatch m ++ quoteStr id
+
+        CorrPersId m id ->
+            "correspondent.person.id" ++ attrMatch m ++ quoteStr id
+
+        ConcPersId m id ->
+            "concerning.person.id" ++ attrMatch m ++ quoteStr id
+
+        ConcEquipId m id ->
+            "concerning.equip.id" ++ attrMatch m ++ quoteStr id
+
+        CustomField m kv ->
+            "f:" ++ kv.field ++ attrMatch m ++ quoteStr kv.value
+
+        DateMs m ms ->
+            "date" ++ attrMatch m ++ "ms" ++ String.fromInt ms
+
+        DueDateMs m ms ->
+            "due" ++ attrMatch m ++ "ms" ++ String.fromInt ms
+
+        Source m str ->
+            "source" ++ attrMatch m ++ quoteStr str
+
+        Dir dir ->
+            "incoming:" ++ boolStr (dir == Data.Direction.Incoming)
+
+        ItemIdIn ids ->
+            "id~=" ++ String.join "," ids
+
+        ItemName m str ->
+            "name" ++ attrMatch m ++ quoteStr str
+
+        AllNames str ->
+            "$names:" ++ quoteStr str
+
+        Contents str ->
+            "content:" ++ quoteStr str
+
+
+attrMatch : AttrMatch -> String
+attrMatch am =
+    case am of
+        Eq ->
+            "="
+
+        Neq ->
+            "!="
+
+        Like ->
+            ":"
+
+        Gt ->
+            ">"
+
+        Gte ->
+            ">="
+
+        Lt ->
+            "<"
+
+        Lte ->
+            "<="
diff --git a/modules/webapp/src/main/elm/Page/Home/Data.elm b/modules/webapp/src/main/elm/Page/Home/Data.elm
index b0bf156c..1e13d309 100644
--- a/modules/webapp/src/main/elm/Page/Home/Data.elm
+++ b/modules/webapp/src/main/elm/Page/Home/Data.elm
@@ -31,6 +31,7 @@ import Comp.SearchMenu
 import Comp.YesNoDimmer
 import Data.Flags exposing (Flags)
 import Data.ItemNav exposing (ItemNav)
+import Data.ItemQuery as Q
 import Data.Items
 import Data.UiSettings exposing (UiSettings)
 import Http
@@ -239,12 +240,13 @@ doSearchDefaultCmd : SearchParam -> Model -> Cmd Msg
 doSearchDefaultCmd param model =
     let
         smask =
-            Comp.SearchMenu.getItemSearch model.searchMenuModel
+            Q.request
+                (Comp.SearchMenu.getItemQuery model.searchMenuModel)
 
         mask =
             { smask
-                | limit = param.pageSize
-                , offset = param.offset
+                | limit = Just param.pageSize
+                , offset = Just param.offset
             }
     in
     if param.offset == 0 then
diff --git a/modules/webapp/src/main/elm/Page/Home/Update.elm b/modules/webapp/src/main/elm/Page/Home/Update.elm
index a2cddb7f..5a7e7594 100644
--- a/modules/webapp/src/main/elm/Page/Home/Update.elm
+++ b/modules/webapp/src/main/elm/Page/Home/Update.elm
@@ -3,7 +3,7 @@ module Page.Home.Update exposing (update)
 import Api
 import Api.Model.IdList exposing (IdList)
 import Api.Model.ItemLightList exposing (ItemLightList)
-import Api.Model.ItemSearch
+import Api.Model.ItemQuery
 import Browser.Navigation as Nav
 import Comp.FixedDropdown
 import Comp.ItemCardList
@@ -13,6 +13,7 @@ import Comp.LinkTarget exposing (LinkTarget)
 import Comp.SearchMenu
 import Comp.YesNoDimmer
 import Data.Flags exposing (Flags)
+import Data.ItemQuery as Q
 import Data.ItemSelection
 import Data.Items
 import Data.UiSettings exposing (UiSettings)
@@ -648,16 +649,15 @@ loadChangedItems flags ids =
 
     else
         let
-            searchInit =
-                Api.Model.ItemSearch.empty
-
             idList =
-                IdList (Set.toList ids)
+                Set.toList ids
+
+            searchInit =
+                Q.request (Just <| Q.ItemIdIn idList)
 
             search =
                 { searchInit
-                    | itemSubset = Just idList
-                    , limit = Set.size ids
+                    | limit = Just <| Set.size ids
                 }
         in
         Api.itemSearch flags search ReplaceChangedItemsResp
diff --git a/modules/webapp/src/main/elm/Page/Home/View.elm b/modules/webapp/src/main/elm/Page/Home/View.elm
index f4d8124d..c12a31c9 100644
--- a/modules/webapp/src/main/elm/Page/Home/View.elm
+++ b/modules/webapp/src/main/elm/Page/Home/View.elm
@@ -320,8 +320,9 @@ viewSearchBar flags model =
         [ a
             [ classList
                 [ ( "search-menu-toggle ui icon button", True )
-                , ( "primary", not (searchMenuFilled model) )
-                , ( "secondary", searchMenuFilled model )
+
+                -- , ( "primary", not (searchMenuFilled model) )
+                -- , ( "secondary", searchMenuFilled model )
                 ]
             , onClick ToggleSearchMenu
             , href "#"
@@ -332,24 +333,23 @@ viewSearchBar flags model =
         , div [ class "right menu" ]
             [ div [ class "fitted item" ]
                 [ div [ class "ui left icon right action input" ]
-                    [ i
-                        [ classList
-                            [ ( "search link icon", not model.searchInProgress )
-                            , ( "loading spinner icon", model.searchInProgress )
-                            ]
-                        , href "#"
-                        , onClick (DoSearch model.searchTypeDropdownValue)
-                        ]
-                        (if hasMoreSearch model then
-                            [ i [ class "icons search-corner-icons" ]
-                                [ i [ class "tiny blue circle icon" ] []
-                                ]
-                            ]
-
-                         else
-                            []
-                        )
-                    , input
+                    [ -- i
+                      --    [ classList
+                      --        [ ( "search link icon", not model.searchInProgress )
+                      --        , ( "loading spinner icon", model.searchInProgress )
+                      --        ]
+                      --    , href "#"
+                      --    , onClick (DoSearch model.searchTypeDropdownValue)
+                      --    ]
+                      --    (if hasMoreSearch model then
+                      --        [ i [ class "icons search-corner-icons" ]
+                      --            [ i [ class "tiny blue circle icon" ] []
+                      --            ]
+                      --        ]
+                      --     else
+                      --        []
+                      --    )
+                      input
                         [ type_ "text"
                         , placeholder
                             (case model.searchTypeDropdownValue of
@@ -384,27 +384,6 @@ viewSearchBar flags model =
         ]
 
 
-searchMenuFilled : Model -> Bool
-searchMenuFilled model =
-    let
-        is =
-            Comp.SearchMenu.getItemSearch model.searchMenuModel
-    in
-    is /= Api.Model.ItemSearch.empty
-
-
-hasMoreSearch : Model -> Bool
-hasMoreSearch model =
-    let
-        is =
-            Comp.SearchMenu.getItemSearch model.searchMenuModel
-
-        is_ =
-            { is | allNames = Nothing, fullText = Nothing }
-    in
-    is_ /= Api.Model.ItemSearch.empty
-
-
 deleteAllDimmer : Comp.YesNoDimmer.Settings
 deleteAllDimmer =
     { message = "Really delete all selected items?"

From 489581d90b1a851fc75c47cc967d6f7d990f8de3 Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Mon, 1 Mar 2021 20:26:30 +0100
Subject: [PATCH 16/33] Fix parsing nested expressions

Since whitespace is used as a separator, it cannot be consumed by
and/or parens.
---
 .../docspell/query/internal/BasicParser.scala |  6 ++---
 .../query/internal/ExprParserTest.scala       | 22 +++++++++++++++++++
 .../query/internal/SimpleExprParserTest.scala |  4 ++++
 3 files changed, 29 insertions(+), 3 deletions(-)

diff --git a/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala b/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala
index c4edd070..bb38fc90 100644
--- a/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala
+++ b/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala
@@ -21,13 +21,13 @@ object BasicParser {
     (('A' to 'Z') ++ ('a' to 'z') ++ ('0' to '9') ++ "-_.").toSet
 
   val parenAnd: P[Unit] =
-    P.stringIn(List("(&", "(and")).void.surroundedBy(ws0)
+    P.stringIn(List("(&", "(and")).void <* ws0
 
   val parenClose: P[Unit] =
-    P.char(')').surroundedBy(ws0)
+    ws0.soft.with1 *> P.char(')')
 
   val parenOr: P[Unit] =
-    P.stringIn(List("(|", "(or")).void.surroundedBy(ws0)
+    P.stringIn(List("(|", "(or")).void <* ws0
 
   val identParser: P[String] =
     P.charsWhile(identChars.contains)
diff --git a/modules/query/src/test/scala/docspell/query/internal/ExprParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/ExprParserTest.scala
index 6f0fe07f..07e14e9b 100644
--- a/modules/query/src/test/scala/docspell/query/internal/ExprParserTest.scala
+++ b/modules/query/src/test/scala/docspell/query/internal/ExprParserTest.scala
@@ -67,4 +67,26 @@ class ExprParserTest extends FunSuite with ValueHelper {
       )
     )
   }
+
+  test("nest and/ with simple expr") {
+    val p = ExprParser.exprParser
+    assertEquals(
+      p.parseAll("(& (& f:usd=\"4.99\" ) source:*test* )"),
+      Right(
+        Expr.and(
+          Expr.and(Expr.CustomFieldMatch("usd", Operator.Eq, "4.99")),
+          Expr.string(Operator.Like, Attr.ItemSource, "*test*")
+        )
+      )
+    )
+    assertEquals(
+      p.parseAll("(& (& f:usd=\"4.99\" ) (| source:*test*) )"),
+      Right(
+        Expr.and(
+          Expr.and(Expr.CustomFieldMatch("usd", Operator.Eq, "4.99")),
+          Expr.or(Expr.string(Operator.Like, Attr.ItemSource, "*test*"))
+        )
+      )
+    )
+  }
 }
diff --git a/modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala
index 32c152a0..7a107ad7 100644
--- a/modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala
+++ b/modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala
@@ -173,6 +173,10 @@ class SimpleExprParserTest extends FunSuite with ValueHelper {
       p.parseAll("f:usd=26.66"),
       Right(Expr.CustomFieldMatch("usd", Operator.Eq, "26.66"))
     )
+    assertEquals(
+      p.parseAll("f:usd=\"26.66\""),
+      Right(Expr.CustomFieldMatch("usd", Operator.Eq, "26.66"))
+    )
   }
 
 }

From 168f5a1a988b3a0fdebb354bc4afed5327c081eb Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Mon, 1 Mar 2021 20:54:35 +0100
Subject: [PATCH 17/33] Fix like search for custom fields

---
 .../scala/docspell/store/qb/generator/ItemQueryGenerator.scala  | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
index 54632acc..7904b62e 100644
--- a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
+++ b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
@@ -232,7 +232,7 @@ object ItemQueryGenerator {
   def itemsWithCustomField(coll: Ident, field: String, op: QOp, value: String): Select = {
     val cf  = RCustomField.as("cf")
     val cfv = RCustomFieldValue.as("cfv")
-    val v   = if (op == QOp.LowerLike) value.toLowerCase else value
+    val v   = if (op == QOp.LowerLike) QueryWildcard.lower(value) else value
     Select(
       select(cfv.itemId),
       from(cfv).innerJoin(cf, cf.id === cfv.field),

From b4b5acde13c9bae52a9c5838580f45fcf5bbdc49 Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Mon, 1 Mar 2021 22:45:17 +0100
Subject: [PATCH 18/33] Enable power search for power users via ui settings

A different search bar is presented if enabled in ui settings that
allows to search via the new query language.
---
 .../src/main/elm/Comp/UiSettingsForm.elm      | 21 +++++
 .../webapp/src/main/elm/Data/ItemQuery.elm    |  4 +
 .../webapp/src/main/elm/Data/UiSettings.elm   |  5 ++
 .../webapp/src/main/elm/Page/Home/Data.elm    | 11 ++-
 .../webapp/src/main/elm/Page/Home/Update.elm  | 11 ++-
 .../webapp/src/main/elm/Page/Home/View2.elm   | 83 ++++++++++++-------
 6 files changed, 102 insertions(+), 33 deletions(-)

diff --git a/modules/webapp/src/main/elm/Comp/UiSettingsForm.elm b/modules/webapp/src/main/elm/Comp/UiSettingsForm.elm
index 9594c7e5..14cbc18c 100644
--- a/modules/webapp/src/main/elm/Comp/UiSettingsForm.elm
+++ b/modules/webapp/src/main/elm/Comp/UiSettingsForm.elm
@@ -58,6 +58,7 @@ type alias Model =
     , showPatternHelp : Bool
     , searchStatsVisible : Bool
     , sideMenuVisible : Bool
+    , powerSearchEnabled : Bool
     , openTabs : Set String
     }
 
@@ -151,6 +152,7 @@ init flags settings =
       , showPatternHelp = False
       , searchStatsVisible = settings.searchStatsVisible
       , sideMenuVisible = settings.sideMenuVisible
+      , powerSearchEnabled = settings.powerSearchEnabled
       , openTabs = Set.empty
       }
     , Api.getTags flags "" GetTagsResp
@@ -178,6 +180,7 @@ type Msg
     | ToggleSearchStatsVisible
     | ToggleAkkordionTab String
     | ToggleSideMenuVisible
+    | TogglePowerSearch
 
 
 
@@ -460,6 +463,15 @@ update sett msg model =
             , Just { sett | sideMenuVisible = next }
             )
 
+        TogglePowerSearch ->
+            let
+                next =
+                    not model.powerSearchEnabled
+            in
+            ( { model | powerSearchEnabled = next }
+            , Just { sett | powerSearchEnabled = next }
+            )
+
 
 
 --- View
@@ -763,6 +775,15 @@ settingFormTabs flags _ model =
                         , label = "Show basic search statistics by default"
                         }
                 ]
+            , div [ class "mb-4" ]
+                [ MB.viewItem <|
+                    MB.Checkbox
+                        { id = "uisetting-powersearch-enabled"
+                        , value = model.powerSearchEnabled
+                        , tagger = \_ -> TogglePowerSearch
+                        , label = "Enable power-user search bar"
+                        }
+                ]
             ]
       }
     , { title = "Item Cards"
diff --git a/modules/webapp/src/main/elm/Data/ItemQuery.elm b/modules/webapp/src/main/elm/Data/ItemQuery.elm
index ab886b3f..4105bac9 100644
--- a/modules/webapp/src/main/elm/Data/ItemQuery.elm
+++ b/modules/webapp/src/main/elm/Data/ItemQuery.elm
@@ -52,6 +52,7 @@ type ItemQuery
     | ItemName AttrMatch String
     | AllNames String
     | Contents String
+    | Fragment String
 
 
 and : List (Maybe ItemQuery) -> Maybe ItemQuery
@@ -176,6 +177,9 @@ render q =
         Contents str ->
             "content:" ++ quoteStr str
 
+        Fragment str ->
+            "(& " ++ str ++ " )"
+
 
 attrMatch : AttrMatch -> String
 attrMatch am =
diff --git a/modules/webapp/src/main/elm/Data/UiSettings.elm b/modules/webapp/src/main/elm/Data/UiSettings.elm
index 0153b63c..988eb2d4 100644
--- a/modules/webapp/src/main/elm/Data/UiSettings.elm
+++ b/modules/webapp/src/main/elm/Data/UiSettings.elm
@@ -62,6 +62,7 @@ type alias StoredUiSettings =
     , cardPreviewFullWidth : Bool
     , uiTheme : Maybe String
     , sideMenuVisible : Bool
+    , powerSearchEnabled : Bool
     }
 
 
@@ -92,6 +93,7 @@ type alias UiSettings =
     , cardPreviewFullWidth : Bool
     , uiTheme : UiTheme
     , sideMenuVisible : Bool
+    , powerSearchEnabled : Bool
     }
 
 
@@ -162,6 +164,7 @@ defaults =
     , cardPreviewFullWidth = False
     , uiTheme = Data.UiTheme.Light
     , sideMenuVisible = True
+    , powerSearchEnabled = False
     }
 
 
@@ -213,6 +216,7 @@ merge given fallback =
         Maybe.andThen Data.UiTheme.fromString given.uiTheme
             |> Maybe.withDefault fallback.uiTheme
     , sideMenuVisible = given.sideMenuVisible
+    , powerSearchEnabled = given.powerSearchEnabled
     }
 
 
@@ -249,6 +253,7 @@ toStoredUiSettings settings =
     , cardPreviewFullWidth = settings.cardPreviewFullWidth
     , uiTheme = Just (Data.UiTheme.toString settings.uiTheme)
     , sideMenuVisible = settings.sideMenuVisible
+    , powerSearchEnabled = settings.powerSearchEnabled
     }
 
 
diff --git a/modules/webapp/src/main/elm/Page/Home/Data.elm b/modules/webapp/src/main/elm/Page/Home/Data.elm
index 1e13d309..809d2526 100644
--- a/modules/webapp/src/main/elm/Page/Home/Data.elm
+++ b/modules/webapp/src/main/elm/Page/Home/Data.elm
@@ -56,6 +56,7 @@ type alias Model =
     , dragDropData : DD.DragDropData
     , scrollToCard : Maybe String
     , searchStats : SearchStats
+    , powerSearchInput : Maybe String
     }
 
 
@@ -121,6 +122,7 @@ init flags viewMode =
     , scrollToCard = Nothing
     , viewMode = viewMode
     , searchStats = Api.Model.SearchStats.empty
+    , powerSearchInput = Nothing
     }
 
 
@@ -194,6 +196,8 @@ type Msg
     | SetLinkTarget LinkTarget
     | SearchStatsResp (Result Http.Error SearchStats)
     | TogglePreviewFullWidth
+    | SetPowerSearch String
+    | KeyUpPowerSearchbarMsg (Maybe KeyCode)
 
 
 type SearchType
@@ -240,8 +244,11 @@ doSearchDefaultCmd : SearchParam -> Model -> Cmd Msg
 doSearchDefaultCmd param model =
     let
         smask =
-            Q.request
-                (Comp.SearchMenu.getItemQuery model.searchMenuModel)
+            Q.request <|
+                Q.and
+                    [ Comp.SearchMenu.getItemQuery model.searchMenuModel
+                    , Maybe.map Q.Fragment model.powerSearchInput
+                    ]
 
         mask =
             { smask
diff --git a/modules/webapp/src/main/elm/Page/Home/Update.elm b/modules/webapp/src/main/elm/Page/Home/Update.elm
index 5a7e7594..d161d58c 100644
--- a/modules/webapp/src/main/elm/Page/Home/Update.elm
+++ b/modules/webapp/src/main/elm/Page/Home/Update.elm
@@ -54,7 +54,7 @@ update mId key flags settings msg model =
         ResetSearch ->
             let
                 nm =
-                    { model | searchOffset = 0 }
+                    { model | searchOffset = 0, powerSearchInput = Nothing }
             in
             update mId key flags settings (SearchMenuMsg Comp.SearchMenu.ResetForm) nm
 
@@ -580,6 +580,15 @@ update mId key flags settings msg model =
             in
             noSub ( model, cmd )
 
+        SetPowerSearch str ->
+            noSub ( { model | powerSearchInput = Util.Maybe.fromString str }, Cmd.none )
+
+        KeyUpPowerSearchbarMsg (Just Enter) ->
+            update mId key flags settings (DoSearch model.searchTypeDropdownValue) model
+
+        KeyUpPowerSearchbarMsg _ ->
+            withSub ( model, Cmd.none )
+
 
 
 --- Helpers
diff --git a/modules/webapp/src/main/elm/Page/Home/View2.elm b/modules/webapp/src/main/elm/Page/Home/View2.elm
index ee8c3bdb..82db65c7 100644
--- a/modules/webapp/src/main/elm/Page/Home/View2.elm
+++ b/modules/webapp/src/main/elm/Page/Home/View2.elm
@@ -92,7 +92,7 @@ itemsBar flags settings model =
 
 
 defaultMenuBar : Flags -> UiSettings -> Model -> Html Msg
-defaultMenuBar flags settings model =
+defaultMenuBar _ settings model =
     let
         btnStyle =
             S.secondaryBasicButton ++ " text-sm"
@@ -100,6 +100,53 @@ defaultMenuBar flags settings model =
         searchInput =
             Comp.SearchMenu.textSearchString
                 model.searchMenuModel.textSearchModel
+
+        simpleSearchBar =
+            div
+                [ class "relative flex flex-row" ]
+                [ input
+                    [ type_ "text"
+                    , placeholder
+                        (case model.searchTypeDropdownValue of
+                            ContentOnlySearch ->
+                                "Content search…"
+
+                            BasicSearch ->
+                                "Search in names…"
+                        )
+                    , onInput SetBasicSearch
+                    , Util.Html.onKeyUpCode KeyUpSearchbarMsg
+                    , Maybe.map value searchInput
+                        |> Maybe.withDefault (value "")
+                    , class (String.replace "rounded" "" S.textInput)
+                    , class "py-1 text-sm border-r-0 rounded-l"
+                    ]
+                    []
+                , a
+                    [ class S.secondaryBasicButtonPlain
+                    , class "text-sm px-4 py-2 border rounded-r"
+                    , href "#"
+                    , onClick ToggleSearchType
+                    ]
+                    [ i [ class "fa fa-exchange-alt" ] []
+                    ]
+                ]
+
+        powerSearchBar =
+            div
+                [ class "relative flex flex-grow flex-row" ]
+                [ input
+                    [ type_ "text"
+                    , placeholder "Search query …"
+                    , onInput SetPowerSearch
+                    , Util.Html.onKeyUpCode KeyUpPowerSearchbarMsg
+                    , Maybe.map value model.powerSearchInput
+                        |> Maybe.withDefault (value "")
+                    , class S.textInput
+                    , class "text-sm "
+                    ]
+                    []
+                ]
     in
     MB.view
         { end =
@@ -129,35 +176,11 @@ defaultMenuBar flags settings model =
             ]
         , start =
             [ MB.CustomElement <|
-                div
-                    [ class "relative flex flex-row" ]
-                    [ input
-                        [ type_ "text"
-                        , placeholder
-                            (case model.searchTypeDropdownValue of
-                                ContentOnlySearch ->
-                                    "Content search…"
+                if settings.powerSearchEnabled then
+                    powerSearchBar
 
-                                BasicSearch ->
-                                    "Search in names…"
-                            )
-                        , onInput SetBasicSearch
-                        , Util.Html.onKeyUpCode KeyUpSearchbarMsg
-                        , Maybe.map value searchInput
-                            |> Maybe.withDefault (value "")
-                        , class (String.replace "rounded" "" S.textInput)
-                        , class "py-1 text-sm border-r-0 rounded-l"
-                        ]
-                        []
-                    , a
-                        [ class S.secondaryBasicButtonPlain
-                        , class "text-sm px-4 py-2 border rounded-r"
-                        , href "#"
-                        , onClick ToggleSearchType
-                        ]
-                        [ i [ class "fa fa-exchange-alt" ] []
-                        ]
-                    ]
+                else
+                    simpleSearchBar
             , MB.CustomButton
                 { tagger = TogglePreviewFullWidth
                 , label = ""
@@ -271,7 +294,7 @@ searchStats _ settings model =
 
 
 itemCardList : Flags -> UiSettings -> Model -> List (Html Msg)
-itemCardList flags settings model =
+itemCardList _ settings model =
     let
         itemViewCfg =
             case model.viewMode of

From a48504debbf888ec16028043929226c1de22b63a Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Tue, 2 Mar 2021 21:09:31 +0100
Subject: [PATCH 19/33] Specificly search for field id vs name

---
 .../src/main/scala/docspell/query/ItemQuery.scala    |  2 ++
 .../scala/docspell/query/internal/ExprUtil.scala     |  2 ++
 .../docspell/query/internal/SimpleExprParser.scala   | 10 ++++++++--
 .../store/qb/generator/ItemQueryGenerator.scala      | 12 +++++++++---
 .../scala/docspell/store/queries/QueryWildcard.scala |  7 +++++++
 modules/webapp/src/main/elm/Comp/SearchMenu.elm      |  2 +-
 modules/webapp/src/main/elm/Data/ItemQuery.elm       |  4 ++++
 7 files changed, 33 insertions(+), 6 deletions(-)

diff --git a/modules/query/src/main/scala/docspell/query/ItemQuery.scala b/modules/query/src/main/scala/docspell/query/ItemQuery.scala
index ec824ecb..8160da7d 100644
--- a/modules/query/src/main/scala/docspell/query/ItemQuery.scala
+++ b/modules/query/src/main/scala/docspell/query/ItemQuery.scala
@@ -103,6 +103,8 @@ object ItemQuery {
 
     final case class CustomFieldMatch(name: String, op: Operator, value: String)
         extends Expr
+    final case class CustomFieldIdMatch(id: String, op: Operator, value: String)
+        extends Expr
 
     final case class Fulltext(query: String) extends Expr
 
diff --git a/modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala b/modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala
index 75007d11..62489247 100644
--- a/modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala
+++ b/modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala
@@ -56,5 +56,7 @@ object ExprUtil {
         expr
       case CustomFieldMatch(_, _, _) =>
         expr
+      case CustomFieldIdMatch(_, _, _) =>
+        expr
     }
 }
diff --git a/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala b/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala
index f76fe947..55b92188 100644
--- a/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala
+++ b/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala
@@ -2,7 +2,6 @@ package docspell.query.internal
 
 import cats.parse.{Parser => P}
 
-import docspell.query.ItemQuery.Expr.CustomFieldMatch
 import docspell.query.ItemQuery._
 
 object SimpleExprParser {
@@ -62,7 +61,13 @@ object SimpleExprParser {
   val customFieldExpr: P[Expr.CustomFieldMatch] =
     (P.string("f:") *> BasicParser.identParser ~ op ~ BasicParser.singleString).map {
       case ((name, op), value) =>
-        CustomFieldMatch(name, op, value)
+        Expr.CustomFieldMatch(name, op, value)
+    }
+
+  val customFieldIdExpr: P[Expr.CustomFieldIdMatch] =
+    (P.string("f.id:") *> BasicParser.identParser ~ op ~ BasicParser.singleString).map {
+      case ((name, op), value) =>
+        Expr.CustomFieldIdMatch(name, op, value)
     }
 
   val inboxExpr: P[Expr.InboxExpr] =
@@ -81,6 +86,7 @@ object SimpleExprParser {
         tagIdExpr,
         tagExpr,
         catExpr,
+        customFieldIdExpr,
         customFieldExpr,
         inboxExpr,
         dirExpr
diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
index 7904b62e..1ed9e7df 100644
--- a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
+++ b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
@@ -145,7 +145,10 @@ object ItemQueryGenerator {
         }
 
       case Expr.CustomFieldMatch(field, op, value) =>
-        tables.item.id.in(itemsWithCustomField(coll, field, makeOp(op), value))
+        tables.item.id.in(itemsWithCustomField(_.name ==== field)(coll, makeOp(op), value))
+
+      case Expr.CustomFieldIdMatch(field, op, value) =>
+        tables.item.id.in(itemsWithCustomField(_.id ==== field)(coll, makeOp(op), value))
 
       case Expr.Fulltext(_) =>
         // not supported here
@@ -229,18 +232,21 @@ object ItemQueryGenerator {
         QOp.Lte
     }
 
-  def itemsWithCustomField(coll: Ident, field: String, op: QOp, value: String): Select = {
+  private def itemsWithCustomField(
+    sel: RCustomField.Table => Condition
+  )(coll: Ident, op: QOp, value: String): Select = {
     val cf  = RCustomField.as("cf")
     val cfv = RCustomFieldValue.as("cfv")
     val v   = if (op == QOp.LowerLike) QueryWildcard.lower(value) else value
     Select(
       select(cfv.itemId),
       from(cfv).innerJoin(cf, cf.id === cfv.field),
-      cf.cid === coll && (cf.name ==== field || cf.id ==== field) && Condition.CompareVal(
+      cf.cid === coll && sel(cf) && Condition.CompareVal(
         cfv.value,
         op,
         v
       )
     )
   }
+
 }
diff --git a/modules/store/src/main/scala/docspell/store/queries/QueryWildcard.scala b/modules/store/src/main/scala/docspell/store/queries/QueryWildcard.scala
index 5ab70d20..16a2f1bb 100644
--- a/modules/store/src/main/scala/docspell/store/queries/QueryWildcard.scala
+++ b/modules/store/src/main/scala/docspell/store/queries/QueryWildcard.scala
@@ -19,4 +19,11 @@ object QueryWildcard {
     else res
   }
 
+  def atEnd(s: String): String =
+    if (s.endsWith("*")) s"${s.dropRight(1)}%"
+    else s
+
+  def addAtEnd(s: String): String =
+    if (s.endsWith("*")) atEnd(s)
+    else s"${s}%"
 }
diff --git a/modules/webapp/src/main/elm/Comp/SearchMenu.elm b/modules/webapp/src/main/elm/Comp/SearchMenu.elm
index 1cc34890..669d71b7 100644
--- a/modules/webapp/src/main/elm/Comp/SearchMenu.elm
+++ b/modules/webapp/src/main/elm/Comp/SearchMenu.elm
@@ -293,7 +293,7 @@ getItemQuery model =
             |> List.head
             |> Maybe.map (Q.ConcEquipId Q.Eq)
         , whenNotEmpty (Data.CustomFieldChange.toFieldValues model.customValues)
-            (List.map (Q.CustomField Q.Like) >> Q.And)
+            (List.map (Q.CustomFieldId Q.Like) >> Q.And)
         , Maybe.map (Q.DateMs Q.Gte) model.fromDate
         , Maybe.map (Q.DateMs Q.Lte) model.untilDate
         , Maybe.map (Q.DueDateMs Q.Gte) model.fromDueDate
diff --git a/modules/webapp/src/main/elm/Data/ItemQuery.elm b/modules/webapp/src/main/elm/Data/ItemQuery.elm
index 4105bac9..d01464b8 100644
--- a/modules/webapp/src/main/elm/Data/ItemQuery.elm
+++ b/modules/webapp/src/main/elm/Data/ItemQuery.elm
@@ -44,6 +44,7 @@ type ItemQuery
     | ConcPersId AttrMatch String
     | ConcEquipId AttrMatch String
     | CustomField AttrMatch CustomFieldValue
+    | CustomFieldId AttrMatch CustomFieldValue
     | DateMs AttrMatch Int
     | DueDateMs AttrMatch Int
     | Source AttrMatch String
@@ -153,6 +154,9 @@ render q =
         CustomField m kv ->
             "f:" ++ kv.field ++ attrMatch m ++ quoteStr kv.value
 
+        CustomFieldId m kv ->
+            "f.id:" ++ kv.field ++ attrMatch m ++ quoteStr kv.value
+
         DateMs m ms ->
             "date" ++ attrMatch m ++ "ms" ++ String.fromInt ms
 

From 71985244f19229af5968168c8ff2bd9c064d0f99 Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Tue, 2 Mar 2021 22:14:26 +0100
Subject: [PATCH 20/33] Use a better representation for macros

---
 .../main/scala/docspell/query/ItemQuery.scala | 20 ++++++
 .../docspell/query/internal/ExprUtil.scala    |  7 ++-
 .../docspell/query/internal/MacroParser.scala | 62 +++++--------------
 .../query/internal/MacroParserTest.scala      | 16 ++---
 .../qb/generator/ItemQueryGenerator.scala     |  9 ++-
 5 files changed, 53 insertions(+), 61 deletions(-)

diff --git a/modules/query/src/main/scala/docspell/query/ItemQuery.scala b/modules/query/src/main/scala/docspell/query/ItemQuery.scala
index 8160da7d..acf2307a 100644
--- a/modules/query/src/main/scala/docspell/query/ItemQuery.scala
+++ b/modules/query/src/main/scala/docspell/query/ItemQuery.scala
@@ -108,6 +108,26 @@ object ItemQuery {
 
     final case class Fulltext(query: String) extends Expr
 
+    // things that can be expressed with terms above
+    sealed trait MacroExpr extends Expr {
+      def body: Expr
+    }
+    case class NamesMacro(searchTerm: String) extends MacroExpr {
+      val body =
+        Expr.or(
+          like(Attr.ItemName, searchTerm),
+          like(Attr.ItemNotes, searchTerm),
+          like(Attr.Correspondent.OrgName, searchTerm),
+          like(Attr.Correspondent.PersonName, searchTerm),
+          like(Attr.Concerning.PersonName, searchTerm),
+          like(Attr.Concerning.EquipName, searchTerm)
+        )
+    }
+    case class DateRangeMacro(attr: DateAttr, left: Date, right: Date) extends MacroExpr {
+      val body =
+        and(date(Operator.Gte, attr, left), date(Operator.Lte, attr, right))
+    }
+
     def or(expr0: Expr, exprs: Expr*): OrExpr =
       OrExpr(Nel.of(expr0, exprs: _*))
 
diff --git a/modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala b/modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala
index 62489247..a1fa2045 100644
--- a/modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala
+++ b/modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala
@@ -5,8 +5,8 @@ import docspell.query.ItemQuery._
 
 object ExprUtil {
 
-  /** Does some basic transformation, like unfolding deeply nested and
-    * trees containing one value etc.
+  /** Does some basic transformation, like unfolding nested and trees
+    * containing one value etc.
     */
   def reduce(expr: Expr): Expr =
     expr match {
@@ -30,6 +30,9 @@ object ExprUtil {
             expr
         }
 
+      case m: MacroExpr =>
+        reduce(m.body)
+
       case DirectionExpr(_) =>
         expr
 
diff --git a/modules/query/src/main/scala/docspell/query/internal/MacroParser.scala b/modules/query/src/main/scala/docspell/query/internal/MacroParser.scala
index 2a2c2d41..856a2d96 100644
--- a/modules/query/src/main/scala/docspell/query/internal/MacroParser.scala
+++ b/modules/query/src/main/scala/docspell/query/internal/MacroParser.scala
@@ -5,61 +5,29 @@ import cats.parse.{Parser => P}
 import docspell.query.ItemQuery._
 
 object MacroParser {
-  private[this] val macroDef: P[String] =
-    P.char('$') *> BasicParser.identParser <* P.char(':')
+  private def macroDef(name: String): P[Unit] =
+    P.char('$').soft.with1 *> P.string(name) <* P.char(':')
 
-  def parser[A](macros: Map[String, P[A]]): P[A] = {
-    val p: P[P[A]] = macroDef.map { name =>
-      macros
-        .get(name)
-        .getOrElse(P.failWith(s"Unknown macro: $name"))
+  private def dateRangeMacroImpl(
+      name: String,
+      attr: Attr.DateAttr
+  ): P[Expr.DateRangeMacro] =
+    (macroDef(name) *> DateParser.dateRange).map { case (left, right) =>
+      Expr.DateRangeMacro(attr, left, right)
     }
 
-    val px = (p ~ P.index ~ BasicParser.singleString).map { case ((pexpr, index), str) =>
-      pexpr
-        .parseAll(str)
-        .left
-        .map(err => err.copy(failedAtOffset = err.failedAtOffset + index))
-    }
+  val namesMacro: P[Expr.NamesMacro] =
+    (macroDef("names") *> BasicParser.singleString).map(Expr.NamesMacro.apply)
 
-    P.select(px)(P.Fail)
-  }
+  val dateRangeMacro: P[Expr.DateRangeMacro] =
+    dateRangeMacroImpl("datein", Attr.Date)
 
-  // --- definitions of available macros
-
-  /** Expands in an OR expression that matches name fields of item and
-    * correspondent/concerning metadata.
-    */
-  val names: P[Expr] =
-    P.string(P.anyChar.rep.void).map { input =>
-      Expr.or(
-        Expr.like(Attr.ItemName, input),
-        Expr.like(Attr.ItemNotes, input),
-        Expr.like(Attr.Correspondent.OrgName, input),
-        Expr.like(Attr.Correspondent.PersonName, input),
-        Expr.like(Attr.Concerning.PersonName, input),
-        Expr.like(Attr.Concerning.EquipName, input)
-      )
-    }
-
-  def dateRange(attr: Attr.DateAttr): P[Expr] =
-    DateParser.dateRange.map { case (left, right) =>
-      Expr.and(
-        Expr.date(Operator.Gte, attr, left),
-        Expr.date(Operator.Lte, attr, right)
-      )
-    }
+  val dueDateRangeMacro: P[Expr.DateRangeMacro] =
+    dateRangeMacroImpl("duein", Attr.DueDate)
 
   // --- all macro parser
 
-  val allMacros: Map[String, P[Expr]] =
-    Map(
-      "names"  -> names,
-      "datein" -> dateRange(Attr.Date),
-      "duein"  -> dateRange(Attr.DueDate)
-    )
-
   val all: P[Expr] =
-    parser(allMacros)
+    P.oneOf(List(namesMacro, dateRangeMacro, dueDateRangeMacro))
 
 }
diff --git a/modules/query/src/test/scala/docspell/query/internal/MacroParserTest.scala b/modules/query/src/test/scala/docspell/query/internal/MacroParserTest.scala
index 045d1120..def772bd 100644
--- a/modules/query/src/test/scala/docspell/query/internal/MacroParserTest.scala
+++ b/modules/query/src/test/scala/docspell/query/internal/MacroParserTest.scala
@@ -1,19 +1,15 @@
 package docspell.query.internal
 
 import munit._
-import cats.parse.{Parser => P}
+//import cats.parse.{Parser => P}
+import docspell.query.ItemQuery.Expr
 
 class MacroParserTest extends FunSuite {
 
-  test("fail with unkown macro names") {
-    val p = MacroParser.parser(Map.empty)
-    assert(p.parseAll("$bla:blup").isLeft) // TODO check error message
+  test("start with $") {
+    val p = MacroParser.namesMacro
+    assertEquals(p.parseAll("$names:test"), Right(Expr.NamesMacro("test")))
+    assert(p.parseAll("names:test").isLeft)
   }
 
-  test("select correct parser") {
-    val p =
-      MacroParser.parser[Int](Map("one" -> P.anyChar.as(1), "two" -> P.anyChar.as(2)))
-    assertEquals(p.parseAll("$one:y"), Right(1))
-    assertEquals(p.parseAll("$two:y"), Right(2))
-  }
 }
diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
index 1ed9e7df..18cdf909 100644
--- a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
+++ b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
@@ -145,7 +145,9 @@ object ItemQueryGenerator {
         }
 
       case Expr.CustomFieldMatch(field, op, value) =>
-        tables.item.id.in(itemsWithCustomField(_.name ==== field)(coll, makeOp(op), value))
+        tables.item.id.in(
+          itemsWithCustomField(_.name ==== field)(coll, makeOp(op), value)
+        )
 
       case Expr.CustomFieldIdMatch(field, op, value) =>
         tables.item.id.in(itemsWithCustomField(_.id ==== field)(coll, makeOp(op), value))
@@ -153,6 +155,9 @@ object ItemQueryGenerator {
       case Expr.Fulltext(_) =>
         // not supported here
         Condition.unit
+
+      case _: Expr.MacroExpr =>
+        Condition.unit
     }
 
   private def dateToTimestamp(today: LocalDate)(date: Date): Timestamp =
@@ -233,7 +238,7 @@ object ItemQueryGenerator {
     }
 
   private def itemsWithCustomField(
-    sel: RCustomField.Table => Condition
+      sel: RCustomField.Table => Condition
   )(coll: Ident, op: QOp, value: String): Select = {
     val cf  = RCustomField.as("cf")
     val cfv = RCustomFieldValue.as("cfv")

From d4006461f64f5c5c4657dc5b294b4a6ea0e4063d Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Tue, 2 Mar 2021 00:51:13 +0100
Subject: [PATCH 21/33] Serving scalajs artifacts and provide errors to js

---
 build.sbt                                     | 17 +++++++++--
 .../docspell/query/js/JSItemQueryParser.scala | 29 +++++++++++++++++++
 .../src/main/scala/docspell/query/Date.scala  |  0
 .../main/scala/docspell/query/ItemQuery.scala |  0
 .../docspell/query/ItemQueryParser.scala      |  5 ----
 .../scala/docspell/query/ParseFailure.scala   |  0
 .../docspell/query/internal/AttrParser.scala  |  0
 .../docspell/query/internal/BasicParser.scala |  0
 .../docspell/query/internal/DateParser.scala  |  0
 .../docspell/query/internal/ExprParser.scala  |  0
 .../docspell/query/internal/ExprUtil.scala    |  0
 .../docspell/query/internal/MacroParser.scala |  0
 .../query/internal/OperatorParser.scala       |  0
 .../query/internal/SimpleExprParser.scala     |  0
 .../docspell/query/internal/StringUtil.scala  |  0
 .../query/internal/AttrParserTest.scala       |  0
 .../query/internal/BasicParserTest.scala      |  0
 .../query/internal/DateParserTest.scala       |  0
 .../query/internal/ExprParserTest.scala       |  0
 .../query/internal/ItemQueryParserTest.scala  |  0
 .../query/internal/MacroParserTest.scala      |  0
 .../query/internal/OperatorParserTest.scala   |  0
 .../query/internal/SimpleExprParserTest.scala |  0
 .../docspell/query/internal/ValueHelper.scala |  0
 .../restserver/webapp/TemplateRoutes.scala    |  3 +-
 25 files changed, 45 insertions(+), 9 deletions(-)
 create mode 100644 modules/query/js/src/main/scala/docspell/query/js/JSItemQueryParser.scala
 rename modules/query/{ => shared}/src/main/scala/docspell/query/Date.scala (100%)
 rename modules/query/{ => shared}/src/main/scala/docspell/query/ItemQuery.scala (100%)
 rename modules/query/{ => shared}/src/main/scala/docspell/query/ItemQueryParser.scala (86%)
 rename modules/query/{ => shared}/src/main/scala/docspell/query/ParseFailure.scala (100%)
 rename modules/query/{ => shared}/src/main/scala/docspell/query/internal/AttrParser.scala (100%)
 rename modules/query/{ => shared}/src/main/scala/docspell/query/internal/BasicParser.scala (100%)
 rename modules/query/{ => shared}/src/main/scala/docspell/query/internal/DateParser.scala (100%)
 rename modules/query/{ => shared}/src/main/scala/docspell/query/internal/ExprParser.scala (100%)
 rename modules/query/{ => shared}/src/main/scala/docspell/query/internal/ExprUtil.scala (100%)
 rename modules/query/{ => shared}/src/main/scala/docspell/query/internal/MacroParser.scala (100%)
 rename modules/query/{ => shared}/src/main/scala/docspell/query/internal/OperatorParser.scala (100%)
 rename modules/query/{ => shared}/src/main/scala/docspell/query/internal/SimpleExprParser.scala (100%)
 rename modules/query/{ => shared}/src/main/scala/docspell/query/internal/StringUtil.scala (100%)
 rename modules/query/{ => shared}/src/test/scala/docspell/query/internal/AttrParserTest.scala (100%)
 rename modules/query/{ => shared}/src/test/scala/docspell/query/internal/BasicParserTest.scala (100%)
 rename modules/query/{ => shared}/src/test/scala/docspell/query/internal/DateParserTest.scala (100%)
 rename modules/query/{ => shared}/src/test/scala/docspell/query/internal/ExprParserTest.scala (100%)
 rename modules/query/{ => shared}/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala (100%)
 rename modules/query/{ => shared}/src/test/scala/docspell/query/internal/MacroParserTest.scala (100%)
 rename modules/query/{ => shared}/src/test/scala/docspell/query/internal/OperatorParserTest.scala (100%)
 rename modules/query/{ => shared}/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala (100%)
 rename modules/query/{ => shared}/src/test/scala/docspell/query/internal/ValueHelper.scala (100%)

diff --git a/build.sbt b/build.sbt
index 1c4c170b..3a1ca00a 100644
--- a/build.sbt
+++ b/build.sbt
@@ -86,7 +86,7 @@ val stylesSettings = Seq(
   Compile / resourceGenerators += stylesBuild.taskValue
 )
 
-val webjarSettings = Seq(
+def webjarSettings(queryJS: Project) = Seq(
   Compile / resourceGenerators += Def.task {
     copyWebjarResources(
       Seq((sourceDirectory in Compile).value / "webjar"),
@@ -96,6 +96,18 @@ val webjarSettings = Seq(
       streams.value.log
     )
   }.taskValue,
+  Compile / resourceGenerators += Def.task {
+    val logger = streams.value.log
+    val out = (queryJS/Compile/fullOptJS).value
+    logger.info(s"Produced query js file: ${out.data}")
+    copyWebjarResources(
+      Seq(out.data),
+      (Compile/resourceManaged).value,
+      name.value,
+      version.value,
+      logger
+    )
+  }.taskValue,
   watchSources += Watched.WatchSource(
     (Compile / sourceDirectory).value / "webjar",
     FileFilter.globFilter("*.js") || FileFilter.globFilter("*.css"),
@@ -273,7 +285,6 @@ ${lines.map(_._1).mkString(",\n")}
 val query =
   crossProject(JSPlatform, JVMPlatform)
     .withoutSuffixFor(JVMPlatform)
-    .crossType(CrossType.Pure)
     .in(file("modules/query"))
     .disablePlugins(RevolverPlugin)
     .settings(sharedSettings)
@@ -446,7 +457,7 @@ val webapp = project
   .settings(sharedSettings)
   .settings(elmSettings)
   .settings(stylesSettings)
-  .settings(webjarSettings)
+  .settings(webjarSettings(query.js))
   .settings(
     name := "docspell-webapp",
     openapiTargetLanguage := Language.Elm,
diff --git a/modules/query/js/src/main/scala/docspell/query/js/JSItemQueryParser.scala b/modules/query/js/src/main/scala/docspell/query/js/JSItemQueryParser.scala
new file mode 100644
index 00000000..68f7cd5e
--- /dev/null
+++ b/modules/query/js/src/main/scala/docspell/query/js/JSItemQueryParser.scala
@@ -0,0 +1,29 @@
+package docspell.query.js
+
+import scala.scalajs.js
+import scala.scalajs.js.annotation._
+
+import docspell.query.ItemQueryParser
+
+@JSExportTopLevel("DsItemQueryParser")
+object JSItemQueryParser {
+
+  @JSExport
+  def parseToFailure(input: String): Failure =
+    ItemQueryParser
+      .parse(input)
+      .swap
+      .toOption
+      .map(fr =>
+        new Failure(
+          fr.input,
+          fr.failedAt,
+          js.Array(fr.messages.toList.toSeq.map(_.msg): _*)
+        )
+      )
+      .orNull
+
+  @JSExportAll
+  case class Failure(input: String, failedAt: Int, messages: js.Array[String])
+
+}
diff --git a/modules/query/src/main/scala/docspell/query/Date.scala b/modules/query/shared/src/main/scala/docspell/query/Date.scala
similarity index 100%
rename from modules/query/src/main/scala/docspell/query/Date.scala
rename to modules/query/shared/src/main/scala/docspell/query/Date.scala
diff --git a/modules/query/src/main/scala/docspell/query/ItemQuery.scala b/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala
similarity index 100%
rename from modules/query/src/main/scala/docspell/query/ItemQuery.scala
rename to modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala
diff --git a/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala b/modules/query/shared/src/main/scala/docspell/query/ItemQueryParser.scala
similarity index 86%
rename from modules/query/src/main/scala/docspell/query/ItemQueryParser.scala
rename to modules/query/shared/src/main/scala/docspell/query/ItemQueryParser.scala
index cf9b491c..2c178140 100644
--- a/modules/query/src/main/scala/docspell/query/ItemQueryParser.scala
+++ b/modules/query/shared/src/main/scala/docspell/query/ItemQueryParser.scala
@@ -1,14 +1,10 @@
 package docspell.query
 
-import scala.scalajs.js.annotation._
-
 import docspell.query.internal.ExprParser
 import docspell.query.internal.ExprUtil
 
-@JSExportTopLevel("DsItemQueryParser")
 object ItemQueryParser {
 
-  @JSExport
   def parse(input: String): Either[ParseFailure, ItemQuery] =
     if (input.isEmpty) Right(ItemQuery.all)
     else {
@@ -22,5 +18,4 @@ object ItemQueryParser {
 
   def parseUnsafe(input: String): ItemQuery =
     parse(input).fold(m => sys.error(m.render), identity)
-
 }
diff --git a/modules/query/src/main/scala/docspell/query/ParseFailure.scala b/modules/query/shared/src/main/scala/docspell/query/ParseFailure.scala
similarity index 100%
rename from modules/query/src/main/scala/docspell/query/ParseFailure.scala
rename to modules/query/shared/src/main/scala/docspell/query/ParseFailure.scala
diff --git a/modules/query/src/main/scala/docspell/query/internal/AttrParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/AttrParser.scala
similarity index 100%
rename from modules/query/src/main/scala/docspell/query/internal/AttrParser.scala
rename to modules/query/shared/src/main/scala/docspell/query/internal/AttrParser.scala
diff --git a/modules/query/src/main/scala/docspell/query/internal/BasicParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/BasicParser.scala
similarity index 100%
rename from modules/query/src/main/scala/docspell/query/internal/BasicParser.scala
rename to modules/query/shared/src/main/scala/docspell/query/internal/BasicParser.scala
diff --git a/modules/query/src/main/scala/docspell/query/internal/DateParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/DateParser.scala
similarity index 100%
rename from modules/query/src/main/scala/docspell/query/internal/DateParser.scala
rename to modules/query/shared/src/main/scala/docspell/query/internal/DateParser.scala
diff --git a/modules/query/src/main/scala/docspell/query/internal/ExprParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/ExprParser.scala
similarity index 100%
rename from modules/query/src/main/scala/docspell/query/internal/ExprParser.scala
rename to modules/query/shared/src/main/scala/docspell/query/internal/ExprParser.scala
diff --git a/modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala b/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala
similarity index 100%
rename from modules/query/src/main/scala/docspell/query/internal/ExprUtil.scala
rename to modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala
diff --git a/modules/query/src/main/scala/docspell/query/internal/MacroParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/MacroParser.scala
similarity index 100%
rename from modules/query/src/main/scala/docspell/query/internal/MacroParser.scala
rename to modules/query/shared/src/main/scala/docspell/query/internal/MacroParser.scala
diff --git a/modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/OperatorParser.scala
similarity index 100%
rename from modules/query/src/main/scala/docspell/query/internal/OperatorParser.scala
rename to modules/query/shared/src/main/scala/docspell/query/internal/OperatorParser.scala
diff --git a/modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/SimpleExprParser.scala
similarity index 100%
rename from modules/query/src/main/scala/docspell/query/internal/SimpleExprParser.scala
rename to modules/query/shared/src/main/scala/docspell/query/internal/SimpleExprParser.scala
diff --git a/modules/query/src/main/scala/docspell/query/internal/StringUtil.scala b/modules/query/shared/src/main/scala/docspell/query/internal/StringUtil.scala
similarity index 100%
rename from modules/query/src/main/scala/docspell/query/internal/StringUtil.scala
rename to modules/query/shared/src/main/scala/docspell/query/internal/StringUtil.scala
diff --git a/modules/query/src/test/scala/docspell/query/internal/AttrParserTest.scala b/modules/query/shared/src/test/scala/docspell/query/internal/AttrParserTest.scala
similarity index 100%
rename from modules/query/src/test/scala/docspell/query/internal/AttrParserTest.scala
rename to modules/query/shared/src/test/scala/docspell/query/internal/AttrParserTest.scala
diff --git a/modules/query/src/test/scala/docspell/query/internal/BasicParserTest.scala b/modules/query/shared/src/test/scala/docspell/query/internal/BasicParserTest.scala
similarity index 100%
rename from modules/query/src/test/scala/docspell/query/internal/BasicParserTest.scala
rename to modules/query/shared/src/test/scala/docspell/query/internal/BasicParserTest.scala
diff --git a/modules/query/src/test/scala/docspell/query/internal/DateParserTest.scala b/modules/query/shared/src/test/scala/docspell/query/internal/DateParserTest.scala
similarity index 100%
rename from modules/query/src/test/scala/docspell/query/internal/DateParserTest.scala
rename to modules/query/shared/src/test/scala/docspell/query/internal/DateParserTest.scala
diff --git a/modules/query/src/test/scala/docspell/query/internal/ExprParserTest.scala b/modules/query/shared/src/test/scala/docspell/query/internal/ExprParserTest.scala
similarity index 100%
rename from modules/query/src/test/scala/docspell/query/internal/ExprParserTest.scala
rename to modules/query/shared/src/test/scala/docspell/query/internal/ExprParserTest.scala
diff --git a/modules/query/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala b/modules/query/shared/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala
similarity index 100%
rename from modules/query/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala
rename to modules/query/shared/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala
diff --git a/modules/query/src/test/scala/docspell/query/internal/MacroParserTest.scala b/modules/query/shared/src/test/scala/docspell/query/internal/MacroParserTest.scala
similarity index 100%
rename from modules/query/src/test/scala/docspell/query/internal/MacroParserTest.scala
rename to modules/query/shared/src/test/scala/docspell/query/internal/MacroParserTest.scala
diff --git a/modules/query/src/test/scala/docspell/query/internal/OperatorParserTest.scala b/modules/query/shared/src/test/scala/docspell/query/internal/OperatorParserTest.scala
similarity index 100%
rename from modules/query/src/test/scala/docspell/query/internal/OperatorParserTest.scala
rename to modules/query/shared/src/test/scala/docspell/query/internal/OperatorParserTest.scala
diff --git a/modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala b/modules/query/shared/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala
similarity index 100%
rename from modules/query/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala
rename to modules/query/shared/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala
diff --git a/modules/query/src/test/scala/docspell/query/internal/ValueHelper.scala b/modules/query/shared/src/test/scala/docspell/query/internal/ValueHelper.scala
similarity index 100%
rename from modules/query/src/test/scala/docspell/query/internal/ValueHelper.scala
rename to modules/query/shared/src/test/scala/docspell/query/internal/ValueHelper.scala
diff --git a/modules/restserver/src/main/scala/docspell/restserver/webapp/TemplateRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/webapp/TemplateRoutes.scala
index b920168a..9d59c201 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/webapp/TemplateRoutes.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/webapp/TemplateRoutes.scala
@@ -170,7 +170,8 @@ object TemplateRoutes {
         chooseUi(uiVersion),
         Seq(
           "/app/assets" + Webjars.clipboardjs + "/clipboard.min.js",
-          s"/app/assets/docspell-webapp/${BuildInfo.version}/docspell-app.js"
+          s"/app/assets/docspell-webapp/${BuildInfo.version}/docspell-app.js",
+          s"/app/assets/docspell-webapp/${BuildInfo.version}/docspell-query-opt.js"
         ),
         s"/app/assets/docspell-webapp/${BuildInfo.version}/favicon",
         s"/app/assets/docspell-webapp/${BuildInfo.version}/docspell.js",

From 1c834cbb771210b4a702ffe3babe84bac0bb6feb Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Wed, 3 Mar 2021 01:58:24 +0100
Subject: [PATCH 22/33] Correctly compare numeric field values

---
 .../migration/h2/V1.21.0__cast_function.sql   | 12 +++++++
 .../mariadb/V1.21.0__cast_function.sql        |  5 +++
 .../postgresql/V1.21.0__cast_function.sql     |  9 +++++
 .../scala/docspell/store/qb/DBFunction.scala  |  2 ++
 .../main/scala/docspell/store/qb/DSL.scala    |  3 ++
 .../qb/generator/ItemQueryGenerator.scala     | 35 ++++++++++++++-----
 .../store/qb/impl/DBFunctionBuilder.scala     |  3 ++
 7 files changed, 60 insertions(+), 9 deletions(-)
 create mode 100644 modules/store/src/main/resources/db/migration/h2/V1.21.0__cast_function.sql
 create mode 100644 modules/store/src/main/resources/db/migration/mariadb/V1.21.0__cast_function.sql
 create mode 100644 modules/store/src/main/resources/db/migration/postgresql/V1.21.0__cast_function.sql

diff --git a/modules/store/src/main/resources/db/migration/h2/V1.21.0__cast_function.sql b/modules/store/src/main/resources/db/migration/h2/V1.21.0__cast_function.sql
new file mode 100644
index 00000000..146bf3d8
--- /dev/null
+++ b/modules/store/src/main/resources/db/migration/h2/V1.21.0__cast_function.sql
@@ -0,0 +1,12 @@
+DROP ALIAS IF EXISTS CAST_TO_NUMERIC;
+CREATE ALIAS CAST_TO_NUMERIC AS '
+import java.text.*;
+import java.math.*;
+@CODE
+BigDecimal castToNumeric(String s) throws Exception {
+  try { return new BigDecimal(s); }
+  catch (Exception e) {
+    return null;
+  }
+}
+'
diff --git a/modules/store/src/main/resources/db/migration/mariadb/V1.21.0__cast_function.sql b/modules/store/src/main/resources/db/migration/mariadb/V1.21.0__cast_function.sql
new file mode 100644
index 00000000..24ae76de
--- /dev/null
+++ b/modules/store/src/main/resources/db/migration/mariadb/V1.21.0__cast_function.sql
@@ -0,0 +1,5 @@
+-- Create a function to cast to a numeric, if an error occurs return null
+-- Could not get it working with decimal type, so using double
+create or replace function CAST_TO_NUMERIC (s char(255))
+returns double deterministic
+return cast(s as double);
diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.21.0__cast_function.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.21.0__cast_function.sql
new file mode 100644
index 00000000..b603275c
--- /dev/null
+++ b/modules/store/src/main/resources/db/migration/postgresql/V1.21.0__cast_function.sql
@@ -0,0 +1,9 @@
+-- Create a function to cast to a numeric, if an error occurs return null
+create or replace function CAST_TO_NUMERIC(text) returns numeric as $$
+begin
+    return cast($1 as numeric);
+exception
+    when invalid_text_representation then
+        return null;
+end;
+$$ language plpgsql immutable;
diff --git a/modules/store/src/main/scala/docspell/store/qb/DBFunction.scala b/modules/store/src/main/scala/docspell/store/qb/DBFunction.scala
index 58cca850..40db91b2 100644
--- a/modules/store/src/main/scala/docspell/store/qb/DBFunction.scala
+++ b/modules/store/src/main/scala/docspell/store/qb/DBFunction.scala
@@ -29,6 +29,8 @@ object DBFunction {
 
   case class Cast(expr: SelectExpr, newType: String) extends DBFunction
 
+  case class CastNumeric(expr: SelectExpr) extends DBFunction
+
   case class Avg(expr: SelectExpr) extends DBFunction
 
   case class Sum(expr: SelectExpr) extends DBFunction
diff --git a/modules/store/src/main/scala/docspell/store/qb/DSL.scala b/modules/store/src/main/scala/docspell/store/qb/DSL.scala
index fba05543..63a0afc4 100644
--- a/modules/store/src/main/scala/docspell/store/qb/DSL.scala
+++ b/modules/store/src/main/scala/docspell/store/qb/DSL.scala
@@ -89,6 +89,9 @@ trait DSL extends DoobieMeta {
   def cast(expr: SelectExpr, targetType: String): DBFunction =
     DBFunction.Cast(expr, targetType)
 
+  def castNumeric(expr: SelectExpr): DBFunction =
+    DBFunction.CastNumeric(expr)
+
   def coalesce(expr: SelectExpr, more: SelectExpr*): DBFunction.Coalesce =
     DBFunction.Coalesce(expr, more.toVector)
 
diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
index 18cdf909..71760463 100644
--- a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
+++ b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
@@ -242,16 +242,33 @@ object ItemQueryGenerator {
   )(coll: Ident, op: QOp, value: String): Select = {
     val cf  = RCustomField.as("cf")
     val cfv = RCustomFieldValue.as("cfv")
-    val v   = if (op == QOp.LowerLike) QueryWildcard.lower(value) else value
-    Select(
-      select(cfv.itemId),
-      from(cfv).innerJoin(cf, cf.id === cfv.field),
-      cf.cid === coll && sel(cf) && Condition.CompareVal(
-        cfv.value,
-        op,
-        v
+
+    val baseSelect =
+      Select(
+        select(cfv.itemId),
+        from(cfv).innerJoin(cf, sel(cf) && cf.cid === coll && cf.id === cfv.field)
       )
-    )
+
+    if (op == QOp.LowerLike) {
+      val v = QueryWildcard.lower(value)
+      baseSelect.where(Condition.CompareVal(cfv.value, op, v))
+    } else {
+      val stringCmp =
+        Condition.CompareVal(cfv.value, op, value)
+
+      value.toDoubleOption
+        .map { n =>
+          val numericCmp = Condition.CompareFVal(castNumeric(cfv.value.s), op, n)
+          val fieldIsNumeric =
+            cf.ftype === CustomFieldType.Numeric || cf.ftype === CustomFieldType.Money
+          val fieldNotNumeric =
+            cf.ftype <> CustomFieldType.Numeric && cf.ftype <> CustomFieldType.Money
+          baseSelect.where(
+            (fieldIsNumeric && numericCmp) || (fieldNotNumeric && stringCmp)
+          )
+        }
+        .getOrElse(baseSelect.where(stringCmp))
+    }
   }
 
 }
diff --git a/modules/store/src/main/scala/docspell/store/qb/impl/DBFunctionBuilder.scala b/modules/store/src/main/scala/docspell/store/qb/impl/DBFunctionBuilder.scala
index 3a75569a..c57a9ac3 100644
--- a/modules/store/src/main/scala/docspell/store/qb/impl/DBFunctionBuilder.scala
+++ b/modules/store/src/main/scala/docspell/store/qb/impl/DBFunctionBuilder.scala
@@ -46,6 +46,9 @@ object DBFunctionBuilder extends CommonBuilder {
           fr" AS" ++ Fragment.const(newType) ++
           sql")"
 
+      case DBFunction.CastNumeric(f) =>
+        sql"CAST_TO_NUMERIC(" ++ SelectExprBuilder.build(f) ++ sql")"
+
       case DBFunction.Avg(expr) =>
         sql"AVG(" ++ SelectExprBuilder.build(expr) ++ fr")"
 

From 63d146c2de048d403ae51d6433b0ee28714f2036 Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Sun, 7 Mar 2021 09:38:39 +0100
Subject: [PATCH 23/33] Resolve fulltext search queries the same way as before

For now, fulltext search is only possible when being the only term or
inside the root AND expression.
---
 .../docspell/backend/ops/OSimpleSearch.scala  |  70 +++++++++--
 .../docspell/query/FulltextExtract.scala      |  71 +++++++++++
 .../main/scala/docspell/query/ItemQuery.scala |   5 +-
 .../docspell/query/FulltextExtractTest.scala  |  57 +++++++++
 .../restserver/routes/ItemRoutes.scala        | 114 +++++++++++-------
 5 files changed, 257 insertions(+), 60 deletions(-)
 create mode 100644 modules/query/shared/src/main/scala/docspell/query/FulltextExtract.scala
 create mode 100644 modules/query/shared/src/test/scala/docspell/query/FulltextExtractTest.scala

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 8eb91c7f..e468eaaf 100644
--- a/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala
+++ b/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala
@@ -5,7 +5,7 @@ import cats.implicits._
 
 import docspell.backend.ops.OSimpleSearch._
 import docspell.common._
-import docspell.query.{ItemQueryParser, ParseFailure}
+import docspell.query._
 import docspell.store.qb.Batch
 import docspell.store.queries.Query
 import docspell.store.queries.SearchSummary
@@ -20,15 +20,29 @@ trait OSimpleSearch[F[_]] {
 
   def searchByString(
       settings: Settings
-  )(fix: Query.Fix, q: ItemQueryString): Either[ParseFailure, F[Items]]
+  )(fix: Query.Fix, q: ItemQueryString): F[StringSearchResult[Items]]
   def searchSummaryByString(
       useFTS: Boolean
-  )(fix: Query.Fix, q: ItemQueryString): Either[ParseFailure, F[SearchSummary]]
+  )(fix: Query.Fix, q: ItemQueryString): F[StringSearchResult[SearchSummary]]
 
 }
 
 object OSimpleSearch {
 
+  sealed trait StringSearchResult[+A]
+  object StringSearchResult {
+    case class ParseFailed(error: ParseFailure) extends StringSearchResult[Nothing]
+    def parseFailed[A](error: ParseFailure): StringSearchResult[A] =
+      ParseFailed(error)
+
+    case class FulltextMismatch(error: FulltextExtract.FailureResult)
+        extends StringSearchResult[Nothing]
+    def fulltextMismatch[A](error: FulltextExtract.FailureResult): StringSearchResult[A] =
+      FulltextMismatch(error)
+
+    case class Success[A](value: A) extends StringSearchResult[A]
+  }
+
   final case class Settings(
       batch: Batch,
       useFTS: Boolean,
@@ -104,19 +118,49 @@ object OSimpleSearch {
       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
+    )(fix: Query.Fix, q: ItemQueryString): F[StringSearchResult[Items]] = {
+      val parsed: Either[StringSearchResult[Items], ItemQuery] =
+        ItemQueryParser.parse(q.query).leftMap(StringSearchResult.parseFailed)
+
+      def makeQuery(iq: ItemQuery): F[StringSearchResult[Items]] =
+        iq.findFulltext match {
+          case FulltextExtract.Result.Success(expr, ftq) =>
+            search(settings)(Query(fix, Query.QueryExpr(iq.copy(expr = expr))), ftq)
+              .map(StringSearchResult.Success.apply)
+          case other: FulltextExtract.FailureResult =>
+            StringSearchResult.fulltextMismatch[Items](other).pure[F]
+        }
+
+      parsed match {
+        case Right(iq) =>
+          makeQuery(iq)
+        case Left(err) =>
+          err.pure[F]
+      }
+    }
 
     def searchSummaryByString(
         useFTS: Boolean
-    )(fix: Query.Fix, q: ItemQueryString): Either[ParseFailure, F[SearchSummary]] =
-      ItemQueryParser
-        .parse(q.query)
-        .map(iq => Query(fix, Query.QueryExpr(iq)))
-        .map(searchSummary(useFTS)(_, None)) //TODO resolve content:xyz expressions
+    )(fix: Query.Fix, q: ItemQueryString): F[StringSearchResult[SearchSummary]] = {
+      val parsed: Either[StringSearchResult[SearchSummary], ItemQuery] =
+        ItemQueryParser.parse(q.query).leftMap(StringSearchResult.parseFailed)
+
+      def makeQuery(iq: ItemQuery): F[StringSearchResult[SearchSummary]] =
+        iq.findFulltext match {
+          case FulltextExtract.Result.Success(expr, ftq) =>
+            searchSummary(useFTS)(Query(fix, Query.QueryExpr(iq.copy(expr = expr))), ftq)
+              .map(StringSearchResult.Success.apply)
+          case other: FulltextExtract.FailureResult =>
+            StringSearchResult.fulltextMismatch[SearchSummary](other).pure[F]
+        }
+
+      parsed match {
+        case Right(iq) =>
+          makeQuery(iq)
+        case Left(err) =>
+          err.pure[F]
+      }
+    }
 
     def searchSummary(
         useFTS: Boolean
diff --git a/modules/query/shared/src/main/scala/docspell/query/FulltextExtract.scala b/modules/query/shared/src/main/scala/docspell/query/FulltextExtract.scala
new file mode 100644
index 00000000..769df244
--- /dev/null
+++ b/modules/query/shared/src/main/scala/docspell/query/FulltextExtract.scala
@@ -0,0 +1,71 @@
+package docspell.query
+
+import cats._
+import cats.implicits._
+
+import docspell.query.ItemQuery.Expr.AndExpr
+import docspell.query.ItemQuery.Expr.NotExpr
+import docspell.query.ItemQuery.Expr.OrExpr
+import docspell.query.ItemQuery._
+
+/** Currently, fulltext in a query is only supported when in "root
+  * AND" position
+  */
+object FulltextExtract {
+
+  sealed trait Result
+  sealed trait SuccessResult extends Result
+  sealed trait FailureResult extends Result
+  object Result {
+    case class Success(query: Expr, fts: Option[String]) extends SuccessResult
+    case object TooMany                                  extends FailureResult
+    case object UnsupportedPosition                      extends FailureResult
+  }
+
+  def findFulltext(expr: Expr): Result =
+    lookForFulltext(expr)
+
+  private def lookForFulltext(expr: Expr): Result =
+    expr match {
+      case Expr.Fulltext(ftq) =>
+        Result.Success(ItemQuery.all.expr, ftq.some)
+      case Expr.AndExpr(inner) =>
+        inner.collect({ case Expr.Fulltext(fq) => fq }) match {
+          case Nil =>
+            checkPosition(expr, 0)
+          case e :: Nil =>
+            val c = foldMap(isFulltextExpr)(expr)
+            if (c > 1) Result.TooMany
+            else Result.Success(expr, e.some)
+          case _ =>
+            Result.TooMany
+        }
+      case _ =>
+        checkPosition(expr, 0)
+    }
+
+  private def checkPosition(expr: Expr, max: Int): Result = {
+    val c = foldMap(isFulltextExpr)(expr)
+    if (c > max) Result.UnsupportedPosition
+    else Result.Success(expr, None)
+  }
+
+  private def foldMap[B: Monoid](f: Expr => B)(expr: Expr): B =
+    expr match {
+      case OrExpr(inner) =>
+        inner.map(foldMap(f)).fold
+      case AndExpr(inner) =>
+        inner.map(foldMap(f)).fold
+      case NotExpr(e) =>
+        f(e)
+      case _ =>
+        f(expr)
+    }
+
+  private def isFulltextExpr(expr: Expr): Int =
+    expr match {
+      case Expr.Fulltext(_) => 1
+      case _                => 0
+    }
+
+}
diff --git a/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala b/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala
index acf2307a..eb927a87 100644
--- a/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala
+++ b/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala
@@ -11,7 +11,10 @@ import docspell.query.ItemQuery.Attr.{DateAttr, StringAttr}
   * against a specific field of an item using some operator or a
   * combination thereof.
   */
-final case class ItemQuery(expr: ItemQuery.Expr, raw: Option[String])
+final case class ItemQuery(expr: ItemQuery.Expr, raw: Option[String]) {
+  def findFulltext: FulltextExtract.Result =
+    FulltextExtract.findFulltext(expr)
+}
 
 object ItemQuery {
   val all = ItemQuery(Expr.Exists(Attr.ItemId), Some(""))
diff --git a/modules/query/shared/src/test/scala/docspell/query/FulltextExtractTest.scala b/modules/query/shared/src/test/scala/docspell/query/FulltextExtractTest.scala
new file mode 100644
index 00000000..0c3b555a
--- /dev/null
+++ b/modules/query/shared/src/test/scala/docspell/query/FulltextExtractTest.scala
@@ -0,0 +1,57 @@
+package docspell.query
+
+import cats.implicits._
+import munit._
+import docspell.query.FulltextExtract.Result
+
+class FulltextExtractTest extends FunSuite {
+
+  def findFts(q: String): Result = {
+    val p = ItemQueryParser.parseUnsafe(q)
+    FulltextExtract.findFulltext(p.expr)
+  }
+
+  def assertFts(qstr: String, expect: Result) =
+    assertEquals(findFts(qstr), expect)
+
+  def assertFtsSuccess(qstr: String, expect: Option[String]) = {
+    val q = ItemQueryParser.parseUnsafe(qstr)
+    assertEquals(findFts(qstr), Result.Success(q.expr, expect))
+  }
+
+  test("find fulltext as root") {
+    assertEquals(findFts("content:what"), Result.Success(ItemQuery.all.expr, "what".some))
+    assertEquals(
+      findFts("content:\"what hello\""),
+      Result.Success(ItemQuery.all.expr, "what hello".some)
+    )
+    assertEquals(
+      findFts("content:\"what OR hello\""),
+      Result.Success(ItemQuery.all.expr, "what OR hello".some)
+    )
+  }
+
+  test("find no fulltext") {
+    assertFtsSuccess("name:test", None)
+  }
+
+  test("find fulltext within and") {
+    assertFtsSuccess("content:what name:test", "what".some)
+    assertFtsSuccess("$names:marc* content:what name:test", "what".some)
+    assertFtsSuccess(
+      "$names:marc* date:2021-02 content:\"what else\" name:test",
+      "what else".some
+    )
+  }
+
+  test("too many fulltext searches") {
+    assertFts("content:yes content:no", Result.TooMany)
+    assertFts("content:yes (| name:test content:no)", Result.TooMany)
+    assertFts("content:yes (| name:test (& date:2021-02 content:no))", Result.TooMany)
+  }
+
+  test("wrong fulltext search position") {
+    assertFts("name:test (| date:2021-02 content:yes)", Result.UnsupportedPosition)
+    assertFts("name:test (& date:2021-02 content:yes)", Result.UnsupportedPosition) //TODO
+  }
+}
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 9a0fd9cc..9422fdb0 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala
@@ -11,8 +11,11 @@ import docspell.backend.ops.OCustomFields.{RemoveValue, SetValue}
 import docspell.backend.ops.OFulltext
 import docspell.backend.ops.OItemSearch.{Batch, Query}
 import docspell.backend.ops.OSimpleSearch
+import docspell.backend.ops.OSimpleSearch.StringSearchResult
 import docspell.common._
 import docspell.common.syntax.all._
+import docspell.query.FulltextExtract.Result.TooMany
+import docspell.query.FulltextExtract.Result.UnsupportedPosition
 import docspell.restapi.model._
 import docspell.restserver.Config
 import docspell.restserver.conv.Conversions
@@ -60,31 +63,12 @@ object ItemRoutes {
           cfg.maxNoteLength
         )
         val fixQuery = Query.Fix(user.account, None, None)
-        backend.simpleSearch.searchByString(settings)(fixQuery, itemQuery) match {
-          case Right(results) =>
-            val items = results.map(
-              _.fold(
-                Conversions.mkItemListFts,
-                Conversions.mkItemListWithTagsFts,
-                Conversions.mkItemList,
-                Conversions.mkItemListWithTags
-              )
-            )
-            Ok(items)
-          case Left(fail) =>
-            BadRequest(BasicResult(false, fail.render))
-        }
+        searchItems(backend, dsl)(settings, fixQuery, itemQuery)
 
       case GET -> Root / "searchStats" :? QP.Query(q) =>
         val itemQuery = ItemQueryString(q)
         val fixQuery  = Query.Fix(user.account, None, None)
-        backend.simpleSearch
-          .searchSummaryByString(cfg.fullTextSearch.enabled)(fixQuery, itemQuery) match {
-          case Right(summary) =>
-            summary.flatMap(s => Ok(Conversions.mkSearchStats(s)))
-          case Left(fail) =>
-            BadRequest(BasicResult(false, fail.render))
-        }
+        searchItemStats(backend, dsl)(cfg.fullTextSearch.enabled, fixQuery, itemQuery)
 
       case req @ POST -> Root / "search" =>
         for {
@@ -103,21 +87,7 @@ object ItemRoutes {
             cfg.maxNoteLength
           )
           fixQuery = Query.Fix(user.account, None, None)
-          resp <- backend.simpleSearch
-            .searchByString(settings)(fixQuery, itemQuery) match {
-            case Right(results) =>
-              val items = results.map(
-                _.fold(
-                  Conversions.mkItemListFts,
-                  Conversions.mkItemListWithTagsFts,
-                  Conversions.mkItemList,
-                  Conversions.mkItemListWithTags
-                )
-              )
-              Ok(items)
-            case Left(fail) =>
-              BadRequest(BasicResult(false, fail.render))
-          }
+          resp <- searchItems(backend, dsl)(settings, fixQuery, itemQuery)
         } yield resp
 
       case req @ POST -> Root / "searchStats" =>
@@ -125,16 +95,11 @@ object ItemRoutes {
           userQuery <- req.as[ItemQuery]
           itemQuery = ItemQueryString(userQuery.query)
           fixQuery  = Query.Fix(user.account, None, None)
-          resp <- backend.simpleSearch
-            .searchSummaryByString(cfg.fullTextSearch.enabled)(
-              fixQuery,
-              itemQuery
-            ) match {
-            case Right(summary) =>
-              summary.flatMap(s => Ok(Conversions.mkSearchStats(s)))
-            case Left(fail) =>
-              BadRequest(BasicResult(false, fail.render))
-          }
+          resp <- searchItemStats(backend, dsl)(
+            cfg.fullTextSearch.enabled,
+            fixQuery,
+            itemQuery
+          )
         } yield resp
 
       //DEPRECATED
@@ -526,6 +491,63 @@ object ItemRoutes {
     }
   }
 
+  def searchItems[F[_]: Sync](
+      backend: BackendApp[F],
+      dsl: Http4sDsl[F]
+  )(settings: OSimpleSearch.Settings, fixQuery: Query.Fix, itemQuery: ItemQueryString) = {
+    import dsl._
+
+    backend.simpleSearch
+      .searchByString(settings)(fixQuery, itemQuery)
+      .flatMap {
+        case StringSearchResult.Success(items) =>
+          Ok(
+            items.fold(
+              Conversions.mkItemListFts,
+              Conversions.mkItemListWithTagsFts,
+              Conversions.mkItemList,
+              Conversions.mkItemListWithTags
+            )
+          )
+        case StringSearchResult.FulltextMismatch(TooMany) =>
+          BadRequest(BasicResult(false, "Only one fulltext search term is allowed."))
+        case StringSearchResult.FulltextMismatch(UnsupportedPosition) =>
+          BadRequest(
+            BasicResult(
+              false,
+              "Fulltext search must be in root position or inside the first AND."
+            )
+          )
+        case StringSearchResult.ParseFailed(pf) =>
+          BadRequest(BasicResult(false, s"Error reading query: ${pf.render}"))
+      }
+  }
+
+  def searchItemStats[F[_]: Sync](
+      backend: BackendApp[F],
+      dsl: Http4sDsl[F]
+  )(ftsEnabled: Boolean, fixQuery: Query.Fix, itemQuery: ItemQueryString) = {
+    import dsl._
+    backend.simpleSearch
+      .searchSummaryByString(ftsEnabled)(fixQuery, itemQuery)
+      .flatMap {
+        case StringSearchResult.Success(summary) =>
+          Ok(Conversions.mkSearchStats(summary))
+        case StringSearchResult.FulltextMismatch(TooMany) =>
+          BadRequest(BasicResult(false, "Only one fulltext search term is allowed."))
+        case StringSearchResult.FulltextMismatch(UnsupportedPosition) =>
+          BadRequest(
+            BasicResult(
+              false,
+              "Fulltext search must be in root position or inside the first AND."
+            )
+          )
+        case StringSearchResult.ParseFailed(pf) =>
+          BadRequest(BasicResult(false, s"Error reading query: ${pf.render}"))
+      }
+
+  }
+
   implicit final class OptionString(opt: Option[String]) {
     def notEmpty: Option[String] =
       opt.map(_.trim).filter(_.nonEmpty)

From 7638dc511151cfac0095606a8af677c64b072246 Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Sun, 7 Mar 2021 12:36:13 +0100
Subject: [PATCH 24/33] Flatten nested and/or queries

---
 .../docspell/query/internal/ExprUtil.scala    | 27 ++++++++++++++++---
 .../docspell/query/FulltextExtractTest.scala  |  2 +-
 .../query/internal/ItemQueryParserTest.scala  | 18 +++++++++++++
 3 files changed, 42 insertions(+), 5 deletions(-)

diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala b/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala
index a1fa2045..3a82dd1f 100644
--- a/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala
+++ b/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala
@@ -1,5 +1,7 @@
 package docspell.query.internal
 
+import cats.data.{NonEmptyList => Nel}
+
 import docspell.query.ItemQuery.Expr._
 import docspell.query.ItemQuery._
 
@@ -11,12 +13,14 @@ object ExprUtil {
   def reduce(expr: Expr): Expr =
     expr match {
       case AndExpr(inner) =>
-        if (inner.tail.isEmpty) reduce(inner.head)
-        else AndExpr(inner.map(reduce))
+        val nodes = spliceAnd(inner)
+        if (nodes.tail.isEmpty) reduce(nodes.head)
+        else AndExpr(nodes.map(reduce))
 
       case OrExpr(inner) =>
-        if (inner.tail.isEmpty) reduce(inner.head)
-        else OrExpr(inner.map(reduce))
+        val nodes = spliceOr(inner)
+        if (nodes.tail.isEmpty) reduce(nodes.head)
+        else OrExpr(nodes.map(reduce))
 
       case NotExpr(inner) =>
         inner match {
@@ -62,4 +66,19 @@ object ExprUtil {
       case CustomFieldIdMatch(_, _, _) =>
         expr
     }
+
+  private def spliceAnd(nodes: Nel[Expr]): Nel[Expr] =
+    nodes.flatMap {
+      case Expr.AndExpr(inner) =>
+        spliceAnd(inner)
+      case node =>
+        Nel.of(node)
+    }
+  private def spliceOr(nodes: Nel[Expr]): Nel[Expr] =
+    nodes.flatMap {
+      case Expr.OrExpr(inner) =>
+        spliceOr(inner)
+      case node =>
+        Nel.of(node)
+    }
 }
diff --git a/modules/query/shared/src/test/scala/docspell/query/FulltextExtractTest.scala b/modules/query/shared/src/test/scala/docspell/query/FulltextExtractTest.scala
index 0c3b555a..8b1fe312 100644
--- a/modules/query/shared/src/test/scala/docspell/query/FulltextExtractTest.scala
+++ b/modules/query/shared/src/test/scala/docspell/query/FulltextExtractTest.scala
@@ -52,6 +52,6 @@ class FulltextExtractTest extends FunSuite {
 
   test("wrong fulltext search position") {
     assertFts("name:test (| date:2021-02 content:yes)", Result.UnsupportedPosition)
-    assertFts("name:test (& date:2021-02 content:yes)", Result.UnsupportedPosition) //TODO
+    assertFtsSuccess("name:test (& date:2021-02 content:yes)", "yes".some)
   }
 }
diff --git a/modules/query/shared/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala b/modules/query/shared/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala
index 61dfdf86..52e35fc4 100644
--- a/modules/query/shared/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala
+++ b/modules/query/shared/src/test/scala/docspell/query/internal/ItemQueryParserTest.scala
@@ -1,5 +1,7 @@
 package docspell.query.internal
 
+import cats.implicits._
+
 import munit._
 import docspell.query.ItemQueryParser
 import docspell.query.ItemQuery
@@ -40,4 +42,20 @@ class ItemQueryParserTest extends FunSuite {
     val q = ItemQueryParser.parseUnsafe("")
     assertEquals(ItemQuery.all, q)
   }
+
+  test("splice inner and nodes") {
+    val raw = "(& name:hello (& date:2021-02 name:world) (& name:hello) )"
+    val q   = ItemQueryParser.parseUnsafe(raw)
+    val expect =
+      ItemQueryParser.parseUnsafe("name:hello date:2021-02 name:world name:hello")
+    assertEquals(expect.copy(raw = raw.some), q)
+  }
+
+  test("splice inner or nodes") {
+    val raw = "(| name:hello (| date:2021-02 name:world) (| name:hello) )"
+    val q   = ItemQueryParser.parseUnsafe(raw)
+    val expect =
+      ItemQueryParser.parseUnsafe("(| name:hello date:2021-02 name:world name:hello )")
+    assertEquals(expect.copy(raw = raw.some), q)
+  }
 }

From 31e2e99c36fed832c272100e6f9f0dbe4b32a378 Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Sun, 7 Mar 2021 13:34:35 +0100
Subject: [PATCH 25/33] Add a `$year` shortcut for selecting items within some
 year

---
 .../shared/src/main/scala/docspell/query/ItemQuery.scala  | 8 ++++++++
 .../main/scala/docspell/query/internal/DateParser.scala   | 3 +++
 .../main/scala/docspell/query/internal/MacroParser.scala  | 8 +++++++-
 3 files changed, 18 insertions(+), 1 deletion(-)

diff --git a/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala b/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala
index eb927a87..61bec94d 100644
--- a/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala
+++ b/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala
@@ -131,6 +131,14 @@ object ItemQuery {
         and(date(Operator.Gte, attr, left), date(Operator.Lte, attr, right))
     }
 
+    case class YearMacro(attr: DateAttr, year: Int) extends MacroExpr {
+      val body =
+        DateRangeMacro(attr, date(year), date(year + 1))
+
+      private def date(y: Int): Date =
+        Date(y, 1, 1).fold(throw _, identity)
+    }
+
     def or(expr0: Expr, exprs: Expr*): OrExpr =
       OrExpr(Nel.of(expr0, exprs: _*))
 
diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/DateParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/DateParser.scala
index 33e0d556..02d99926 100644
--- a/modules/query/shared/src/main/scala/docspell/query/internal/DateParser.scala
+++ b/modules/query/shared/src/main/scala/docspell/query/internal/DateParser.scala
@@ -43,6 +43,9 @@ object DateParser {
   private val dateFromToday: P[Date.DateLiteral] =
     P.string("today").as(Date.Today)
 
+  val yearOnly: P[Int] =
+    digits4
+
   val dateLiteral: P[Date.DateLiteral] =
     P.oneOf(List(dateFromString, dateFromToday, dateFromMillis))
 
diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/MacroParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/MacroParser.scala
index 856a2d96..0cfdda0f 100644
--- a/modules/query/shared/src/main/scala/docspell/query/internal/MacroParser.scala
+++ b/modules/query/shared/src/main/scala/docspell/query/internal/MacroParser.scala
@@ -16,6 +16,9 @@ object MacroParser {
       Expr.DateRangeMacro(attr, left, right)
     }
 
+  private def yearMacroImpl(name: String, attr: Attr.DateAttr): P[Expr.YearMacro] =
+    (macroDef(name) *> DateParser.yearOnly).map(year => Expr.YearMacro(attr, year))
+
   val namesMacro: P[Expr.NamesMacro] =
     (macroDef("names") *> BasicParser.singleString).map(Expr.NamesMacro.apply)
 
@@ -25,9 +28,12 @@ object MacroParser {
   val dueDateRangeMacro: P[Expr.DateRangeMacro] =
     dateRangeMacroImpl("duein", Attr.DueDate)
 
+  val yearDateMacro: P[Expr.YearMacro] =
+    yearMacroImpl("year", Attr.Date)
+
   // --- all macro parser
 
   val all: P[Expr] =
-    P.oneOf(List(namesMacro, dateRangeMacro, dueDateRangeMacro))
+    P.oneOf(List(namesMacro, dateRangeMacro, dueDateRangeMacro, yearDateMacro))
 
 }

From c6032ff27965d2fa310436d750ca1efd127aafde Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Sun, 7 Mar 2021 23:46:31 +0100
Subject: [PATCH 26/33] Check query in client

---
 .../src/main/elm/Comp/PowerSearchInput.elm    | 177 ++++++++++++++++++
 .../webapp/src/main/elm/Data/ItemQuery.elm    |   4 +-
 .../src/main/elm/Data/QueryParseResult.elm    |  14 ++
 .../webapp/src/main/elm/Page/Home/Data.elm    |   9 +-
 .../webapp/src/main/elm/Page/Home/Update.elm  |  24 ++-
 .../webapp/src/main/elm/Page/Home/View2.elm   |  18 +-
 modules/webapp/src/main/elm/Ports.elm         |  12 +-
 modules/webapp/src/main/elm/Styles.elm        |   7 +-
 modules/webapp/src/main/webjar/docspell.js    |  26 +++
 9 files changed, 267 insertions(+), 24 deletions(-)
 create mode 100644 modules/webapp/src/main/elm/Comp/PowerSearchInput.elm
 create mode 100644 modules/webapp/src/main/elm/Data/QueryParseResult.elm

diff --git a/modules/webapp/src/main/elm/Comp/PowerSearchInput.elm b/modules/webapp/src/main/elm/Comp/PowerSearchInput.elm
new file mode 100644
index 00000000..2a868fa0
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/PowerSearchInput.elm
@@ -0,0 +1,177 @@
+module Comp.PowerSearchInput exposing
+    ( Action(..)
+    , Model
+    , Msg
+    , init
+    , update
+    , viewInput
+    , viewResult
+    )
+
+import Data.DropdownStyle
+import Data.QueryParseResult exposing (QueryParseResult)
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onInput)
+import Ports
+import Styles as S
+import Throttle exposing (Throttle)
+import Time
+import Util.Html exposing (KeyCode(..))
+import Util.Maybe
+
+
+type alias Model =
+    { input : Maybe String
+    , result : QueryParseResult
+    , parseThrottle : Throttle Msg
+    }
+
+
+init : Model
+init =
+    { input = Nothing
+    , result = Data.QueryParseResult.success
+    , parseThrottle = Throttle.create 1
+    }
+
+
+type Msg
+    = SetSearch String
+    | KeyUpMsg (Maybe KeyCode)
+    | ParseResultMsg QueryParseResult
+    | UpdateThrottle
+
+
+type Action
+    = NoAction
+    | SubmitSearch
+
+
+type alias Result =
+    { model : Model
+    , cmd : Cmd Msg
+    , action : Action
+    , subs : Sub Msg
+    }
+
+
+
+--- Update
+
+
+update : Msg -> Model -> Result
+update msg model =
+    case msg of
+        SetSearch str ->
+            let
+                parseCmd =
+                    Ports.checkSearchQueryString str
+
+                parseSub =
+                    Ports.receiveCheckQueryResult ParseResultMsg
+
+                ( newThrottle, cmd ) =
+                    Throttle.try parseCmd model.parseThrottle
+
+                model_ =
+                    { model
+                        | input = Util.Maybe.fromString str
+                        , parseThrottle = newThrottle
+                        , result =
+                            if str == "" then
+                                Data.QueryParseResult.success
+
+                            else
+                                model.result
+                    }
+            in
+            { model = model_
+            , cmd = cmd
+            , action = NoAction
+            , subs = Sub.batch [ throttleUpdate model_, parseSub ]
+            }
+
+        KeyUpMsg (Just Enter) ->
+            Result model Cmd.none SubmitSearch Sub.none
+
+        KeyUpMsg _ ->
+            let
+                parseSub =
+                    Ports.receiveCheckQueryResult ParseResultMsg
+            in
+            Result model Cmd.none NoAction (Sub.batch [ throttleUpdate model, parseSub ])
+
+        ParseResultMsg lm ->
+            Result { model | result = lm } Cmd.none NoAction Sub.none
+
+        UpdateThrottle ->
+            let
+                parseSub =
+                    Ports.receiveCheckQueryResult ParseResultMsg
+
+                ( newThrottle, cmd ) =
+                    Throttle.update model.parseThrottle
+
+                model_ =
+                    { model | parseThrottle = newThrottle }
+            in
+            { model = model_
+            , cmd = cmd
+            , action = NoAction
+            , subs = Sub.batch [ throttleUpdate model_, parseSub ]
+            }
+
+
+throttleUpdate : Model -> Sub Msg
+throttleUpdate model =
+    Throttle.ifNeeded
+        (Time.every 100 (\_ -> UpdateThrottle))
+        model.parseThrottle
+
+
+
+--- View
+
+
+viewInput : List (Attribute Msg) -> Model -> Html Msg
+viewInput attrs model =
+    input
+        (attrs
+            ++ [ type_ "text"
+               , placeholder "Search query …"
+               , onInput SetSearch
+               , Util.Html.onKeyUpCode KeyUpMsg
+               , Maybe.map value model.input
+                    |> Maybe.withDefault (value "")
+               , class S.textInput
+               , class "text-sm "
+               ]
+        )
+        []
+
+
+viewResult : List ( String, Bool ) -> Model -> Html Msg
+viewResult classes model =
+    div
+        [ classList [ ( "hidden", model.result.success ) ]
+        , classList classes
+        , class resultStyle
+        ]
+        [ p [ class "font-mono text-sm" ]
+            [ text model.result.input
+            ]
+        , pre [ class "font-mono text-sm" ]
+            [ List.repeat model.result.index " "
+                |> String.join ""
+                |> text
+            , text "^"
+            ]
+        , ul []
+            (List.map (\line -> li [] [ text line ]) model.result.messages)
+        ]
+
+
+resultStyle : String
+resultStyle =
+    S.warnMessageColors ++ " absolute left-0 max-h-44 w-full overflow-y-auto z-50 shadow-lg transition duration-200 top-9 border-0 border-b border-l border-r rounded-b px-2 py-2"
diff --git a/modules/webapp/src/main/elm/Data/ItemQuery.elm b/modules/webapp/src/main/elm/Data/ItemQuery.elm
index d01464b8..54a54e8e 100644
--- a/modules/webapp/src/main/elm/Data/ItemQuery.elm
+++ b/modules/webapp/src/main/elm/Data/ItemQuery.elm
@@ -106,8 +106,8 @@ render q =
                     "="
 
         quoteStr =
-            --TODO escape quotes
-            surround "\""
+            String.replace "\"" "\\\""
+                >> surround "\""
     in
     case q of
         And inner ->
diff --git a/modules/webapp/src/main/elm/Data/QueryParseResult.elm b/modules/webapp/src/main/elm/Data/QueryParseResult.elm
new file mode 100644
index 00000000..bb0046ba
--- /dev/null
+++ b/modules/webapp/src/main/elm/Data/QueryParseResult.elm
@@ -0,0 +1,14 @@
+module Data.QueryParseResult exposing (QueryParseResult, success)
+
+
+type alias QueryParseResult =
+    { success : Bool
+    , input : String
+    , index : Int
+    , messages : List String
+    }
+
+
+success : QueryParseResult
+success =
+    QueryParseResult True "" 0 []
diff --git a/modules/webapp/src/main/elm/Page/Home/Data.elm b/modules/webapp/src/main/elm/Page/Home/Data.elm
index 809d2526..7e6246cb 100644
--- a/modules/webapp/src/main/elm/Page/Home/Data.elm
+++ b/modules/webapp/src/main/elm/Page/Home/Data.elm
@@ -27,6 +27,7 @@ import Comp.ItemCardList
 import Comp.ItemDetail.FormChange exposing (FormChange)
 import Comp.ItemDetail.MultiEditMenu exposing (SaveNameState(..))
 import Comp.LinkTarget exposing (LinkTarget)
+import Comp.PowerSearchInput
 import Comp.SearchMenu
 import Comp.YesNoDimmer
 import Data.Flags exposing (Flags)
@@ -56,7 +57,7 @@ type alias Model =
     , dragDropData : DD.DragDropData
     , scrollToCard : Maybe String
     , searchStats : SearchStats
-    , powerSearchInput : Maybe String
+    , powerSearchInput : Comp.PowerSearchInput.Model
     }
 
 
@@ -122,7 +123,7 @@ init flags viewMode =
     , scrollToCard = Nothing
     , viewMode = viewMode
     , searchStats = Api.Model.SearchStats.empty
-    , powerSearchInput = Nothing
+    , powerSearchInput = Comp.PowerSearchInput.init
     }
 
 
@@ -196,7 +197,7 @@ type Msg
     | SetLinkTarget LinkTarget
     | SearchStatsResp (Result Http.Error SearchStats)
     | TogglePreviewFullWidth
-    | SetPowerSearch String
+    | PowerSearchMsg Comp.PowerSearchInput.Msg
     | KeyUpPowerSearchbarMsg (Maybe KeyCode)
 
 
@@ -247,7 +248,7 @@ doSearchDefaultCmd param model =
             Q.request <|
                 Q.and
                     [ Comp.SearchMenu.getItemQuery model.searchMenuModel
-                    , Maybe.map Q.Fragment model.powerSearchInput
+                    , Maybe.map Q.Fragment model.powerSearchInput.input
                     ]
 
         mask =
diff --git a/modules/webapp/src/main/elm/Page/Home/Update.elm b/modules/webapp/src/main/elm/Page/Home/Update.elm
index d161d58c..9d8efee9 100644
--- a/modules/webapp/src/main/elm/Page/Home/Update.elm
+++ b/modules/webapp/src/main/elm/Page/Home/Update.elm
@@ -1,15 +1,14 @@
 module Page.Home.Update exposing (update)
 
 import Api
-import Api.Model.IdList exposing (IdList)
 import Api.Model.ItemLightList exposing (ItemLightList)
-import Api.Model.ItemQuery
 import Browser.Navigation as Nav
 import Comp.FixedDropdown
 import Comp.ItemCardList
 import Comp.ItemDetail.FormChange exposing (FormChange(..))
 import Comp.ItemDetail.MultiEditMenu exposing (SaveNameState(..))
 import Comp.LinkTarget exposing (LinkTarget)
+import Comp.PowerSearchInput
 import Comp.SearchMenu
 import Comp.YesNoDimmer
 import Data.Flags exposing (Flags)
@@ -54,7 +53,7 @@ update mId key flags settings msg model =
         ResetSearch ->
             let
                 nm =
-                    { model | searchOffset = 0, powerSearchInput = Nothing }
+                    { model | searchOffset = 0, powerSearchInput = Comp.PowerSearchInput.init }
             in
             update mId key flags settings (SearchMenuMsg Comp.SearchMenu.ResetForm) nm
 
@@ -580,8 +579,23 @@ update mId key flags settings msg model =
             in
             noSub ( model, cmd )
 
-        SetPowerSearch str ->
-            noSub ( { model | powerSearchInput = Util.Maybe.fromString str }, Cmd.none )
+        PowerSearchMsg lm ->
+            let
+                result =
+                    Comp.PowerSearchInput.update lm model.powerSearchInput
+
+                cmd_ =
+                    Cmd.map PowerSearchMsg result.cmd
+
+                model_ =
+                    { model | powerSearchInput = result.model }
+            in
+            case result.action of
+                Comp.PowerSearchInput.NoAction ->
+                    ( model_, cmd_, Sub.map PowerSearchMsg result.subs )
+
+                Comp.PowerSearchInput.SubmitSearch ->
+                    update mId key flags settings (DoSearch model_.searchTypeDropdownValue) model_
 
         KeyUpPowerSearchbarMsg (Just Enter) ->
             update mId key flags settings (DoSearch model.searchTypeDropdownValue) model
diff --git a/modules/webapp/src/main/elm/Page/Home/View2.elm b/modules/webapp/src/main/elm/Page/Home/View2.elm
index 82db65c7..4b6d6f8e 100644
--- a/modules/webapp/src/main/elm/Page/Home/View2.elm
+++ b/modules/webapp/src/main/elm/Page/Home/View2.elm
@@ -3,6 +3,7 @@ module Page.Home.View2 exposing (viewContent, viewSidebar)
 import Comp.Basic as B
 import Comp.ItemCardList
 import Comp.MenuBar as MB
+import Comp.PowerSearchInput
 import Comp.SearchMenu
 import Comp.SearchStatsView
 import Comp.YesNoDimmer
@@ -135,17 +136,12 @@ defaultMenuBar _ settings model =
         powerSearchBar =
             div
                 [ class "relative flex flex-grow flex-row" ]
-                [ input
-                    [ type_ "text"
-                    , placeholder "Search query …"
-                    , onInput SetPowerSearch
-                    , Util.Html.onKeyUpCode KeyUpPowerSearchbarMsg
-                    , Maybe.map value model.powerSearchInput
-                        |> Maybe.withDefault (value "")
-                    , class S.textInput
-                    , class "text-sm "
-                    ]
-                    []
+                [ Html.map PowerSearchMsg
+                    (Comp.PowerSearchInput.viewInput []
+                        model.powerSearchInput
+                    )
+                , Html.map PowerSearchMsg
+                    (Comp.PowerSearchInput.viewResult [] model.powerSearchInput)
                 ]
     in
     MB.view
diff --git a/modules/webapp/src/main/elm/Ports.elm b/modules/webapp/src/main/elm/Ports.elm
index a8874539..9f630a81 100644
--- a/modules/webapp/src/main/elm/Ports.elm
+++ b/modules/webapp/src/main/elm/Ports.elm
@@ -1,8 +1,10 @@
 port module Ports exposing
-    ( getUiSettings
+    ( checkSearchQueryString
+    , getUiSettings
     , initClipboard
     , loadUiSettings
     , onUiSettingsSaved
+    , receiveCheckQueryResult
     , removeAccount
     , setAccount
     , setUiTheme
@@ -10,7 +12,9 @@ port module Ports exposing
     )
 
 import Api.Model.AuthResult exposing (AuthResult)
+import Api.Model.BasicResult exposing (BasicResult)
 import Data.Flags exposing (Flags)
+import Data.QueryParseResult exposing (QueryParseResult)
 import Data.UiSettings exposing (StoredUiSettings, UiSettings)
 import Data.UiTheme exposing (UiTheme)
 
@@ -38,6 +42,12 @@ port uiSettingsSaved : (() -> msg) -> Sub msg
 port internalSetUiTheme : String -> Cmd msg
 
 
+port checkSearchQueryString : String -> Cmd msg
+
+
+port receiveCheckQueryResult : (QueryParseResult -> msg) -> Sub msg
+
+
 setUiTheme : UiTheme -> Cmd msg
 setUiTheme theme =
     internalSetUiTheme (Data.UiTheme.toString theme)
diff --git a/modules/webapp/src/main/elm/Styles.elm b/modules/webapp/src/main/elm/Styles.elm
index 56a727bc..2d917c49 100644
--- a/modules/webapp/src/main/elm/Styles.elm
+++ b/modules/webapp/src/main/elm/Styles.elm
@@ -43,7 +43,12 @@ errorMessage =
 
 warnMessage : String
 warnMessage =
-    " border border-yellow-800 bg-yellow-50 text-yellow-800 dark:border-amber-200 dark:bg-amber-800 dark:text-amber-200 dark:bg-opacity-25 px-2 py-2 rounded "
+    warnMessageColors ++ " border dark:bg-opacity-25 px-2 py-2 rounded "
+
+
+warnMessageColors : String
+warnMessageColors =
+    " border-yellow-800 bg-yellow-50 text-yellow-800 dark:border-amber-200 dark:bg-amber-800 dark:text-amber-200 "
 
 
 infoMessage : String
diff --git a/modules/webapp/src/main/webjar/docspell.js b/modules/webapp/src/main/webjar/docspell.js
index 17b1901f..d6fcc4c6 100644
--- a/modules/webapp/src/main/webjar/docspell.js
+++ b/modules/webapp/src/main/webjar/docspell.js
@@ -97,3 +97,29 @@ elmApp.ports.initClipboard.subscribe(function(args) {
         docspell_clipboards[page] = new ClipboardJS(sel);
     }
 });
+
+elmApp.ports.checkSearchQueryString.subscribe(function(args) {
+    var qStr = args;
+    if (qStr && DsItemQueryParser && DsItemQueryParser['parseToFailure']) {
+        var result = DsItemQueryParser.parseToFailure(qStr);
+        var answer;
+        if (result) {
+            answer =
+                { success: false,
+                  input: result.input,
+                  index: result.failedAt,
+                  messages: result.messages
+                };
+
+        } else {
+            answer =
+                { success: true,
+                  input: qStr,
+                  index: 0,
+                  messages: []
+                };
+        }
+        console.log("Sending: " + answer.success);
+        elmApp.ports.receiveCheckQueryResult.send(answer);
+    }
+});

From 7b1ec97c97bcdaecc413266ec71e4b5f13e1a75c Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Mon, 8 Mar 2021 00:46:36 +0100
Subject: [PATCH 27/33] Fix sort when using fulltext only

---
 .../docspell/backend/ops/OSimpleSearch.scala  | 46 ++++++++++---------
 .../docspell/query/FulltextExtractTest.scala  |  5 ++
 .../restserver/src/main/resources/logback.xml |  1 -
 .../restserver/conv/Conversions.scala         |  4 ++
 .../restserver/routes/ItemRoutes.scala        | 12 ++++-
 5 files changed, 44 insertions(+), 24 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 e468eaaf..1c5e54df 100644
--- a/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala
+++ b/modules/backend/src/main/scala/docspell/backend/ops/OSimpleSearch.scala
@@ -52,37 +52,41 @@ object OSimpleSearch {
 
   sealed trait Items {
     def fold[A](
-        f1: Vector[OFulltext.FtsItem] => A,
-        f2: Vector[OFulltext.FtsItemWithTags] => A,
+        f1: Items.FtsItems => A,
+        f2: Items.FtsItemsFull => A,
         f3: Vector[OItemSearch.ListItem] => A,
         f4: Vector[OItemSearch.ListItemWithTags] => A
     ): A
 
   }
   object Items {
-    def ftsItems(items: Vector[OFulltext.FtsItem]): Items =
-      FtsItems(items)
+    def ftsItems(indexOnly: Boolean)(items: Vector[OFulltext.FtsItem]): Items =
+      FtsItems(items, indexOnly)
 
-    case class FtsItems(items: Vector[OFulltext.FtsItem]) extends Items {
+    case class FtsItems(items: Vector[OFulltext.FtsItem], indexOnly: Boolean)
+        extends Items {
       def fold[A](
-          f1: Vector[OFulltext.FtsItem] => A,
-          f2: Vector[OFulltext.FtsItemWithTags] => A,
+          f1: FtsItems => A,
+          f2: FtsItemsFull => A,
           f3: Vector[OItemSearch.ListItem] => A,
           f4: Vector[OItemSearch.ListItemWithTags] => A
-      ): A = f1(items)
+      ): A = f1(this)
 
     }
 
-    def ftsItemsFull(items: Vector[OFulltext.FtsItemWithTags]): Items =
-      FtsItemsFull(items)
+    def ftsItemsFull(indexOnly: Boolean)(
+        items: Vector[OFulltext.FtsItemWithTags]
+    ): Items =
+      FtsItemsFull(items, indexOnly)
 
-    case class FtsItemsFull(items: Vector[OFulltext.FtsItemWithTags]) extends Items {
+    case class FtsItemsFull(items: Vector[OFulltext.FtsItemWithTags], indexOnly: Boolean)
+        extends Items {
       def fold[A](
-          f1: Vector[OFulltext.FtsItem] => A,
-          f2: Vector[OFulltext.FtsItemWithTags] => A,
+          f1: FtsItems => A,
+          f2: FtsItemsFull => A,
           f3: Vector[OItemSearch.ListItem] => A,
           f4: Vector[OItemSearch.ListItemWithTags] => A
-      ): A = f2(items)
+      ): A = f2(this)
     }
 
     def itemsPlain(items: Vector[OItemSearch.ListItem]): Items =
@@ -90,8 +94,8 @@ object OSimpleSearch {
 
     case class ItemsPlain(items: Vector[OItemSearch.ListItem]) extends Items {
       def fold[A](
-          f1: Vector[OFulltext.FtsItem] => A,
-          f2: Vector[OFulltext.FtsItemWithTags] => A,
+          f1: FtsItems => A,
+          f2: FtsItemsFull => A,
           f3: Vector[OItemSearch.ListItem] => A,
           f4: Vector[OItemSearch.ListItemWithTags] => A
       ): A = f3(items)
@@ -102,8 +106,8 @@ object OSimpleSearch {
 
     case class ItemsFull(items: Vector[OItemSearch.ListItemWithTags]) extends Items {
       def fold[A](
-          f1: Vector[OFulltext.FtsItem] => A,
-          f2: Vector[OFulltext.FtsItemWithTags] => A,
+          f1: FtsItems => A,
+          f2: FtsItemsFull => A,
           f3: Vector[OItemSearch.ListItem] => A,
           f4: Vector[OItemSearch.ListItemWithTags] => A
       ): A = f4(items)
@@ -190,7 +194,7 @@ object OSimpleSearch {
                 q.fix.account,
                 settings.batch
               )
-              .map(Items.ftsItemsFull)
+              .map(Items.ftsItemsFull(true))
           else if (settings.resolveDetails)
             fts
               .findItemsWithTags(settings.maxNoteLen)(
@@ -198,11 +202,11 @@ object OSimpleSearch {
                 OFulltext.FtsInput(ftq),
                 settings.batch
               )
-              .map(Items.ftsItemsFull)
+              .map(Items.ftsItemsFull(false))
           else
             fts
               .findItems(settings.maxNoteLen)(q, OFulltext.FtsInput(ftq), settings.batch)
-              .map(Items.ftsItems)
+              .map(Items.ftsItems(false))
 
         case _ =>
           if (settings.resolveDetails)
diff --git a/modules/query/shared/src/test/scala/docspell/query/FulltextExtractTest.scala b/modules/query/shared/src/test/scala/docspell/query/FulltextExtractTest.scala
index 8b1fe312..9b6ea799 100644
--- a/modules/query/shared/src/test/scala/docspell/query/FulltextExtractTest.scala
+++ b/modules/query/shared/src/test/scala/docspell/query/FulltextExtractTest.scala
@@ -29,6 +29,11 @@ class FulltextExtractTest extends FunSuite {
       findFts("content:\"what OR hello\""),
       Result.Success(ItemQuery.all.expr, "what OR hello".some)
     )
+
+    assertEquals(
+      findFts("(& content:\"what OR hello\" )"),
+      Result.Success(ItemQuery.all.expr, "what OR hello".some)
+    )
   }
 
   test("find no fulltext") {
diff --git a/modules/restserver/src/main/resources/logback.xml b/modules/restserver/src/main/resources/logback.xml
index d972abcb..f9b2d921 100644
--- a/modules/restserver/src/main/resources/logback.xml
+++ b/modules/restserver/src/main/resources/logback.xml
@@ -9,7 +9,6 @@
 
   <logger name="docspell" level="debug" />
   <logger name="emil" level="debug"/>
-  <logger name="docspell.store.queries.QItem" level="trace"/>
 
   <root level="INFO">
     <appender-ref ref="STDOUT" />
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 a6516bc0..cede3845 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala
@@ -224,6 +224,10 @@ trait Conversions {
     if (v.isEmpty) ItemLightList(Nil)
     else ItemLightList(List(ItemLightGroup("Results", v.map(mkItemLightWithTags).toList)))
 
+  def mkItemListFtsPlain(v: Vector[OFulltext.FtsItem]): ItemLightList =
+    if (v.isEmpty) ItemLightList(Nil)
+    else ItemLightList(List(ItemLightGroup("Results", v.map(mkItemLight).toList)))
+
   def mkItemLight(i: OItemSearch.ListItem): ItemLight =
     ItemLight(
       i.id,
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 9422fdb0..2e7fbbb5 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala
@@ -497,14 +497,22 @@ object ItemRoutes {
   )(settings: OSimpleSearch.Settings, fixQuery: Query.Fix, itemQuery: ItemQueryString) = {
     import dsl._
 
+    def convertFts(res: OSimpleSearch.Items.FtsItems): ItemLightList =
+      if (res.indexOnly) Conversions.mkItemListFtsPlain(res.items)
+      else Conversions.mkItemListFts(res.items)
+
+    def convertFtsFull(res: OSimpleSearch.Items.FtsItemsFull): ItemLightList =
+      if (res.indexOnly) Conversions.mkItemListWithTagsFtsPlain(res.items)
+      else Conversions.mkItemListWithTagsFts(res.items)
+
     backend.simpleSearch
       .searchByString(settings)(fixQuery, itemQuery)
       .flatMap {
         case StringSearchResult.Success(items) =>
           Ok(
             items.fold(
-              Conversions.mkItemListFts,
-              Conversions.mkItemListWithTagsFts,
+              convertFts,
+              convertFtsFull,
               Conversions.mkItemList,
               Conversions.mkItemListWithTags
             )

From 2b2f913e8550f21e08110be05d4b1da7ee084cef Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Mon, 8 Mar 2021 01:53:21 +0100
Subject: [PATCH 28/33] Add checksum query expr

---
 .../main/scala/docspell/query/ItemQuery.scala |  3 +-
 .../docspell/query/internal/ExprUtil.scala    |  2 +
 .../query/internal/SimpleExprParser.scala     |  6 ++-
 .../qb/generator/ItemQueryGenerator.scala     | 13 +++--
 .../scala/docspell/store/queries/QItem.scala  | 47 +++++++++++--------
 5 files changed, 45 insertions(+), 26 deletions(-)

diff --git a/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala b/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala
index 61bec94d..4fce13d2 100644
--- a/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala
+++ b/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala
@@ -109,7 +109,8 @@ object ItemQuery {
     final case class CustomFieldIdMatch(id: String, op: Operator, value: String)
         extends Expr
 
-    final case class Fulltext(query: String) extends Expr
+    final case class Fulltext(query: String)         extends Expr
+    final case class ChecksumMatch(checksum: String) extends Expr
 
     // things that can be expressed with terms above
     sealed trait MacroExpr extends Expr {
diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala b/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala
index 3a82dd1f..f4f8193c 100644
--- a/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala
+++ b/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala
@@ -65,6 +65,8 @@ object ExprUtil {
         expr
       case CustomFieldIdMatch(_, _, _) =>
         expr
+      case ChecksumMatch(_) =>
+        expr
     }
 
   private def spliceAnd(nodes: Nel[Expr]): Nel[Expr] =
diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/SimpleExprParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/SimpleExprParser.scala
index 55b92188..00bf5aa0 100644
--- a/modules/query/shared/src/main/scala/docspell/query/internal/SimpleExprParser.scala
+++ b/modules/query/shared/src/main/scala/docspell/query/internal/SimpleExprParser.scala
@@ -76,6 +76,9 @@ object SimpleExprParser {
   val dirExpr: P[Expr.DirectionExpr] =
     (P.string("incoming:") *> BasicParser.bool).map(Expr.DirectionExpr.apply)
 
+  val checksumExpr: P[Expr.ChecksumMatch] =
+    (P.string("checksum:") *> BasicParser.singleString).map(Expr.ChecksumMatch.apply)
+
   val simpleExpr: P[Expr] =
     P.oneOf(
       List(
@@ -89,7 +92,8 @@ object SimpleExprParser {
         customFieldIdExpr,
         customFieldExpr,
         inboxExpr,
-        dirExpr
+        dirExpr,
+        checksumExpr
       )
     )
 }
diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
index 71760463..c92367fe 100644
--- a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
+++ b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
@@ -3,15 +3,16 @@ package docspell.store.qb.generator
 import java.time.Instant
 import java.time.LocalDate
 
-import cats.data.NonEmptyList
+import cats.data.{NonEmptyList => Nel}
 
 import docspell.common._
 import docspell.query.ItemQuery._
 import docspell.query.{Date, ItemQuery}
 import docspell.store.qb.DSL._
 import docspell.store.qb.{Operator => QOp, _}
+import docspell.store.queries.QItem
 import docspell.store.queries.QueryWildcard
-import docspell.store.records.{RCustomField, RCustomFieldValue, TagItemName}
+import docspell.store.records._
 
 import doobie.util.Put
 
@@ -39,7 +40,7 @@ object ItemQueryGenerator {
 
           case Expr.TagIdsMatch(op, tags) =>
             val ids = tags.toList.flatMap(s => Ident.fromString(s).toOption)
-            NonEmptyList
+            Nel
               .fromList(ids)
               .map { nel =>
                 op match {
@@ -114,7 +115,7 @@ object ItemQueryGenerator {
 
       case Expr.TagIdsMatch(op, tags) =>
         val ids = tags.toList.flatMap(s => Ident.fromString(s).toOption)
-        NonEmptyList
+        Nel
           .fromList(ids)
           .map { nel =>
             op match {
@@ -152,6 +153,10 @@ object ItemQueryGenerator {
       case Expr.CustomFieldIdMatch(field, op, value) =>
         tables.item.id.in(itemsWithCustomField(_.id ==== field)(coll, makeOp(op), value))
 
+      case Expr.ChecksumMatch(checksum) =>
+        val select = QItem.findByChecksumQuery(checksum, coll, Set.empty)
+        tables.item.id.in(select.withSelect(Nel.of(RItem.as("i").id.s)))
+
       case Expr.Fulltext(_) =>
         // not supported here
         Condition.unit
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 a79db262..2e1e1296 100644
--- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala
+++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala
@@ -509,6 +509,16 @@ object QItem {
       collective: Ident,
       excludeFileMeta: Set[Ident]
   ): ConnectionIO[Vector[RItem]] = {
+    val qq = findByChecksumQuery(checksum, collective, excludeFileMeta).build
+    logger.debug(s"FindByChecksum: $qq")
+    qq.query[RItem].to[Vector]
+  }
+
+  def findByChecksumQuery(
+      checksum: String,
+      collective: Ident,
+      excludeFileMeta: Set[Ident]
+  ): Select = {
     val m1  = RFileMeta.as("m1")
     val m2  = RFileMeta.as("m2")
     val m3  = RFileMeta.as("m3")
@@ -517,26 +527,23 @@ object QItem {
     val s   = RAttachmentSource.as("s")
     val r   = RAttachmentArchive.as("r")
     val fms = Nel.of(m1, m2, m3)
-    val qq =
-      Select(
-        select(i.all),
-        from(i)
-          .innerJoin(a, a.itemId === i.id)
-          .innerJoin(s, s.id === a.id)
-          .innerJoin(m1, m1.id === a.fileId)
-          .innerJoin(m2, m2.id === s.fileId)
-          .leftJoin(r, r.id === a.id)
-          .leftJoin(m3, m3.id === r.fileId),
-        where(
-          i.cid === collective &&
-            Condition.Or(fms.map(m => m.checksum === checksum)) &&?
-            Nel
-              .fromList(excludeFileMeta.toList)
-              .map(excl => Condition.And(fms.map(m => m.id.isNull || m.id.notIn(excl))))
-        )
-      ).distinct.build
-    logger.debug(s"FindByChecksum: $qq")
-    qq.query[RItem].to[Vector]
+    Select(
+      select(i.all),
+      from(i)
+        .innerJoin(a, a.itemId === i.id)
+        .innerJoin(s, s.id === a.id)
+        .innerJoin(m1, m1.id === a.fileId)
+        .innerJoin(m2, m2.id === s.fileId)
+        .leftJoin(r, r.id === a.id)
+        .leftJoin(m3, m3.id === r.fileId),
+      where(
+        i.cid === collective &&
+          Condition.Or(fms.map(m => m.checksum === checksum)) &&?
+          Nel
+            .fromList(excludeFileMeta.toList)
+            .map(excl => Condition.And(fms.map(m => m.id.isNull || m.id.notIn(excl))))
+      )
+    ).distinct
   }
 
   final case class NameAndNotes(

From 30c901ddf1824a0b5f084a33dec6eb50073377c0 Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Mon, 8 Mar 2021 09:30:47 +0100
Subject: [PATCH 29/33] Add more ways to query for attachments

- find items with a specified attachment count
- find items by attachment id
---
 .../main/scala/docspell/query/ItemQuery.scala | 21 ++++++++++++-------
 .../docspell/query/internal/AttrParser.scala  | 10 ++++++++-
 .../docspell/query/internal/ExprUtil.scala    |  2 ++
 .../query/internal/SimpleExprParser.scala     | 16 +++++++++++++-
 .../qb/generator/ItemQueryGenerator.scala     | 20 ++++++++++++++++++
 .../docspell/store/qb/generator/Tables.scala  |  4 +++-
 .../store/queries/AttachCountTable.scala      | 16 ++++++++++++++
 .../scala/docspell/store/queries/QItem.scala  | 21 +++++++------------
 .../generator/ItemQueryGeneratorTest.scala    |  4 +++-
 9 files changed, 89 insertions(+), 25 deletions(-)
 create mode 100644 modules/store/src/main/scala/docspell/store/queries/AttachCountTable.scala

diff --git a/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala b/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala
index 4fce13d2..94f3868f 100644
--- a/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala
+++ b/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala
@@ -2,7 +2,7 @@ package docspell.query
 
 import cats.data.{NonEmptyList => Nel}
 
-import docspell.query.ItemQuery.Attr.{DateAttr, StringAttr}
+import docspell.query.ItemQuery.Attr.{DateAttr, IntAttr, StringAttr}
 
 /** A query evaluates to `true` or `false` given enough details about
   * an item.
@@ -40,13 +40,15 @@ object ItemQuery {
   object Attr {
     sealed trait StringAttr extends Attr
     sealed trait DateAttr   extends Attr
+    sealed trait IntAttr    extends Attr
 
-    case object ItemName   extends StringAttr
-    case object ItemSource extends StringAttr
-    case object ItemNotes  extends StringAttr
-    case object ItemId     extends StringAttr
-    case object Date       extends DateAttr
-    case object DueDate    extends DateAttr
+    case object ItemName    extends StringAttr
+    case object ItemSource  extends StringAttr
+    case object ItemNotes   extends StringAttr
+    case object ItemId      extends StringAttr
+    case object Date        extends DateAttr
+    case object DueDate     extends DateAttr
+    case object AttachCount extends IntAttr
 
     object Correspondent {
       case object OrgId      extends StringAttr
@@ -72,12 +74,16 @@ object ItemQuery {
   object Property {
     final case class StringProperty(attr: StringAttr, value: String) extends Property
     final case class DateProperty(attr: DateAttr, value: Date)       extends Property
+    final case class IntProperty(attr: IntAttr, value: Int)          extends Property
 
     def apply(sa: StringAttr, value: String): Property =
       StringProperty(sa, value)
 
     def apply(da: DateAttr, value: Date): Property =
       DateProperty(da, value)
+
+    def apply(na: IntAttr, value: Int): Property =
+      IntProperty(na, value)
   }
 
   sealed trait Expr {
@@ -111,6 +117,7 @@ object ItemQuery {
 
     final case class Fulltext(query: String)         extends Expr
     final case class ChecksumMatch(checksum: String) extends Expr
+    final case class AttachId(id: String)            extends Expr
 
     // things that can be expressed with terms above
     sealed trait MacroExpr extends Expr {
diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/AttrParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/AttrParser.scala
index d5289c67..d7aab3ed 100644
--- a/modules/query/shared/src/main/scala/docspell/query/internal/AttrParser.scala
+++ b/modules/query/shared/src/main/scala/docspell/query/internal/AttrParser.scala
@@ -62,6 +62,14 @@ object AttrParser {
   val folderName: P[Attr.StringAttr] =
     P.ignoreCase("folder").as(Attr.Folder.FolderName)
 
+  val attachCountAttr: P[Attr.IntAttr] =
+    P.ignoreCase("attach.count").as(Attr.AttachCount)
+
+  // combining grouped by type
+
+  val intAttr: P[Attr.IntAttr] =
+    attachCountAttr
+
   val dateAttr: P[Attr.DateAttr] =
     P.oneOf(List(date, dueDate))
 
@@ -86,5 +94,5 @@ object AttrParser {
     )
 
   val anyAttr: P[Attr] =
-    P.oneOf(List(dateAttr, stringAttr))
+    P.oneOf(List(dateAttr, stringAttr, intAttr))
 }
diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala b/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala
index f4f8193c..df81983f 100644
--- a/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala
+++ b/modules/query/shared/src/main/scala/docspell/query/internal/ExprUtil.scala
@@ -67,6 +67,8 @@ object ExprUtil {
         expr
       case ChecksumMatch(_) =>
         expr
+      case AttachId(_) =>
+        expr
     }
 
   private def spliceAnd(nodes: Nel[Expr]): Nel[Expr] =
diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/SimpleExprParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/SimpleExprParser.scala
index 00bf5aa0..56a0077a 100644
--- a/modules/query/shared/src/main/scala/docspell/query/internal/SimpleExprParser.scala
+++ b/modules/query/shared/src/main/scala/docspell/query/internal/SimpleExprParser.scala
@@ -1,5 +1,6 @@
 package docspell.query.internal
 
+import cats.parse.Numbers
 import cats.parse.{Parser => P}
 
 import docspell.query.ItemQuery._
@@ -18,6 +19,9 @@ object SimpleExprParser {
   private[this] val inOrOpDate =
     P.eitherOr(op ~ DateParser.date, inOp *> DateParser.dateOrMore)
 
+  private[this] val opInt =
+    op ~ Numbers.digits.map(_.toInt)
+
   val stringExpr: P[Expr] =
     (AttrParser.stringAttr ~ inOrOpStr).map {
       case (attr, Right((op, value))) =>
@@ -34,6 +38,11 @@ object SimpleExprParser {
         Expr.InDateExpr(attr, values)
     }
 
+  val intExpr: P[Expr] =
+    (AttrParser.intAttr ~ opInt).map { case (attr, (op, value)) =>
+      Expr.SimpleExpr(op, Property(attr, value))
+    }
+
   val existsExpr: P[Expr.Exists] =
     (P.ignoreCase("exists:") *> AttrParser.anyAttr).map(attr => Expr.Exists(attr))
 
@@ -79,11 +88,15 @@ object SimpleExprParser {
   val checksumExpr: P[Expr.ChecksumMatch] =
     (P.string("checksum:") *> BasicParser.singleString).map(Expr.ChecksumMatch.apply)
 
+  val attachIdExpr: P[Expr.AttachId] =
+    (P.ignoreCase("attach.id:") *> BasicParser.singleString).map(Expr.AttachId.apply)
+
   val simpleExpr: P[Expr] =
     P.oneOf(
       List(
         dateExpr,
         stringExpr,
+        intExpr,
         existsExpr,
         fulltextExpr,
         tagIdExpr,
@@ -93,7 +106,8 @@ object SimpleExprParser {
         customFieldExpr,
         inboxExpr,
         dirExpr,
-        checksumExpr
+        checksumExpr,
+        attachIdExpr
       )
     )
 }
diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
index c92367fe..a99516ff 100644
--- a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
+++ b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
@@ -94,6 +94,10 @@ object ItemQueryGenerator {
         val noLikeOp = if (op == Operator.Like) Operator.Eq else op
         Condition.CompareVal(col, makeOp(noLikeOp), dt)
 
+      case Expr.SimpleExpr(op, Property.IntProperty(attr, value)) =>
+        val col = intColumn(tables)(attr)
+        Condition.CompareVal(col, makeOp(op), value)
+
       case Expr.InExpr(attr, values) =>
         val col = stringColumn(tables)(attr)
         if (values.tail.isEmpty) col === values.head
@@ -157,6 +161,15 @@ object ItemQueryGenerator {
         val select = QItem.findByChecksumQuery(checksum, coll, Set.empty)
         tables.item.id.in(select.withSelect(Nel.of(RItem.as("i").id.s)))
 
+      case Expr.AttachId(id) =>
+        tables.item.id.in(
+          Select(
+            select(RAttachment.T.itemId),
+            from(RAttachment.T),
+            RAttachment.T.id.cast[String] === id
+          ).distinct
+        )
+
       case Expr.Fulltext(_) =>
         // not supported here
         Condition.unit
@@ -196,6 +209,8 @@ object ItemQueryGenerator {
         stringColumn(tables)(s)
       case t: Attr.DateAttr =>
         timestampColumn(tables)(t)
+      case n: Attr.IntAttr =>
+        intColumn(tables)(n)
     }
 
   private def timestampColumn(tables: Tables)(attr: Attr.DateAttr) =
@@ -224,6 +239,11 @@ object ItemQueryGenerator {
       case Attr.Folder.FolderName        => tables.folder.name
     }
 
+  private def intColumn(tables: Tables)(attr: Attr.IntAttr): Column[Int] =
+    attr match {
+      case Attr.AttachCount => tables.attachCount.num
+    }
+
   private def makeOp(operator: Operator): QOp =
     operator match {
       case Operator.Eq =>
diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/Tables.scala b/modules/store/src/main/scala/docspell/store/qb/generator/Tables.scala
index 966b129d..0d30c99c 100644
--- a/modules/store/src/main/scala/docspell/store/qb/generator/Tables.scala
+++ b/modules/store/src/main/scala/docspell/store/qb/generator/Tables.scala
@@ -1,5 +1,6 @@
 package docspell.store.qb.generator
 
+import docspell.store.queries.AttachCountTable
 import docspell.store.records._
 
 final case class Tables(
@@ -10,5 +11,6 @@ final case class Tables(
     concEquip: REquipment.Table,
     folder: RFolder.Table,
     attach: RAttachment.Table,
-    meta: RAttachmentMeta.Table
+    meta: RAttachmentMeta.Table,
+    attachCount: AttachCountTable
 )
diff --git a/modules/store/src/main/scala/docspell/store/queries/AttachCountTable.scala b/modules/store/src/main/scala/docspell/store/queries/AttachCountTable.scala
new file mode 100644
index 00000000..2ed6fc3c
--- /dev/null
+++ b/modules/store/src/main/scala/docspell/store/queries/AttachCountTable.scala
@@ -0,0 +1,16 @@
+package docspell.store.queries
+
+import docspell.common.Ident
+import docspell.store.qb.Column
+import docspell.store.qb.TableDef
+
+final case class AttachCountTable(aliasName: String) extends TableDef {
+  val tableName             = "attachs"
+  val alias: Option[String] = Some(aliasName)
+
+  val num    = Column[Int]("num", this)
+  val itemId = Column[Ident]("item_id", this)
+
+  def as(alias: String): AttachCountTable =
+    copy(aliasName = alias)
+}
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 2e1e1296..c1ee5f2c 100644
--- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala
+++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala
@@ -122,15 +122,8 @@ object QItem {
   }
 
   private def findItemsBase(q: Query.Fix, noteMaxLen: Int): Select = {
-    object Attachs extends TableDef {
-      val tableName = "attachs"
-      val aliasName = "cta"
-      val alias     = Some(aliasName)
-      val num       = Column[Int]("num", this)
-      val itemId    = Column[Ident]("item_id", this)
-    }
-
-    val coll = q.account.collective
+    val attachs = AttachCountTable("cta")
+    val coll    = q.account.collective
 
     Select(
       select(
@@ -142,7 +135,7 @@ object QItem {
         i.source.s,
         i.incoming.s,
         i.created.s,
-        coalesce(Attachs.num.s, const(0)).s,
+        coalesce(attachs.num.s, const(0)).s,
         org.oid.s,
         org.name.s,
         pers0.pid.s,
@@ -162,14 +155,14 @@ object QItem {
         .leftJoin(f, f.id === i.folder && f.collective === coll)
         .leftJoin(
           Select(
-            select(countAll.as(Attachs.num), a.itemId.as(Attachs.itemId)),
+            select(countAll.as(attachs.num), a.itemId.as(attachs.itemId)),
             from(a)
               .innerJoin(i, i.id === a.itemId),
             i.cid === q.account.collective,
             GroupBy(a.itemId)
           ),
-          Attachs.aliasName,
-          Attachs.itemId === i.id
+          attachs.aliasName,
+          attachs.itemId === i.id
         )
         .leftJoin(pers0, pers0.pid === i.corrPerson && pers0.cid === coll)
         .leftJoin(org, org.oid === i.corrOrg && org.cid === coll)
@@ -229,7 +222,7 @@ object QItem {
         .map(itemIds => i.id.in(itemIds))
 
   def queryCondFromExpr(today: LocalDate, coll: Ident, q: ItemQuery): Condition = {
-    val tables = Tables(i, org, pers0, pers1, equip, f, a, m)
+    val tables = Tables(i, org, pers0, pers1, equip, f, a, m, AttachCountTable("cta"))
     ItemQueryGenerator.fromExpr(today, tables, coll)(q.expr)
   }
 
diff --git a/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala b/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala
index 3d9e5b2e..7d511026 100644
--- a/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala
+++ b/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala
@@ -6,6 +6,7 @@ import docspell.store.records._
 import minitest._
 import docspell.common._
 import docspell.query.ItemQueryParser
+import docspell.store.queries.AttachCountTable
 import docspell.store.qb.DSL._
 import docspell.store.qb.generator.{ItemQueryGenerator, Tables}
 
@@ -20,7 +21,8 @@ object ItemQueryGeneratorTest extends SimpleTestSuite {
     REquipment.as("ne"),
     RFolder.as("f"),
     RAttachment.as("a"),
-    RAttachmentMeta.as("m")
+    RAttachmentMeta.as("m"),
+    AttachCountTable("cta")
   )
   val now: LocalDate = LocalDate.of(2021, 2, 25)
 

From b514b85f39c2462c86ac19e1aced1ba27a0620a6 Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Mon, 8 Mar 2021 10:26:39 +0100
Subject: [PATCH 30/33] Improve parser error messages a bit

---
 .../docspell/query/js/JSItemQueryParser.scala |  2 +-
 .../scala/docspell/query/ParseFailure.scala   | 57 ++++++++++++++-----
 2 files changed, 45 insertions(+), 14 deletions(-)

diff --git a/modules/query/js/src/main/scala/docspell/query/js/JSItemQueryParser.scala b/modules/query/js/src/main/scala/docspell/query/js/JSItemQueryParser.scala
index 68f7cd5e..a58371cd 100644
--- a/modules/query/js/src/main/scala/docspell/query/js/JSItemQueryParser.scala
+++ b/modules/query/js/src/main/scala/docspell/query/js/JSItemQueryParser.scala
@@ -18,7 +18,7 @@ object JSItemQueryParser {
         new Failure(
           fr.input,
           fr.failedAt,
-          js.Array(fr.messages.toList.toSeq.map(_.msg): _*)
+          js.Array(fr.messages.toList.toSeq.map(_.render): _*)
         )
       )
       .orNull
diff --git a/modules/query/shared/src/main/scala/docspell/query/ParseFailure.scala b/modules/query/shared/src/main/scala/docspell/query/ParseFailure.scala
index 05235c03..4562d68c 100644
--- a/modules/query/shared/src/main/scala/docspell/query/ParseFailure.scala
+++ b/modules/query/shared/src/main/scala/docspell/query/ParseFailure.scala
@@ -18,48 +18,79 @@ final case class ParseFailure(
 ) {
 
   def render: String = {
-    val items = messages.map(_.msg).toList.mkString(", ")
+    val items = messages.map(_.render).toList.mkString(", ")
     s"Failed to read input near $failedAt: $input\nDetails: $items"
   }
 }
 
 object ParseFailure {
 
-  final case class Message(offset: Int, msg: String)
+  sealed trait Message {
+    def offset: Int
+    def render: String
+  }
+  final case class SimpleMessage(offset: Int, msg: String) extends Message {
+    def render: String =
+       s"Failed at $offset: $msg"
+  }
+  final case class ExpectMessage(offset: Int, expected: List[String], exhaustive: Boolean) extends Message {
+    def render: String = {
+      val opts = expected.mkString(", ")
+      val dots = if (exhaustive) "" else "…"
+      s"Expected: ${opts}${dots}"
+    }
+  }
 
   private[query] def fromError(input: String)(pe: Parser.Error): ParseFailure =
     ParseFailure(
       input,
       pe.failedAtOffset,
-      Parser.Expectation.unify(pe.expected).map(expectationToMsg)
+      packMsg(Parser.Expectation.unify(pe.expected).map(expectationToMsg))
     )
 
+  private[query] def packMsg(msg: Nel[Message]): Nel[Message] = {
+    val expectMsg = combineExpected(msg.collect({ case em: ExpectMessage => em }))
+      .sortBy(_.offset).headOption
+
+    val simpleMsg = msg.collect({ case sm: SimpleMessage => sm })
+
+    Nel.fromListUnsafe((simpleMsg ++ expectMsg).sortBy(_.offset))
+  }
+
+  private[query] def combineExpected(msg: List[ExpectMessage]): List[ExpectMessage] =
+    msg.groupBy(_.offset).map({ case (offset, es) =>
+      ExpectMessage(offset, es.flatMap(_.expected).distinct.sorted, es.forall(_.exhaustive))
+    }).toList
+
   private[query] def expectationToMsg(e: Parser.Expectation): Message =
     e match {
       case StartOfString(offset) =>
-        Message(offset, "Expected start of string")
+        SimpleMessage(offset, "Expected start of string")
 
       case FailWith(offset, message) =>
-        Message(offset, message)
+        SimpleMessage(offset, message)
 
       case InRange(offset, lower, upper) =>
-        if (lower == upper) Message(offset, s"Expected character: $lower")
-        else Message(offset, s"Expected character from range: [$lower .. $upper]")
+        if (lower == upper) ExpectMessage(offset, List(lower.toString), true)
+        else {
+          val expect = s"${lower}-${upper}"
+          ExpectMessage(offset, List(expect), true)
+        }
 
       case Length(offset, expected, actual) =>
-        Message(offset, s"Expected input of length $expected, but got $actual")
+        SimpleMessage(offset, s"Expected input of length $expected, but got $actual")
 
       case ExpectedFailureAt(offset, matched) =>
-        Message(offset, s"Expected failing, but matched '$matched'")
+        SimpleMessage(offset, s"Expected failing, but matched '$matched'")
 
       case EndOfString(offset, length) =>
-        Message(offset, s"Expected end of string at length: $length")
+        SimpleMessage(offset, s"Expected end of string at length: $length")
 
       case Fail(offset) =>
-        Message(offset, s"Failed to parse near $offset")
+        SimpleMessage(offset, s"Failed to parse near $offset")
 
       case OneOfStr(offset, strs) =>
-        val options = strs.mkString(", ")
-        Message(offset, s"Expected one of the following strings: $options")
+        val options = strs.take(8)
+        ExpectMessage(offset, options.take(7), options.size < 8)
     }
 }

From e681ffa96f894015c37fcfac0a71de18d5e43d61 Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Mon, 8 Mar 2021 20:46:42 +0100
Subject: [PATCH 31/33] Extend query builder allowing more conditions

Before only a column or a dbfunction could be used in a condition. It
is now allowed for all `SelectExpr`.
---
 .../scala/docspell/store/qb/Condition.scala   |  8 +--
 .../main/scala/docspell/store/qb/DSL.scala    | 57 ++++++++++++++-----
 .../qb/generator/ItemQueryGenerator.scala     | 16 +++---
 .../store/qb/impl/ConditionBuilder.scala      | 11 ++--
 4 files changed, 62 insertions(+), 30 deletions(-)

diff --git a/modules/store/src/main/scala/docspell/store/qb/Condition.scala b/modules/store/src/main/scala/docspell/store/qb/Condition.scala
index 0b0c0692..9a329033 100644
--- a/modules/store/src/main/scala/docspell/store/qb/Condition.scala
+++ b/modules/store/src/main/scala/docspell/store/qb/Condition.scala
@@ -15,7 +15,7 @@ object Condition {
       val P: Put[A]
   ) extends Condition
 
-  case class CompareFVal[A](dbf: DBFunction, op: Operator, value: A)(implicit
+  case class CompareFVal[A](sel: SelectExpr, op: Operator, value: A)(implicit
       val P: Put[A]
   ) extends Condition
 
@@ -23,11 +23,11 @@ object Condition {
       extends Condition
 
   case class InSubSelect[A](col: Column[A], subSelect: Select) extends Condition
-  case class InValues[A](col: Column[A], values: NonEmptyList[A], lower: Boolean)(implicit
-      val P: Put[A]
+  case class InValues[A](sel: SelectExpr, values: NonEmptyList[A], lower: Boolean)(
+      implicit val P: Put[A]
   ) extends Condition
 
-  case class IsNull(col: Column[_]) extends Condition
+  case class IsNull(sel: SelectExpr) extends Condition
 
   case class And(inner: NonEmptyList[Condition]) extends Condition {
     def append(other: Condition): And =
diff --git a/modules/store/src/main/scala/docspell/store/qb/DSL.scala b/modules/store/src/main/scala/docspell/store/qb/DSL.scala
index 63a0afc4..d2e78be1 100644
--- a/modules/store/src/main/scala/docspell/store/qb/DSL.scala
+++ b/modules/store/src/main/scala/docspell/store/qb/DSL.scala
@@ -207,22 +207,22 @@ trait DSL extends DoobieMeta {
       in(subsel).negate
 
     def in(values: Nel[A])(implicit P: Put[A]): Condition =
-      Condition.InValues(col, values, false)
+      Condition.InValues(col.s, values, false)
 
     def notIn(values: Nel[A])(implicit P: Put[A]): Condition =
       in(values).negate
 
     def inLower(values: Nel[A])(implicit P: Put[A]): Condition =
-      Condition.InValues(col, values, true)
+      Condition.InValues(col.s, values, true)
 
     def notInLower(values: Nel[A])(implicit P: Put[A]): Condition =
-      Condition.InValues(col, values, true).negate
+      Condition.InValues(col.s, values, true).negate
 
     def isNull: Condition =
-      Condition.IsNull(col)
+      Condition.IsNull(col.s)
 
     def isNotNull: Condition =
-      Condition.IsNull(col).negate
+      Condition.IsNull(col.s).negate
 
     def ===(other: Column[A]): Condition =
       Condition.CompareCol(col, Operator.Eq, other)
@@ -267,31 +267,31 @@ trait DSL extends DoobieMeta {
       SelectExpr.SelectFun(dbf, Some(otherCol.name))
 
     def ===[A](value: A)(implicit P: Put[A]): Condition =
-      Condition.CompareFVal(dbf, Operator.Eq, value)
+      Condition.CompareFVal(dbf.s, Operator.Eq, value)
 
     def ====(value: String): Condition =
-      Condition.CompareFVal(dbf, Operator.Eq, value)
+      Condition.CompareFVal(dbf.s, Operator.Eq, value)
 
     def like[A](value: A)(implicit P: Put[A]): Condition =
-      Condition.CompareFVal(dbf, Operator.LowerLike, value)
+      Condition.CompareFVal(dbf.s, Operator.LowerLike, value)
 
     def likes(value: String): Condition =
-      Condition.CompareFVal(dbf, Operator.LowerLike, value)
+      Condition.CompareFVal(dbf.s, Operator.LowerLike, value)
 
     def <=[A](value: A)(implicit P: Put[A]): Condition =
-      Condition.CompareFVal(dbf, Operator.Lte, value)
+      Condition.CompareFVal(dbf.s, Operator.Lte, value)
 
     def >=[A](value: A)(implicit P: Put[A]): Condition =
-      Condition.CompareFVal(dbf, Operator.Gte, value)
+      Condition.CompareFVal(dbf.s, Operator.Gte, value)
 
     def >[A](value: A)(implicit P: Put[A]): Condition =
-      Condition.CompareFVal(dbf, Operator.Gt, value)
+      Condition.CompareFVal(dbf.s, Operator.Gt, value)
 
     def <[A](value: A)(implicit P: Put[A]): Condition =
-      Condition.CompareFVal(dbf, Operator.Lt, value)
+      Condition.CompareFVal(dbf.s, Operator.Lt, value)
 
     def <>[A](value: A)(implicit P: Put[A]): Condition =
-      Condition.CompareFVal(dbf, Operator.Neq, value)
+      Condition.CompareFVal(dbf.s, Operator.Neq, value)
 
     def -[A](value: A)(implicit P: Put[A]): DBFunction =
       DBFunction.Calc(
@@ -300,6 +300,35 @@ trait DSL extends DoobieMeta {
         SelectExpr.SelectConstant(value, None)
       )
   }
+
+  implicit final class SelectExprOps(sel: SelectExpr) {
+    def isNull: Condition =
+      Condition.IsNull(sel)
+
+    def isNotNull: Condition =
+      Condition.IsNull(sel).negate
+
+    def ===[A](value: A)(implicit P: Put[A]): Condition =
+      Condition.CompareFVal(sel, Operator.Eq, value)
+
+    def <=[A](value: A)(implicit P: Put[A]): Condition =
+      Condition.CompareFVal(sel, Operator.Lte, value)
+
+    def >=[A](value: A)(implicit P: Put[A]): Condition =
+      Condition.CompareFVal(sel, Operator.Gte, value)
+
+    def >[A](value: A)(implicit P: Put[A]): Condition =
+      Condition.CompareFVal(sel, Operator.Gt, value)
+
+    def <[A](value: A)(implicit P: Put[A]): Condition =
+      Condition.CompareFVal(sel, Operator.Lt, value)
+
+    def <>[A](value: A)(implicit P: Put[A]): Condition =
+      Condition.CompareFVal(sel, Operator.Neq, value)
+
+    def in[A](values: Nel[A])(implicit P: Put[A]): Condition =
+      Condition.InValues(sel, values, false)
+  }
 }
 
 object DSL extends DSL {
diff --git a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
index a99516ff..6a721270 100644
--- a/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
+++ b/modules/store/src/main/scala/docspell/store/qb/generator/ItemQueryGenerator.scala
@@ -92,7 +92,7 @@ object ItemQueryGenerator {
         val dt       = dateToTimestamp(today)(value)
         val col      = timestampColumn(tables)(attr)
         val noLikeOp = if (op == Operator.Like) Operator.Eq else op
-        Condition.CompareVal(col, makeOp(noLikeOp), dt)
+        Condition.CompareFVal(col, makeOp(noLikeOp), dt)
 
       case Expr.SimpleExpr(op, Property.IntProperty(attr, value)) =>
         val col = intColumn(tables)(attr)
@@ -203,22 +203,22 @@ object ItemQueryGenerator {
         today
     }
 
-  private def anyColumn(tables: Tables)(attr: Attr): Column[_] =
+  private def anyColumn(tables: Tables)(attr: Attr): SelectExpr =
     attr match {
       case s: Attr.StringAttr =>
-        stringColumn(tables)(s)
+        stringColumn(tables)(s).s
       case t: Attr.DateAttr =>
         timestampColumn(tables)(t)
       case n: Attr.IntAttr =>
-        intColumn(tables)(n)
+        intColumn(tables)(n).s
     }
 
-  private def timestampColumn(tables: Tables)(attr: Attr.DateAttr) =
+  private def timestampColumn(tables: Tables)(attr: Attr.DateAttr): SelectExpr =
     attr match {
       case Attr.Date =>
-        tables.item.itemDate
+        coalesce(tables.item.itemDate.s, tables.item.created.s).s
       case Attr.DueDate =>
-        tables.item.dueDate
+        tables.item.dueDate.s
     }
 
   private def stringColumn(tables: Tables)(attr: Attr.StringAttr): Column[String] =
@@ -283,7 +283,7 @@ object ItemQueryGenerator {
 
       value.toDoubleOption
         .map { n =>
-          val numericCmp = Condition.CompareFVal(castNumeric(cfv.value.s), op, n)
+          val numericCmp = Condition.CompareFVal(castNumeric(cfv.value.s).s, op, n)
           val fieldIsNumeric =
             cf.ftype === CustomFieldType.Numeric || cf.ftype === CustomFieldType.Money
           val fieldNotNumeric =
diff --git a/modules/store/src/main/scala/docspell/store/qb/impl/ConditionBuilder.scala b/modules/store/src/main/scala/docspell/store/qb/impl/ConditionBuilder.scala
index f110bb92..c9e50575 100644
--- a/modules/store/src/main/scala/docspell/store/qb/impl/ConditionBuilder.scala
+++ b/modules/store/src/main/scala/docspell/store/qb/impl/ConditionBuilder.scala
@@ -85,7 +85,7 @@ object ConditionBuilder {
           case Operator.LowerEq =>
             lower(dbf)
           case _ =>
-            DBFunctionBuilder.build(dbf)
+            SelectExprBuilder.build(dbf)
         }
         dbfFrag ++ opFrag ++ valFrag
 
@@ -105,13 +105,13 @@ object ConditionBuilder {
         SelectExprBuilder.column(col) ++ sql" IN (" ++ sub ++ parenClose
 
       case c @ Condition.InValues(col, values, toLower) =>
-        val cfrag = if (toLower) lower(col) else SelectExprBuilder.column(col)
+        val cfrag = if (toLower) lower(col) else SelectExprBuilder.build(col)
         cfrag ++ sql" IN (" ++ values.toList
           .map(a => buildValue(a)(c.P))
           .reduce(_ ++ comma ++ _) ++ parenClose
 
       case Condition.IsNull(col) =>
-        SelectExprBuilder.column(col) ++ fr" is null"
+        SelectExprBuilder.build(col) ++ fr" is null"
 
       case Condition.And(ands) =>
         val inner = ands.map(build).reduceLeft(_ ++ and ++ _)
@@ -124,7 +124,7 @@ object ConditionBuilder {
         else parenOpen ++ inner ++ parenClose
 
       case Condition.Not(Condition.IsNull(col)) =>
-        SelectExprBuilder.column(col) ++ fr" is not null"
+        SelectExprBuilder.build(col) ++ fr" is not null"
 
       case Condition.Not(c) =>
         fr"NOT" ++ build(c)
@@ -159,6 +159,9 @@ object ConditionBuilder {
   def buildOptValue[A: Put](v: Option[A]): Fragment =
     fr"$v"
 
+  def lower(sel: SelectExpr): Fragment =
+    Fragment.const0("LOWER(") ++ SelectExprBuilder.build(sel) ++ parenClose
+
   def lower(col: Column[_]): Fragment =
     Fragment.const0("LOWER(") ++ SelectExprBuilder.column(col) ++ parenClose
 

From 77a87782b7bcb5ebf51119f8a8ef77e34bfcdfb3 Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Mon, 8 Mar 2021 22:46:40 +0100
Subject: [PATCH 32/33] Refactoring parser

- put all used strings in one place to have it easier to track
- don't use `$` for shortcuts, it's a detail not interesting to a
  user; now names must not clash (which is a good idea anyways)
- Added two more shortcuts `conc` and `corr`
---
 .../main/scala/docspell/query/ItemQuery.scala | 27 ++++++++--
 .../scala/docspell/query/ParseFailure.scala   | 21 +++++---
 .../docspell/query/internal/AttrParser.scala  | 35 ++++++-------
 .../docspell/query/internal/Constants.scala   | 50 +++++++++++++++++++
 .../docspell/query/internal/ExprParser.scala  |  5 +-
 .../docspell/query/internal/MacroParser.scala | 28 ++++++++---
 .../query/internal/OperatorParser.scala       | 19 +++----
 .../query/internal/SimpleExprParser.scala     | 44 ++++++++++------
 .../docspell/query/FulltextExtractTest.scala  |  4 +-
 .../query/internal/AttrParserTest.scala       |  4 --
 .../query/internal/MacroParserTest.scala      |  6 +--
 .../query/internal/SimpleExprParserTest.scala |  8 +--
 .../generator/ItemQueryGeneratorTest.scala    |  3 +-
 .../webapp/src/main/elm/Data/ItemQuery.elm    |  8 +--
 14 files changed, 183 insertions(+), 79 deletions(-)
 create mode 100644 modules/query/shared/src/main/scala/docspell/query/internal/Constants.scala

diff --git a/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala b/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala
index 94f3868f..e2b7ef06 100644
--- a/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala
+++ b/modules/query/shared/src/main/scala/docspell/query/ItemQuery.scala
@@ -123,23 +123,40 @@ object ItemQuery {
     sealed trait MacroExpr extends Expr {
       def body: Expr
     }
-    case class NamesMacro(searchTerm: String) extends MacroExpr {
+    final case class NamesMacro(searchTerm: String) extends MacroExpr {
       val body =
         Expr.or(
           like(Attr.ItemName, searchTerm),
-          like(Attr.ItemNotes, searchTerm),
           like(Attr.Correspondent.OrgName, searchTerm),
           like(Attr.Correspondent.PersonName, searchTerm),
           like(Attr.Concerning.PersonName, searchTerm),
           like(Attr.Concerning.EquipName, searchTerm)
         )
     }
-    case class DateRangeMacro(attr: DateAttr, left: Date, right: Date) extends MacroExpr {
+
+    final case class CorrMacro(term: String) extends MacroExpr {
       val body =
-        and(date(Operator.Gte, attr, left), date(Operator.Lte, attr, right))
+        Expr.or(
+          like(Attr.Correspondent.OrgName, term),
+          like(Attr.Correspondent.PersonName, term)
+        )
     }
 
-    case class YearMacro(attr: DateAttr, year: Int) extends MacroExpr {
+    final case class ConcMacro(term: String) extends MacroExpr {
+      val body =
+        Expr.or(
+          like(Attr.Concerning.PersonName, term),
+          like(Attr.Concerning.EquipName, term)
+        )
+    }
+
+    final case class DateRangeMacro(attr: DateAttr, left: Date, right: Date)
+        extends MacroExpr {
+      val body =
+        and(date(Operator.Gte, attr, left), date(Operator.Lt, attr, right))
+    }
+
+    final case class YearMacro(attr: DateAttr, year: Int) extends MacroExpr {
       val body =
         DateRangeMacro(attr, date(year), date(year + 1))
 
diff --git a/modules/query/shared/src/main/scala/docspell/query/ParseFailure.scala b/modules/query/shared/src/main/scala/docspell/query/ParseFailure.scala
index 4562d68c..128a08c4 100644
--- a/modules/query/shared/src/main/scala/docspell/query/ParseFailure.scala
+++ b/modules/query/shared/src/main/scala/docspell/query/ParseFailure.scala
@@ -31,9 +31,10 @@ object ParseFailure {
   }
   final case class SimpleMessage(offset: Int, msg: String) extends Message {
     def render: String =
-       s"Failed at $offset: $msg"
+      s"Failed at $offset: $msg"
   }
-  final case class ExpectMessage(offset: Int, expected: List[String], exhaustive: Boolean) extends Message {
+  final case class ExpectMessage(offset: Int, expected: List[String], exhaustive: Boolean)
+      extends Message {
     def render: String = {
       val opts = expected.mkString(", ")
       val dots = if (exhaustive) "" else "…"
@@ -50,7 +51,8 @@ object ParseFailure {
 
   private[query] def packMsg(msg: Nel[Message]): Nel[Message] = {
     val expectMsg = combineExpected(msg.collect({ case em: ExpectMessage => em }))
-      .sortBy(_.offset).headOption
+      .sortBy(_.offset)
+      .headOption
 
     val simpleMsg = msg.collect({ case sm: SimpleMessage => sm })
 
@@ -58,9 +60,16 @@ object ParseFailure {
   }
 
   private[query] def combineExpected(msg: List[ExpectMessage]): List[ExpectMessage] =
-    msg.groupBy(_.offset).map({ case (offset, es) =>
-      ExpectMessage(offset, es.flatMap(_.expected).distinct.sorted, es.forall(_.exhaustive))
-    }).toList
+    msg
+      .groupBy(_.offset)
+      .map({ case (offset, es) =>
+        ExpectMessage(
+          offset,
+          es.flatMap(_.expected).distinct.sorted,
+          es.forall(_.exhaustive)
+        )
+      })
+      .toList
 
   private[query] def expectationToMsg(e: Parser.Expectation): Message =
     e match {
diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/AttrParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/AttrParser.scala
index d7aab3ed..6d74ea59 100644
--- a/modules/query/shared/src/main/scala/docspell/query/internal/AttrParser.scala
+++ b/modules/query/shared/src/main/scala/docspell/query/internal/AttrParser.scala
@@ -3,67 +3,68 @@ package docspell.query.internal
 import cats.parse.{Parser => P}
 
 import docspell.query.ItemQuery.Attr
+import docspell.query.internal.{Constants => C}
 
 object AttrParser {
 
   val name: P[Attr.StringAttr] =
-    P.ignoreCase("name").as(Attr.ItemName)
+    P.ignoreCase(C.name).as(Attr.ItemName)
 
   val source: P[Attr.StringAttr] =
-    P.ignoreCase("source").as(Attr.ItemSource)
+    P.ignoreCase(C.source).as(Attr.ItemSource)
 
   val id: P[Attr.StringAttr] =
-    P.ignoreCase("id").as(Attr.ItemId)
+    P.ignoreCase(C.id).as(Attr.ItemId)
 
   val date: P[Attr.DateAttr] =
-    P.ignoreCase("date").as(Attr.Date)
+    P.ignoreCase(C.date).as(Attr.Date)
 
   val notes: P[Attr.StringAttr] =
-    P.ignoreCase("notes").as(Attr.ItemNotes)
+    P.ignoreCase(C.notes).as(Attr.ItemNotes)
 
   val dueDate: P[Attr.DateAttr] =
-    P.stringIn(List("dueDate", "due", "due-date")).as(Attr.DueDate)
+    P.ignoreCase(C.due).as(Attr.DueDate)
 
   val corrOrgId: P[Attr.StringAttr] =
-    P.stringIn(List("correspondent.org.id", "corr.org.id"))
+    P.ignoreCase(C.corrOrgId)
       .as(Attr.Correspondent.OrgId)
 
   val corrOrgName: P[Attr.StringAttr] =
-    P.stringIn(List("correspondent.org.name", "corr.org.name"))
+    P.ignoreCase(C.corrOrgName)
       .as(Attr.Correspondent.OrgName)
 
   val corrPersId: P[Attr.StringAttr] =
-    P.stringIn(List("correspondent.person.id", "corr.pers.id"))
+    P.ignoreCase(C.corrPersId)
       .as(Attr.Correspondent.PersonId)
 
   val corrPersName: P[Attr.StringAttr] =
-    P.stringIn(List("correspondent.person.name", "corr.pers.name"))
+    P.ignoreCase(C.corrPersName)
       .as(Attr.Correspondent.PersonName)
 
   val concPersId: P[Attr.StringAttr] =
-    P.stringIn(List("concerning.person.id", "conc.pers.id"))
+    P.ignoreCase(C.concPersId)
       .as(Attr.Concerning.PersonId)
 
   val concPersName: P[Attr.StringAttr] =
-    P.stringIn(List("concerning.person.name", "conc.pers.name"))
+    P.ignoreCase(C.concPersName)
       .as(Attr.Concerning.PersonName)
 
   val concEquipId: P[Attr.StringAttr] =
-    P.stringIn(List("concerning.equip.id", "conc.equip.id"))
+    P.ignoreCase(C.concEquipId)
       .as(Attr.Concerning.EquipId)
 
   val concEquipName: P[Attr.StringAttr] =
-    P.stringIn(List("concerning.equip.name", "conc.equip.name"))
+    P.ignoreCase(C.concEquipName)
       .as(Attr.Concerning.EquipName)
 
   val folderId: P[Attr.StringAttr] =
-    P.ignoreCase("folder.id").as(Attr.Folder.FolderId)
+    P.ignoreCase(C.folderId).as(Attr.Folder.FolderId)
 
   val folderName: P[Attr.StringAttr] =
-    P.ignoreCase("folder").as(Attr.Folder.FolderName)
+    P.ignoreCase(C.folder).as(Attr.Folder.FolderName)
 
   val attachCountAttr: P[Attr.IntAttr] =
-    P.ignoreCase("attach.count").as(Attr.AttachCount)
+    P.ignoreCase(C.attachCount).as(Attr.AttachCount)
 
   // combining grouped by type
 
diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/Constants.scala b/modules/query/shared/src/main/scala/docspell/query/internal/Constants.scala
new file mode 100644
index 00000000..232d6940
--- /dev/null
+++ b/modules/query/shared/src/main/scala/docspell/query/internal/Constants.scala
@@ -0,0 +1,50 @@
+package docspell.query.internal
+
+object Constants {
+
+  val attachCount   = "attach.count"
+  val attachId      = "attach.id"
+  val cat           = "cat"
+  val checksum      = "checksum"
+  val conc          = "conc"
+  val concEquipId   = "conc.equip.id"
+  val concEquipName = "conc.equip.name"
+  val concPersId    = "conc.pers.id"
+  val concPersName  = "conc.pers.name"
+  val content       = "content"
+  val corr          = "corr"
+  val corrOrgId     = "corr.org.id"
+  val corrOrgName   = "corr.org.name"
+  val corrPersId    = "corr.pers.id"
+  val corrPersName  = "corr.pers.name"
+  val customField   = "f"
+  val customFieldId = "f.id"
+  val date          = "date"
+  val dateIn        = "dateIn"
+  val due           = "due"
+  val dueIn         = "dueIn"
+  val exist         = "exist"
+  val folder        = "folder"
+  val folderId      = "folder.id"
+  val id            = "id"
+  val inbox         = "inbox"
+  val incoming      = "incoming"
+  val name          = "name"
+  val names         = "names"
+  val notPrefix     = '!'
+  val notes         = "notes"
+  val source        = "source"
+  val tag           = "tag"
+  val tagId         = "tag.id"
+  val year          = "year"
+
+  // operators
+  val eqs  = '='
+  val gt   = '>'
+  val gte  = ">="
+  val in   = "~="
+  val like = ':'
+  val lt   = '<'
+  val lte  = "<="
+  val neq  = "!="
+}
diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/ExprParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/ExprParser.scala
index 329ec030..de6e9165 100644
--- a/modules/query/shared/src/main/scala/docspell/query/internal/ExprParser.scala
+++ b/modules/query/shared/src/main/scala/docspell/query/internal/ExprParser.scala
@@ -4,6 +4,7 @@ import cats.parse.{Parser => P}
 
 import docspell.query.ItemQuery
 import docspell.query.ItemQuery._
+import docspell.query.internal.{Constants => C}
 
 object ExprParser {
 
@@ -20,7 +21,7 @@ object ExprParser {
       .map(Expr.OrExpr.apply)
 
   def not(inner: P[Expr]): P[Expr] =
-    (P.char('!') *> inner).map(_.negate)
+    (P.char(C.notPrefix) *> inner).map(_.negate)
 
   val exprParser: P[Expr] =
     P.recursive[Expr] { recurse =>
@@ -28,7 +29,7 @@ object ExprParser {
       val orP    = or(recurse)
       val notP   = not(recurse)
       val macros = MacroParser.all
-      P.oneOf(SimpleExprParser.simpleExpr :: macros :: andP :: orP :: notP :: Nil)
+      P.oneOf(macros :: SimpleExprParser.simpleExpr :: andP :: orP :: notP :: Nil)
     }
 
   def parseQuery(input: String): Either[P.Error, ItemQuery] = {
diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/MacroParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/MacroParser.scala
index 0cfdda0f..44b2d93f 100644
--- a/modules/query/shared/src/main/scala/docspell/query/internal/MacroParser.scala
+++ b/modules/query/shared/src/main/scala/docspell/query/internal/MacroParser.scala
@@ -3,10 +3,11 @@ package docspell.query.internal
 import cats.parse.{Parser => P}
 
 import docspell.query.ItemQuery._
+import docspell.query.internal.{Constants => C}
 
 object MacroParser {
   private def macroDef(name: String): P[Unit] =
-    P.char('$').soft.with1 *> P.string(name) <* P.char(':')
+    P.ignoreCase(name).soft.with1 <* P.char(':')
 
   private def dateRangeMacroImpl(
       name: String,
@@ -20,20 +21,35 @@ object MacroParser {
     (macroDef(name) *> DateParser.yearOnly).map(year => Expr.YearMacro(attr, year))
 
   val namesMacro: P[Expr.NamesMacro] =
-    (macroDef("names") *> BasicParser.singleString).map(Expr.NamesMacro.apply)
+    (macroDef(C.names) *> BasicParser.singleString).map(Expr.NamesMacro.apply)
 
   val dateRangeMacro: P[Expr.DateRangeMacro] =
-    dateRangeMacroImpl("datein", Attr.Date)
+    dateRangeMacroImpl(C.dateIn, Attr.Date)
 
   val dueDateRangeMacro: P[Expr.DateRangeMacro] =
-    dateRangeMacroImpl("duein", Attr.DueDate)
+    dateRangeMacroImpl(C.dueIn, Attr.DueDate)
 
   val yearDateMacro: P[Expr.YearMacro] =
-    yearMacroImpl("year", Attr.Date)
+    yearMacroImpl(C.year, Attr.Date)
+
+  val corrMacro: P[Expr.CorrMacro] =
+    (macroDef(C.corr) *> BasicParser.singleString).map(Expr.CorrMacro.apply)
+
+  val concMacro: P[Expr.ConcMacro] =
+    (macroDef(C.conc) *> BasicParser.singleString).map(Expr.ConcMacro.apply)
 
   // --- all macro parser
 
   val all: P[Expr] =
-    P.oneOf(List(namesMacro, dateRangeMacro, dueDateRangeMacro, yearDateMacro))
+    P.oneOf(
+      List(
+        namesMacro,
+        dateRangeMacro,
+        dueDateRangeMacro,
+        yearDateMacro,
+        corrMacro,
+        concMacro
+      )
+    )
 
 }
diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/OperatorParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/OperatorParser.scala
index de93fb4f..2b5956a4 100644
--- a/modules/query/shared/src/main/scala/docspell/query/internal/OperatorParser.scala
+++ b/modules/query/shared/src/main/scala/docspell/query/internal/OperatorParser.scala
@@ -3,37 +3,38 @@ package docspell.query.internal
 import cats.parse.{Parser => P}
 
 import docspell.query.ItemQuery._
+import docspell.query.internal.{Constants => C}
 
 object OperatorParser {
   private[this] val Eq: P[Operator] =
-    P.char('=').as(Operator.Eq)
+    P.char(C.eqs).as(Operator.Eq)
 
   private[this] val Neq: P[Operator] =
-    P.string("!=").as(Operator.Neq)
+    P.string(C.neq).as(Operator.Neq)
 
   private[this] val Like: P[Operator] =
-    P.char(':').as(Operator.Like)
+    P.char(C.like).as(Operator.Like)
 
   private[this] val Gt: P[Operator] =
-    P.char('>').as(Operator.Gt)
+    P.char(C.gt).as(Operator.Gt)
 
   private[this] val Lt: P[Operator] =
-    P.char('<').as(Operator.Lt)
+    P.char(C.lt).as(Operator.Lt)
 
   private[this] val Gte: P[Operator] =
-    P.string(">=").as(Operator.Gte)
+    P.string(C.gte).as(Operator.Gte)
 
   private[this] val Lte: P[Operator] =
-    P.string("<=").as(Operator.Lte)
+    P.string(C.lte).as(Operator.Lte)
 
   val op: P[Operator] =
     P.oneOf(List(Like, Eq, Neq, Gte, Lte, Gt, Lt))
 
   private[this] val anyOp: P[TagOperator] =
-    P.char(':').as(TagOperator.AnyMatch)
+    P.char(C.like).as(TagOperator.AnyMatch)
 
   private[this] val allOp: P[TagOperator] =
-    P.char('=').as(TagOperator.AllMatch)
+    P.char(C.eqs).as(TagOperator.AllMatch)
 
   val tagOp: P[TagOperator] =
     P.oneOf(List(anyOp, allOp))
diff --git a/modules/query/shared/src/main/scala/docspell/query/internal/SimpleExprParser.scala b/modules/query/shared/src/main/scala/docspell/query/internal/SimpleExprParser.scala
index 56a0077a..950df0cb 100644
--- a/modules/query/shared/src/main/scala/docspell/query/internal/SimpleExprParser.scala
+++ b/modules/query/shared/src/main/scala/docspell/query/internal/SimpleExprParser.scala
@@ -4,6 +4,7 @@ import cats.parse.Numbers
 import cats.parse.{Parser => P}
 
 import docspell.query.ItemQuery._
+import docspell.query.internal.{Constants => C}
 
 object SimpleExprParser {
 
@@ -11,7 +12,7 @@ object SimpleExprParser {
     OperatorParser.op.surroundedBy(BasicParser.ws0)
 
   private[this] val inOp: P[Unit] =
-    P.string("~=").surroundedBy(BasicParser.ws0)
+    P.string(C.in).surroundedBy(BasicParser.ws0)
 
   private[this] val inOrOpStr =
     P.eitherOr(op ~ BasicParser.singleString, inOp *> BasicParser.stringOrMore)
@@ -44,52 +45,63 @@ object SimpleExprParser {
     }
 
   val existsExpr: P[Expr.Exists] =
-    (P.ignoreCase("exists:") *> AttrParser.anyAttr).map(attr => Expr.Exists(attr))
+    (P.ignoreCase(C.exist) *> P.char(C.like) *> AttrParser.anyAttr).map(attr =>
+      Expr.Exists(attr)
+    )
 
   val fulltextExpr: P[Expr.Fulltext] =
-    (P.ignoreCase("content:") *> BasicParser.singleString).map(q => Expr.Fulltext(q))
+    (P.ignoreCase(C.content) *> P.char(C.like) *> BasicParser.singleString).map(q =>
+      Expr.Fulltext(q)
+    )
 
   val tagIdExpr: P[Expr.TagIdsMatch] =
-    (P.ignoreCase("tag.id") *> OperatorParser.tagOp ~ BasicParser.stringOrMore).map {
+    (P.ignoreCase(C.tagId) *> OperatorParser.tagOp ~ BasicParser.stringOrMore).map {
       case (op, values) =>
         Expr.TagIdsMatch(op, values)
     }
 
   val tagExpr: P[Expr.TagsMatch] =
-    (P.ignoreCase("tag") *> OperatorParser.tagOp ~ BasicParser.stringOrMore).map {
+    (P.ignoreCase(C.tag) *> OperatorParser.tagOp ~ BasicParser.stringOrMore).map {
       case (op, values) =>
         Expr.TagsMatch(op, values)
     }
 
   val catExpr: P[Expr.TagCategoryMatch] =
-    (P.ignoreCase("cat") *> OperatorParser.tagOp ~ BasicParser.stringOrMore).map {
+    (P.ignoreCase(C.cat) *> OperatorParser.tagOp ~ BasicParser.stringOrMore).map {
       case (op, values) =>
         Expr.TagCategoryMatch(op, values)
     }
 
   val customFieldExpr: P[Expr.CustomFieldMatch] =
-    (P.string("f:") *> BasicParser.identParser ~ op ~ BasicParser.singleString).map {
-      case ((name, op), value) =>
+    (P.string(C.customField) *> P.char(
+      C.like
+    ) *> BasicParser.identParser ~ op ~ BasicParser.singleString)
+      .map { case ((name, op), value) =>
         Expr.CustomFieldMatch(name, op, value)
-    }
+      }
 
   val customFieldIdExpr: P[Expr.CustomFieldIdMatch] =
-    (P.string("f.id:") *> BasicParser.identParser ~ op ~ BasicParser.singleString).map {
-      case ((name, op), value) =>
+    (P.string(C.customFieldId) *> P.char(
+      C.like
+    ) *> BasicParser.identParser ~ op ~ BasicParser.singleString)
+      .map { case ((name, op), value) =>
         Expr.CustomFieldIdMatch(name, op, value)
-    }
+      }
 
   val inboxExpr: P[Expr.InboxExpr] =
-    (P.string("inbox:") *> BasicParser.bool).map(Expr.InboxExpr.apply)
+    (P.string(C.inbox) *> P.char(C.like) *> BasicParser.bool).map(Expr.InboxExpr.apply)
 
   val dirExpr: P[Expr.DirectionExpr] =
-    (P.string("incoming:") *> BasicParser.bool).map(Expr.DirectionExpr.apply)
+    (P.string(C.incoming) *> P.char(C.like) *> BasicParser.bool)
+      .map(Expr.DirectionExpr.apply)
 
   val checksumExpr: P[Expr.ChecksumMatch] =
-    (P.string("checksum:") *> BasicParser.singleString).map(Expr.ChecksumMatch.apply)
+    (P.string(C.checksum) *> P.char(C.like) *> BasicParser.singleString)
+      .map(Expr.ChecksumMatch.apply)
 
   val attachIdExpr: P[Expr.AttachId] =
-    (P.ignoreCase("attach.id:") *> BasicParser.singleString).map(Expr.AttachId.apply)
+    (P.ignoreCase(C.attachId) *> P.char(C.eqs) *> BasicParser.singleString)
+      .map(Expr.AttachId.apply)
 
   val simpleExpr: P[Expr] =
     P.oneOf(
diff --git a/modules/query/shared/src/test/scala/docspell/query/FulltextExtractTest.scala b/modules/query/shared/src/test/scala/docspell/query/FulltextExtractTest.scala
index 9b6ea799..755ea121 100644
--- a/modules/query/shared/src/test/scala/docspell/query/FulltextExtractTest.scala
+++ b/modules/query/shared/src/test/scala/docspell/query/FulltextExtractTest.scala
@@ -42,9 +42,9 @@ class FulltextExtractTest extends FunSuite {
 
   test("find fulltext within and") {
     assertFtsSuccess("content:what name:test", "what".some)
-    assertFtsSuccess("$names:marc* content:what name:test", "what".some)
+    assertFtsSuccess("names:marc* content:what name:test", "what".some)
     assertFtsSuccess(
-      "$names:marc* date:2021-02 content:\"what else\" name:test",
+      "names:marc* date:2021-02 content:\"what else\" name:test",
       "what else".some
     )
   }
diff --git a/modules/query/shared/src/test/scala/docspell/query/internal/AttrParserTest.scala b/modules/query/shared/src/test/scala/docspell/query/internal/AttrParserTest.scala
index 4c6dce3c..34c2270f 100644
--- a/modules/query/shared/src/test/scala/docspell/query/internal/AttrParserTest.scala
+++ b/modules/query/shared/src/test/scala/docspell/query/internal/AttrParserTest.scala
@@ -13,10 +13,8 @@ class AttrParserTest extends FunSuite {
     assertEquals(p.parseAll("id"), Right(Attr.ItemId))
     assertEquals(p.parseAll("corr.org.id"), Right(Attr.Correspondent.OrgId))
     assertEquals(p.parseAll("corr.org.name"), Right(Attr.Correspondent.OrgName))
-    assertEquals(p.parseAll("correspondent.org.name"), Right(Attr.Correspondent.OrgName))
     assertEquals(p.parseAll("conc.pers.id"), Right(Attr.Concerning.PersonId))
     assertEquals(p.parseAll("conc.pers.name"), Right(Attr.Concerning.PersonName))
-    assertEquals(p.parseAll("concerning.person.name"), Right(Attr.Concerning.PersonName))
     assertEquals(p.parseAll("folder"), Right(Attr.Folder.FolderName))
     assertEquals(p.parseAll("folder.id"), Right(Attr.Folder.FolderId))
   }
@@ -24,14 +22,12 @@ class AttrParserTest extends FunSuite {
   test("date attributes") {
     val p = AttrParser.dateAttr
     assertEquals(p.parseAll("date"), Right(Attr.Date))
-    assertEquals(p.parseAll("dueDate"), Right(Attr.DueDate))
     assertEquals(p.parseAll("due"), Right(Attr.DueDate))
   }
 
   test("all attributes parser") {
     val p = AttrParser.anyAttr
     assertEquals(p.parseAll("date"), Right(Attr.Date))
-    assertEquals(p.parseAll("dueDate"), Right(Attr.DueDate))
     assertEquals(p.parseAll("name"), Right(Attr.ItemName))
     assertEquals(p.parseAll("source"), Right(Attr.ItemSource))
     assertEquals(p.parseAll("id"), Right(Attr.ItemId))
diff --git a/modules/query/shared/src/test/scala/docspell/query/internal/MacroParserTest.scala b/modules/query/shared/src/test/scala/docspell/query/internal/MacroParserTest.scala
index def772bd..15855916 100644
--- a/modules/query/shared/src/test/scala/docspell/query/internal/MacroParserTest.scala
+++ b/modules/query/shared/src/test/scala/docspell/query/internal/MacroParserTest.scala
@@ -6,10 +6,10 @@ import docspell.query.ItemQuery.Expr
 
 class MacroParserTest extends FunSuite {
 
-  test("start with $") {
+  test("recognize names shortcut") {
     val p = MacroParser.namesMacro
-    assertEquals(p.parseAll("$names:test"), Right(Expr.NamesMacro("test")))
-    assert(p.parseAll("names:test").isLeft)
+    assertEquals(p.parseAll("names:test"), Right(Expr.NamesMacro("test")))
+    assert(p.parseAll("$names:test").isLeft)
   }
 
 }
diff --git a/modules/query/shared/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala b/modules/query/shared/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala
index 7a107ad7..35660dfa 100644
--- a/modules/query/shared/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala
+++ b/modules/query/shared/src/test/scala/docspell/query/internal/SimpleExprParserTest.scala
@@ -77,10 +77,10 @@ class SimpleExprParserTest extends FunSuite with ValueHelper {
 
   test("exists expr") {
     val p = SimpleExprParser.existsExpr
-    assertEquals(p.parseAll("exists:name"), Right(Expr.Exists(Attr.ItemName)))
-    assert(p.parseAll("exists:blabla").isLeft)
+    assertEquals(p.parseAll("exist:name"), Right(Expr.Exists(Attr.ItemName)))
+    assert(p.parseAll("exist:blabla").isLeft)
     assertEquals(
-      p.parseAll("exists:conc.pers.id"),
+      p.parseAll("exist:conc.pers.id"),
       Right(Expr.Exists(Attr.Concerning.PersonId))
     )
   }
@@ -153,7 +153,7 @@ class SimpleExprParserTest extends FunSuite with ValueHelper {
       Right(dateExpr(Operator.Lt, Attr.DueDate, ld(2021, 3, 14)))
     )
     assertEquals(
-      p.parseAll("exists:conc.pers.id"),
+      p.parseAll("exist:conc.pers.id"),
       Right(Expr.Exists(Attr.Concerning.PersonId))
     )
     assertEquals(p.parseAll("content:test"), Right(Expr.Fulltext("test")))
diff --git a/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala b/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala
index 7d511026..ebc8fd33 100644
--- a/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala
+++ b/modules/store/src/test/scala/docspell/store/generator/ItemQueryGeneratorTest.scala
@@ -35,7 +35,8 @@ object ItemQueryGeneratorTest extends SimpleTestSuite {
     val cond = ItemQueryGenerator(now, tables, Ident.unsafe("coll"))(q)
     val expect =
       tables.item.name.like("hello") &&
-        tables.item.itemDate >= mkTimestamp(2020, 2, 1) &&
+        coalesce(tables.item.itemDate.s, tables.item.created.s) >=
+        mkTimestamp(2020, 2, 1) &&
         (tables.item.source.like("expense%") || tables.folder.name === "test")
 
     assertEquals(cond, expect)
diff --git a/modules/webapp/src/main/elm/Data/ItemQuery.elm b/modules/webapp/src/main/elm/Data/ItemQuery.elm
index 54a54e8e..f301abe6 100644
--- a/modules/webapp/src/main/elm/Data/ItemQuery.elm
+++ b/modules/webapp/src/main/elm/Data/ItemQuery.elm
@@ -140,16 +140,16 @@ render q =
             "folder.id" ++ attrMatch m ++ quoteStr id
 
         CorrOrgId m id ->
-            "correspondent.org.id" ++ attrMatch m ++ quoteStr id
+            "corr.org.id" ++ attrMatch m ++ quoteStr id
 
         CorrPersId m id ->
-            "correspondent.person.id" ++ attrMatch m ++ quoteStr id
+            "corr.pers.id" ++ attrMatch m ++ quoteStr id
 
         ConcPersId m id ->
-            "concerning.person.id" ++ attrMatch m ++ quoteStr id
+            "conc.pers.id" ++ attrMatch m ++ quoteStr id
 
         ConcEquipId m id ->
-            "concerning.equip.id" ++ attrMatch m ++ quoteStr id
+            "conc.equip.id" ++ attrMatch m ++ quoteStr id
 
         CustomField m kv ->
             "f:" ++ kv.field ++ attrMatch m ++ quoteStr kv.value

From ba898e4af405e8bf8cee238ab1dd9ad07464c249 Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Mon, 8 Mar 2021 22:59:41 +0100
Subject: [PATCH 33/33] Add some docs for using queries

---
 website/site/content/docs/api/intro.md        |  71 +++
 website/site/content/docs/features/_index.md  |   2 +
 website/site/content/docs/query/_index.md     | 527 ++++++++++++++++++
 .../content/docs/query/enable-powersearch.png | Bin 0 -> 91680 bytes
 4 files changed, 600 insertions(+)
 create mode 100644 website/site/content/docs/query/_index.md
 create mode 100644 website/site/content/docs/query/enable-powersearch.png

diff --git a/website/site/content/docs/api/intro.md b/website/site/content/docs/api/intro.md
index 6d9a7818..c6359ab0 100644
--- a/website/site/content/docs/api/intro.md
+++ b/website/site/content/docs/api/intro.md
@@ -116,3 +116,74 @@ $ curl -H 'X-Docspell-Auth: 1568142446077-ZWlrZS9laWtl-$2a$10$3B0teJ9rMpsBJPzHfZ
 ,"tagCloud":{"items":[]}
 }
 ```
+
+### Search for items
+
+``` bash
+$ curl -i -H 'X-Docspell-Auth: 1615240493…kYtFynj4' \
+  'http://localhost:7880/api/v1/sec/item/search?q=tag=todo,invoice%20year:2021'
+{
+  "groups": [
+    {
+      "name": "2021-02",
+      "items": [
+        {
+          "id": "41J962DjS7T-sjP9idxJ6o9-hJrmBk34YJN-mQqysHwcFD6",
+          "name": "something.txt",
+          "state": "confirmed",
+          "date": 1613598750202,
+          "dueDate": 1617883200000,
+          "source": "webapp",
+          "direction": "outgoing",
+          "corrOrg": {
+            "id": "J58tYifCh4X-cze5R8eSJcc-YAFr6qt1VKL-1ZmhRwiTXoH",
+            "name": "EasyCare AG"
+          },
+          "corrPerson": null,
+          "concPerson": null,
+          "concEquipment": null,
+          "folder": {
+            "id": "GKwSvYVdvfb-QeAwzzT7pBM-Gbji2hQc2bL-uCyrMCAg3wo",
+            "name": "test"
+          },
+          "attachments": [],
+          "tags": [],
+          "customfields": [],
+          "notes": null,
+          "highlighting": []
+        }
+      ]
+    },
+    {
+      "name": "2021-01",
+      "items": [
+        {
+          "id": "ANqtuDynXWU-PrhzUxzQVmH-PDuJfeJ6dYB-Ut3g1jrcFhw",
+          "name": "letter-de.pdf",
+          "state": "confirmed",
+          "date": 1611144000000,
+          "dueDate": null,
+          "source": "webapp",
+          "direction": "incoming",
+          "corrOrg": {
+            "id": "J58tYifCh4X-cze5R8eSJcc-YAFr6qt1VKL-1ZmhRwiTXoH",
+            "name": "EasyCare AG"
+          },
+          "corrPerson": null,
+          "concPerson": {
+            "id": "AA5sV1nH9ve-mDCn4DxDRvu-tWkUquiW4fZ-fVJimW4Vq79",
+            "name": "Max Mustermann"
+          },
+          "concEquipment": null,
+          "folder": null,
+          "attachments": [],
+          "tags": [],
+          "customfields": [],
+          "notes": null,
+          "highlighting": []
+        }
+      ]
+    }
+  ]
+}
+```
diff --git a/website/site/content/docs/features/_index.md b/website/site/content/docs/features/_index.md
index 2160da95..cf213621 100644
--- a/website/site/content/docs/features/_index.md
+++ b/website/site/content/docs/features/_index.md
@@ -17,6 +17,8 @@ description = "A list of features and limitations."
 - Conversion to PDF: all files are converted into a PDF file. PDFs
   with only images (as often returned from scanners) are converted
   into searchable PDF/A pdfs.
+- A powerful [query language](@/docs/query/_index.md) to find
+  documents
 - Non-destructive: all your uploaded files are never modified and can
   always be downloaded untouched
 - Organize files using tags, folders, [Custom
diff --git a/website/site/content/docs/query/_index.md b/website/site/content/docs/query/_index.md
new file mode 100644
index 00000000..dad3d732
--- /dev/null
+++ b/website/site/content/docs/query/_index.md
@@ -0,0 +1,527 @@
++++
+title = "Query Language"
+weight = 55
+description = "The query language is a powerful way to search for documents."
+insert_anchor_links = "right"
+[extra]
+mktoc = true
++++
+
+
+Docspell uses a query language to provide a powerful way to search for
+your documents. It is targeted at advanced users and it needs to be
+enabled explicitely in your user settings.
+
+<div class="colums">
+{{ figure(file="enable-powersearch.png") }}
+</div>
+
+This changes the search bar on the items list page to expect a query
+as described below.
+
+The search menu works as before, the query coming from the search menu
+is combined with a query from the search bar.
+
+For taking a quick look, head over to the [examples](#examples).
+
+# Structure
+
+The overall query is an expression that evaluates to `true` or `false`
+when applied to an item and so selects whether to include it in the
+results or not. It consists of smaller expressions that can be
+combined via the common ways: `and`, `or` and `not`.
+
+Simple expressions check some property of an item. The form is:
+
+```
+<field><operator><value>
+```
+
+For example: `tag=invoice` – where `tag` is the field, `=` the
+operator and `invoice` the value. It would evaluate to `true` if the
+item has a tag with name `invoice` and to `false` if the item doesn't
+have a tag with name `invoice`.
+
+Multiple expressions are separated by whitespace and are combined via
+`AND` by default. To explicitely combine them, wrap a list of
+expressions into one of these:
+
+- `(& … )` to combine them via `AND`
+- `(| … )` to combine them via `OR`
+
+It is also possible to negate an expression, by prefixing it with a
+`!`; for example `!tag=invoice`.
+
+# The Parts
+
+## Operators
+
+There are 7 operators:
+
+- `=` for equals
+- `>` for greater-than
+- `>=` for greater-equals
+- `~=` for "in" (a shorter way to say "a or b or c or d")
+- `:` for "like"
+- `<` for lower than
+- `<=` for lower-equal
+- `!=` for not-equals
+
+Not all operators work with every field.
+
+## Fields
+
+Fields are used to identify a property of an item. They also define
+what operators are allowed. There are fields where an item can have at
+most one value (like `name` or `notes`) and there are fields where an
+item can have multiple values (like `tag`). At last there are special
+fields that are either implemented directly using custom sql or that
+are shortcuts to a longer form.
+
+Here is the list of all available fields.
+
+These fields map to at most one value:
+
+- `name` the item name
+- `source` the source used for uploading
+- `notes` the item notes
+- `id` the item id
+- `date` the item date
+- `due` the due date of the item
+- `attach.count` the number of attachments of the item
+- `corr.org.id` the id of the correspondent organization
+- `corr.org.name` the name of the correspondent organization
+- `corr.pers.name` name of correspondent person
+- `corr.pers.id` id of correspondent person
+- `conc.pers.name` name of concerning person
+- `conc.pers.id` id of concerning person
+- `conc.equip.name` name of equipment
+- `conc.equip.id` id of equipment
+- `folder.id` id of a folder
+- `folder` name of a folder
+- `inbox` whether to return "new" items (boolean)
+- `incoming` whether to return incoming items (boolean), `true` to
+  show only incoming, `false` to show only outgoing.
+
+These fields support all operators, except `incoming` and `inbox`
+which expect boolean values and there these operators don't make much
+sense.
+
+Fields that map to more than one value:
+
+- `tag` the tag name
+- `tag.id` the tag id
+- `cat` name of the tag category
+
+The tag and category fields use two operators: `:` and `=`.
+
+Other special fields:
+
+- `attach.id`
+- `checksum`
+- `content`
+- `f` for referencing custom fields by name
+- `f.id` for referencing custom fields by their id
+- `dateIn` a shortcut for a range search
+- `dueIn` a shortcut for a range search
+- `exist` check if some porperty exists
+- `names`
+- `year`
+- `conc`
+- `corr`
+
+These fields are often using the `:` operator to simply separate field
+and value. They are often backed by a custom implementation, or they
+are shortcuts for a longer query.
+
+## Values
+
+Values are the data you want to search for. There are different kinds
+of that, too: there are text-based values, numbers, boolean and dates.
+When multiple values are allowed, they must be separated by comma `,`.
+
+### Text Values
+
+Text values need to be put in quotes (`"`) if they contain one of
+these characters:
+- whitespace ` `
+- quotes `"`
+- backslash `\`
+- comma `,`
+- brackets `[]`
+- parens `()`
+
+Any quotes inside a quoted string must be escaped with a backslash.
+Examples: `scan_123`, `a-b-c`, `x.y.z`, `"scan from today"`, `"a \"strange\"
+name.pdf"`
+
+### Numeric and Boolean Values
+
+Numeric values can be entered literally; an optional fraction part is
+separetd by a dot. Examples: `1`, `2.15`.
+
+A boolean value can be specfied by `yes` or `true` and `no` or
+`false`, respectively. Example: `inbox:yes`
+
+### Dates
+
+Dates are always treated as local dates and can be entered in multiple
+ways.
+
+#### Date Pattern
+
+They can be in the following form: `YYYY-MM-DD` or `YYYY/MM/DD`.
+The month and day part are optional; if they are missing they are
+filled automatically with a `1`. So `2020-01` would be the same as
+`2020-01-01`.
+
+A special pattern is `today` which marks the current day.
+
+#### Unix Epoch
+
+Dates can be given in milliseconds from unix epoch. Then it must be
+prefixed by `ms`. The time part is ignored. Examples:
+`ms1615209591627`.
+
+#### Calculation
+
+Dates can be defined by providing a base date and a period to add or
+substract. This is especially useful with the `today` pattern. The
+period must be separated from the date by a semi-colon `;`. Then write
+a `+` or a `-` to add or substract and at last the number of days
+(suffix `d`) or months (suffix `m`).
+
+Examples: `today;-14d`, `2020-02;+1m`
+
+# Simple Expressions
+
+Simple expressions are made up of a field with at most one value, an
+operator and one or more values. These fields support all operators,
+except for boolean fields.
+
+The like operator `:` can be used with all values, but makes only
+sense for text values. It allows to do a substring search for a field.
+For example, to look for an item with a name of exactly 'invoice_22':
+
+```
+name=invoice_22
+```
+
+Using `:` it is possible to look for items that have 'invoice' in
+their name:
+
+```
+name:*invoice*
+```
+
+The asterisk `*` can be added at the beginning and/or end of the
+value. Furthermore, the like operator is case-insensitive, whereas `=`
+is not. This applies to all fields with a text value; this is another
+example looking for a correspondent person of with 'marcus' in the
+name:
+```
+corr.pers.name:*marcus*
+```
+
+
+----
+
+Comparisons via `<`/`>` are done alphanumerically for text based
+values and numerically for numeric values. For booleans these
+operators don't make sense and therefore don't work there.
+
+----
+
+All these fields (except boolean fields) allow to use the in-operator,
+`~=`. This is a more efficient form to specify a list of alternatives
+and is logically the same as combining multiple expressions with
+`OR`. For example:
+
+```
+source~=webapp,mailbox
+```
+
+is the same as
+```
+(| source=webapp source=mailbox )
+```
+
+The `~=` version is nicer to read, safes some key strokes and also
+runs more efficient when the list grows. It is *not* possible to use a
+wildcard `*` here. If a wildcard is required, you need to write the
+longer form.
+
+If one value contains whitespace or other characters that require
+quoting, each value must be quoted, not the whole list. So this is
+correct:
+```
+source~="web app","mail box"
+```
+
+This is not correct: `source~="web app,mail box"` – it would be treated
+as one single value and is then essentially the same as using `=`.
+
+----
+
+The two fields `incoming` and `inbox` expect a boolean value: one of
+`true` or `false`. The synonyms `yes` and `no` can also be used to
+make it better readable.
+
+This finds all items that have not been confirmed:
+```
+inbox:yes
+```
+
+The `incoming` can be used to show only incoming or only outgoing
+documents:
+
+```
+incoming:yes
+```
+
+For outgoing, you need to say:
+```
+incoming:no
+```
+
+
+# Tags
+
+Tags have their own syntax, because they are an important tool for
+organizing items. Tags only allow for two operators: `=` and `:`.
+Combined with negation (the `!` operator), this is quite flexible.
+
+For tags, `=` means that items must have *all* specified tags (or
+more), while `:` means that items must have at least *one* of the
+specified tags. Tags can be identified by their name or id and are
+given as a comma separated list (just like when using the
+in-operator).
+
+Some examples: Find all invoices that are todo:
+```
+tag=invoice,todo
+```
+
+This returns all items that have tags `invoice` and `todo` – and
+possible some other tags. Negating this:
+```
+!tag=invoice,todo
+```
+
+… results in an expression that returns all items that don't have
+*both* tags. It might return items with tag `invoice` and also items
+with tag `todo`, but no items that have both of them.
+
+Using `:` is just analog to `=`. This finds all items that are either
+`waiting` or `todo` (or both):
+
+```
+tag:waiting,todo
+```
+
+When negating this:
+```
+!tag:waiting,todo
+```
+
+it finds all items that have *none* of the tags.
+
+Tag names are always compared case-insensitive. Tags can also be
+selected using their id, then the field name `tag.id` must be used
+instead of `tag`.
+
+The field `cat` can be used the same way to search for tag categories.
+
+# Custom Fields
+
+Custom fields are implemented via the following syntax:
+
+```
+f:<field-name><operator><value>
+```
+
+They look almost like a simple expression, only prefixed with a `f:`
+to indicate that the following is the name of a custom field.
+
+The type of a custom field is honored. So if you have a money or
+numeric type, comparsions are done numerically. Otherwise a
+alphnumeric comparison is performed. Custom fields do not support the
+in-operator (`~=`).
+
+For example: assuming there is a custom field of type *money* and name
+*usd*, the following selects all items with an amount between 10 and
+150:
+
+```
+f:usd>10 f:usd<150
+```
+
+The like-operator can be used, too. For example, to find all items
+that have a custom field `asn` (often used for a serial number printed
+on the document):
+
+```
+f:asn:*
+```
+
+If the like operator is used on numeric fields, it falls back to
+text-comparison.
+
+Instead of using the name, the field-id can be used to select a field.
+Then the prefix is `f.id`:
+
+```
+f.id:J2ES1Z4Ni9W-xw1VdFbt3KA-rL725kuyVzh-7La95Yw7Ax2:15.00
+```
+
+
+# Fulltext Search
+
+The special field `content` allows to add a fulltext search. Using
+this is currently restricted: it must occur in the root query and
+cannot be nested in other complex expressions.
+
+The form is:
+
+```
+content:<your search query>
+```
+
+The search query is interpreted by the fulltext index (currently it is
+SOLR). This is usually very powerful and in many cases this value must
+be quoted.
+
+For example, do a fulltext search for 'red needle':
+```
+content:"red needle"
+```
+
+It can be combined in an AND expression (but not deeper):
+```
+content:"red needle" tag:todo
+```
+
+
+# File Checksums
+
+The `checksum` field can be used to look for items that have a certain
+file attached. It expects a SHA256 string.
+
+For example, this is the sha256 checksum of some file on the hard
+disk:
+`40675c22ab035b8a4ffe760732b65e5f1d452c59b44d3d0a2a08a95d28853497`.
+
+To find all items that have (exactly) this file attached:
+```
+checksum:40675c22ab035b8a4ffe760732b65e5f1d452c59b44d3d0a2a08a95d28853497
+```
+
+# Exist
+
+The `exist` field can be used with another field, to check whether an
+item has some value for a given field. It only works for fields that
+have at most one value.
+
+For example, it could be used to find fields that are in any folder:
+
+```
+exist:folder
+```
+
+When negating, it finds all items that are not in a folder:
+```
+!exist:folder
+```
+
+
+# Attach-Id
+
+The `attach.id` field is a special field to find items by providing
+the id of an attachment. This can be helpful in certain situations
+when you only have the id of an attachment. It always uses equality,
+so all other operators are not supported.
+
+```
+attach.id=5YjdnuTAdKJ-V6ofWTYsqKV-mAwB5aXTNWE-FAbeRU58qLb
+```
+
+# Shortcuts
+
+Shortcuts are only a short form of a longer query and are provided for
+convenience. The following exist:
+
+- `dateIn` and `dueIn`
+- `year`
+- `names`
+- `conc`
+- `corr`
+
+
+### Date Ranges
+
+The first three are all short forms to specify a range search. With
+`dateIn` and `dueIn` have three forms that are translated into a range
+search:
+
+- `dateIn:2020-01;+15d`  →  `date>=2020-01 date<2020-01;+15d`
+- `dateIn:2020-01;-15d`  →  `date>=2020-01;-15d date<2020-01`
+- `dateIn:2020-01;/15d`  →  `date>=2020-01;-15d date<2020-01;+15d`
+
+The syntax is the same as defining a date by adding a period to some
+base date. These two dates are used to expand the form into a range
+search. There is an additional `/` character to allow to subtract and
+add the period.
+
+The `year` is almost the same thing, only a lot shorter to write. It
+expands into a range search (only for the item date!) that selects all
+items with a date in the specified year:
+
+- `year:2020`  →  `date>=2020-01-01 date<2021-01-01`
+
+The last shortcut is `names`. It allows to search in many "names" of
+related entities at once:
+
+### Names
+
+- `names:tim` → `(| name:tim corr.org.name:tim corr.pers.name:tim conc.pers.name:tim conc.equip.name:tim )`
+
+The `names` field uses the like-operator.
+
+The fields `conc` and `corr` are analog to `names`, only that they
+look into correspondent names and concerning names.
+
+- `conc:marc*` → `(| conc.pers.name:marc* conc.equip.name:marc* )`
+- `corr:marc*` → `(| corr.org.name:marc* corr.pers.name:marc* )`
+
+
+# Examples
+
+Find items with 2 or more attachments:
+```
+attach.count>2
+```
+
+Find items with at least one tag invoice or todo:
+```
+tag:invoice,todo
+```
+
+Find items with at least both tags invoice and todo:
+```
+tag=invoice,todo
+```
+
+Find items with a concerning person of name starting with "Marcus":
+```
+conc.pers.name:marcus*
+```
+
+Find items with at least a tag "todo" in year 2020:
+```
+tag:todo year:2020
+```
+
+Find items within the last 30 days:
+```
+date>today;-30d
+```
diff --git a/website/site/content/docs/query/enable-powersearch.png b/website/site/content/docs/query/enable-powersearch.png
new file mode 100644
index 0000000000000000000000000000000000000000..ec5dbcc47319682ae76276a5818d41cde6e7f187
GIT binary patch
literal 91680
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
OOXtQNjpA#V$o~LFO7=bg

literal 0
HcmV?d00001