mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-04-04 10:29:34 +00:00
Remove unused code (search update)
This commit is contained in:
parent
c6a9a17f89
commit
7ce6bc2f9d
@ -47,7 +47,6 @@ trait BackendApp[F[_]] {
|
||||
def userTask: OUserTask[F]
|
||||
def folder: OFolder[F]
|
||||
def customFields: OCustomFields[F]
|
||||
def simpleSearch: OSimpleSearch[F]
|
||||
def clientSettings: OClientSettings[F]
|
||||
def totp: OTotp[F]
|
||||
def share: OShare[F]
|
||||
@ -99,8 +98,6 @@ object BackendApp {
|
||||
itemImpl <- OItem(store, ftsClient, createIndex, schedulerModule.jobs)
|
||||
itemSearchImpl <- OItemSearch(store)
|
||||
fulltextImpl <- OFulltext(
|
||||
itemSearchImpl,
|
||||
ftsClient,
|
||||
store,
|
||||
schedulerModule.jobs
|
||||
)
|
||||
@ -112,15 +109,15 @@ object BackendApp {
|
||||
)
|
||||
folderImpl <- OFolder(store)
|
||||
customFieldsImpl <- OCustomFields(store)
|
||||
simpleSearchImpl = OSimpleSearch(fulltextImpl, itemSearchImpl)
|
||||
clientSettingsImpl <- OClientSettings(store)
|
||||
searchImpl <- Resource.pure(OSearch(store, ftsClient))
|
||||
shareImpl <- Resource.pure(
|
||||
OShare(store, itemSearchImpl, simpleSearchImpl, javaEmil)
|
||||
OShare(store, itemSearchImpl, searchImpl, javaEmil)
|
||||
)
|
||||
notifyImpl <- ONotification(store, notificationMod)
|
||||
bookmarksImpl <- OQueryBookmarks(store)
|
||||
fileRepoImpl <- OFileRepository(store, schedulerModule.jobs)
|
||||
itemLinkImpl <- Resource.pure(OItemLink(store, itemSearchImpl))
|
||||
itemLinkImpl <- Resource.pure(OItemLink(store, searchImpl))
|
||||
downloadAllImpl <- Resource.pure(ODownloadAll(store, jobImpl, schedulerModule.jobs))
|
||||
attachImpl <- Resource.pure(OAttachment(store, ftsClient, schedulerModule.jobs))
|
||||
addonsImpl <- Resource.pure(
|
||||
@ -132,7 +129,6 @@ object BackendApp {
|
||||
joexImpl
|
||||
)
|
||||
)
|
||||
searchImpl <- Resource.pure(OSearch(store, ftsClient))
|
||||
} yield new BackendApp[F] {
|
||||
val pubSub = pubSubT
|
||||
val login = loginImpl
|
||||
@ -153,7 +149,6 @@ object BackendApp {
|
||||
val userTask = userTaskImpl
|
||||
val folder = folderImpl
|
||||
val customFields = customFieldsImpl
|
||||
val simpleSearch = simpleSearchImpl
|
||||
val clientSettings = clientSettingsImpl
|
||||
val totp = totpImpl
|
||||
val share = shareImpl
|
||||
|
@ -6,46 +6,17 @@
|
||||
|
||||
package docspell.backend.ops
|
||||
|
||||
import cats.data.NonEmptyList
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
import fs2.Stream
|
||||
|
||||
import docspell.backend.JobFactory
|
||||
import docspell.backend.ops.OItemSearch._
|
||||
import docspell.common._
|
||||
import docspell.ftsclient._
|
||||
import docspell.query.ItemQuery._
|
||||
import docspell.query.ItemQueryDsl._
|
||||
import docspell.scheduler.JobStore
|
||||
import docspell.store.queries.{QFolder, QItem, SelectedItem}
|
||||
import docspell.store.Store
|
||||
import docspell.store.records.RJob
|
||||
import docspell.store.{Store, qb}
|
||||
|
||||
trait OFulltext[F[_]] {
|
||||
|
||||
def findItems(maxNoteLen: Int)(
|
||||
q: Query,
|
||||
fts: OFulltext.FtsInput,
|
||||
batch: qb.Batch
|
||||
): F[Vector[OFulltext.FtsItem]]
|
||||
|
||||
/** Same as `findItems` but does more queries per item to find all tags. */
|
||||
def findItemsWithTags(maxNoteLen: Int)(
|
||||
q: Query,
|
||||
fts: OFulltext.FtsInput,
|
||||
batch: qb.Batch
|
||||
): F[Vector[OFulltext.FtsItemWithTags]]
|
||||
|
||||
def findIndexOnly(maxNoteLen: Int)(
|
||||
fts: OFulltext.FtsInput,
|
||||
account: AccountId,
|
||||
batch: qb.Batch
|
||||
): F[Vector[OFulltext.FtsItemWithTags]]
|
||||
|
||||
def findIndexOnlySummary(account: AccountId, fts: OFulltext.FtsInput): F[SearchSummary]
|
||||
def findItemsSummary(q: Query, fts: OFulltext.FtsInput): F[SearchSummary]
|
||||
|
||||
/** Clears the full-text index completely and launches a task that indexes all data. */
|
||||
def reindexAll: F[Unit]
|
||||
|
||||
@ -56,30 +27,7 @@ trait OFulltext[F[_]] {
|
||||
}
|
||||
|
||||
object OFulltext {
|
||||
|
||||
case class FtsInput(
|
||||
query: String,
|
||||
highlightPre: String = "***",
|
||||
highlightPost: String = "***"
|
||||
)
|
||||
|
||||
case class FtsDataItem(
|
||||
score: Double,
|
||||
matchData: FtsResult.MatchData,
|
||||
context: List[String]
|
||||
)
|
||||
case class FtsData(
|
||||
maxScore: Double,
|
||||
count: Int,
|
||||
qtime: Duration,
|
||||
items: List[FtsDataItem]
|
||||
)
|
||||
case class FtsItem(item: ListItem, ftsData: FtsData)
|
||||
case class FtsItemWithTags(item: ListItemWithTags, ftsData: FtsData)
|
||||
|
||||
def apply[F[_]: Async](
|
||||
itemSearch: OItemSearch[F],
|
||||
fts: FtsClient[F],
|
||||
store: Store[F],
|
||||
jobStore: JobStore[F]
|
||||
): Resource[F, OFulltext[F]] =
|
||||
@ -103,232 +51,5 @@ object OFulltext {
|
||||
if (exist.isDefined) ().pure[F]
|
||||
else jobStore.insertIfNew(job.encode)
|
||||
} yield ()
|
||||
|
||||
def findIndexOnly(maxNoteLen: Int)(
|
||||
ftsQ: OFulltext.FtsInput,
|
||||
account: AccountId,
|
||||
batch: qb.Batch
|
||||
): F[Vector[OFulltext.FtsItemWithTags]] = {
|
||||
val fq = FtsQuery(
|
||||
ftsQ.query,
|
||||
account.collective,
|
||||
Set.empty,
|
||||
Set.empty,
|
||||
batch.limit,
|
||||
batch.offset,
|
||||
FtsQuery.HighlightSetting(ftsQ.highlightPre, ftsQ.highlightPost)
|
||||
)
|
||||
for {
|
||||
_ <- logger.trace(s"Find index only: ${ftsQ.query}/$batch")
|
||||
folders <- store.transact(QFolder.getMemberFolders(account))
|
||||
ftsR <- fts.search(fq.withFolders(folders))
|
||||
ftsItems = ftsR.results.groupBy(_.itemId)
|
||||
select =
|
||||
ftsItems.values
|
||||
.map(_.minBy(-_.score))
|
||||
.map(r => SelectedItem(r.itemId, r.score))
|
||||
.toSet
|
||||
now <- Timestamp.current[F]
|
||||
itemsWithTags <-
|
||||
store
|
||||
.transact(
|
||||
QItem.findItemsWithTags(
|
||||
account.collective,
|
||||
QItem.findSelectedItems(
|
||||
Query.all(account),
|
||||
now.toUtcDate,
|
||||
maxNoteLen,
|
||||
select
|
||||
)
|
||||
)
|
||||
)
|
||||
.take(batch.limit.toLong)
|
||||
.compile
|
||||
.toVector
|
||||
res =
|
||||
itemsWithTags
|
||||
.collect(convertFtsData(ftsR, ftsItems))
|
||||
.map { case (li, fd) => FtsItemWithTags(li, fd) }
|
||||
} yield res
|
||||
}
|
||||
|
||||
def findIndexOnlySummary(
|
||||
account: AccountId,
|
||||
ftsQ: OFulltext.FtsInput
|
||||
): F[SearchSummary] = {
|
||||
val fq = FtsQuery(
|
||||
ftsQ.query,
|
||||
account.collective,
|
||||
Set.empty,
|
||||
Set.empty,
|
||||
500,
|
||||
0,
|
||||
FtsQuery.HighlightSetting.default
|
||||
)
|
||||
|
||||
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)
|
||||
itemIdsQuery = NonEmptyList
|
||||
.fromList(itemIds.toList)
|
||||
.map(ids => Attr.ItemId.in(ids.map(_.id)))
|
||||
.getOrElse(Attr.ItemId.notExists)
|
||||
q = Query
|
||||
.all(account)
|
||||
.withFix(_.copy(query = itemIdsQuery.some))
|
||||
res <- store.transact(QItem.searchStats(now.toUtcDate, None)(q))
|
||||
} yield res
|
||||
}
|
||||
|
||||
def findItems(
|
||||
maxNoteLen: Int
|
||||
)(q: Query, ftsQ: FtsInput, batch: qb.Batch): F[Vector[FtsItem]] =
|
||||
findItemsFts(
|
||||
q,
|
||||
ftsQ,
|
||||
batch.first,
|
||||
itemSearch.findItems(maxNoteLen),
|
||||
convertFtsData[ListItem]
|
||||
)
|
||||
.drop(batch.offset.toLong)
|
||||
.take(batch.limit.toLong)
|
||||
.map { case (li, fd) => FtsItem(li, fd) }
|
||||
.compile
|
||||
.toVector
|
||||
|
||||
def findItemsWithTags(maxNoteLen: Int)(
|
||||
q: Query,
|
||||
ftsQ: FtsInput,
|
||||
batch: qb.Batch
|
||||
): F[Vector[FtsItemWithTags]] =
|
||||
findItemsFts(
|
||||
q,
|
||||
ftsQ,
|
||||
batch.first,
|
||||
itemSearch.findItemsWithTags(maxNoteLen),
|
||||
convertFtsData[ListItemWithTags]
|
||||
)
|
||||
.drop(batch.offset.toLong)
|
||||
.take(batch.limit.toLong)
|
||||
.map { case (li, fd) => FtsItemWithTags(li, fd) }
|
||||
.compile
|
||||
.toVector
|
||||
|
||||
def findItemsSummary(q: Query, ftsQ: OFulltext.FtsInput): F[SearchSummary] =
|
||||
for {
|
||||
search <- itemSearch.findItems(0)(q, Batch.all)
|
||||
fq = FtsQuery(
|
||||
ftsQ.query,
|
||||
q.fix.account.collective,
|
||||
search.map(_.id).toSet,
|
||||
Set.empty,
|
||||
500,
|
||||
0,
|
||||
FtsQuery.HighlightSetting.default
|
||||
)
|
||||
items <- fts
|
||||
.searchAll(fq)
|
||||
.flatMap(r => Stream.emits(r.results.map(_.itemId)))
|
||||
.compile
|
||||
.to(Set)
|
||||
itemIdsQuery = NonEmptyList
|
||||
.fromList(items.toList)
|
||||
.map(ids => Attr.ItemId.in(ids.map(_.id)))
|
||||
.getOrElse(Attr.ItemId.notExists)
|
||||
qnext = q.withFix(_.copy(query = itemIdsQuery.some))
|
||||
now <- Timestamp.current[F]
|
||||
res <- store.transact(QItem.searchStats(now.toUtcDate, None)(qnext))
|
||||
} yield res
|
||||
|
||||
// Helper
|
||||
|
||||
private def findItemsFts[A: ItemId, B](
|
||||
q: Query,
|
||||
ftsQ: FtsInput,
|
||||
batch: qb.Batch,
|
||||
search: (Query, qb.Batch) => F[Vector[A]],
|
||||
convert: (
|
||||
FtsResult,
|
||||
Map[Ident, List[FtsResult.ItemMatch]]
|
||||
) => PartialFunction[A, (A, FtsData)]
|
||||
): Stream[F, (A, FtsData)] =
|
||||
findItemsFts0(q, ftsQ, batch, search, convert)
|
||||
.takeThrough(_._1 >= batch.limit)
|
||||
.flatMap(x => Stream.emits(x._2))
|
||||
|
||||
private def findItemsFts0[A: ItemId, B](
|
||||
q: Query,
|
||||
ftsQ: FtsInput,
|
||||
batch: qb.Batch,
|
||||
search: (Query, qb.Batch) => F[Vector[A]],
|
||||
convert: (
|
||||
FtsResult,
|
||||
Map[Ident, List[FtsResult.ItemMatch]]
|
||||
) => PartialFunction[A, (A, FtsData)]
|
||||
): Stream[F, (Int, Vector[(A, FtsData)])] = {
|
||||
val sqlResult = search(q, batch)
|
||||
val fq = FtsQuery(
|
||||
ftsQ.query,
|
||||
q.fix.account.collective,
|
||||
Set.empty,
|
||||
Set.empty,
|
||||
0,
|
||||
0,
|
||||
FtsQuery.HighlightSetting(ftsQ.highlightPre, ftsQ.highlightPost)
|
||||
)
|
||||
|
||||
val qres =
|
||||
for {
|
||||
items <- sqlResult
|
||||
ids = items.map(a => ItemId[A].itemId(a))
|
||||
idsNel = NonEmptyList.fromFoldable(ids)
|
||||
// must find all index results involving the items.
|
||||
// Currently there is one result per item + one result per
|
||||
// attachment
|
||||
limit <- idsNel
|
||||
.map(itemIds => store.transact(QItem.countAttachmentsAndItems(itemIds)))
|
||||
.getOrElse(0.pure[F])
|
||||
ftsQ = fq.copy(items = ids.toSet, limit = limit)
|
||||
ftsR <- fts.search(ftsQ)
|
||||
ftsItems = ftsR.results.groupBy(_.itemId)
|
||||
res = items.collect(convert(ftsR, ftsItems))
|
||||
} yield (items.size, res)
|
||||
|
||||
Stream.eval(qres) ++ findItemsFts0(q, ftsQ, batch.next, search, convert)
|
||||
}
|
||||
|
||||
private def convertFtsData[A: ItemId](
|
||||
ftr: FtsResult,
|
||||
ftrItems: Map[Ident, List[FtsResult.ItemMatch]]
|
||||
): PartialFunction[A, (A, FtsData)] = {
|
||||
case a if ftrItems.contains(ItemId[A].itemId(a)) =>
|
||||
val ftsDataItems = ftrItems
|
||||
.getOrElse(ItemId[A].itemId(a), Nil)
|
||||
.map(im =>
|
||||
FtsDataItem(im.score, im.data, ftr.highlight.getOrElse(im.id, Nil))
|
||||
)
|
||||
(a, FtsData(ftr.maxScore, ftr.count, ftr.qtime, ftsDataItems))
|
||||
}
|
||||
})
|
||||
|
||||
trait ItemId[A] {
|
||||
def itemId(a: A): Ident
|
||||
}
|
||||
object ItemId {
|
||||
def apply[A](implicit ev: ItemId[A]): ItemId[A] = ev
|
||||
|
||||
def from[A](f: A => Ident): ItemId[A] =
|
||||
(a: A) => f(a)
|
||||
|
||||
implicit val listItemId: ItemId[ListItem] =
|
||||
ItemId.from(_.id)
|
||||
|
||||
implicit val listItemWithTagsId: ItemId[ListItemWithTags] =
|
||||
ItemId.from(_.item.id)
|
||||
}
|
||||
}
|
||||
|
@ -11,11 +11,12 @@ import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.backend.ops.OItemLink.LinkResult
|
||||
import docspell.backend.ops.search.OSearch
|
||||
import docspell.common.{AccountId, Ident}
|
||||
import docspell.query.ItemQuery
|
||||
import docspell.query.ItemQueryDsl._
|
||||
import docspell.store.qb.Batch
|
||||
import docspell.store.queries.Query
|
||||
import docspell.store.queries.{ListItemWithTags, Query}
|
||||
import docspell.store.records.RItemLink
|
||||
import docspell.store.{AddResult, Store}
|
||||
|
||||
@ -29,7 +30,7 @@ trait OItemLink[F[_]] {
|
||||
account: AccountId,
|
||||
item: Ident,
|
||||
batch: Batch
|
||||
): F[Vector[OItemSearch.ListItemWithTags]]
|
||||
): F[Vector[ListItemWithTags]]
|
||||
}
|
||||
|
||||
object OItemLink {
|
||||
@ -44,13 +45,13 @@ object OItemLink {
|
||||
def linkTargetItemError: LinkResult = LinkTargetItemError
|
||||
}
|
||||
|
||||
def apply[F[_]: Sync](store: Store[F], search: OItemSearch[F]): OItemLink[F] =
|
||||
def apply[F[_]: Sync](store: Store[F], search: OSearch[F]): OItemLink[F] =
|
||||
new OItemLink[F] {
|
||||
def getRelated(
|
||||
accountId: AccountId,
|
||||
item: Ident,
|
||||
batch: Batch
|
||||
): F[Vector[OItemSearch.ListItemWithTags]] =
|
||||
): F[Vector[ListItemWithTags]] =
|
||||
store
|
||||
.transact(RItemLink.findLinked(accountId.collective, item))
|
||||
.map(ids => NonEmptyList.fromList(ids.toList))
|
||||
@ -62,10 +63,10 @@ object OItemLink {
|
||||
.Fix(accountId, Some(ItemQuery.Expr.ValidItemStates), None),
|
||||
Query.QueryExpr(expr)
|
||||
)
|
||||
search.findItemsWithTags(0)(query, batch)
|
||||
search.searchWithDetails(0, None, batch)(query, None)
|
||||
|
||||
case None =>
|
||||
Vector.empty[OItemSearch.ListItemWithTags].pure[F]
|
||||
Vector.empty[ListItemWithTags].pure[F]
|
||||
}
|
||||
|
||||
def addAll(cid: Ident, target: Ident, related: NonEmptyList[Ident]): F[LinkResult] =
|
||||
|
@ -25,15 +25,6 @@ trait OItemSearch[F[_]] {
|
||||
|
||||
def findDeleted(collective: Ident, maxUpdate: Timestamp, limit: Int): F[Vector[RItem]]
|
||||
|
||||
def findItems(maxNoteLen: Int)(q: Query, batch: Batch): F[Vector[ListItem]]
|
||||
|
||||
/** Same as `findItems` but does more queries per item to find all tags. */
|
||||
def findItemsWithTags(
|
||||
maxNoteLen: Int
|
||||
)(q: Query, batch: Batch): F[Vector[ListItemWithTags]]
|
||||
|
||||
def findItemsSummary(q: Query): F[SearchSummary]
|
||||
|
||||
def findAttachment(id: Ident, collective: Ident): F[Option[AttachmentData[F]]]
|
||||
|
||||
def findAttachmentSource(
|
||||
@ -63,9 +54,6 @@ trait OItemSearch[F[_]] {
|
||||
|
||||
object OItemSearch {
|
||||
|
||||
type SearchSummary = queries.SearchSummary
|
||||
val SearchSummary = queries.SearchSummary
|
||||
|
||||
type CustomValue = queries.CustomValue
|
||||
val CustomValue = queries.CustomValue
|
||||
|
||||
@ -75,12 +63,6 @@ object OItemSearch {
|
||||
type Batch = qb.Batch
|
||||
val Batch = docspell.store.qb.Batch
|
||||
|
||||
type ListItem = queries.ListItem
|
||||
val ListItem = queries.ListItem
|
||||
|
||||
type ListItemWithTags = queries.ListItemWithTags
|
||||
val ListItemWithTags = queries.ListItemWithTags
|
||||
|
||||
type ItemFieldValue = queries.ItemFieldValue
|
||||
val ItemFieldValue = queries.ItemFieldValue
|
||||
|
||||
@ -136,19 +118,6 @@ object OItemSearch {
|
||||
store
|
||||
.transact(QItem.findItem(id, collective))
|
||||
|
||||
def findItems(maxNoteLen: Int)(q: Query, batch: Batch): F[Vector[ListItem]] =
|
||||
Timestamp
|
||||
.current[F]
|
||||
.map(_.toUtcDate)
|
||||
.flatMap { today =>
|
||||
store
|
||||
.transact(
|
||||
QItem.findItems(q, today, maxNoteLen, batch).take(batch.limit.toLong)
|
||||
)
|
||||
.compile
|
||||
.toVector
|
||||
}
|
||||
|
||||
def findDeleted(
|
||||
collective: Ident,
|
||||
maxUpdate: Timestamp,
|
||||
@ -160,28 +129,6 @@ object OItemSearch {
|
||||
.compile
|
||||
.toVector
|
||||
|
||||
def findItemsWithTags(
|
||||
maxNoteLen: Int
|
||||
)(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] =
|
||||
Timestamp
|
||||
.current[F]
|
||||
.map(_.toUtcDate)
|
||||
.flatMap(today => store.transact(QItem.searchStats(today, None)(q)))
|
||||
|
||||
def findAttachment(id: Ident, collective: Ident): F[Option[AttachmentData[F]]] =
|
||||
store
|
||||
.transact(RAttachment.findByIdAndCollective(id, collective))
|
||||
@ -298,6 +245,5 @@ object OItemSearch {
|
||||
coll <- OptionT(RSource.findCollective(sourceId))
|
||||
items <- OptionT.liftF(QItem.findByChecksum(checksum, coll, Set.empty))
|
||||
} yield items).value)
|
||||
|
||||
})
|
||||
}
|
||||
|
@ -14,13 +14,12 @@ import docspell.backend.PasswordCrypt
|
||||
import docspell.backend.auth.ShareToken
|
||||
import docspell.backend.ops.OItemSearch._
|
||||
import docspell.backend.ops.OShare._
|
||||
import docspell.backend.ops.OSimpleSearch.StringSearchResult
|
||||
import docspell.backend.ops.search.{OSearch, QueryParseResult}
|
||||
import docspell.common._
|
||||
import docspell.query.ItemQuery.Expr
|
||||
import docspell.query.ItemQuery.Expr.AttachId
|
||||
import docspell.query.{FulltextExtract, ItemQuery}
|
||||
import docspell.store.Store
|
||||
import docspell.store.queries.SearchSummary
|
||||
import docspell.store.records._
|
||||
|
||||
import emil._
|
||||
@ -67,9 +66,10 @@ trait OShare[F[_]] {
|
||||
|
||||
def findItem(itemId: Ident, shareId: Ident): OptionT[F, ItemData]
|
||||
|
||||
def searchSummary(
|
||||
settings: OSimpleSearch.StatsSettings
|
||||
)(shareId: Ident, q: ItemQueryString): OptionT[F, StringSearchResult[SearchSummary]]
|
||||
/** Parses a query and amends the result with the stored query of the share. The result
|
||||
* can be used with [[OSearch]] to search for items.
|
||||
*/
|
||||
def parseQuery(share: ShareQuery, qs: String): QueryParseResult
|
||||
|
||||
def sendMail(account: AccountId, connection: Ident, mail: ShareMail): F[SendResult]
|
||||
}
|
||||
@ -148,7 +148,7 @@ object OShare {
|
||||
def apply[F[_]: Async](
|
||||
store: Store[F],
|
||||
itemSearch: OItemSearch[F],
|
||||
simpleSearch: OSimpleSearch[F],
|
||||
search: OSearch[F],
|
||||
emil: Emil[F]
|
||||
): OShare[F] =
|
||||
new OShare[F] {
|
||||
@ -325,8 +325,8 @@ object OShare {
|
||||
Query.QueryExpr(idExpr)
|
||||
)
|
||||
OptionT(
|
||||
itemSearch
|
||||
.findItems(0)(checkQuery, Batch.limit(1))
|
||||
search
|
||||
.search(0, None, Batch.limit(1))(checkQuery, None)
|
||||
.map(_.headOption.map(_ => ()))
|
||||
).flatTapNone(
|
||||
logger.info(
|
||||
@ -335,22 +335,11 @@ object OShare {
|
||||
)
|
||||
}
|
||||
|
||||
def searchSummary(
|
||||
settings: OSimpleSearch.StatsSettings
|
||||
)(
|
||||
shareId: Ident,
|
||||
q: ItemQueryString
|
||||
): OptionT[F, StringSearchResult[SearchSummary]] =
|
||||
findShareQuery(shareId)
|
||||
.semiflatMap { share =>
|
||||
val fix = Query.Fix(share.account, Some(share.query.expr), None)
|
||||
simpleSearch
|
||||
.searchSummaryByString(settings)(fix, q)
|
||||
.map {
|
||||
case StringSearchResult.Success(summary) =>
|
||||
StringSearchResult.Success(summary.onlyExisting)
|
||||
case other => other
|
||||
}
|
||||
def parseQuery(share: ShareQuery, qs: String): QueryParseResult =
|
||||
search
|
||||
.parseQueryString(share.account, SearchMode.Normal, qs)
|
||||
.map { case QueryParseResult.Success(q, ftq) =>
|
||||
QueryParseResult.Success(q.withFix(_.andQuery(share.query.expr)), ftq)
|
||||
}
|
||||
|
||||
def sendMail(
|
||||
|
@ -1,300 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.backend.ops
|
||||
|
||||
import cats.Applicative
|
||||
import cats.effect.Sync
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.backend.ops.OSimpleSearch._
|
||||
import docspell.common._
|
||||
import docspell.query._
|
||||
import docspell.store.qb.Batch
|
||||
import docspell.store.queries.Query
|
||||
import docspell.store.queries.SearchSummary
|
||||
|
||||
import org.log4s.getLogger
|
||||
|
||||
/** A "porcelain" api on top of OFulltext and OItemSearch. This takes care of restricting
|
||||
* the items to a subset, e.g. only items that have a "valid" state.
|
||||
*/
|
||||
trait OSimpleSearch[F[_]] {
|
||||
|
||||
/** Search for items using the given query and optional fulltext search.
|
||||
*
|
||||
* When using fulltext search only (the query is empty), only the index is searched. It
|
||||
* is assumed that the index doesn't contain "invalid" items. When using a query, then
|
||||
* a condition to select only valid items is added to it.
|
||||
*/
|
||||
def search(settings: Settings)(q: Query, fulltextQuery: Option[String]): F[Items]
|
||||
|
||||
/** Using the same arguments as in `search`, this returns a summary and not the results.
|
||||
*/
|
||||
def searchSummary(
|
||||
settings: StatsSettings
|
||||
)(q: Query, fulltextQuery: Option[String]): F[SearchSummary]
|
||||
|
||||
/** Calls `search` by parsing the given query string into a query that is then amended
|
||||
* wtih the given `fix` query.
|
||||
*/
|
||||
final def searchByString(
|
||||
settings: Settings
|
||||
)(fix: Query.Fix, q: ItemQueryString)(implicit
|
||||
F: Applicative[F]
|
||||
): F[StringSearchResult[Items]] =
|
||||
OSimpleSearch.applySearch[F, Items](fix, q)((iq, fts) => search(settings)(iq, fts))
|
||||
|
||||
/** Same as `searchByString` but returning a summary instead of the results. */
|
||||
final def searchSummaryByString(
|
||||
settings: StatsSettings
|
||||
)(fix: Query.Fix, q: ItemQueryString)(implicit
|
||||
F: Applicative[F]
|
||||
): F[StringSearchResult[SearchSummary]] =
|
||||
OSimpleSearch.applySearch[F, SearchSummary](fix, q)((iq, fts) =>
|
||||
searchSummary(settings)(iq, fts)
|
||||
)
|
||||
}
|
||||
|
||||
object OSimpleSearch {
|
||||
private[this] val logger = getLogger
|
||||
|
||||
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,
|
||||
resolveDetails: Boolean,
|
||||
maxNoteLen: Int,
|
||||
searchMode: SearchMode
|
||||
)
|
||||
final case class StatsSettings(
|
||||
useFTS: Boolean,
|
||||
searchMode: SearchMode
|
||||
)
|
||||
|
||||
sealed trait Items {
|
||||
def fold[A](
|
||||
f1: Items.FtsItems => A,
|
||||
f2: Items.FtsItemsFull => A,
|
||||
f3: Vector[OItemSearch.ListItem] => A,
|
||||
f4: Vector[OItemSearch.ListItemWithTags] => A
|
||||
): A
|
||||
|
||||
}
|
||||
object Items {
|
||||
def ftsItems(indexOnly: Boolean)(items: Vector[OFulltext.FtsItem]): Items =
|
||||
FtsItems(items, indexOnly)
|
||||
|
||||
case class FtsItems(items: Vector[OFulltext.FtsItem], indexOnly: Boolean)
|
||||
extends Items {
|
||||
def fold[A](
|
||||
f1: FtsItems => A,
|
||||
f2: FtsItemsFull => A,
|
||||
f3: Vector[OItemSearch.ListItem] => A,
|
||||
f4: Vector[OItemSearch.ListItemWithTags] => A
|
||||
): A = f1(this)
|
||||
|
||||
}
|
||||
|
||||
def ftsItemsFull(indexOnly: Boolean)(
|
||||
items: Vector[OFulltext.FtsItemWithTags]
|
||||
): Items =
|
||||
FtsItemsFull(items, indexOnly)
|
||||
|
||||
case class FtsItemsFull(items: Vector[OFulltext.FtsItemWithTags], indexOnly: Boolean)
|
||||
extends Items {
|
||||
def fold[A](
|
||||
f1: FtsItems => A,
|
||||
f2: FtsItemsFull => A,
|
||||
f3: Vector[OItemSearch.ListItem] => A,
|
||||
f4: Vector[OItemSearch.ListItemWithTags] => A
|
||||
): A = f2(this)
|
||||
}
|
||||
|
||||
def itemsPlain(items: Vector[OItemSearch.ListItem]): Items =
|
||||
ItemsPlain(items)
|
||||
|
||||
case class ItemsPlain(items: Vector[OItemSearch.ListItem]) extends Items {
|
||||
def fold[A](
|
||||
f1: FtsItems => A,
|
||||
f2: FtsItemsFull => 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: FtsItems => A,
|
||||
f2: FtsItemsFull => 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)
|
||||
|
||||
/** Parses the query and calls `run` with the result, which searches items. */
|
||||
private def applySearch[F[_]: Applicative, A](fix: Query.Fix, q: ItemQueryString)(
|
||||
run: (Query, Option[String]) => F[A]
|
||||
): F[StringSearchResult[A]] = {
|
||||
val parsed: Either[StringSearchResult[A], Option[ItemQuery]] =
|
||||
if (q.isEmpty) Right(None)
|
||||
else
|
||||
ItemQueryParser
|
||||
.parse(q.query)
|
||||
.leftMap(StringSearchResult.parseFailed)
|
||||
.map(_.some)
|
||||
|
||||
def makeQuery(itemQuery: Option[ItemQuery]): F[StringSearchResult[A]] =
|
||||
runQuery[F, A](itemQuery) {
|
||||
case Some(s) =>
|
||||
run(Query(fix, Query.QueryExpr(s.getExprPart)), s.getFulltextPart)
|
||||
case None =>
|
||||
run(Query(fix), None)
|
||||
}
|
||||
|
||||
parsed match {
|
||||
case Right(iq) =>
|
||||
makeQuery(iq)
|
||||
case Left(err) =>
|
||||
err.pure[F]
|
||||
}
|
||||
}
|
||||
|
||||
/** Calls `run` with one of the success results when extracting the fulltext search node
|
||||
* from the query.
|
||||
*/
|
||||
private def runQuery[F[_]: Applicative, A](
|
||||
itemQuery: Option[ItemQuery]
|
||||
)(run: Option[FulltextExtract.SuccessResult] => F[A]): F[StringSearchResult[A]] =
|
||||
itemQuery match {
|
||||
case Some(iq) =>
|
||||
iq.findFulltext match {
|
||||
case s: FulltextExtract.SuccessResult =>
|
||||
run(Some(s)).map(StringSearchResult.Success.apply)
|
||||
case other: FulltextExtract.FailureResult =>
|
||||
StringSearchResult.fulltextMismatch[A](other).pure[F]
|
||||
}
|
||||
case None =>
|
||||
run(None).map(StringSearchResult.Success.apply)
|
||||
}
|
||||
|
||||
final class Impl[F[_]: Sync](fts: OFulltext[F], is: OItemSearch[F])
|
||||
extends OSimpleSearch[F] {
|
||||
|
||||
/** Implements searching like this: it exploits the fact that teh fulltext index only
|
||||
* contains valid items. When searching via sql the query expression selecting only
|
||||
* valid items is added here.
|
||||
*/
|
||||
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)
|
||||
val validItemQuery =
|
||||
settings.searchMode match {
|
||||
case SearchMode.Trashed => q.withFix(_.andQuery(ItemQuery.Expr.Trashed))
|
||||
case SearchMode.Normal => q.withFix(_.andQuery(ItemQuery.Expr.ValidItemStates))
|
||||
case SearchMode.All => q.withFix(_.andQuery(ItemQuery.Expr.ValidItemsOrTrashed))
|
||||
}
|
||||
fulltextQuery match {
|
||||
case Some(ftq) if settings.useFTS =>
|
||||
if (q.isEmpty) {
|
||||
logger.debug(s"Using index only search: $fulltextQuery")
|
||||
if (settings.searchMode == SearchMode.Trashed)
|
||||
Items.ftsItemsFull(true)(Vector.empty).pure[F]
|
||||
else
|
||||
fts
|
||||
.findIndexOnly(settings.maxNoteLen)(
|
||||
OFulltext.FtsInput(ftq),
|
||||
q.fix.account,
|
||||
settings.batch
|
||||
)
|
||||
.map(Items.ftsItemsFull(true))
|
||||
} else if (settings.resolveDetails) {
|
||||
logger.debug(
|
||||
s"Using index+sql search with tags: $validItemQuery / $fulltextQuery"
|
||||
)
|
||||
fts
|
||||
.findItemsWithTags(settings.maxNoteLen)(
|
||||
validItemQuery,
|
||||
OFulltext.FtsInput(ftq),
|
||||
settings.batch
|
||||
)
|
||||
.map(Items.ftsItemsFull(false))
|
||||
} else {
|
||||
logger.debug(
|
||||
s"Using index+sql search no tags: $validItemQuery / $fulltextQuery"
|
||||
)
|
||||
fts
|
||||
.findItems(settings.maxNoteLen)(
|
||||
validItemQuery,
|
||||
OFulltext.FtsInput(ftq),
|
||||
settings.batch
|
||||
)
|
||||
.map(Items.ftsItems(false))
|
||||
}
|
||||
case _ =>
|
||||
if (settings.resolveDetails) {
|
||||
logger.debug(
|
||||
s"Using sql only search with tags: $validItemQuery / $fulltextQuery"
|
||||
)
|
||||
is.findItemsWithTags(settings.maxNoteLen)(validItemQuery, settings.batch)
|
||||
.map(Items.itemsFull)
|
||||
} else {
|
||||
logger.debug(
|
||||
s"Using sql only search no tags: $validItemQuery / $fulltextQuery"
|
||||
)
|
||||
is.findItems(settings.maxNoteLen)(validItemQuery, settings.batch)
|
||||
.map(Items.itemsPlain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def searchSummary(
|
||||
settings: StatsSettings
|
||||
)(q: Query, fulltextQuery: Option[String]): F[SearchSummary] = {
|
||||
val validItemQuery =
|
||||
settings.searchMode match {
|
||||
case SearchMode.Trashed => q.withFix(_.andQuery(ItemQuery.Expr.Trashed))
|
||||
case SearchMode.Normal => q.withFix(_.andQuery(ItemQuery.Expr.ValidItemStates))
|
||||
case SearchMode.All => q.withFix(_.andQuery(ItemQuery.Expr.ValidItemsOrTrashed))
|
||||
}
|
||||
fulltextQuery match {
|
||||
case Some(ftq) if settings.useFTS =>
|
||||
if (q.isEmpty)
|
||||
fts.findIndexOnlySummary(q.fix.account, OFulltext.FtsInput(ftq))
|
||||
else
|
||||
fts
|
||||
.findItemsSummary(validItemQuery, OFulltext.FtsInput(ftq))
|
||||
|
||||
case _ =>
|
||||
is.findItemsSummary(validItemQuery)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -8,13 +8,13 @@ package docspell.backend.ops.search
|
||||
|
||||
import java.time.LocalDate
|
||||
|
||||
import cats.data.OptionT
|
||||
import cats.effect._
|
||||
import cats.syntax.all._
|
||||
import cats.{Functor, ~>}
|
||||
import fs2.Stream
|
||||
|
||||
import docspell.backend.ops.OItemSearch.{ListItemWithTags, SearchSummary}
|
||||
import docspell.common.{AccountId, Duration, SearchMode}
|
||||
import docspell.common._
|
||||
import docspell.ftsclient.{FtsClient, FtsQuery}
|
||||
import docspell.query.{FulltextExtract, ItemQuery, ItemQueryParser}
|
||||
import docspell.store.Store
|
||||
@ -35,7 +35,7 @@ trait OSearch[F[_]] {
|
||||
* from fulltext search. Any "fulltext search" query node is discarded. It is assumed
|
||||
* that the fulltext search node has been extracted into the argument.
|
||||
*/
|
||||
def search(maxNoteLen: Int, today: LocalDate, batch: Batch)(
|
||||
def search(maxNoteLen: Int, today: Option[LocalDate], batch: Batch)(
|
||||
q: Query,
|
||||
fulltextQuery: Option[String]
|
||||
): F[Vector[ListItem]]
|
||||
@ -45,7 +45,7 @@ trait OSearch[F[_]] {
|
||||
*/
|
||||
def searchWithDetails(
|
||||
maxNoteLen: Int,
|
||||
today: LocalDate,
|
||||
today: Option[LocalDate],
|
||||
batch: Batch
|
||||
)(
|
||||
q: Query,
|
||||
@ -58,7 +58,7 @@ trait OSearch[F[_]] {
|
||||
final def searchSelect(
|
||||
withDetails: Boolean,
|
||||
maxNoteLen: Int,
|
||||
today: LocalDate,
|
||||
today: Option[LocalDate],
|
||||
batch: Batch
|
||||
)(
|
||||
q: Query,
|
||||
@ -69,12 +69,14 @@ trait OSearch[F[_]] {
|
||||
|
||||
/** Run multiple database calls with the give query to collect a summary. */
|
||||
def searchSummary(
|
||||
today: LocalDate
|
||||
today: Option[LocalDate]
|
||||
)(q: Query, fulltextQuery: Option[String]): F[SearchSummary]
|
||||
|
||||
/** Parses a query string and creates a `Query` object, to be used with the other
|
||||
* methods. The query object contains the parsed query amended with more conditions to
|
||||
* restrict to valid items only (as specified with `mode`).
|
||||
* methods. The query object contains the parsed query amended with more conditions,
|
||||
* for example to restrict to valid items only (as specified with `mode`). An empty
|
||||
* query string is allowed and returns a query containing only the restrictions in the
|
||||
* `q.fix` part.
|
||||
*/
|
||||
def parseQueryString(
|
||||
accountId: AccountId,
|
||||
@ -139,7 +141,7 @@ object OSearch {
|
||||
}
|
||||
}
|
||||
|
||||
def search(maxNoteLen: Int, today: LocalDate, batch: Batch)(
|
||||
def search(maxNoteLen: Int, today: Option[LocalDate], batch: Batch)(
|
||||
q: Query,
|
||||
fulltextQuery: Option[String]
|
||||
): F[Vector[ListItem]] =
|
||||
@ -148,6 +150,9 @@ object OSearch {
|
||||
for {
|
||||
timed <- Duration.stopTime[F]
|
||||
ftq <- createFtsQuery(q.fix.account, ftq)
|
||||
date <- OptionT
|
||||
.fromOption(today)
|
||||
.getOrElseF(Timestamp.current[F].map(_.toUtcDate))
|
||||
|
||||
results <- WeakAsync.liftK[F, ConnectionIO].use { nat =>
|
||||
val tempTable = temporaryFtsTable(ftq, nat)
|
||||
@ -156,7 +161,7 @@ object OSearch {
|
||||
Stream
|
||||
.eval(tempTable)
|
||||
.flatMap(tt =>
|
||||
QItem.queryItems(q, today, maxNoteLen, batch, tt.some)
|
||||
QItem.queryItems(q, date, maxNoteLen, batch, tt.some)
|
||||
)
|
||||
)
|
||||
.compile
|
||||
@ -169,19 +174,21 @@ object OSearch {
|
||||
case None =>
|
||||
for {
|
||||
timed <- Duration.stopTime[F]
|
||||
date <- OptionT
|
||||
.fromOption(today)
|
||||
.getOrElseF(Timestamp.current[F].map(_.toUtcDate))
|
||||
results <- store
|
||||
.transact(QItem.queryItems(q, today, maxNoteLen, batch, None))
|
||||
.transact(QItem.queryItems(q, date, maxNoteLen, batch, None))
|
||||
.compile
|
||||
.toVector
|
||||
duration <- timed
|
||||
_ <- logger.debug(s"Simple search sql in: ${duration.formatExact}")
|
||||
} yield results
|
||||
|
||||
}
|
||||
|
||||
def searchWithDetails(
|
||||
maxNoteLen: Int,
|
||||
today: LocalDate,
|
||||
today: Option[LocalDate],
|
||||
batch: Batch
|
||||
)(
|
||||
q: Query,
|
||||
@ -201,22 +208,28 @@ object OSearch {
|
||||
} yield resolved
|
||||
|
||||
def searchSummary(
|
||||
today: LocalDate
|
||||
today: Option[LocalDate]
|
||||
)(q: Query, fulltextQuery: Option[String]): F[SearchSummary] =
|
||||
fulltextQuery match {
|
||||
case Some(ftq) =>
|
||||
for {
|
||||
ftq <- createFtsQuery(q.fix.account, ftq)
|
||||
date <- OptionT
|
||||
.fromOption(today)
|
||||
.getOrElseF(Timestamp.current[F].map(_.toUtcDate))
|
||||
results <- WeakAsync.liftK[F, ConnectionIO].use { nat =>
|
||||
val tempTable = temporaryFtsTable(ftq, nat)
|
||||
store.transact(
|
||||
tempTable.flatMap(tt => QItem.searchStats(today, tt.some)(q))
|
||||
tempTable.flatMap(tt => QItem.searchStats(date, tt.some)(q))
|
||||
)
|
||||
}
|
||||
} yield results
|
||||
|
||||
case None =>
|
||||
store.transact(QItem.searchStats(today, None)(q))
|
||||
OptionT
|
||||
.fromOption(today)
|
||||
.getOrElseF(Timestamp.current[F].map(_.toUtcDate))
|
||||
.flatMap(date => store.transact(QItem.searchStats(date, None)(q)))
|
||||
}
|
||||
|
||||
private def createFtsQuery(
|
||||
|
@ -15,6 +15,8 @@ sealed trait QueryParseResult {
|
||||
def get: Option[(Query, Option[String])]
|
||||
def isSuccess: Boolean = get.isDefined
|
||||
def isFailure: Boolean = !isSuccess
|
||||
|
||||
def map(f: QueryParseResult.Success => QueryParseResult): QueryParseResult
|
||||
}
|
||||
|
||||
object QueryParseResult {
|
||||
@ -25,15 +27,22 @@ object QueryParseResult {
|
||||
def withFtsEnabled(enabled: Boolean) =
|
||||
if (enabled || ftq.isEmpty) this else copy(ftq = None)
|
||||
|
||||
def map(f: QueryParseResult.Success => QueryParseResult): QueryParseResult =
|
||||
f(this)
|
||||
|
||||
val get = Some(q -> ftq)
|
||||
}
|
||||
|
||||
final case class ParseFailed(error: ParseFailure) extends QueryParseResult {
|
||||
val get = None
|
||||
def map(f: QueryParseResult.Success => QueryParseResult): QueryParseResult =
|
||||
this
|
||||
}
|
||||
|
||||
final case class FulltextMismatch(error: FulltextExtract.FailureResult)
|
||||
extends QueryParseResult {
|
||||
val get = None
|
||||
def map(f: QueryParseResult.Success => QueryParseResult): QueryParseResult =
|
||||
this
|
||||
}
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ import docspell.backend.BackendCommands
|
||||
import docspell.backend.fulltext.CreateIndex
|
||||
import docspell.backend.joex.AddonOps
|
||||
import docspell.backend.ops._
|
||||
import docspell.backend.ops.search.OSearch
|
||||
import docspell.backend.task.DownloadZipArgs
|
||||
import docspell.common._
|
||||
import docspell.config.FtsType
|
||||
@ -62,6 +63,7 @@ final class JoexTasks[F[_]: Async](
|
||||
joex: OJoex[F],
|
||||
jobs: OJob[F],
|
||||
itemSearch: OItemSearch[F],
|
||||
search: OSearch[F],
|
||||
addons: AddonOps[F]
|
||||
) {
|
||||
val downloadAll: ODownloadAll[F] =
|
||||
@ -201,7 +203,7 @@ final class JoexTasks[F[_]: Async](
|
||||
.withTask(
|
||||
JobTask.json(
|
||||
PeriodicQueryTask.taskName,
|
||||
PeriodicQueryTask[F](store, notification),
|
||||
PeriodicQueryTask[F](store, search, notification),
|
||||
PeriodicQueryTask.onCancel[F]
|
||||
)
|
||||
)
|
||||
@ -273,6 +275,7 @@ object JoexTasks {
|
||||
createIndex <- CreateIndex.resource(fts, store)
|
||||
itemOps <- OItem(store, fts, createIndex, jobStoreModule.jobs)
|
||||
itemSearchOps <- OItemSearch(store)
|
||||
searchOps <- Resource.pure(OSearch(store, fts))
|
||||
analyser <- TextAnalyser.create[F](cfg.textAnalysis.textAnalysisConfig)
|
||||
regexNer <- RegexNerFile(cfg.textAnalysis.regexNerFileConfig, store)
|
||||
updateCheck <- UpdateCheck.resource(httpClient)
|
||||
@ -306,6 +309,7 @@ object JoexTasks {
|
||||
joex,
|
||||
jobs,
|
||||
itemSearchOps,
|
||||
searchOps,
|
||||
addons
|
||||
)
|
||||
|
||||
|
@ -89,7 +89,7 @@ object PeriodicDueItemsTask {
|
||||
store
|
||||
.transact(
|
||||
QItem
|
||||
.findItems(q, now.toUtcDate, 0, Batch.limit(limit))
|
||||
.queryItems(q, now.toUtcDate, 0, Batch.limit(limit), None)
|
||||
.take(limit.toLong)
|
||||
)
|
||||
.compile
|
||||
|
@ -7,25 +7,21 @@
|
||||
package docspell.joex.notify
|
||||
|
||||
import cats.data.OptionT
|
||||
import cats.data.{NonEmptyList => Nel}
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.backend.ops.ONotification
|
||||
import docspell.backend.ops.search.{OSearch, QueryParseResult}
|
||||
import docspell.common._
|
||||
import docspell.notification.api.EventContext
|
||||
import docspell.notification.api.NotificationChannel
|
||||
import docspell.notification.api.PeriodicQueryArgs
|
||||
import docspell.query.ItemQuery
|
||||
import docspell.query.ItemQuery.Expr
|
||||
import docspell.query.ItemQuery.Expr.AndExpr
|
||||
import docspell.query.ItemQueryParser
|
||||
import docspell.query.{FulltextExtract, ItemQuery, ItemQueryParser}
|
||||
import docspell.scheduler.Context
|
||||
import docspell.scheduler.Task
|
||||
import docspell.store.Store
|
||||
import docspell.store.qb.Batch
|
||||
import docspell.store.queries.ListItem
|
||||
import docspell.store.queries.{QItem, Query}
|
||||
import docspell.store.records.RQueryBookmark
|
||||
import docspell.store.records.RShare
|
||||
|
||||
@ -39,12 +35,13 @@ object PeriodicQueryTask {
|
||||
|
||||
def apply[F[_]: Sync](
|
||||
store: Store[F],
|
||||
search: OSearch[F],
|
||||
notificationOps: ONotification[F]
|
||||
): Task[F, Args, Unit] =
|
||||
Task { ctx =>
|
||||
val limit = 7
|
||||
Timestamp.current[F].flatMap { now =>
|
||||
withItems(ctx, store, limit, now) { items =>
|
||||
withItems(ctx, store, search, limit, now) { items =>
|
||||
withEventContext(ctx, items, limit, now) { eventCtx =>
|
||||
withChannel(ctx, notificationOps) { channels =>
|
||||
notificationOps.sendMessage(ctx.logger, eventCtx, channels)
|
||||
@ -62,8 +59,8 @@ object PeriodicQueryTask {
|
||||
private def queryString(q: ItemQuery.Expr) =
|
||||
ItemQueryParser.asString(q)
|
||||
|
||||
def withQuery[F[_]: Sync](ctx: Context[F, Args], store: Store[F])(
|
||||
cont: Query => F[Unit]
|
||||
def withQuery[F[_]: Sync](ctx: Context[F, Args], store: Store[F], search: OSearch[F])(
|
||||
cont: QueryParseResult.Success => F[Unit]
|
||||
): F[Unit] = {
|
||||
def fromBookmark(id: String) =
|
||||
store
|
||||
@ -84,33 +81,51 @@ object PeriodicQueryTask {
|
||||
def fromBookmarkOrShare(id: String) =
|
||||
OptionT(fromBookmark(id)).orElse(OptionT(fromShare(id))).value
|
||||
|
||||
def runQuery(bm: Option[ItemQuery], str: String): F[Unit] =
|
||||
ItemQueryParser.parse(str) match {
|
||||
case Right(q) =>
|
||||
val expr = bm.map(b => AndExpr(Nel.of(b.expr, q.expr))).getOrElse(q.expr)
|
||||
val query = Query
|
||||
.all(ctx.args.account)
|
||||
.withFix(_.copy(query = Expr.ValidItemStates.some))
|
||||
.withCond(_ => Query.QueryExpr(expr))
|
||||
def runQuery(bm: Option[ItemQuery], str: Option[String]): F[Unit] = {
|
||||
val bmFtsQuery = bm.map(e => FulltextExtract.findFulltext(e.expr))
|
||||
val queryStrResult =
|
||||
str.map(search.parseQueryString(ctx.args.account, SearchMode.Normal, _))
|
||||
|
||||
ctx.logger.debug(s"Running query: ${queryString(expr)}") *> cont(query)
|
||||
(bmFtsQuery, queryStrResult) match {
|
||||
case (
|
||||
Some(bmr: FulltextExtract.SuccessResult),
|
||||
Some(QueryParseResult.Success(q, ftq))
|
||||
) =>
|
||||
val nq = bmr.getExprPart.map(q.andCond).getOrElse(q)
|
||||
val nftq =
|
||||
(bmr.getFulltextPart |+| Some(" ") |+| ftq).map(_.trim).filter(_.nonEmpty)
|
||||
val r = QueryParseResult.Success(nq, nftq)
|
||||
ctx.logger.debug(s"Running query: $r") *> cont(r)
|
||||
|
||||
case Left(err) =>
|
||||
ctx.logger.error(
|
||||
s"Item query is invalid, stopping: ${ctx.args.query.map(_.query)} - ${err.render}"
|
||||
)
|
||||
case (None, Some(r: QueryParseResult.Success)) =>
|
||||
ctx.logger.debug(s"Running query: $r") *> cont(r)
|
||||
|
||||
case (Some(bmr: FulltextExtract.SuccessResult), None) =>
|
||||
search.parseQueryString(ctx.args.account, SearchMode.Normal, "") match {
|
||||
case QueryParseResult.Success(q, _) =>
|
||||
val nq = bmr.getExprPart.map(q.andCond).getOrElse(q)
|
||||
ctx.logger.debug(s"Running query: $nq") *>
|
||||
cont(QueryParseResult.Success(nq, bmr.getFulltextPart))
|
||||
|
||||
case err =>
|
||||
ctx.logger.error(s"Internal error: $err")
|
||||
}
|
||||
|
||||
case (failure1, res2) =>
|
||||
ctx.logger.error(s"One or more error reading queries: $failure1 and $res2")
|
||||
}
|
||||
}
|
||||
|
||||
(ctx.args.bookmark, ctx.args.query) match {
|
||||
case (Some(bm), Some(qstr)) =>
|
||||
ctx.logger.debug(s"Using bookmark $bm and query $qstr") *>
|
||||
fromBookmarkOrShare(bm).flatMap(bq => runQuery(bq, qstr.query))
|
||||
fromBookmarkOrShare(bm).flatMap(bq => runQuery(bq, qstr.query.some))
|
||||
|
||||
case (Some(bm), None) =>
|
||||
fromBookmarkOrShare(bm).flatMap {
|
||||
case Some(bq) =>
|
||||
val query = Query(Query.Fix(ctx.args.account, Some(bq.expr), None))
|
||||
ctx.logger.debug(s"Using bookmark: ${queryString(bq.expr)}") *> cont(query)
|
||||
ctx.logger.debug(s"Using bookmark: ${queryString(bq.expr)}") *>
|
||||
runQuery(bq.some, None)
|
||||
|
||||
case None =>
|
||||
ctx.logger.error(
|
||||
@ -119,7 +134,7 @@ object PeriodicQueryTask {
|
||||
}
|
||||
|
||||
case (None, Some(qstr)) =>
|
||||
ctx.logger.debug(s"Using query: ${qstr.query}") *> runQuery(None, qstr.query)
|
||||
ctx.logger.debug(s"Using query: ${qstr.query}") *> runQuery(None, qstr.query.some)
|
||||
|
||||
case (None, None) =>
|
||||
ctx.logger.error(s"No query provided for task $taskName!")
|
||||
@ -129,17 +144,14 @@ object PeriodicQueryTask {
|
||||
def withItems[F[_]: Sync](
|
||||
ctx: Context[F, Args],
|
||||
store: Store[F],
|
||||
search: OSearch[F],
|
||||
limit: Int,
|
||||
now: Timestamp
|
||||
)(
|
||||
cont: Vector[ListItem] => F[Unit]
|
||||
): F[Unit] =
|
||||
withQuery(ctx, store) { query =>
|
||||
val items = store
|
||||
.transact(QItem.findItems(query, now.toUtcDate, 0, Batch.limit(limit)))
|
||||
.compile
|
||||
.to(Vector)
|
||||
|
||||
withQuery(ctx, store, search) { qs =>
|
||||
val items = search.search(0, now.toUtcDate.some, Batch.limit(limit))(qs.q, qs.ftq)
|
||||
items.flatMap(cont)
|
||||
}
|
||||
|
||||
|
@ -84,7 +84,7 @@ object BasicData {
|
||||
)
|
||||
for {
|
||||
items <- QItem
|
||||
.findItems(q, now.toUtcDate, 25, Batch.limit(itemIds.size))
|
||||
.queryItems(q, now.toUtcDate, 25, Batch.limit(itemIds.size), None)
|
||||
.compile
|
||||
.to(Vector)
|
||||
} yield items.map(apply(now))
|
||||
|
@ -70,9 +70,6 @@ docspell.server {
|
||||
# In order to keep this low, a limit can be defined here.
|
||||
max-note-length = 180
|
||||
|
||||
feature-search-2 = true
|
||||
|
||||
|
||||
# This defines whether the classification form in the collective
|
||||
# settings is displayed or not. If all joex instances have document
|
||||
# classification disabled, it makes sense to hide its settings from
|
||||
|
@ -19,12 +19,16 @@ import docspell.backend.ops.OUpload.{UploadData, UploadMeta, UploadResult}
|
||||
import docspell.backend.ops._
|
||||
import docspell.common._
|
||||
import docspell.common.syntax.all._
|
||||
import docspell.ftsclient.FtsResult
|
||||
import docspell.restapi.model._
|
||||
import docspell.restserver.conv.Conversions._
|
||||
import docspell.restserver.http4s.ContentDisposition
|
||||
import docspell.store.qb.Batch
|
||||
import docspell.store.queries.{AttachmentLight => QAttachmentLight, IdRefCount}
|
||||
import docspell.store.queries.{
|
||||
AttachmentLight => QAttachmentLight,
|
||||
FieldStats => QFieldStats,
|
||||
ItemFieldValue => QItemFieldValue,
|
||||
TagCount => QTagCount,
|
||||
_
|
||||
}
|
||||
import docspell.store.records._
|
||||
import docspell.store.{AddResult, UpdateResult}
|
||||
|
||||
@ -34,7 +38,7 @@ import org.log4s.Logger
|
||||
|
||||
trait Conversions {
|
||||
|
||||
def mkSearchStats(sum: OItemSearch.SearchSummary): SearchStats =
|
||||
def mkSearchStats(sum: SearchSummary): SearchStats =
|
||||
SearchStats(
|
||||
sum.count,
|
||||
mkTagCloud(sum.tags),
|
||||
@ -53,7 +57,7 @@ trait Conversions {
|
||||
def mkFolderStats(fs: docspell.store.queries.FolderCount): FolderStats =
|
||||
FolderStats(fs.id, fs.name, mkIdName(fs.owner), fs.count)
|
||||
|
||||
def mkFieldStats(fs: docspell.store.queries.FieldStats): FieldStats =
|
||||
def mkFieldStats(fs: QFieldStats): FieldStats =
|
||||
FieldStats(
|
||||
fs.field.id,
|
||||
fs.field.name,
|
||||
@ -76,7 +80,7 @@ trait Conversions {
|
||||
mkTagCloud(d.tags)
|
||||
)
|
||||
|
||||
def mkTagCloud(tags: List[OCollective.TagCount]) =
|
||||
def mkTagCloud(tags: List[QTagCount]) =
|
||||
TagCloud(tags.map(tc => TagCount(mkTag(tc.tag), tc.count)))
|
||||
|
||||
def mkTagCategoryCloud(tags: List[OCollective.CategoryCount]) =
|
||||
@ -144,7 +148,7 @@ trait Conversions {
|
||||
data.relatedItems.map(mkItemLight).toList
|
||||
)
|
||||
|
||||
def mkItemFieldValue(v: OItemSearch.ItemFieldValue): ItemFieldValue =
|
||||
def mkItemFieldValue(v: QItemFieldValue): ItemFieldValue =
|
||||
ItemFieldValue(v.fieldId, v.fieldName, v.fieldLabel, v.fieldType, v.value)
|
||||
|
||||
def mkAttachment(
|
||||
@ -173,28 +177,13 @@ trait Conversions {
|
||||
OItemSearch.CustomValue(v.field, v.value)
|
||||
|
||||
def mkItemList(
|
||||
v: Vector[OItemSearch.ListItem],
|
||||
v: Vector[ListItem],
|
||||
batch: Batch,
|
||||
capped: Boolean
|
||||
): ItemLightList = {
|
||||
val groups = v.groupBy(item => item.date.toUtcDate.toString.substring(0, 7))
|
||||
|
||||
def mkGroup(g: (String, Vector[OItemSearch.ListItem])): ItemLightGroup =
|
||||
ItemLightGroup(g._1, g._2.map(mkItemLight).toList)
|
||||
|
||||
val gs =
|
||||
groups.map(mkGroup).toList.sortWith((g1, g2) => g1.name.compareTo(g2.name) >= 0)
|
||||
ItemLightList(gs, batch.limit, batch.offset, capped)
|
||||
}
|
||||
|
||||
def mkItemListFts(
|
||||
v: Vector[OFulltext.FtsItem],
|
||||
batch: Batch,
|
||||
capped: Boolean
|
||||
): ItemLightList = {
|
||||
val groups = v.groupBy(item => item.item.date.toUtcDate.toString.substring(0, 7))
|
||||
|
||||
def mkGroup(g: (String, Vector[OFulltext.FtsItem])): ItemLightGroup =
|
||||
def mkGroup(g: (String, Vector[ListItem])): ItemLightGroup =
|
||||
ItemLightGroup(g._1, g._2.map(mkItemLight).toList)
|
||||
|
||||
val gs =
|
||||
@ -203,13 +192,13 @@ trait Conversions {
|
||||
}
|
||||
|
||||
def mkItemListWithTags(
|
||||
v: Vector[OItemSearch.ListItemWithTags],
|
||||
v: Vector[ListItemWithTags],
|
||||
batch: Batch,
|
||||
capped: Boolean
|
||||
): ItemLightList = {
|
||||
val groups = v.groupBy(ti => ti.item.date.toUtcDate.toString.substring(0, 7))
|
||||
|
||||
def mkGroup(g: (String, Vector[OItemSearch.ListItemWithTags])): ItemLightGroup =
|
||||
def mkGroup(g: (String, Vector[ListItemWithTags])): ItemLightGroup =
|
||||
ItemLightGroup(g._1, g._2.map(mkItemLightWithTags).toList)
|
||||
|
||||
val gs =
|
||||
@ -217,50 +206,7 @@ trait Conversions {
|
||||
ItemLightList(gs, batch.limit, batch.offset, capped)
|
||||
}
|
||||
|
||||
def mkItemListWithTagsFts(
|
||||
v: Vector[OFulltext.FtsItemWithTags],
|
||||
batch: Batch,
|
||||
capped: Boolean
|
||||
): ItemLightList = {
|
||||
val groups = v.groupBy(ti => ti.item.item.date.toUtcDate.toString.substring(0, 7))
|
||||
|
||||
def mkGroup(g: (String, Vector[OFulltext.FtsItemWithTags])): ItemLightGroup =
|
||||
ItemLightGroup(g._1, g._2.map(mkItemLightWithTags).toList)
|
||||
|
||||
val gs =
|
||||
groups.map(mkGroup).toList.sortWith((g1, g2) => g1.name.compareTo(g2.name) >= 0)
|
||||
ItemLightList(gs, batch.limit, batch.offset, capped)
|
||||
}
|
||||
|
||||
def mkItemListWithTagsFtsPlain(
|
||||
v: Vector[OFulltext.FtsItemWithTags],
|
||||
batch: Batch,
|
||||
capped: Boolean
|
||||
): ItemLightList =
|
||||
if (v.isEmpty) ItemLightList(Nil, batch.limit, batch.offset, capped)
|
||||
else
|
||||
ItemLightList(
|
||||
List(ItemLightGroup("Results", v.map(mkItemLightWithTags).toList)),
|
||||
batch.limit,
|
||||
batch.offset,
|
||||
capped
|
||||
)
|
||||
|
||||
def mkItemListFtsPlain(
|
||||
v: Vector[OFulltext.FtsItem],
|
||||
batch: Batch,
|
||||
capped: Boolean
|
||||
): ItemLightList =
|
||||
if (v.isEmpty) ItemLightList(Nil, batch.limit, batch.offset, capped)
|
||||
else
|
||||
ItemLightList(
|
||||
List(ItemLightGroup("Results", v.map(mkItemLight).toList)),
|
||||
batch.limit,
|
||||
batch.offset,
|
||||
capped
|
||||
)
|
||||
|
||||
def mkItemLight(i: OItemSearch.ListItem): ItemLight =
|
||||
def mkItemLight(i: ListItem): ItemLight =
|
||||
ItemLight(
|
||||
i.id,
|
||||
i.name,
|
||||
@ -282,13 +228,7 @@ trait Conversions {
|
||||
Nil // highlight
|
||||
)
|
||||
|
||||
def mkItemLight(i: OFulltext.FtsItem): ItemLight = {
|
||||
val il = mkItemLight(i.item)
|
||||
val highlight = mkHighlight(i.ftsData)
|
||||
il.copy(highlighting = highlight)
|
||||
}
|
||||
|
||||
def mkItemLightWithTags(i: OItemSearch.ListItemWithTags): ItemLight =
|
||||
def mkItemLightWithTags(i: ListItemWithTags): ItemLight =
|
||||
mkItemLight(i.item)
|
||||
.copy(
|
||||
tags = i.tags.map(mkTag),
|
||||
@ -300,22 +240,6 @@ trait Conversions {
|
||||
def mkAttachmentLight(qa: QAttachmentLight): AttachmentLight =
|
||||
AttachmentLight(qa.id, qa.position, qa.name, qa.pageCount)
|
||||
|
||||
def mkItemLightWithTags(i: OFulltext.FtsItemWithTags): ItemLight = {
|
||||
val il = mkItemLightWithTags(i.item)
|
||||
val highlight = mkHighlight(i.ftsData)
|
||||
il.copy(highlighting = highlight)
|
||||
}
|
||||
|
||||
private def mkHighlight(ftsData: OFulltext.FtsData): List[HighlightEntry] =
|
||||
ftsData.items.filter(_.context.nonEmpty).sortBy(-_.score).map { fdi =>
|
||||
fdi.matchData match {
|
||||
case FtsResult.AttachmentData(_, aName) =>
|
||||
HighlightEntry(aName, fdi.context)
|
||||
case FtsResult.ItemData =>
|
||||
HighlightEntry("Item", fdi.context)
|
||||
}
|
||||
}
|
||||
|
||||
// job
|
||||
def mkJobQueueState(state: OJob.CollectiveQueueState): JobQueueState = {
|
||||
def desc(f: JobDetail => Option[Timestamp])(j1: JobDetail, j2: JobDetail): Boolean = {
|
||||
@ -571,7 +495,7 @@ trait Conversions {
|
||||
oid: Option[Ident],
|
||||
pid: Option[Ident]
|
||||
): F[RContact] =
|
||||
timeId.map { case (id, now) =>
|
||||
Conversions.timeId.map { case (id, now) =>
|
||||
RContact(id, c.value.trim, c.kind, pid, oid, now)
|
||||
}
|
||||
|
||||
@ -590,7 +514,7 @@ trait Conversions {
|
||||
)
|
||||
|
||||
def newUser[F[_]: Sync](u: User, cid: Ident): F[RUser] =
|
||||
timeId.map { case (id, now) =>
|
||||
Conversions.timeId.map { case (id, now) =>
|
||||
RUser(
|
||||
id,
|
||||
u.login,
|
||||
@ -625,7 +549,7 @@ trait Conversions {
|
||||
Tag(rt.tagId, rt.name, rt.category, rt.created)
|
||||
|
||||
def newTag[F[_]: Sync](t: Tag, cid: Ident): F[RTag] =
|
||||
timeId.map { case (id, now) =>
|
||||
Conversions.timeId.map { case (id, now) =>
|
||||
RTag(id, cid, t.name.trim, t.category.map(_.trim), now)
|
||||
}
|
||||
|
||||
@ -653,7 +577,7 @@ trait Conversions {
|
||||
)
|
||||
|
||||
def newSource[F[_]: Sync](s: Source, cid: Ident): F[RSource] =
|
||||
timeId.map { case (id, now) =>
|
||||
Conversions.timeId.map { case (id, now) =>
|
||||
RSource(
|
||||
id,
|
||||
cid,
|
||||
@ -691,7 +615,7 @@ trait Conversions {
|
||||
Equipment(re.eid, re.name, re.created, re.notes, re.use)
|
||||
|
||||
def newEquipment[F[_]: Sync](e: Equipment, cid: Ident): F[REquipment] =
|
||||
timeId.map { case (id, now) =>
|
||||
Conversions.timeId.map { case (id, now) =>
|
||||
REquipment(id, cid, e.name.trim, now, now, e.notes, e.use)
|
||||
}
|
||||
|
||||
@ -785,7 +709,7 @@ trait Conversions {
|
||||
header.mediaType.mainType,
|
||||
header.mediaType.subType,
|
||||
None
|
||||
).withCharsetName(header.mediaType.extensions.get("charset").getOrElse("unknown"))
|
||||
).withCharsetName(header.mediaType.extensions.getOrElse("charset", "unknown"))
|
||||
}
|
||||
|
||||
object Conversions extends Conversions {
|
||||
|
@ -13,12 +13,7 @@ import cats.implicits._
|
||||
import docspell.backend.BackendApp
|
||||
import docspell.backend.auth.AuthToken
|
||||
import docspell.backend.ops.OCustomFields.{RemoveValue, SetValue}
|
||||
import docspell.backend.ops.OItemSearch.{Batch, Query}
|
||||
import docspell.backend.ops.OSimpleSearch
|
||||
import docspell.backend.ops.OSimpleSearch.StringSearchResult
|
||||
import docspell.common._
|
||||
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
|
||||
@ -27,11 +22,11 @@ import docspell.restserver.http4s.ClientRequestInfo
|
||||
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._
|
||||
import org.http4s.dsl.Http4sDsl
|
||||
import org.http4s.headers._
|
||||
import org.http4s.{HttpRoutes, Response}
|
||||
|
||||
object ItemRoutes {
|
||||
def apply[F[_]: Async](
|
||||
@ -40,75 +35,12 @@ object ItemRoutes {
|
||||
user: AuthToken
|
||||
): HttpRoutes[F] = {
|
||||
val logger = docspell.logging.getLogger[F]
|
||||
val searchPart = ItemSearchPart[F](backend, cfg, user)
|
||||
val searchPart = ItemSearchPart[F](backend.search, cfg, user)
|
||||
val dsl = new Http4sDsl[F] {}
|
||||
import dsl._
|
||||
|
||||
searchPart.routes <+>
|
||||
HttpRoutes.of {
|
||||
case GET -> Root / "search" :? QP.Query(q) :? QP.Limit(limit) :? QP.Offset(
|
||||
offset
|
||||
) :? QP.WithDetails(detailFlag) :? QP.SearchKind(searchMode) =>
|
||||
val batch = Batch(offset.getOrElse(0), limit.getOrElse(cfg.maxItemPageSize))
|
||||
.restrictLimitTo(cfg.maxItemPageSize)
|
||||
val limitCapped = limit.exists(_ > cfg.maxItemPageSize)
|
||||
val itemQuery = ItemQueryString(q)
|
||||
val settings = OSimpleSearch.Settings(
|
||||
batch,
|
||||
cfg.fullTextSearch.enabled,
|
||||
detailFlag.getOrElse(false),
|
||||
cfg.maxNoteLength,
|
||||
searchMode.getOrElse(SearchMode.Normal)
|
||||
)
|
||||
val fixQuery = Query.Fix(user.account, None, None)
|
||||
searchItems(backend, dsl)(settings, fixQuery, itemQuery, limitCapped)
|
||||
|
||||
case GET -> Root / "searchStats" :? QP.Query(q) :? QP.SearchKind(searchMode) =>
|
||||
val itemQuery = ItemQueryString(q)
|
||||
val fixQuery = Query.Fix(user.account, None, None)
|
||||
val settings = OSimpleSearch.StatsSettings(
|
||||
useFTS = cfg.fullTextSearch.enabled,
|
||||
searchMode = searchMode.getOrElse(SearchMode.Normal)
|
||||
)
|
||||
searchItemStats(backend, dsl)(settings, fixQuery, itemQuery)
|
||||
|
||||
case req @ POST -> Root / "search" =>
|
||||
for {
|
||||
timed <- Duration.stopTime[F]
|
||||
userQuery <- req.as[ItemQuery]
|
||||
batch = Batch(
|
||||
userQuery.offset.getOrElse(0),
|
||||
userQuery.limit.getOrElse(cfg.maxItemPageSize)
|
||||
).restrictLimitTo(
|
||||
cfg.maxItemPageSize
|
||||
)
|
||||
limitCapped = userQuery.limit.exists(_ > cfg.maxItemPageSize)
|
||||
itemQuery = ItemQueryString(userQuery.query)
|
||||
settings = OSimpleSearch.Settings(
|
||||
batch,
|
||||
cfg.fullTextSearch.enabled,
|
||||
userQuery.withDetails.getOrElse(false),
|
||||
cfg.maxNoteLength,
|
||||
searchMode = userQuery.searchMode.getOrElse(SearchMode.Normal)
|
||||
)
|
||||
fixQuery = Query.Fix(user.account, None, None)
|
||||
resp <- searchItems(backend, dsl)(settings, fixQuery, itemQuery, limitCapped)
|
||||
dur <- timed
|
||||
_ <- logger.debug(s"Search request: ${dur.formatExact}")
|
||||
} yield resp
|
||||
|
||||
case req @ POST -> Root / "searchStats" =>
|
||||
for {
|
||||
userQuery <- req.as[ItemQuery]
|
||||
itemQuery = ItemQueryString(userQuery.query)
|
||||
fixQuery = Query.Fix(user.account, None, None)
|
||||
settings = OSimpleSearch.StatsSettings(
|
||||
useFTS = cfg.fullTextSearch.enabled,
|
||||
searchMode = userQuery.searchMode.getOrElse(SearchMode.Normal)
|
||||
)
|
||||
resp <- searchItemStats(backend, dsl)(settings, fixQuery, itemQuery)
|
||||
} yield resp
|
||||
|
||||
case GET -> Root / Ident(id) =>
|
||||
for {
|
||||
item <- backend.itemSearch.findItem(id, user.account.collective)
|
||||
@ -412,82 +344,6 @@ object ItemRoutes {
|
||||
}
|
||||
}
|
||||
|
||||
def searchItems[F[_]: Sync](
|
||||
backend: BackendApp[F],
|
||||
dsl: Http4sDsl[F]
|
||||
)(
|
||||
settings: OSimpleSearch.Settings,
|
||||
fixQuery: Query.Fix,
|
||||
itemQuery: ItemQueryString,
|
||||
limitCapped: Boolean
|
||||
): F[Response[F]] = {
|
||||
import dsl._
|
||||
|
||||
def convertFts(res: OSimpleSearch.Items.FtsItems): ItemLightList =
|
||||
if (res.indexOnly)
|
||||
Conversions.mkItemListFtsPlain(res.items, settings.batch, limitCapped)
|
||||
else Conversions.mkItemListFts(res.items, settings.batch, limitCapped)
|
||||
|
||||
def convertFtsFull(res: OSimpleSearch.Items.FtsItemsFull): ItemLightList =
|
||||
if (res.indexOnly)
|
||||
Conversions.mkItemListWithTagsFtsPlain(res.items, settings.batch, limitCapped)
|
||||
else Conversions.mkItemListWithTagsFts(res.items, settings.batch, limitCapped)
|
||||
|
||||
backend.simpleSearch
|
||||
.searchByString(settings)(fixQuery, itemQuery)
|
||||
.flatMap {
|
||||
case StringSearchResult.Success(items) =>
|
||||
Ok(
|
||||
items.fold(
|
||||
convertFts,
|
||||
convertFtsFull,
|
||||
els => Conversions.mkItemList(els, settings.batch, limitCapped),
|
||||
els => Conversions.mkItemListWithTags(els, settings.batch, limitCapped)
|
||||
)
|
||||
)
|
||||
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]
|
||||
)(
|
||||
settings: OSimpleSearch.StatsSettings,
|
||||
fixQuery: Query.Fix,
|
||||
itemQuery: ItemQueryString
|
||||
): F[Response[F]] = {
|
||||
import dsl._
|
||||
|
||||
backend.simpleSearch
|
||||
.searchSummaryByString(settings)(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)
|
||||
|
@ -11,26 +11,30 @@ import java.time.LocalDate
|
||||
import cats.effect._
|
||||
import cats.syntax.all._
|
||||
|
||||
import docspell.backend.BackendApp
|
||||
import docspell.backend.auth.AuthToken
|
||||
import docspell.backend.ops.search.QueryParseResult
|
||||
import docspell.common.{Duration, SearchMode, Timestamp}
|
||||
import docspell.backend.ops.OShare
|
||||
import docspell.backend.ops.OShare.ShareQuery
|
||||
import docspell.backend.ops.search.{OSearch, QueryParseResult}
|
||||
import docspell.common._
|
||||
import docspell.query.FulltextExtract.Result
|
||||
import docspell.restapi.model._
|
||||
import docspell.restserver.Config
|
||||
import docspell.restserver.conv.Conversions
|
||||
import docspell.restserver.http4s.{QueryParam => QP}
|
||||
import docspell.store.qb.Batch
|
||||
import docspell.store.queries.ListItemWithTags
|
||||
import docspell.store.queries.{ListItemWithTags, SearchSummary}
|
||||
|
||||
import org.http4s.circe.CirceEntityCodec._
|
||||
import org.http4s.dsl.Http4sDsl
|
||||
import org.http4s.{HttpRoutes, Response}
|
||||
|
||||
final class ItemSearchPart[F[_]: Async](
|
||||
backend: BackendApp[F],
|
||||
searchOps: OSearch[F],
|
||||
cfg: Config,
|
||||
authToken: AuthToken
|
||||
parseQuery: (SearchMode, String) => QueryParseResult,
|
||||
changeSummary: SearchSummary => SearchSummary = identity,
|
||||
searchPath: String = "search",
|
||||
searchStatsPath: String = "searchStats"
|
||||
) extends Http4sDsl[F] {
|
||||
|
||||
private[this] val logger = docspell.logging.getLogger[F]
|
||||
@ -39,9 +43,9 @@ final class ItemSearchPart[F[_]: Async](
|
||||
if (!cfg.featureSearch2) HttpRoutes.empty
|
||||
else
|
||||
HttpRoutes.of {
|
||||
case GET -> Root / "search" :? QP.Query(q) :? QP.Limit(limit) :? QP.Offset(
|
||||
offset
|
||||
) :? QP.WithDetails(detailFlag) :? QP.SearchKind(searchMode) =>
|
||||
case GET -> Root / `searchPath` :? QP.Query(q) :? QP.Limit(limit) :?
|
||||
QP.Offset(offset) :? QP.WithDetails(detailFlag) :?
|
||||
QP.SearchKind(searchMode) =>
|
||||
val userQuery =
|
||||
ItemQuery(offset, limit, detailFlag, searchMode, q.getOrElse(""))
|
||||
for {
|
||||
@ -49,7 +53,7 @@ final class ItemSearchPart[F[_]: Async](
|
||||
resp <- search(userQuery, today)
|
||||
} yield resp
|
||||
|
||||
case req @ POST -> Root / "search" =>
|
||||
case req @ POST -> Root / `searchPath` =>
|
||||
for {
|
||||
timed <- Duration.stopTime[F]
|
||||
userQuery <- req.as[ItemQuery]
|
||||
@ -59,14 +63,15 @@ final class ItemSearchPart[F[_]: Async](
|
||||
_ <- logger.debug(s"Search request: ${dur.formatExact}")
|
||||
} yield resp
|
||||
|
||||
case GET -> Root / "searchStats" :? QP.Query(q) :? QP.SearchKind(searchMode) =>
|
||||
case GET -> Root / `searchStatsPath` :? QP.Query(q) :?
|
||||
QP.SearchKind(searchMode) =>
|
||||
val userQuery = ItemQuery(None, None, None, searchMode, q.getOrElse(""))
|
||||
for {
|
||||
today <- Timestamp.current[F].map(_.toUtcDate)
|
||||
resp <- searchStats(userQuery, today)
|
||||
} yield resp
|
||||
|
||||
case req @ POST -> Root / "searchStats" =>
|
||||
case req @ POST -> Root / `searchStatsPath` =>
|
||||
for {
|
||||
timed <- Duration.stopTime[F]
|
||||
userQuery <- req.as[ItemQuery]
|
||||
@ -84,8 +89,8 @@ final class ItemSearchPart[F[_]: Async](
|
||||
identity,
|
||||
res =>
|
||||
for {
|
||||
summary <- backend.search.searchSummary(today)(res.q, res.ftq)
|
||||
resp <- Ok(Conversions.mkSearchStats(summary))
|
||||
summary <- searchOps.searchSummary(today.some)(res.q, res.ftq)
|
||||
resp <- Ok(Conversions.mkSearchStats(changeSummary(summary)))
|
||||
} yield resp
|
||||
)
|
||||
}
|
||||
@ -103,8 +108,9 @@ final class ItemSearchPart[F[_]: Async](
|
||||
identity,
|
||||
res =>
|
||||
for {
|
||||
items <- backend.search
|
||||
.searchSelect(details, cfg.maxNoteLength, today, batch)(
|
||||
_ <- logger.warn(s"Searching with query: $res")
|
||||
items <- searchOps
|
||||
.searchSelect(details, cfg.maxNoteLength, today.some, batch)(
|
||||
res.q,
|
||||
res.ftq
|
||||
)
|
||||
@ -122,29 +128,10 @@ final class ItemSearchPart[F[_]: Async](
|
||||
userQuery: ItemQuery,
|
||||
mode: SearchMode
|
||||
): Either[F[Response[F]], QueryParseResult.Success] =
|
||||
backend.search.parseQueryString(authToken.account, mode, userQuery.query) match {
|
||||
case s: QueryParseResult.Success =>
|
||||
Right(s.withFtsEnabled(cfg.fullTextSearch.enabled))
|
||||
|
||||
case QueryParseResult.ParseFailed(err) =>
|
||||
Left(BadRequest(BasicResult(false, s"Invalid query: $err")))
|
||||
|
||||
case QueryParseResult.FulltextMismatch(Result.TooMany) =>
|
||||
Left(
|
||||
BadRequest(
|
||||
BasicResult(false, "Only one fulltext search expression is allowed.")
|
||||
)
|
||||
)
|
||||
case QueryParseResult.FulltextMismatch(Result.UnsupportedPosition) =>
|
||||
Left(
|
||||
BadRequest(
|
||||
BasicResult(
|
||||
false,
|
||||
"A fulltext search may only appear in the root and expression."
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
convertParseResult(
|
||||
parseQuery(mode, userQuery.query)
|
||||
.map(_.withFtsEnabled(cfg.fullTextSearch.enabled))
|
||||
)
|
||||
|
||||
def convert(
|
||||
items: Vector[ListItemWithTags],
|
||||
@ -202,13 +189,56 @@ final class ItemSearchPart[F[_]: Async](
|
||||
Nil
|
||||
}
|
||||
)
|
||||
|
||||
def convertParseResult(
|
||||
r: QueryParseResult
|
||||
): Either[F[Response[F]], QueryParseResult.Success] =
|
||||
r match {
|
||||
case s: QueryParseResult.Success =>
|
||||
Right(s)
|
||||
|
||||
case QueryParseResult.ParseFailed(err) =>
|
||||
BadRequest(BasicResult(false, s"Invalid query: $err")).asLeft
|
||||
|
||||
case QueryParseResult.FulltextMismatch(Result.TooMany) =>
|
||||
BadRequest(
|
||||
BasicResult(false, "Only one fulltext search expression is allowed.")
|
||||
).asLeft
|
||||
|
||||
case QueryParseResult.FulltextMismatch(Result.UnsupportedPosition) =>
|
||||
BadRequest(
|
||||
BasicResult(
|
||||
false,
|
||||
"A fulltext search may only appear in the root and expression."
|
||||
)
|
||||
).asLeft
|
||||
}
|
||||
}
|
||||
|
||||
object ItemSearchPart {
|
||||
def apply[F[_]: Async](
|
||||
backend: BackendApp[F],
|
||||
search: OSearch[F],
|
||||
cfg: Config,
|
||||
token: AuthToken
|
||||
): ItemSearchPart[F] =
|
||||
new ItemSearchPart[F](backend, cfg, token)
|
||||
new ItemSearchPart[F](
|
||||
search,
|
||||
cfg,
|
||||
(m, s) => search.parseQueryString(token.account, m, s)
|
||||
)
|
||||
|
||||
def apply[F[_]: Async](
|
||||
search: OSearch[F],
|
||||
share: OShare[F],
|
||||
cfg: Config,
|
||||
shareQuery: ShareQuery
|
||||
): ItemSearchPart[F] =
|
||||
new ItemSearchPart[F](
|
||||
search,
|
||||
cfg,
|
||||
(_, s) => share.parseQuery(shareQuery, s),
|
||||
changeSummary = _.onlyExisting,
|
||||
searchPath = "query",
|
||||
searchStatsPath = "stats"
|
||||
)
|
||||
}
|
||||
|
@ -6,25 +6,14 @@
|
||||
|
||||
package docspell.restserver.routes
|
||||
|
||||
import cats.data.Kleisli
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.backend.BackendApp
|
||||
import docspell.backend.auth.ShareToken
|
||||
import docspell.backend.ops.OSimpleSearch
|
||||
import docspell.backend.ops.OSimpleSearch.StringSearchResult
|
||||
import docspell.common._
|
||||
import docspell.query.FulltextExtract.Result.{TooMany, UnsupportedPosition}
|
||||
import docspell.restapi.model._
|
||||
import docspell.restserver.Config
|
||||
import docspell.restserver.conv.Conversions
|
||||
import docspell.store.qb.Batch
|
||||
import docspell.store.queries.{Query, SearchSummary}
|
||||
|
||||
import org.http4s.circe.CirceEntityDecoder._
|
||||
import org.http4s.circe.CirceEntityEncoder._
|
||||
import org.http4s.dsl.Http4sDsl
|
||||
import org.http4s.{HttpRoutes, Response}
|
||||
import org.http4s.HttpRoutes
|
||||
|
||||
object ShareSearchRoutes {
|
||||
|
||||
@ -32,80 +21,13 @@ object ShareSearchRoutes {
|
||||
backend: BackendApp[F],
|
||||
cfg: Config,
|
||||
token: ShareToken
|
||||
): HttpRoutes[F] = {
|
||||
val logger = docspell.logging.getLogger[F]
|
||||
|
||||
val dsl = new Http4sDsl[F] {}
|
||||
import dsl._
|
||||
|
||||
HttpRoutes.of {
|
||||
case req @ POST -> Root / "query" =>
|
||||
backend.share
|
||||
.findShareQuery(token.id)
|
||||
.semiflatMap { share =>
|
||||
for {
|
||||
userQuery <- req.as[ItemQuery]
|
||||
batch = Batch(
|
||||
userQuery.offset.getOrElse(0),
|
||||
userQuery.limit.getOrElse(cfg.maxItemPageSize)
|
||||
).restrictLimitTo(
|
||||
cfg.maxItemPageSize
|
||||
)
|
||||
limitCapped = userQuery.limit.exists(_ > cfg.maxItemPageSize)
|
||||
itemQuery = ItemQueryString(userQuery.query)
|
||||
settings = OSimpleSearch.Settings(
|
||||
batch,
|
||||
cfg.fullTextSearch.enabled,
|
||||
userQuery.withDetails.getOrElse(false),
|
||||
cfg.maxNoteLength,
|
||||
searchMode = SearchMode.Normal
|
||||
)
|
||||
account = share.account
|
||||
fixQuery = Query.Fix(account, Some(share.query.expr), None)
|
||||
_ <- logger.debug(s"Searching in share ${share.id.id}: ${userQuery.query}")
|
||||
resp <- ItemRoutes.searchItems(backend, dsl)(
|
||||
settings,
|
||||
fixQuery,
|
||||
itemQuery,
|
||||
limitCapped
|
||||
)
|
||||
} yield resp
|
||||
}
|
||||
.getOrElseF(NotFound())
|
||||
|
||||
case req @ POST -> Root / "stats" =>
|
||||
for {
|
||||
userQuery <- req.as[ItemQuery]
|
||||
itemQuery = ItemQueryString(userQuery.query)
|
||||
settings = OSimpleSearch.StatsSettings(
|
||||
useFTS = cfg.fullTextSearch.enabled,
|
||||
searchMode = userQuery.searchMode.getOrElse(SearchMode.Normal)
|
||||
)
|
||||
stats <- backend.share.searchSummary(settings)(token.id, itemQuery).value
|
||||
resp <- stats.map(mkSummaryResponse(dsl)).getOrElse(NotFound())
|
||||
} yield resp
|
||||
): HttpRoutes[F] =
|
||||
Kleisli { req =>
|
||||
for {
|
||||
shareQuery <- backend.share.findShareQuery(token.id)
|
||||
searchPart = ItemSearchPart(backend.search, backend.share, cfg, shareQuery)
|
||||
routes = searchPart.routes
|
||||
resp <- routes(req)
|
||||
} yield resp
|
||||
}
|
||||
}
|
||||
|
||||
def mkSummaryResponse[F[_]: Sync](
|
||||
dsl: Http4sDsl[F]
|
||||
)(r: StringSearchResult[SearchSummary]): F[Response[F]] = {
|
||||
import dsl._
|
||||
r match {
|
||||
case StringSearchResult.Success(summary) =>
|
||||
Ok(Conversions.mkSearchStats(summary))
|
||||
case StringSearchResult.FulltextMismatch(TooMany) =>
|
||||
BadRequest(BasicResult(false, "Fulltext search is not possible in this share."))
|
||||
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}"))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -73,14 +73,6 @@ object QItem extends FtsSupport {
|
||||
sql.query[ListItem].stream
|
||||
}
|
||||
|
||||
// ----
|
||||
|
||||
def countAttachmentsAndItems(items: Nel[Ident]): ConnectionIO[Int] =
|
||||
Select(count(a.id).s, from(a), a.itemId.in(items)).build
|
||||
.query[Int]
|
||||
.unique
|
||||
.map(_ + items.size)
|
||||
|
||||
def findItem(id: Ident, collective: Ident): ConnectionIO[Option[ItemData]] = {
|
||||
val ref = RItem.as("ref")
|
||||
val cq =
|
||||
@ -314,14 +306,6 @@ object QItem extends FtsSupport {
|
||||
Condition.unit
|
||||
}
|
||||
|
||||
def findItems(
|
||||
q: Query,
|
||||
today: LocalDate,
|
||||
maxNoteLen: Int,
|
||||
batch: Batch
|
||||
): Stream[ConnectionIO, ListItem] =
|
||||
queryItems(q, today, maxNoteLen, batch, None)
|
||||
|
||||
def searchStats(today: LocalDate, ftsTable: Option[RFtsResult.Table])(
|
||||
q: Query
|
||||
): ConnectionIO[SearchSummary] =
|
||||
@ -524,47 +508,6 @@ object QItem extends FtsSupport {
|
||||
}
|
||||
}
|
||||
|
||||
def findSelectedItems(
|
||||
q: Query,
|
||||
today: LocalDate,
|
||||
maxNoteLen: Int,
|
||||
items: Set[SelectedItem]
|
||||
): Stream[ConnectionIO, ListItem] =
|
||||
if (items.isEmpty) Stream.empty
|
||||
else {
|
||||
val i = RItem.as("i")
|
||||
|
||||
object Tids extends TableDef {
|
||||
val tableName = "tids"
|
||||
val alias = Some("tw")
|
||||
val itemId = Column[Ident]("item_id", this)
|
||||
val weight = Column[Double]("weight", this)
|
||||
val all = Vector[Column[_]](itemId, weight)
|
||||
}
|
||||
|
||||
val cte =
|
||||
CteBind(
|
||||
Tids,
|
||||
Tids.all,
|
||||
Select.RawSelect(
|
||||
fr"VALUES" ++
|
||||
items
|
||||
.map(it => fr"(${it.itemId}, ${it.weight})")
|
||||
.reduce((r, e) => r ++ fr"," ++ e)
|
||||
)
|
||||
)
|
||||
|
||||
val from = findItemsBase(q.fix, today, maxNoteLen, None)
|
||||
.appendCte(cte)
|
||||
.appendSelect(Tids.weight.s)
|
||||
.changeFrom(_.innerJoin(Tids, Tids.itemId === i.id))
|
||||
.orderBy(Tids.weight.desc)
|
||||
.build
|
||||
|
||||
logger.stream.trace(s"fts query: $from").drain ++
|
||||
from.query[ListItem].stream
|
||||
}
|
||||
|
||||
/** Same as `findItems` but resolves the tags for each item. Note that this is
|
||||
* implemented by running an additional query per item.
|
||||
*/
|
||||
|
@ -17,6 +17,15 @@ case class Query(fix: Query.Fix, cond: Query.QueryCond) {
|
||||
def withCond(f: Query.QueryCond => Query.QueryCond): Query =
|
||||
copy(cond = f(cond))
|
||||
|
||||
def andCond(c: ItemQuery.Expr): Query =
|
||||
withCond {
|
||||
case Query.QueryExpr(Some(q)) =>
|
||||
Query.QueryExpr(ItemQuery.Expr.and(q, c))
|
||||
|
||||
case Query.QueryExpr(None) =>
|
||||
Query.QueryExpr(c)
|
||||
}
|
||||
|
||||
def withOrder(orderAsc: RItem.Table => Column[_]): Query =
|
||||
withFix(_.copy(order = Some(_.byItemColumnAsc(orderAsc))))
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user