mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-06-22 10:28:27 +00:00
Serving scalajs artifacts and provide errors to js
This commit is contained in:
@ -0,0 +1,32 @@
|
||||
package docspell.query
|
||||
|
||||
import java.time.LocalDate
|
||||
import java.time.Period
|
||||
|
||||
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)
|
||||
|
||||
sealed trait DateLiteral 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
|
||||
}
|
@ -0,0 +1,147 @@
|
||||
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 {
|
||||
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
|
||||
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 ItemNotes 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
|
||||
|
||||
def apply(sa: StringAttr, value: String): Property =
|
||||
StringProperty(sa, value)
|
||||
|
||||
def apply(da: DateAttr, value: Date): Property =
|
||||
DateProperty(da, value)
|
||||
}
|
||||
|
||||
sealed trait Expr {
|
||||
def negate: Expr =
|
||||
Expr.NotExpr(this)
|
||||
}
|
||||
|
||||
object 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
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
final case class TagCategoryMatch(op: TagOperator, cats: Nel[String]) extends Expr
|
||||
|
||||
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
|
||||
|
||||
// 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: _*))
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
package docspell.query
|
||||
|
||||
import docspell.query.internal.ExprParser
|
||||
import docspell.query.internal.ExprUtil
|
||||
|
||||
object ItemQueryParser {
|
||||
|
||||
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(ParseFailure.fromError(in))
|
||||
.map(q => q.copy(expr = ExprUtil.reduce(q.expr)))
|
||||
}
|
||||
|
||||
def parseUnsafe(input: String): ItemQuery =
|
||||
parse(input).fold(m => sys.error(m.render), identity)
|
||||
}
|
@ -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")
|
||||
}
|
||||
}
|
@ -0,0 +1,90 @@
|
||||
package docspell.query.internal
|
||||
|
||||
import cats.parse.{Parser => P}
|
||||
|
||||
import docspell.query.ItemQuery.Attr
|
||||
|
||||
object AttrParser {
|
||||
|
||||
val name: P[Attr.StringAttr] =
|
||||
P.ignoreCase("name").as(Attr.ItemName)
|
||||
|
||||
val source: P[Attr.StringAttr] =
|
||||
P.ignoreCase("source").as(Attr.ItemSource)
|
||||
|
||||
val id: P[Attr.StringAttr] =
|
||||
P.ignoreCase("id").as(Attr.ItemId)
|
||||
|
||||
val date: P[Attr.DateAttr] =
|
||||
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")).as(Attr.DueDate)
|
||||
|
||||
val corrOrgId: P[Attr.StringAttr] =
|
||||
P.stringIn(List("correspondent.org.id", "corr.org.id"))
|
||||
.as(Attr.Correspondent.OrgId)
|
||||
|
||||
val corrOrgName: P[Attr.StringAttr] =
|
||||
P.stringIn(List("correspondent.org.name", "corr.org.name"))
|
||||
.as(Attr.Correspondent.OrgName)
|
||||
|
||||
val corrPersId: P[Attr.StringAttr] =
|
||||
P.stringIn(List("correspondent.person.id", "corr.pers.id"))
|
||||
.as(Attr.Correspondent.PersonId)
|
||||
|
||||
val corrPersName: P[Attr.StringAttr] =
|
||||
P.stringIn(List("correspondent.person.name", "corr.pers.name"))
|
||||
.as(Attr.Correspondent.PersonName)
|
||||
|
||||
val concPersId: P[Attr.StringAttr] =
|
||||
P.stringIn(List("concerning.person.id", "conc.pers.id"))
|
||||
.as(Attr.Concerning.PersonId)
|
||||
|
||||
val concPersName: P[Attr.StringAttr] =
|
||||
P.stringIn(List("concerning.person.name", "conc.pers.name"))
|
||||
.as(Attr.Concerning.PersonName)
|
||||
|
||||
val concEquipId: P[Attr.StringAttr] =
|
||||
P.stringIn(List("concerning.equip.id", "conc.equip.id"))
|
||||
.as(Attr.Concerning.EquipId)
|
||||
|
||||
val concEquipName: P[Attr.StringAttr] =
|
||||
P.stringIn(List("concerning.equip.name", "conc.equip.name"))
|
||||
.as(Attr.Concerning.EquipName)
|
||||
|
||||
val folderId: P[Attr.StringAttr] =
|
||||
P.ignoreCase("folder.id").as(Attr.Folder.FolderId)
|
||||
|
||||
val folderName: P[Attr.StringAttr] =
|
||||
P.ignoreCase("folder").as(Attr.Folder.FolderName)
|
||||
|
||||
val dateAttr: P[Attr.DateAttr] =
|
||||
P.oneOf(List(date, dueDate))
|
||||
|
||||
val stringAttr: P[Attr.StringAttr] =
|
||||
P.oneOf(
|
||||
List(
|
||||
name,
|
||||
source,
|
||||
id,
|
||||
notes,
|
||||
corrOrgId,
|
||||
corrOrgName,
|
||||
corrPersId,
|
||||
corrPersName,
|
||||
concPersId,
|
||||
concPersName,
|
||||
concEquipId,
|
||||
concEquipName,
|
||||
folderId,
|
||||
folderName
|
||||
)
|
||||
)
|
||||
|
||||
val anyAttr: P[Attr] =
|
||||
P.oneOf(List(dateAttr, stringAttr))
|
||||
}
|
@ -0,0 +1,47 @@
|
||||
package docspell.query.internal
|
||||
|
||||
import cats.data.{NonEmptyList => Nel}
|
||||
import cats.parse.{Parser => P, Parser0}
|
||||
|
||||
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.void
|
||||
|
||||
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 != ')'
|
||||
)
|
||||
|
||||
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 <* ws0
|
||||
|
||||
val parenClose: P[Unit] =
|
||||
ws0.soft.with1 *> P.char(')')
|
||||
|
||||
val parenOr: P[Unit] =
|
||||
P.stringIn(List("(|", "(or")).void <* ws0
|
||||
|
||||
val identParser: P[String] =
|
||||
P.charsWhile(identChars.contains)
|
||||
|
||||
val singleString: P[String] =
|
||||
basicString.backtrack.orElse(StringUtil.quoted('"'))
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,105 @@
|
||||
package docspell.query.internal
|
||||
|
||||
import java.time.Period
|
||||
|
||||
import cats.data.{NonEmptyList => Nel}
|
||||
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.charIn('-', '/').void
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private[internal] val dateFromMillis: P[Date.DateLiteral] =
|
||||
P.string("ms") *> longParser.map(Date.apply)
|
||||
|
||||
private val dateFromToday: P[Date.DateLiteral] =
|
||||
P.string("today").as(Date.Today)
|
||||
|
||||
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)
|
||||
}
|
@ -0,0 +1,38 @@
|
||||
package docspell.query.internal
|
||||
|
||||
import cats.parse.{Parser => P}
|
||||
|
||||
import docspell.query.ItemQuery
|
||||
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] =
|
||||
(P.char('!') *> inner).map(_.negate)
|
||||
|
||||
val exprParser: P[Expr] =
|
||||
P.recursive[Expr] { recurse =>
|
||||
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] = {
|
||||
val p = BasicParser.ws0 *> exprParser <* (BasicParser.ws0 ~ P.end)
|
||||
p.parseAll(input).map(expr => ItemQuery(expr, Some(input)))
|
||||
}
|
||||
}
|
@ -0,0 +1,65 @@
|
||||
package docspell.query.internal
|
||||
|
||||
import docspell.query.ItemQuery.Expr._
|
||||
import docspell.query.ItemQuery._
|
||||
|
||||
object ExprUtil {
|
||||
|
||||
/** Does some basic transformation, like unfolding 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 InboxExpr(flag) =>
|
||||
InboxExpr(!flag)
|
||||
case DirectionExpr(flag) =>
|
||||
DirectionExpr(!flag)
|
||||
case _ =>
|
||||
expr
|
||||
}
|
||||
|
||||
case m: MacroExpr =>
|
||||
reduce(m.body)
|
||||
|
||||
case DirectionExpr(_) =>
|
||||
expr
|
||||
|
||||
case InboxExpr(_) =>
|
||||
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
|
||||
case CustomFieldIdMatch(_, _, _) =>
|
||||
expr
|
||||
}
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
package docspell.query.internal
|
||||
|
||||
import cats.parse.{Parser => P}
|
||||
|
||||
import docspell.query.ItemQuery._
|
||||
|
||||
object MacroParser {
|
||||
private def macroDef(name: String): P[Unit] =
|
||||
P.char('$').soft.with1 *> P.string(name) <* P.char(':')
|
||||
|
||||
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 namesMacro: P[Expr.NamesMacro] =
|
||||
(macroDef("names") *> BasicParser.singleString).map(Expr.NamesMacro.apply)
|
||||
|
||||
val dateRangeMacro: P[Expr.DateRangeMacro] =
|
||||
dateRangeMacroImpl("datein", Attr.Date)
|
||||
|
||||
val dueDateRangeMacro: P[Expr.DateRangeMacro] =
|
||||
dateRangeMacroImpl("duein", Attr.DueDate)
|
||||
|
||||
// --- all macro parser
|
||||
|
||||
val all: P[Expr] =
|
||||
P.oneOf(List(namesMacro, dateRangeMacro, dueDateRangeMacro))
|
||||
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
package docspell.query.internal
|
||||
|
||||
import cats.parse.{Parser => P}
|
||||
|
||||
import docspell.query.ItemQuery._
|
||||
|
||||
object OperatorParser {
|
||||
private[this] val Eq: P[Operator] =
|
||||
P.char('=').as(Operator.Eq)
|
||||
|
||||
private[this] val Neq: P[Operator] =
|
||||
P.string("!=").as(Operator.Neq)
|
||||
|
||||
private[this] val Like: P[Operator] =
|
||||
P.char(':').as(Operator.Like)
|
||||
|
||||
private[this] val Gt: P[Operator] =
|
||||
P.char('>').as(Operator.Gt)
|
||||
|
||||
private[this] val Lt: P[Operator] =
|
||||
P.char('<').as(Operator.Lt)
|
||||
|
||||
private[this] val Gte: P[Operator] =
|
||||
P.string(">=").as(Operator.Gte)
|
||||
|
||||
private[this] val Lte: P[Operator] =
|
||||
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(':').as(TagOperator.AnyMatch)
|
||||
|
||||
private[this] val allOp: P[TagOperator] =
|
||||
P.char('=').as(TagOperator.AllMatch)
|
||||
|
||||
val tagOp: P[TagOperator] =
|
||||
P.oneOf(List(anyOp, allOp))
|
||||
}
|
@ -0,0 +1,95 @@
|
||||
package docspell.query.internal
|
||||
|
||||
import cats.parse.{Parser => P}
|
||||
|
||||
import docspell.query.ItemQuery._
|
||||
|
||||
object SimpleExprParser {
|
||||
|
||||
private[this] val op: P[Operator] =
|
||||
OperatorParser.op.surroundedBy(BasicParser.ws0)
|
||||
|
||||
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.date, inOp *> DateParser.dateOrMore)
|
||||
|
||||
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] =
|
||||
(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] =
|
||||
(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) =>
|
||||
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] =
|
||||
(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(
|
||||
dateExpr,
|
||||
stringExpr,
|
||||
existsExpr,
|
||||
fulltextExpr,
|
||||
tagIdExpr,
|
||||
tagExpr,
|
||||
catExpr,
|
||||
customFieldIdExpr,
|
||||
customFieldExpr,
|
||||
inboxExpr,
|
||||
dirExpr
|
||||
)
|
||||
)
|
||||
}
|
@ -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.{Parser => P, Parser0 => P0}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
package docspell.query.internal
|
||||
|
||||
import docspell.query.ItemQuery.Attr
|
||||
import docspell.query.internal.AttrParser
|
||||
import munit._
|
||||
|
||||
class AttrParserTest extends FunSuite {
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
package docspell.query.internal
|
||||
|
||||
import munit._
|
||||
import cats.data.{NonEmptyList => Nel}
|
||||
import docspell.query.internal.BasicParser
|
||||
|
||||
class BasicParserTest extends FunSuite {
|
||||
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.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)
|
||||
}
|
||||
|
||||
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.parse("a, b, c "), Right((" ", Nel.of("a", "b", "c"))))
|
||||
}
|
||||
}
|
@ -0,0 +1,60 @@
|
||||
package docspell.query.internal
|
||||
|
||||
import munit._
|
||||
import docspell.query.Date
|
||||
import java.time.Period
|
||||
|
||||
class DateParserTest extends FunSuite with ValueHelper {
|
||||
|
||||
test("local date string") {
|
||||
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").isLeft)
|
||||
}
|
||||
|
||||
test("local date millis") {
|
||||
val p = DateParser.dateFromMillis
|
||||
assertEquals(p.parseAll("ms0"), Right(Date(0)))
|
||||
assertEquals(
|
||||
p.parseAll("ms1600000065463"),
|
||||
Right(Date(1600000065463L))
|
||||
)
|
||||
}
|
||||
|
||||
test("local date") {
|
||||
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("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))))
|
||||
}
|
||||
}
|
@ -0,0 +1,92 @@
|
||||
package docspell.query.internal
|
||||
|
||||
import docspell.query.ItemQuery._
|
||||
import munit._
|
||||
import cats.data.{NonEmptyList => Nel}
|
||||
|
||||
class ExprParserTest extends FunSuite with ValueHelper {
|
||||
|
||||
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")
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
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"))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
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*"))
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
package docspell.query.internal
|
||||
|
||||
import munit._
|
||||
import docspell.query.ItemQueryParser
|
||||
import docspell.query.ItemQuery
|
||||
|
||||
class ItemQueryParserTest extends FunSuite {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
package docspell.query.internal
|
||||
|
||||
import munit._
|
||||
//import cats.parse.{Parser => P}
|
||||
import docspell.query.ItemQuery.Expr
|
||||
|
||||
class MacroParserTest extends FunSuite {
|
||||
|
||||
test("start with $") {
|
||||
val p = MacroParser.namesMacro
|
||||
assertEquals(p.parseAll("$names:test"), Right(Expr.NamesMacro("test")))
|
||||
assert(p.parseAll("names:test").isLeft)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
package docspell.query.internal
|
||||
|
||||
import munit._
|
||||
import docspell.query.ItemQuery.{Operator, TagOperator}
|
||||
import docspell.query.internal.OperatorParser
|
||||
|
||||
class OperatorParserTest extends FunSuite {
|
||||
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))
|
||||
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))
|
||||
}
|
||||
}
|
@ -0,0 +1,182 @@
|
||||
package docspell.query.internal
|
||||
|
||||
import cats.data.{NonEmptyList => Nel}
|
||||
import docspell.query.ItemQuery._
|
||||
import munit._
|
||||
import docspell.query.Date
|
||||
import java.time.Period
|
||||
|
||||
class SimpleExprParserTest extends FunSuite with ValueHelper {
|
||||
|
||||
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"))
|
||||
)
|
||||
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") {
|
||||
val p = SimpleExprParser.dateExpr
|
||||
assertEquals(
|
||||
p.parseAll("date:2021-03-14"),
|
||||
Right(dateExpr(Operator.Like, Attr.Date, ld(2021, 3, 14)))
|
||||
)
|
||||
assertEquals(
|
||||
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))))
|
||||
)
|
||||
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") {
|
||||
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"))
|
||||
)
|
||||
assertEquals(
|
||||
p.parseAll("f:usd=\"26.66\""),
|
||||
Right(Expr.CustomFieldMatch("usd", Operator.Eq, "26.66"))
|
||||
)
|
||||
}
|
||||
|
||||
}
|
@ -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))
|
||||
|
||||
}
|
Reference in New Issue
Block a user