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 )