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)
simpleSearchImpl = OSimpleSearch(fulltextImpl, itemSearchImpl)
clientSettingsImpl <- OClientSettings(store)
shareImpl <- Resource.pure(OShare(store, itemSearchImpl))
shareImpl <- Resource.pure(OShare(store, itemSearchImpl, simpleSearchImpl))
} yield new BackendApp[F] {
val login = loginImpl
val signup = signupImpl

View File

@ -9,15 +9,19 @@ package docspell.backend.ops
import cats.data.OptionT
import cats.effect._
import cats.implicits._
import docspell.backend.PasswordCrypt
import docspell.backend.auth.ShareToken
import docspell.backend.ops.OItemSearch.{AttachmentPreviewData, Batch, Query}
import docspell.backend.ops.OShare.{ShareQuery, VerifyResult}
import docspell.backend.ops.OSimpleSearch.StringSearchResult
import docspell.common._
import docspell.query.ItemQuery
import docspell.query.ItemQuery.Expr.AttachId
import docspell.store.Store
import docspell.store.queries.SearchSummary
import docspell.store.records.RShare
import scodec.bits.ByteVector
trait OShare[F[_]] {
@ -51,6 +55,9 @@ trait OShare[F[_]] {
shareId: Ident
): OptionT[F, AttachmentPreviewData[F]]
def searchSummary(
settings: OSimpleSearch.StatsSettings
)(shareId: Ident, q: ItemQueryString): OptionT[F, StringSearchResult[SearchSummary]]
}
object OShare {
@ -101,7 +108,11 @@ object OShare {
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] {
private[this] val logger = Logger.log4s[F](org.log4s.getLogger)
@ -238,5 +249,23 @@ object OShare {
.mapFilter(_ => None)
else OptionT(itemSearch.findAttachmentPreview(attachId, sq.cid))
} 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:
$ref: "#/components/schemas/BasicResult"
/share/search:
/share/search/query:
post:
operationId: "share-search"
operationId: "share-search-query"
tags: [Share]
summary: Performs a search in a share.
description: |
@ -1581,6 +1581,72 @@ paths:
application/json:
schema:
$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:
post:

View File

@ -10,17 +10,18 @@ import cats.data.NonEmptyList
import cats.data.OptionT
import cats.effect._
import cats.implicits._
import docspell.backend.ops.OItemSearch.AttachmentPreviewData
import docspell.backend.ops._
import docspell.restapi.model.BasicResult
import docspell.store.records.RFileMeta
import docspell.restserver.http4s.{QueryParam => QP}
import docspell.store.records.RFileMeta
import org.http4s._
import org.http4s.circe.CirceEntityEncoder._
import org.http4s.dsl.Http4sDsl
import org.http4s.headers._
import org.http4s.headers.ETag.EntityTag
import org.http4s.headers._
import org.typelevel.ci.CIString
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]])(
data: OItemSearch.BinaryData[F]
): F[Response[F]] = {

View File

@ -125,10 +125,7 @@ object AttachmentRoutes {
for {
fileData <-
backend.itemSearch.findAttachmentPreview(id, user.account.collective)
resp <-
fileData
.map(data => withResponseHeaders(Ok())(data))
.getOrElse(NotFound(BasicResult(false, "Not found")))
resp <- BinaryUtil.respondHead(dsl)(fileData)
} yield resp
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.{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}
import org.log4s._
object ItemRoutes {
@ -415,7 +415,11 @@ object ItemRoutes {
def searchItems[F[_]: Sync](
backend: BackendApp[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._
def convertFts(res: OSimpleSearch.Items.FtsItems): ItemLightList =
@ -459,7 +463,7 @@ object ItemRoutes {
settings: OSimpleSearch.StatsSettings,
fixQuery: Query.Fix,
itemQuery: ItemQueryString
) = {
): F[Response[F]] = {
import dsl._
backend.simpleSearch
@ -479,7 +483,6 @@ object ItemRoutes {
case StringSearchResult.ParseFailed(pf) =>
BadRequest(BasicResult(false, s"Error reading query: ${pf.render}"))
}
}
implicit final class OptionString(opt: Option[String]) {

View File

@ -26,12 +26,20 @@ object ShareAttachmentRoutes {
val dsl = new Http4sDsl[F] {}
import dsl._
HttpRoutes.of { case req @ GET -> Root / Ident(id) / "preview" =>
for {
fileData <-
backend.share.findAttachmentPreview(id, token.id).value
resp <- BinaryUtil.respond(dsl, req)(fileData)
} yield resp
HttpRoutes.of {
case req @ GET -> Root / Ident(id) / "preview" =>
for {
fileData <-
backend.share.findAttachmentPreview(id, token.id).value
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.auth.ShareToken
import docspell.backend.ops.OSimpleSearch
import docspell.backend.ops.OSimpleSearch.StringSearchResult
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.conv.Conversions
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.CirceEntityEncoder._
import org.http4s.dsl.Http4sDsl
import org.http4s.{HttpRoutes, Response}
object ShareSearchRoutes {
@ -34,33 +38,68 @@ object ShareSearchRoutes {
val dsl = new Http4sDsl[F] {}
import dsl._
HttpRoutes.of { case req @ POST -> Root =>
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
)
itemQuery = ItemQueryString(userQuery.query)
settings = OSimpleSearch.Settings(
batch,
cfg.fullTextSearch.enabled,
userQuery.withDetails.getOrElse(false),
cfg.maxNoteLength,
searchMode = SearchMode.Normal
)
account = share.asAccount
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)
} yield resp
}
.getOrElseF(NotFound())
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
)
itemQuery = ItemQueryString(userQuery.query)
settings = OSimpleSearch.Settings(
batch,
cfg.fullTextSearch.enabled,
userQuery.withDetails.getOrElse(false),
cfg.maxNoteLength,
searchMode = SearchMode.Normal
)
account = share.asAccount
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)
} 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
}
}
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],
fields: List[FieldStats],
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
, saveClientSettings
, searchShare
, searchShareStats
, sendMail
, setAttachmentName
, setCollectiveSettings
@ -2283,13 +2284,23 @@ verifyShare flags secret receive =
searchShare : Flags -> String -> ItemQuery -> (Result Http.Error ItemLightList -> msg) -> Cmd msg
searchShare flags token search receive =
Http2.sharePost
{ url = flags.config.baseUrl ++ "/api/v1/share/search"
{ url = flags.config.baseUrl ++ "/api/v1/share/search/query"
, token = token
, body = Http.jsonBody (Api.Model.ItemQuery.encode search)
, 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 id =
"/api/v1/share/attachment/" ++ id ++ "/preview?withFallback=true"

View File

@ -15,6 +15,7 @@ module Comp.SearchMenu exposing
, isFulltextSearch
, isNamesSearch
, linkTargetMsg
, setFromStats
, textSearchString
, update
, updateDrop
@ -379,6 +380,11 @@ type Msg
| ToggleOpenAllAkkordionTabs
setFromStats : SearchStats -> Msg
setFromStats stats =
GetStatsResp (Ok stats)
linkTargetMsg : LinkTarget -> Maybe Msg
linkTargetMsg linkTarget =
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
= ToggleTag String
| ToggleCat String
@ -422,6 +428,7 @@ viewTagsDrop2 texts ddm wm settings model =
[ a
[ class S.secondaryBasicButtonPlain
, class "border rounded flex-none px-1 py-1"
, classList [ ( "hidden", noEmptyTags model ) ]
, href "#"
, onClick ToggleShowEmpty
]

View File

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

View File

@ -91,6 +91,16 @@ update flags settings shareId msg model =
SearchResp (Err err) ->
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 ->
let
pm =
@ -191,8 +201,14 @@ makeSearchCmd flags model =
, query = Q.renderMaybe mq
, 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
Api.searchShare flags model.verifyResult.token (request xq) SearchResp
Cmd.batch [ searchCmd, statsCmd ]
linkTargetMsg : LinkTarget -> Maybe Msg