diff --git a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala index 1e58654c..9037e138 100644 --- a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala +++ b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala @@ -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 diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OShare.scala b/modules/backend/src/main/scala/docspell/backend/ops/OShare.scala index 77b882f2..0f064c36 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OShare.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OShare.scala @@ -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 + } + } } } diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 2f563116..46a4766a 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -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: diff --git a/modules/restserver/src/main/scala/docspell/restserver/http4s/BinaryUtil.scala b/modules/restserver/src/main/scala/docspell/restserver/http4s/BinaryUtil.scala index 91baf47e..208847a6 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/http4s/BinaryUtil.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/http4s/BinaryUtil.scala @@ -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]] = { diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/AttachmentRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/AttachmentRoutes.scala index 63b818cd..cf4e23f7 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/AttachmentRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/AttachmentRoutes.scala @@ -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" => diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala index 5277d0c8..5db15101 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala @@ -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]) { diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ShareAttachmentRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ShareAttachmentRoutes.scala index af02bf16..d763530b 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ShareAttachmentRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ShareAttachmentRoutes.scala @@ -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 } } } diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ShareSearchRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ShareSearchRoutes.scala index 39a45412..96202f14 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ShareSearchRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ShareSearchRoutes.scala @@ -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}")) + } + } + } diff --git a/modules/store/src/main/scala/docspell/store/queries/SearchSummary.scala b/modules/store/src/main/scala/docspell/store/queries/SearchSummary.scala index 0b6a1b1c..1eeaef2e 100644 --- a/modules/store/src/main/scala/docspell/store/queries/SearchSummary.scala +++ b/modules/store/src/main/scala/docspell/store/queries/SearchSummary.scala @@ -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) + ) +} diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index 226b3740..a049bce3 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -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" diff --git a/modules/webapp/src/main/elm/Comp/SearchMenu.elm b/modules/webapp/src/main/elm/Comp/SearchMenu.elm index 5a2aa5f7..c70a1c3d 100644 --- a/modules/webapp/src/main/elm/Comp/SearchMenu.elm +++ b/modules/webapp/src/main/elm/Comp/SearchMenu.elm @@ -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 diff --git a/modules/webapp/src/main/elm/Comp/TagSelect.elm b/modules/webapp/src/main/elm/Comp/TagSelect.elm index 62f2a65d..3fe96ac1 100644 --- a/modules/webapp/src/main/elm/Comp/TagSelect.elm +++ b/modules/webapp/src/main/elm/Comp/TagSelect.elm @@ -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 ] diff --git a/modules/webapp/src/main/elm/Page/Share/Data.elm b/modules/webapp/src/main/elm/Page/Share/Data.elm index 0689e9dd..eb47a41c 100644 --- a/modules/webapp/src/main/elm/Page/Share/Data.elm +++ b/modules/webapp/src/main/elm/Page/Share/Data.elm @@ -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 diff --git a/modules/webapp/src/main/elm/Page/Share/Update.elm b/modules/webapp/src/main/elm/Page/Share/Update.elm index 56070017..01b7ec73 100644 --- a/modules/webapp/src/main/elm/Page/Share/Update.elm +++ b/modules/webapp/src/main/elm/Page/Share/Update.elm @@ -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