From 757ad311658a8feae46f1d18c2bc0f53aa1d93bd Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Sun, 8 Nov 2020 14:22:33 +0100 Subject: [PATCH] Add a route to get the item preview This is the first available preview of an attachment wrt position. If all attachments have a preview image, the preview of the first attachment is returned. --- .../docspell/backend/ops/OItemSearch.scala | 22 ++++++++++ .../src/main/resources/docspell-openapi.yml | 41 +++++++++++++++++++ .../restserver/http4s/Responses.scala | 9 ++++ .../restserver/routes/ItemRoutes.scala | 26 ++++++++++++ .../store/records/RAttachmentPreview.scala | 25 +++++++++++ 5 files changed, 123 insertions(+) diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala b/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala index ff312503..6a5cb49b 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OItemSearch.scala @@ -41,6 +41,8 @@ trait OItemSearch[F[_]] { collective: Ident ): F[Option[AttachmentPreviewData[F]]] + def findItemPreview(item: Ident, collective: Ident): F[Option[AttachmentPreviewData[F]]] + def findAttachmentMeta(id: Ident, collective: Ident): F[Option[RAttachmentMeta]] def findByFileCollective(checksum: String, collective: Ident): F[Vector[RItem]] @@ -188,6 +190,26 @@ object OItemSearch { (None: Option[AttachmentPreviewData[F]]).pure[F] }) + def findItemPreview( + item: Ident, + collective: Ident + ): F[Option[AttachmentPreviewData[F]]] = + store + .transact(RAttachmentPreview.findByItemAndCollective(item, collective)) + .flatMap({ + case Some(ra) => + makeBinaryData(ra.fileId) { m => + AttachmentPreviewData[F]( + ra, + m, + store.bitpeace.fetchData2(RangeDef.all)(Stream.emit(m)) + ) + } + + case None => + (None: Option[AttachmentPreviewData[F]]).pure[F] + }) + def findAttachmentArchive( id: Ident, collective: Ident diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index 4aa09894..4b8c51b7 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -1847,6 +1847,47 @@ paths: application/json: schema: $ref: "#/components/schemas/ItemProposals" + /sec/item/{id}/preview: + head: + tags: [ Attachment ] + summary: Get a preview image of an attachment file. + description: | + Checks if an image file showing a preview of the item is + available. If not available, a 404 is returned. The preview is + currently the an image of the first page of the first + attachment. + security: + - authTokenHeader: [] + parameters: + - $ref: "#/components/parameters/id" + responses: + 200: + description: Ok + 404: + description: NotFound + get: + tags: [ Attachment ] + summary: Get a preview image of an attachment file. + description: | + Gets a image file showing a preview of the item. Usually it is + a small image of the first page of the first attachment. 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. + security: + - authTokenHeader: [] + parameters: + - $ref: "#/components/parameters/id" + - $ref: "#/components/parameters/withFallback" + responses: + 200: + description: Ok + content: + application/octet-stream: + schema: + type: string + format: binary /sec/item/{itemId}/reprocess: post: diff --git a/modules/restserver/src/main/scala/docspell/restserver/http4s/Responses.scala b/modules/restserver/src/main/scala/docspell/restserver/http4s/Responses.scala index 01bf9774..fbd300a3 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/http4s/Responses.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/http4s/Responses.scala @@ -1,5 +1,6 @@ package docspell.restserver.http4s +import cats.data.NonEmptyList import fs2.text.utf8Encode import fs2.{Pure, Stream} @@ -27,4 +28,12 @@ object Responses { def unauthorized[F[_]]: Response[F] = pureUnauthorized.copy(body = pureUnauthorized.body.covary[F]) + + def noCache[F[_]](r: Response[F]): Response[F] = + r.withHeaders( + `Cache-Control`( + NonEmptyList.of(CacheDirective.`no-cache`(), CacheDirective.`private`()) + ) + ) + } 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 1966a6f1..62e084be 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala @@ -13,11 +13,14 @@ import docspell.common.{Ident, ItemState} import docspell.restapi.model._ import docspell.restserver.Config import docspell.restserver.conv.Conversions +import docspell.restserver.http4s.BinaryUtil +import docspell.restserver.http4s.Responses 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.log4s._ object ItemRoutes { @@ -315,6 +318,29 @@ object ItemRoutes { resp <- Ok(Conversions.basicResult(res, "Attachment moved.")) } yield resp + case req @ GET -> Root / Ident(id) / "preview" => + for { + preview <- backend.itemSearch.findItemPreview(id, user.account.collective) + inm = req.headers.get(`If-None-Match`).flatMap(_.tags) + matches = BinaryUtil.matchETag(preview.map(_.meta), inm) + resp <- + preview + .map { data => + if (matches) BinaryUtil.withResponseHeaders(dsl, NotModified())(data) + else BinaryUtil.makeByteResp(dsl)(data).map(Responses.noCache) + } + .getOrElse(NotFound(BasicResult(false, "Not found"))) + } yield resp + + case HEAD -> Root / Ident(id) / "preview" => + for { + preview <- backend.itemSearch.findItemPreview(id, user.account.collective) + resp <- + preview + .map(data => BinaryUtil.withResponseHeaders(dsl, Ok())(data)) + .getOrElse(NotFound(BasicResult(false, "Not found"))) + } yield resp + case req @ POST -> Root / Ident(id) / "reprocess" => for { data <- req.as[IdList] diff --git a/modules/store/src/main/scala/docspell/store/records/RAttachmentPreview.scala b/modules/store/src/main/scala/docspell/store/records/RAttachmentPreview.scala index 65f1235b..c28169b7 100644 --- a/modules/store/src/main/scala/docspell/store/records/RAttachmentPreview.scala +++ b/modules/store/src/main/scala/docspell/store/records/RAttachmentPreview.scala @@ -72,6 +72,31 @@ object RAttachmentPreview { .to[Vector] } + def findByItemAndCollective( + itemId: Ident, + coll: Ident + ): ConnectionIO[Option[RAttachmentPreview]] = { + val sId = Columns.id.prefix("s") + val aId = RAttachment.Columns.id.prefix("a") + val aItem = RAttachment.Columns.itemId.prefix("a") + val aPos = RAttachment.Columns.position.prefix("a") + val iId = RItem.Columns.id.prefix("i") + val iColl = RItem.Columns.cid.prefix("i") + + val from = + table ++ fr"s INNER JOIN" ++ RAttachment.table ++ fr"a ON" ++ sId.is(aId) ++ + fr"INNER JOIN" ++ RItem.table ++ fr"i ON" ++ iId.is(aItem) + + selectSimple( + all.map(_.prefix("s")) ++ List(aPos), + from, + and(aItem.is(itemId), iColl.is(coll)) + ) + .query[(RAttachmentPreview, Int)] + .to[Vector] + .map(_.sortBy(_._2).headOption.map(_._1)) + } + def findByItemWithMeta( id: Ident ): ConnectionIO[Vector[(RAttachmentPreview, FileMeta)]] = {