Use search stats to populate search menu

This commit is contained in:
eikek 2021-10-05 10:27:21 +02:00
parent e52271f9cd
commit e961a5ac10
14 changed files with 257 additions and 55 deletions

View File

@ -86,7 +86,7 @@ object BackendApp {
customFieldsImpl <- OCustomFields(store) customFieldsImpl <- OCustomFields(store)
simpleSearchImpl = OSimpleSearch(fulltextImpl, itemSearchImpl) simpleSearchImpl = OSimpleSearch(fulltextImpl, itemSearchImpl)
clientSettingsImpl <- OClientSettings(store) clientSettingsImpl <- OClientSettings(store)
shareImpl <- Resource.pure(OShare(store, itemSearchImpl)) shareImpl <- Resource.pure(OShare(store, itemSearchImpl, simpleSearchImpl))
} yield new BackendApp[F] { } yield new BackendApp[F] {
val login = loginImpl val login = loginImpl
val signup = signupImpl val signup = signupImpl

View File

@ -9,15 +9,19 @@ package docspell.backend.ops
import cats.data.OptionT import cats.data.OptionT
import cats.effect._ import cats.effect._
import cats.implicits._ import cats.implicits._
import docspell.backend.PasswordCrypt import docspell.backend.PasswordCrypt
import docspell.backend.auth.ShareToken import docspell.backend.auth.ShareToken
import docspell.backend.ops.OItemSearch.{AttachmentPreviewData, Batch, Query} import docspell.backend.ops.OItemSearch.{AttachmentPreviewData, Batch, Query}
import docspell.backend.ops.OShare.{ShareQuery, VerifyResult} import docspell.backend.ops.OShare.{ShareQuery, VerifyResult}
import docspell.backend.ops.OSimpleSearch.StringSearchResult
import docspell.common._ import docspell.common._
import docspell.query.ItemQuery import docspell.query.ItemQuery
import docspell.query.ItemQuery.Expr.AttachId import docspell.query.ItemQuery.Expr.AttachId
import docspell.store.Store import docspell.store.Store
import docspell.store.queries.SearchSummary
import docspell.store.records.RShare import docspell.store.records.RShare
import scodec.bits.ByteVector import scodec.bits.ByteVector
trait OShare[F[_]] { trait OShare[F[_]] {
@ -51,6 +55,9 @@ trait OShare[F[_]] {
shareId: Ident shareId: Ident
): OptionT[F, AttachmentPreviewData[F]] ): OptionT[F, AttachmentPreviewData[F]]
def searchSummary(
settings: OSimpleSearch.StatsSettings
)(shareId: Ident, q: ItemQueryString): OptionT[F, StringSearchResult[SearchSummary]]
} }
object OShare { object OShare {
@ -101,7 +108,11 @@ object OShare {
def publishUntilInPast: ChangeResult = PublishUntilInPast def publishUntilInPast: ChangeResult = PublishUntilInPast
} }
def apply[F[_]: Async](store: Store[F], itemSearch: OItemSearch[F]): OShare[F] = def apply[F[_]: Async](
store: Store[F],
itemSearch: OItemSearch[F],
simpleSearch: OSimpleSearch[F]
): OShare[F] =
new OShare[F] { new OShare[F] {
private[this] val logger = Logger.log4s[F](org.log4s.getLogger) private[this] val logger = Logger.log4s[F](org.log4s.getLogger)
@ -238,5 +249,23 @@ object OShare {
.mapFilter(_ => None) .mapFilter(_ => None)
else OptionT(itemSearch.findAttachmentPreview(attachId, sq.cid)) else OptionT(itemSearch.findAttachmentPreview(attachId, sq.cid))
} yield res } yield res
def searchSummary(
settings: OSimpleSearch.StatsSettings
)(
shareId: Ident,
q: ItemQueryString
): OptionT[F, StringSearchResult[SearchSummary]] =
findShareQuery(shareId)
.semiflatMap { share =>
val fix = Query.Fix(share.asAccount, Some(share.query.expr), None)
simpleSearch
.searchSummaryByString(settings)(fix, q)
.map {
case StringSearchResult.Success(summary) =>
StringSearchResult.Success(summary.onlyExisting)
case other => other
}
}
} }
} }

View File

@ -1558,9 +1558,9 @@ paths:
schema: schema:
$ref: "#/components/schemas/BasicResult" $ref: "#/components/schemas/BasicResult"
/share/search: /share/search/query:
post: post:
operationId: "share-search" operationId: "share-search-query"
tags: [Share] tags: [Share]
summary: Performs a search in a share. summary: Performs a search in a share.
description: | description: |
@ -1581,6 +1581,72 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/ItemLightList" $ref: "#/components/schemas/ItemLightList"
/share/search/stats:
post:
operationId: "share-search-stats"
tags: [ Share ]
summary: Get basic statistics about search results.
description: |
Instead of returning the results of a query, uses it to return
a summary, constraint to the share.
security:
- shareTokenHeader: []
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/ItemQuery"
responses:
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/SearchStats"
/share/attachment/{id}/preview:
head:
operationId: "share-attach-check-preview"
tags: [ Attachment ]
summary: Get the headers to a preview image of an attachment file.
description: |
Checks if an image file showing a preview of the attachment is
available. If not available, a 404 is returned.
security:
- shareTokenHeader: []
parameters:
- $ref: "#/components/parameters/id"
responses:
200:
description: Ok
404:
description: NotFound
get:
operationId: "share-attach-get-preview"
tags: [ Attachment ]
summary: Get a preview image of an attachment file.
description: |
Gets a image file showing a preview of the attachment. Usually
it is a small image of the first page of the document.If not
available, a 404 is returned. However, if the query parameter
`withFallback` is `true`, a fallback preview image is
returned. You can also use the `HEAD` method to check for
existence.
The attachment must be in the search results of the current
share.
security:
- shareTokenHeader: []
parameters:
- $ref: "#/components/parameters/id"
- $ref: "#/components/parameters/withFallback"
responses:
200:
description: Ok
content:
application/octet-stream:
schema:
type: string
format: binary
/admin/user/resetPassword: /admin/user/resetPassword:
post: post:

View File

@ -10,17 +10,18 @@ import cats.data.NonEmptyList
import cats.data.OptionT import cats.data.OptionT
import cats.effect._ import cats.effect._
import cats.implicits._ import cats.implicits._
import docspell.backend.ops.OItemSearch.AttachmentPreviewData import docspell.backend.ops.OItemSearch.AttachmentPreviewData
import docspell.backend.ops._ import docspell.backend.ops._
import docspell.restapi.model.BasicResult import docspell.restapi.model.BasicResult
import docspell.store.records.RFileMeta
import docspell.restserver.http4s.{QueryParam => QP} import docspell.restserver.http4s.{QueryParam => QP}
import docspell.store.records.RFileMeta
import org.http4s._ import org.http4s._
import org.http4s.circe.CirceEntityEncoder._ import org.http4s.circe.CirceEntityEncoder._
import org.http4s.dsl.Http4sDsl import org.http4s.dsl.Http4sDsl
import org.http4s.headers._
import org.http4s.headers.ETag.EntityTag import org.http4s.headers.ETag.EntityTag
import org.http4s.headers._
import org.typelevel.ci.CIString import org.typelevel.ci.CIString
object BinaryUtil { object BinaryUtil {
@ -53,6 +54,15 @@ object BinaryUtil {
} }
} }
def respondHead[F[_]: Async](
dsl: Http4sDsl[F]
)(fileData: Option[AttachmentPreviewData[F]]): F[Response[F]] = {
import dsl._
fileData
.map(data => withResponseHeaders(dsl, Ok())(data))
.getOrElse(NotFound(BasicResult(false, "Not found")))
}
def withResponseHeaders[F[_]: Sync](dsl: Http4sDsl[F], resp: F[Response[F]])( def withResponseHeaders[F[_]: Sync](dsl: Http4sDsl[F], resp: F[Response[F]])(
data: OItemSearch.BinaryData[F] data: OItemSearch.BinaryData[F]
): F[Response[F]] = { ): F[Response[F]] = {

View File

@ -125,10 +125,7 @@ object AttachmentRoutes {
for { for {
fileData <- fileData <-
backend.itemSearch.findAttachmentPreview(id, user.account.collective) backend.itemSearch.findAttachmentPreview(id, user.account.collective)
resp <- resp <- BinaryUtil.respondHead(dsl)(fileData)
fileData
.map(data => withResponseHeaders(Ok())(data))
.getOrElse(NotFound(BasicResult(false, "Not found")))
} yield resp } yield resp
case POST -> Root / Ident(id) / "preview" => case POST -> Root / Ident(id) / "preview" =>

View File

@ -28,11 +28,11 @@ import docspell.restserver.http4s.BinaryUtil
import docspell.restserver.http4s.Responses import docspell.restserver.http4s.Responses
import docspell.restserver.http4s.{QueryParam => QP} import docspell.restserver.http4s.{QueryParam => QP}
import org.http4s.HttpRoutes
import org.http4s.circe.CirceEntityDecoder._ import org.http4s.circe.CirceEntityDecoder._
import org.http4s.circe.CirceEntityEncoder._ import org.http4s.circe.CirceEntityEncoder._
import org.http4s.dsl.Http4sDsl import org.http4s.dsl.Http4sDsl
import org.http4s.headers._ import org.http4s.headers._
import org.http4s.{HttpRoutes, Response}
import org.log4s._ import org.log4s._
object ItemRoutes { object ItemRoutes {
@ -415,7 +415,11 @@ object ItemRoutes {
def searchItems[F[_]: Sync]( def searchItems[F[_]: Sync](
backend: BackendApp[F], backend: BackendApp[F],
dsl: Http4sDsl[F] dsl: Http4sDsl[F]
)(settings: OSimpleSearch.Settings, fixQuery: Query.Fix, itemQuery: ItemQueryString) = { )(
settings: OSimpleSearch.Settings,
fixQuery: Query.Fix,
itemQuery: ItemQueryString
): F[Response[F]] = {
import dsl._ import dsl._
def convertFts(res: OSimpleSearch.Items.FtsItems): ItemLightList = def convertFts(res: OSimpleSearch.Items.FtsItems): ItemLightList =
@ -459,7 +463,7 @@ object ItemRoutes {
settings: OSimpleSearch.StatsSettings, settings: OSimpleSearch.StatsSettings,
fixQuery: Query.Fix, fixQuery: Query.Fix,
itemQuery: ItemQueryString itemQuery: ItemQueryString
) = { ): F[Response[F]] = {
import dsl._ import dsl._
backend.simpleSearch backend.simpleSearch
@ -479,7 +483,6 @@ object ItemRoutes {
case StringSearchResult.ParseFailed(pf) => case StringSearchResult.ParseFailed(pf) =>
BadRequest(BasicResult(false, s"Error reading query: ${pf.render}")) BadRequest(BasicResult(false, s"Error reading query: ${pf.render}"))
} }
} }
implicit final class OptionString(opt: Option[String]) { implicit final class OptionString(opt: Option[String]) {

View File

@ -26,12 +26,20 @@ object ShareAttachmentRoutes {
val dsl = new Http4sDsl[F] {} val dsl = new Http4sDsl[F] {}
import dsl._ import dsl._
HttpRoutes.of { case req @ GET -> Root / Ident(id) / "preview" => HttpRoutes.of {
for { case req @ GET -> Root / Ident(id) / "preview" =>
fileData <- for {
backend.share.findAttachmentPreview(id, token.id).value fileData <-
resp <- BinaryUtil.respond(dsl, req)(fileData) backend.share.findAttachmentPreview(id, token.id).value
} yield resp resp <- BinaryUtil.respond(dsl, req)(fileData)
} yield resp
case HEAD -> Root / Ident(id) / "preview" =>
for {
fileData <-
backend.share.findAttachmentPreview(id, token.id).value
resp <- BinaryUtil.respondHead(dsl)(fileData)
} yield resp
} }
} }
} }

View File

@ -12,15 +12,19 @@ import cats.implicits._
import docspell.backend.BackendApp import docspell.backend.BackendApp
import docspell.backend.auth.ShareToken import docspell.backend.auth.ShareToken
import docspell.backend.ops.OSimpleSearch import docspell.backend.ops.OSimpleSearch
import docspell.backend.ops.OSimpleSearch.StringSearchResult
import docspell.common._ import docspell.common._
import docspell.restapi.model.ItemQuery import docspell.query.FulltextExtract.Result.{TooMany, UnsupportedPosition}
import docspell.restapi.model._
import docspell.restserver.Config import docspell.restserver.Config
import docspell.restserver.conv.Conversions
import docspell.store.qb.Batch import docspell.store.qb.Batch
import docspell.store.queries.Query import docspell.store.queries.{Query, SearchSummary}
import org.http4s.HttpRoutes
import org.http4s.circe.CirceEntityDecoder._ import org.http4s.circe.CirceEntityDecoder._
import org.http4s.circe.CirceEntityEncoder._
import org.http4s.dsl.Http4sDsl import org.http4s.dsl.Http4sDsl
import org.http4s.{HttpRoutes, Response}
object ShareSearchRoutes { object ShareSearchRoutes {
@ -34,33 +38,68 @@ object ShareSearchRoutes {
val dsl = new Http4sDsl[F] {} val dsl = new Http4sDsl[F] {}
import dsl._ import dsl._
HttpRoutes.of { case req @ POST -> Root => HttpRoutes.of {
backend.share case req @ POST -> Root / "query" =>
.findShareQuery(token.id) backend.share
.semiflatMap { share => .findShareQuery(token.id)
for { .semiflatMap { share =>
userQuery <- req.as[ItemQuery] for {
batch = Batch( userQuery <- req.as[ItemQuery]
userQuery.offset.getOrElse(0), batch = Batch(
userQuery.limit.getOrElse(cfg.maxItemPageSize) userQuery.offset.getOrElse(0),
).restrictLimitTo( userQuery.limit.getOrElse(cfg.maxItemPageSize)
cfg.maxItemPageSize ).restrictLimitTo(
) cfg.maxItemPageSize
itemQuery = ItemQueryString(userQuery.query) )
settings = OSimpleSearch.Settings( itemQuery = ItemQueryString(userQuery.query)
batch, settings = OSimpleSearch.Settings(
cfg.fullTextSearch.enabled, batch,
userQuery.withDetails.getOrElse(false), cfg.fullTextSearch.enabled,
cfg.maxNoteLength, userQuery.withDetails.getOrElse(false),
searchMode = SearchMode.Normal cfg.maxNoteLength,
) searchMode = SearchMode.Normal
account = share.asAccount )
fixQuery = Query.Fix(account, Some(share.query.expr), None) account = share.asAccount
_ <- logger.debug(s"Searching in share ${share.id.id}: ${userQuery.query}") fixQuery = Query.Fix(account, Some(share.query.expr), None)
resp <- ItemRoutes.searchItems(backend, dsl)(settings, fixQuery, itemQuery) _ <- logger.debug(s"Searching in share ${share.id.id}: ${userQuery.query}")
} yield resp resp <- ItemRoutes.searchItems(backend, dsl)(settings, fixQuery, itemQuery)
} } yield resp
.getOrElseF(NotFound()) }
.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
} }
} }
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}"))
}
}
} }

View File

@ -12,4 +12,14 @@ case class SearchSummary(
cats: List[CategoryCount], cats: List[CategoryCount],
fields: List[FieldStats], fields: List[FieldStats],
folders: List[FolderCount] folders: List[FolderCount]
) ) {
def onlyExisting: SearchSummary =
SearchSummary(
count,
tags.filter(_.count > 0),
cats.filter(_.count > 0),
fields.filter(_.count > 0),
folders.filter(_.count > 0)
)
}

View File

@ -114,6 +114,7 @@ module Api exposing
, restoreItem , restoreItem
, saveClientSettings , saveClientSettings
, searchShare , searchShare
, searchShareStats
, sendMail , sendMail
, setAttachmentName , setAttachmentName
, setCollectiveSettings , setCollectiveSettings
@ -2283,13 +2284,23 @@ verifyShare flags secret receive =
searchShare : Flags -> String -> ItemQuery -> (Result Http.Error ItemLightList -> msg) -> Cmd msg searchShare : Flags -> String -> ItemQuery -> (Result Http.Error ItemLightList -> msg) -> Cmd msg
searchShare flags token search receive = searchShare flags token search receive =
Http2.sharePost Http2.sharePost
{ url = flags.config.baseUrl ++ "/api/v1/share/search" { url = flags.config.baseUrl ++ "/api/v1/share/search/query"
, token = token , token = token
, body = Http.jsonBody (Api.Model.ItemQuery.encode search) , body = Http.jsonBody (Api.Model.ItemQuery.encode search)
, expect = Http.expectJson receive Api.Model.ItemLightList.decoder , expect = Http.expectJson receive Api.Model.ItemLightList.decoder
} }
searchShareStats : Flags -> String -> ItemQuery -> (Result Http.Error SearchStats -> msg) -> Cmd msg
searchShareStats flags token search receive =
Http2.sharePost
{ url = flags.config.baseUrl ++ "/api/v1/share/search/stats"
, token = token
, body = Http.jsonBody (Api.Model.ItemQuery.encode search)
, expect = Http.expectJson receive Api.Model.SearchStats.decoder
}
shareAttachmentPreviewURL : String -> String shareAttachmentPreviewURL : String -> String
shareAttachmentPreviewURL id = shareAttachmentPreviewURL id =
"/api/v1/share/attachment/" ++ id ++ "/preview?withFallback=true" "/api/v1/share/attachment/" ++ id ++ "/preview?withFallback=true"

View File

@ -15,6 +15,7 @@ module Comp.SearchMenu exposing
, isFulltextSearch , isFulltextSearch
, isNamesSearch , isNamesSearch
, linkTargetMsg , linkTargetMsg
, setFromStats
, textSearchString , textSearchString
, update , update
, updateDrop , updateDrop
@ -379,6 +380,11 @@ type Msg
| ToggleOpenAllAkkordionTabs | ToggleOpenAllAkkordionTabs
setFromStats : SearchStats -> Msg
setFromStats stats =
GetStatsResp (Ok stats)
linkTargetMsg : LinkTarget -> Maybe Msg linkTargetMsg : LinkTarget -> Maybe Msg
linkTargetMsg linkTarget = linkTargetMsg linkTarget =
case linkTarget of case linkTarget of

View File

@ -245,6 +245,12 @@ makeWorkModel sel model =
} }
noEmptyTags : Model -> Bool
noEmptyTags model =
Dict.filter (\k -> \v -> v.count == 0) model.availableTags
|> Dict.isEmpty
type Msg type Msg
= ToggleTag String = ToggleTag String
| ToggleCat String | ToggleCat String
@ -422,6 +428,7 @@ viewTagsDrop2 texts ddm wm settings model =
[ a [ a
[ class S.secondaryBasicButtonPlain [ class S.secondaryBasicButtonPlain
, class "border rounded flex-none px-1 py-1" , class "border rounded flex-none px-1 py-1"
, classList [ ( "hidden", noEmptyTags model ) ]
, href "#" , href "#"
, onClick ToggleShowEmpty , onClick ToggleShowEmpty
] ]

View File

@ -9,6 +9,7 @@ module Page.Share.Data exposing (Mode(..), Model, Msg(..), PageError(..), init)
import Api import Api
import Api.Model.ItemLightList exposing (ItemLightList) import Api.Model.ItemLightList exposing (ItemLightList)
import Api.Model.SearchStats exposing (SearchStats)
import Api.Model.ShareSecret exposing (ShareSecret) import Api.Model.ShareSecret exposing (ShareSecret)
import Api.Model.ShareVerifyResult exposing (ShareVerifyResult) import Api.Model.ShareVerifyResult exposing (ShareVerifyResult)
import Comp.ItemCardList import Comp.ItemCardList
@ -41,7 +42,6 @@ type alias Model =
, verifyResult : ShareVerifyResult , verifyResult : ShareVerifyResult
, passwordModel : PasswordModel , passwordModel : PasswordModel
, pageError : PageError , pageError : PageError
, items : ItemLightList
, searchMenuModel : Comp.SearchMenu.Model , searchMenuModel : Comp.SearchMenu.Model
, powerSearchInput : Comp.PowerSearchInput.Model , powerSearchInput : Comp.PowerSearchInput.Model
, searchInProgress : Bool , searchInProgress : Bool
@ -58,7 +58,6 @@ emptyModel flags =
, passwordFailed = False , passwordFailed = False
} }
, pageError = PageErrorNone , pageError = PageErrorNone
, items = Api.Model.ItemLightList.empty
, searchMenuModel = Comp.SearchMenu.init flags , searchMenuModel = Comp.SearchMenu.init flags
, powerSearchInput = Comp.PowerSearchInput.init , powerSearchInput = Comp.PowerSearchInput.init
, searchInProgress = False , searchInProgress = False
@ -79,6 +78,7 @@ init shareId flags =
type Msg type Msg
= VerifyResp (Result Http.Error ShareVerifyResult) = VerifyResp (Result Http.Error ShareVerifyResult)
| SearchResp (Result Http.Error ItemLightList) | SearchResp (Result Http.Error ItemLightList)
| StatsResp (Result Http.Error SearchStats)
| SetPassword String | SetPassword String
| SubmitPassword | SubmitPassword
| SearchMenuMsg Comp.SearchMenu.Msg | SearchMenuMsg Comp.SearchMenu.Msg

View File

@ -91,6 +91,16 @@ update flags settings shareId msg model =
SearchResp (Err err) -> SearchResp (Err err) ->
noSub ( { model | pageError = PageErrorHttp err, searchInProgress = False }, Cmd.none ) noSub ( { model | pageError = PageErrorHttp err, searchInProgress = False }, Cmd.none )
StatsResp (Ok stats) ->
update flags
settings
shareId
(SearchMenuMsg (Comp.SearchMenu.setFromStats stats))
model
StatsResp (Err err) ->
noSub ( { model | pageError = PageErrorHttp err }, Cmd.none )
SetPassword pw -> SetPassword pw ->
let let
pm = pm =
@ -191,8 +201,14 @@ makeSearchCmd flags model =
, query = Q.renderMaybe mq , query = Q.renderMaybe mq
, searchMode = Just (Data.SearchMode.asString Data.SearchMode.Normal) , searchMode = Just (Data.SearchMode.asString Data.SearchMode.Normal)
} }
searchCmd =
Api.searchShare flags model.verifyResult.token (request xq) SearchResp
statsCmd =
Api.searchShareStats flags model.verifyResult.token (request xq) StatsResp
in in
Api.searchShare flags model.verifyResult.token (request xq) SearchResp Cmd.batch [ searchCmd, statsCmd ]
linkTargetMsg : LinkTarget -> Maybe Msg linkTargetMsg : LinkTarget -> Maybe Msg