mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-03-25 16:45:05 +00:00
Merge pull request #428 from eikek/attachment-preview
Attachment preview
This commit is contained in:
commit
5f217e6a76
@ -37,14 +37,19 @@ final class StanfordTextClassifier[F[_]: Sync: ContextShift](
|
||||
def classify(
|
||||
logger: Logger[F],
|
||||
model: ClassifierModel,
|
||||
text: String
|
||||
txt: String
|
||||
): F[Option[String]] =
|
||||
Sync[F].delay {
|
||||
val cls = ColumnDataClassifier.getClassifier(
|
||||
model.model.normalize().toAbsolutePath().toString()
|
||||
)
|
||||
val cat = cls.classOf(cls.makeDatumFromLine("\t\t" + normalisedText(text)))
|
||||
Option(cat)
|
||||
Option(txt).map(_.trim).filter(_.nonEmpty) match {
|
||||
case Some(text) =>
|
||||
Sync[F].delay {
|
||||
val cls = ColumnDataClassifier.getClassifier(
|
||||
model.model.normalize().toAbsolutePath().toString()
|
||||
)
|
||||
val cat = cls.classOf(cls.makeDatumFromLine("\t\t" + normalisedText(text)))
|
||||
Option(cat)
|
||||
}
|
||||
case None =>
|
||||
(None: Option[String]).pure[F]
|
||||
}
|
||||
|
||||
// --- helpers
|
||||
|
@ -8,6 +8,45 @@ import docspell.store.records.RJob
|
||||
|
||||
object JobFactory {
|
||||
|
||||
def makePreview[F[_]: Sync](
|
||||
args: MakePreviewArgs,
|
||||
account: Option[AccountId]
|
||||
): F[RJob] =
|
||||
for {
|
||||
id <- Ident.randomId[F]
|
||||
now <- Timestamp.current[F]
|
||||
job = RJob.newJob(
|
||||
id,
|
||||
MakePreviewArgs.taskName,
|
||||
account.map(_.collective).getOrElse(DocspellSystem.taskGroup),
|
||||
args,
|
||||
s"Generate preview image",
|
||||
now,
|
||||
account.map(_.user).getOrElse(DocspellSystem.user),
|
||||
Priority.Low,
|
||||
Some(MakePreviewArgs.taskName / args.attachment)
|
||||
)
|
||||
} yield job
|
||||
|
||||
def allPreviews[F[_]: Sync](
|
||||
args: AllPreviewsArgs,
|
||||
submitter: Option[Ident]
|
||||
): F[RJob] =
|
||||
for {
|
||||
id <- Ident.randomId[F]
|
||||
now <- Timestamp.current[F]
|
||||
} yield RJob.newJob(
|
||||
id,
|
||||
AllPreviewsArgs.taskName,
|
||||
args.collective.getOrElse(DocspellSystem.taskGroup),
|
||||
args,
|
||||
"Create preview images",
|
||||
now,
|
||||
submitter.getOrElse(DocspellSystem.taskGroup),
|
||||
Priority.Low,
|
||||
Some(DocspellSystem.allPreviewTaskTracker)
|
||||
)
|
||||
|
||||
def convertAllPdfs[F[_]: Sync](
|
||||
collective: Option[Ident],
|
||||
account: AccountId,
|
||||
|
@ -4,9 +4,11 @@ import cats.effect.{Effect, Resource}
|
||||
import cats.implicits._
|
||||
import fs2.Stream
|
||||
|
||||
import docspell.backend.JobFactory
|
||||
import docspell.backend.PasswordCrypt
|
||||
import docspell.backend.ops.OCollective._
|
||||
import docspell.common._
|
||||
import docspell.store.UpdateResult
|
||||
import docspell.store.queries.QCollective
|
||||
import docspell.store.queue.JobQueue
|
||||
import docspell.store.records._
|
||||
@ -51,6 +53,15 @@ trait OCollective[F[_]] {
|
||||
def findEnabledSource(sourceId: Ident): F[Option[RSource]]
|
||||
|
||||
def startLearnClassifier(collective: Ident): F[Unit]
|
||||
|
||||
/** Submits a task that (re)generates the preview images for all
|
||||
* attachments of the given collective.
|
||||
*/
|
||||
def generatePreviews(
|
||||
storeMode: MakePreviewArgs.StoreMode,
|
||||
account: AccountId,
|
||||
notifyJoex: Boolean
|
||||
): F[UpdateResult]
|
||||
}
|
||||
|
||||
object OCollective {
|
||||
@ -210,5 +221,20 @@ object OCollective {
|
||||
|
||||
def findEnabledSource(sourceId: Ident): F[Option[RSource]] =
|
||||
store.transact(RSource.findEnabled(sourceId))
|
||||
|
||||
def generatePreviews(
|
||||
storeMode: MakePreviewArgs.StoreMode,
|
||||
account: AccountId,
|
||||
notifyJoex: Boolean
|
||||
): F[UpdateResult] =
|
||||
for {
|
||||
job <- JobFactory.allPreviews[F](
|
||||
AllPreviewsArgs(Some(account.collective), storeMode),
|
||||
Some(account.user)
|
||||
)
|
||||
_ <- queue.insertIfNew(job)
|
||||
_ <- if (notifyJoex) joex.notifyAllNodes else ().pure[F]
|
||||
} yield UpdateResult.success
|
||||
|
||||
})
|
||||
}
|
||||
|
@ -175,6 +175,15 @@ trait OItem[F[_]] {
|
||||
account: AccountId,
|
||||
notifyJoex: Boolean
|
||||
): F[UpdateResult]
|
||||
|
||||
/** Submits a task that (re)generates the preview image for an
|
||||
* attachment.
|
||||
*/
|
||||
def generatePreview(
|
||||
args: MakePreviewArgs,
|
||||
account: AccountId,
|
||||
notifyJoex: Boolean
|
||||
): F[UpdateResult]
|
||||
}
|
||||
|
||||
object OItem {
|
||||
@ -656,6 +665,17 @@ object OItem {
|
||||
_ <- if (notifyJoex) joex.notifyAllNodes else ().pure[F]
|
||||
} yield UpdateResult.success
|
||||
|
||||
def generatePreview(
|
||||
args: MakePreviewArgs,
|
||||
account: AccountId,
|
||||
notifyJoex: Boolean
|
||||
): F[UpdateResult] =
|
||||
for {
|
||||
job <- JobFactory.makePreview[F](args, account.some)
|
||||
_ <- queue.insertIfNew(job)
|
||||
_ <- if (notifyJoex) joex.notifyAllNodes else ().pure[F]
|
||||
} yield UpdateResult.success
|
||||
|
||||
private def onSuccessIgnoreError(update: F[Unit])(ar: UpdateResult): F[Unit] =
|
||||
ar match {
|
||||
case UpdateResult.Success =>
|
||||
|
@ -36,6 +36,13 @@ trait OItemSearch[F[_]] {
|
||||
collective: Ident
|
||||
): F[Option[AttachmentArchiveData[F]]]
|
||||
|
||||
def findAttachmentPreview(
|
||||
id: Ident,
|
||||
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]]
|
||||
@ -82,6 +89,15 @@ object OItemSearch {
|
||||
val fileId = rs.fileId
|
||||
}
|
||||
|
||||
case class AttachmentPreviewData[F[_]](
|
||||
rs: RAttachmentPreview,
|
||||
meta: FileMeta,
|
||||
data: Stream[F, Byte]
|
||||
) extends BinaryData[F] {
|
||||
val name = rs.name
|
||||
val fileId = rs.fileId
|
||||
}
|
||||
|
||||
case class AttachmentArchiveData[F[_]](
|
||||
rs: RAttachmentArchive,
|
||||
meta: FileMeta,
|
||||
@ -154,6 +170,46 @@ object OItemSearch {
|
||||
(None: Option[AttachmentSourceData[F]]).pure[F]
|
||||
})
|
||||
|
||||
def findAttachmentPreview(
|
||||
id: Ident,
|
||||
collective: Ident
|
||||
): F[Option[AttachmentPreviewData[F]]] =
|
||||
store
|
||||
.transact(RAttachmentPreview.findByIdAndCollective(id, 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 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
|
||||
|
@ -0,0 +1,26 @@
|
||||
package docspell.common
|
||||
|
||||
import io.circe.generic.semiauto._
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
/** Arguments for the `AllPreviewsTask` that submits tasks to
|
||||
* generates a preview image for attachments.
|
||||
*
|
||||
* It can replace the current preview image or only generate one, if
|
||||
* it is missing. If no collective is specified, it considers all
|
||||
* attachments.
|
||||
*/
|
||||
case class AllPreviewsArgs(
|
||||
collective: Option[Ident],
|
||||
storeMode: MakePreviewArgs.StoreMode
|
||||
)
|
||||
|
||||
object AllPreviewsArgs {
|
||||
|
||||
val taskName = Ident.unsafe("all-previews")
|
||||
|
||||
implicit val jsonEncoder: Encoder[AllPreviewsArgs] =
|
||||
deriveEncoder[AllPreviewsArgs]
|
||||
implicit val jsonDecoder: Decoder[AllPreviewsArgs] =
|
||||
deriveDecoder[AllPreviewsArgs]
|
||||
}
|
@ -2,8 +2,8 @@ package docspell.common
|
||||
|
||||
object DocspellSystem {
|
||||
|
||||
val user = Ident.unsafe("docspell-system")
|
||||
val taskGroup = user
|
||||
val migrationTaskTracker = Ident.unsafe("full-text-index-tracker")
|
||||
|
||||
val user = Ident.unsafe("docspell-system")
|
||||
val taskGroup = user
|
||||
val migrationTaskTracker = Ident.unsafe("full-text-index-tracker")
|
||||
val allPreviewTaskTracker = Ident.unsafe("generate-all-previews")
|
||||
}
|
||||
|
48
modules/common/src/main/scala/docspell/common/FileName.scala
Normal file
48
modules/common/src/main/scala/docspell/common/FileName.scala
Normal file
@ -0,0 +1,48 @@
|
||||
package docspell.common
|
||||
|
||||
case class FileName private (name: String) {
|
||||
|
||||
private[this] val (base, ext) =
|
||||
name.lastIndexOf('.') match {
|
||||
case -1 => (name, None)
|
||||
case n => (name.take(n), Some(name.drop(n + 1)))
|
||||
}
|
||||
|
||||
/** Returns the name part without the extension. If there is no
|
||||
* extension, it is the same as fullname.
|
||||
*/
|
||||
def baseName: String =
|
||||
base
|
||||
|
||||
/** Returns the extension part if available without the dot. */
|
||||
def extension: Option[String] =
|
||||
ext
|
||||
|
||||
def fullName: String =
|
||||
name
|
||||
|
||||
/** Creates a new name where part is spliced into the name before the
|
||||
* extension, separated by separator.
|
||||
*/
|
||||
def withPart(part: String, sep: Char): FileName =
|
||||
if (part.isEmpty()) this
|
||||
else
|
||||
ext
|
||||
.map(e => new FileName(s"${base}${sep}${part}.${e}"))
|
||||
.getOrElse(new FileName(s"${base}${sep}${part}"))
|
||||
|
||||
/** Create a new name using the given extension. */
|
||||
def withExtension(newExt: String): FileName =
|
||||
if (newExt.isEmpty()) new FileName(base)
|
||||
else new FileName(s"${base}.${newExt}")
|
||||
|
||||
}
|
||||
object FileName {
|
||||
|
||||
def apply(name: String): FileName =
|
||||
Option(name)
|
||||
.map(_.trim)
|
||||
.filter(_.nonEmpty)
|
||||
.map(n => new FileName(n))
|
||||
.getOrElse(new FileName("unknown-file"))
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
package docspell.common
|
||||
|
||||
import io.circe.generic.semiauto._
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
/** Arguments for the `MakePreviewTask` that generates a preview image
|
||||
* for an attachment.
|
||||
*
|
||||
* It can replace the current preview image or only generate one, if
|
||||
* it is missing.
|
||||
*/
|
||||
case class MakePreviewArgs(
|
||||
attachment: Ident,
|
||||
store: MakePreviewArgs.StoreMode
|
||||
)
|
||||
|
||||
object MakePreviewArgs {
|
||||
|
||||
val taskName = Ident.unsafe("make-preview")
|
||||
|
||||
def replace(attach: Ident): MakePreviewArgs =
|
||||
MakePreviewArgs(attach, StoreMode.Replace)
|
||||
|
||||
def whenMissing(attach: Ident): MakePreviewArgs =
|
||||
MakePreviewArgs(attach, StoreMode.WhenMissing)
|
||||
|
||||
sealed trait StoreMode extends Product {
|
||||
final def name: String =
|
||||
productPrefix.toLowerCase()
|
||||
}
|
||||
object StoreMode {
|
||||
|
||||
/** Replace any preview file that may already exist. */
|
||||
case object Replace extends StoreMode
|
||||
|
||||
/** Only create a preview image, if it is missing. */
|
||||
case object WhenMissing extends StoreMode
|
||||
|
||||
def fromString(str: String): Either[String, StoreMode] =
|
||||
Option(str).map(_.trim.toLowerCase()) match {
|
||||
case Some("replace") => Right(Replace)
|
||||
case Some("whenmissing") => Right(WhenMissing)
|
||||
case _ => Left(s"Invalid store mode: $str")
|
||||
}
|
||||
|
||||
implicit val jsonEncoder: Encoder[StoreMode] =
|
||||
Encoder.encodeString.contramap(_.name)
|
||||
|
||||
implicit val jsonDecoder: Decoder[StoreMode] =
|
||||
Decoder.decodeString.emap(fromString)
|
||||
}
|
||||
|
||||
implicit val jsonEncoder: Encoder[MakePreviewArgs] =
|
||||
deriveEncoder[MakePreviewArgs]
|
||||
|
||||
implicit val jsonDecoder: Decoder[MakePreviewArgs] =
|
||||
deriveDecoder[MakePreviewArgs]
|
||||
|
||||
}
|
@ -0,0 +1,58 @@
|
||||
package docspell.common
|
||||
|
||||
import minitest._
|
||||
|
||||
object FileNameTest extends SimpleTestSuite {
|
||||
|
||||
test("make filename") {
|
||||
val data = List(
|
||||
(FileName("test"), "test", None),
|
||||
(FileName("test.pdf"), "test", Some("pdf")),
|
||||
(FileName("bla.xml.gz"), "bla.xml", Some("gz")),
|
||||
(FileName(""), "unknown-file", None)
|
||||
)
|
||||
|
||||
data.foreach { case (fn, base, ext) =>
|
||||
assertEquals(fn.baseName, base)
|
||||
assertEquals(fn.extension, ext)
|
||||
}
|
||||
}
|
||||
|
||||
test("with part") {
|
||||
assertEquals(
|
||||
FileName("test.pdf").withPart("converted", '_'),
|
||||
FileName("test_converted.pdf")
|
||||
)
|
||||
assertEquals(
|
||||
FileName("bla.xml.gz").withPart("converted", '_'),
|
||||
FileName("bla.xml_converted.gz")
|
||||
)
|
||||
assertEquals(
|
||||
FileName("test").withPart("converted", '_'),
|
||||
FileName("test_converted")
|
||||
)
|
||||
assertEquals(
|
||||
FileName("test").withPart("", '_'),
|
||||
FileName("test")
|
||||
)
|
||||
}
|
||||
|
||||
test("with extension") {
|
||||
assertEquals(
|
||||
FileName("test.pdf").withExtension("xml"),
|
||||
FileName("test.xml")
|
||||
)
|
||||
assertEquals(
|
||||
FileName("test").withExtension("xml"),
|
||||
FileName("test.xml")
|
||||
)
|
||||
assertEquals(
|
||||
FileName("test.pdf.gz").withExtension("xml"),
|
||||
FileName("test.pdf.xml")
|
||||
)
|
||||
assertEquals(
|
||||
FileName("test.pdf.gz").withExtension(""),
|
||||
FileName("test.pdf")
|
||||
)
|
||||
}
|
||||
}
|
@ -6,6 +6,7 @@ import docspell.convert.flexmark.MarkdownConfig
|
||||
|
||||
case class ConvertConfig(
|
||||
chunkSize: Int,
|
||||
convertedFilenamePart: String,
|
||||
maxImageSize: Int,
|
||||
markdown: MarkdownConfig,
|
||||
wkhtmlpdf: WkHtmlPdfConfig,
|
||||
|
@ -23,6 +23,7 @@ object ConversionTest extends SimpleTestSuite with FileChecks {
|
||||
|
||||
val convertConfig = ConvertConfig(
|
||||
8192,
|
||||
"converted",
|
||||
3000 * 3000,
|
||||
MarkdownConfig("body { padding: 2em 5em; }"),
|
||||
WkHtmlPdfConfig(
|
||||
|
@ -1,5 +1,6 @@
|
||||
package docspell.extract
|
||||
|
||||
import docspell.extract.ocr.OcrConfig
|
||||
import docspell.extract.pdfbox.PreviewConfig
|
||||
|
||||
case class ExtractConfig(ocr: OcrConfig, pdf: PdfConfig)
|
||||
case class ExtractConfig(ocr: OcrConfig, pdf: PdfConfig, preview: PreviewConfig)
|
||||
|
@ -0,0 +1,24 @@
|
||||
package docspell.extract.pdfbox
|
||||
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
import fs2.Stream
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument
|
||||
|
||||
object PdfLoader {
|
||||
|
||||
private def readBytes1[F[_]: Sync](bytes: Array[Byte]): F[PDDocument] =
|
||||
Sync[F].delay(PDDocument.load(bytes))
|
||||
|
||||
private def closePDDocument[F[_]: Sync](pd: PDDocument): F[Unit] =
|
||||
Sync[F].delay(pd.close())
|
||||
|
||||
def withDocumentBytes[F[_]: Sync, A](pdf: Array[Byte])(f: PDDocument => F[A]): F[A] =
|
||||
Sync[F].bracket(readBytes1(pdf))(f)(pd => closePDDocument(pd))
|
||||
|
||||
def withDocumentStream[F[_]: Sync, A](pdf: Stream[F, Byte])(
|
||||
f: PDDocument => F[A]
|
||||
): F[A] =
|
||||
pdf.compile.to(Array).flatMap(bytes => withDocumentBytes(bytes)(f))
|
||||
}
|
@ -0,0 +1,56 @@
|
||||
package docspell.extract.pdfbox
|
||||
|
||||
import java.awt.image.BufferedImage
|
||||
import java.awt.image.RenderedImage
|
||||
import javax.imageio.ImageIO
|
||||
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
import fs2.Chunk
|
||||
import fs2.Stream
|
||||
|
||||
import org.apache.commons.io.output.ByteArrayOutputStream
|
||||
import org.apache.pdfbox.pdmodel.PDDocument
|
||||
import org.apache.pdfbox.rendering.PDFRenderer
|
||||
|
||||
trait PdfboxPreview[F[_]] {
|
||||
|
||||
def previewPNG(pdf: Stream[F, Byte]): F[Option[Stream[F, Byte]]]
|
||||
|
||||
}
|
||||
|
||||
object PdfboxPreview {
|
||||
|
||||
def apply[F[_]: Sync](cfg: PreviewConfig): F[PdfboxPreview[F]] =
|
||||
Sync[F].pure(new PdfboxPreview[F] {
|
||||
|
||||
def previewImage(pdf: Stream[F, Byte]): F[Option[BufferedImage]] =
|
||||
PdfLoader.withDocumentStream(pdf)(doc =>
|
||||
Sync[F].delay(getPageImage(doc, 0, cfg.dpi))
|
||||
)
|
||||
|
||||
def previewPNG(pdf: Stream[F, Byte]): F[Option[Stream[F, Byte]]] =
|
||||
previewImage(pdf).map(_.map(pngStream[F]))
|
||||
|
||||
})
|
||||
|
||||
private def getPageImage(
|
||||
pdoc: PDDocument,
|
||||
page: Int,
|
||||
dpi: Float
|
||||
): Option[BufferedImage] = {
|
||||
val count = pdoc.getNumberOfPages
|
||||
if (count <= 0 || page < 0 || count <= page) None
|
||||
else {
|
||||
val renderer = new PDFRenderer(pdoc)
|
||||
Option(renderer.renderImageWithDPI(page, dpi))
|
||||
}
|
||||
}
|
||||
|
||||
private def pngStream[F[_]](img: RenderedImage): Stream[F, Byte] = {
|
||||
val out = new ByteArrayOutputStream()
|
||||
ImageIO.write(img, "PNG", out)
|
||||
Stream.chunk(Chunk.bytes(out.toByteArray()))
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
package docspell.extract.pdfbox
|
||||
|
||||
case class PreviewConfig(dpi: Float)
|
@ -0,0 +1,46 @@
|
||||
package docspell.extract.pdfbox
|
||||
|
||||
import cats.effect._
|
||||
import docspell.files.{ExampleFiles, TestFiles}
|
||||
import minitest.SimpleTestSuite
|
||||
import java.nio.file.Path
|
||||
import fs2.Stream
|
||||
|
||||
object PdfboxPreviewTest extends SimpleTestSuite {
|
||||
val blocker = TestFiles.blocker
|
||||
implicit val CS = TestFiles.CS
|
||||
|
||||
val testPDFs = List(
|
||||
ExampleFiles.letter_de_pdf -> "83bdb379fe9ce86e830adfbe11238808bed9da6e31c1b66687d70b6b59a0d815",
|
||||
ExampleFiles.letter_en_pdf -> "699655a162c0c21dd9f19d8638f4e03811c6626a52bb30a1ac733d7fa5638932",
|
||||
ExampleFiles.scanner_pdf13_pdf -> "a1680b80b42d8e04365ffd1e806ea2a8adb0492104cc41d8b40435b0fe4d4e65"
|
||||
)
|
||||
|
||||
test("extract first page image from PDFs") {
|
||||
testPDFs.foreach { case (file, checksum) =>
|
||||
val data = file.readURL[IO](8192, blocker)
|
||||
val sha256out =
|
||||
Stream
|
||||
.eval(PdfboxPreview[IO](PreviewConfig(48)))
|
||||
.evalMap(_.previewPNG(data))
|
||||
.flatMap(_.get)
|
||||
.through(fs2.hash.sha256)
|
||||
.chunks
|
||||
.map(_.toByteVector)
|
||||
.fold1(_ ++ _)
|
||||
.compile
|
||||
.lastOrError
|
||||
.map(_.toHex.toLowerCase)
|
||||
|
||||
assertEquals(sha256out.unsafeRunSync(), checksum)
|
||||
}
|
||||
}
|
||||
|
||||
def writeToFile(data: Stream[IO, Byte], file: Path): IO[Unit] =
|
||||
data
|
||||
.through(
|
||||
fs2.io.file.writeAll(file, blocker)
|
||||
)
|
||||
.compile
|
||||
.drain
|
||||
}
|
@ -172,6 +172,18 @@ docspell.joex {
|
||||
min-text-len = 500
|
||||
}
|
||||
|
||||
preview {
|
||||
# When rendering a pdf page, use this dpi. This results in
|
||||
# scaling the image. A standard A4 page rendered at 96dpi
|
||||
# results in roughly 790x1100px image. Using 32 results in
|
||||
# roughly 200x300px image.
|
||||
#
|
||||
# Note, when this is changed, you might want to re-generate
|
||||
# preview images. Check the api for this, there is an endpoint
|
||||
# to regenerate all for a collective.
|
||||
dpi = 32
|
||||
}
|
||||
|
||||
# Extracting text using OCR works for image and pdf files. It will
|
||||
# first run ghostscript to create a gray image from a pdf. Then
|
||||
# unpaper is run to optimize the image for the upcoming ocr, which
|
||||
@ -328,6 +340,11 @@ docspell.joex {
|
||||
# as used with the rest server.
|
||||
chunk-size = 524288
|
||||
|
||||
# A string used to change the filename of the converted pdf file.
|
||||
# If empty, the original file name is used for the pdf file ( the
|
||||
# extension is always replaced with `pdf`).
|
||||
converted-filename-part = "converted"
|
||||
|
||||
# When reading images, this is the maximum size. Images that are
|
||||
# larger are not processed.
|
||||
max-image-size = ${docspell.joex.extraction.ocr.max-image-size}
|
||||
|
@ -18,6 +18,7 @@ import docspell.joex.learn.LearnClassifierTask
|
||||
import docspell.joex.notify._
|
||||
import docspell.joex.pdfconv.ConvertAllPdfTask
|
||||
import docspell.joex.pdfconv.PdfConvTask
|
||||
import docspell.joex.preview._
|
||||
import docspell.joex.process.ItemHandler
|
||||
import docspell.joex.process.ReProcessItem
|
||||
import docspell.joex.scanmailbox._
|
||||
@ -68,7 +69,10 @@ final class JoexAppImpl[F[_]: ConcurrentEffect: ContextShift: Timer](
|
||||
HouseKeepingTask
|
||||
.periodicTask[F](cfg.houseKeeping.schedule)
|
||||
.flatMap(pstore.insert) *>
|
||||
MigrationTask.job.flatMap(queue.insertIfNew)
|
||||
MigrationTask.job.flatMap(queue.insertIfNew) *>
|
||||
AllPreviewsTask
|
||||
.job(MakePreviewArgs.StoreMode.WhenMissing, None)
|
||||
.flatMap(queue.insertIfNew)
|
||||
}
|
||||
|
||||
object JoexAppImpl {
|
||||
@ -167,6 +171,20 @@ object JoexAppImpl {
|
||||
LearnClassifierTask.onCancel[F]
|
||||
)
|
||||
)
|
||||
.withTask(
|
||||
JobTask.json(
|
||||
MakePreviewArgs.taskName,
|
||||
MakePreviewTask[F](cfg.convert, cfg.extraction.preview),
|
||||
MakePreviewTask.onCancel[F]
|
||||
)
|
||||
)
|
||||
.withTask(
|
||||
JobTask.json(
|
||||
AllPreviewsArgs.taskName,
|
||||
AllPreviewsTask[F](queue, joex),
|
||||
AllPreviewsTask.onCancel[F]
|
||||
)
|
||||
)
|
||||
.resource
|
||||
psch <- PeriodicScheduler.create(
|
||||
cfg.periodicScheduler,
|
||||
|
@ -0,0 +1,84 @@
|
||||
package docspell.joex.preview
|
||||
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
import fs2.{Chunk, Stream}
|
||||
|
||||
import docspell.backend.JobFactory
|
||||
import docspell.backend.ops.OJoex
|
||||
import docspell.common.MakePreviewArgs.StoreMode
|
||||
import docspell.common._
|
||||
import docspell.joex.scheduler.Context
|
||||
import docspell.joex.scheduler.Task
|
||||
import docspell.store.queue.JobQueue
|
||||
import docspell.store.records.RAttachment
|
||||
import docspell.store.records.RJob
|
||||
|
||||
object AllPreviewsTask {
|
||||
|
||||
type Args = AllPreviewsArgs
|
||||
|
||||
def apply[F[_]: Sync](queue: JobQueue[F], joex: OJoex[F]): Task[F, Args, Unit] =
|
||||
Task { ctx =>
|
||||
for {
|
||||
_ <- ctx.logger.info("Generating previews for attachments")
|
||||
n <- submitConversionJobs(ctx, queue)
|
||||
_ <- ctx.logger.info(s"Submitted $n jobs")
|
||||
_ <- joex.notifyAllNodes
|
||||
} yield ()
|
||||
}
|
||||
|
||||
def onCancel[F[_]: Sync]: Task[F, Args, Unit] =
|
||||
Task.log(_.warn("Cancelling all-previews task"))
|
||||
|
||||
def submitConversionJobs[F[_]: Sync](
|
||||
ctx: Context[F, Args],
|
||||
queue: JobQueue[F]
|
||||
): F[Int] =
|
||||
ctx.store
|
||||
.transact(findAttachments(ctx))
|
||||
.chunks
|
||||
.flatMap(createJobs[F](ctx))
|
||||
.chunks
|
||||
.evalMap(jobs => queue.insertAllIfNew(jobs.toVector).map(_ => jobs.size))
|
||||
.evalTap(n => ctx.logger.debug(s"Submitted $n jobs …"))
|
||||
.compile
|
||||
.foldMonoid
|
||||
|
||||
private def findAttachments[F[_]](ctx: Context[F, Args]) =
|
||||
ctx.args.storeMode match {
|
||||
case StoreMode.Replace =>
|
||||
RAttachment.findAll(ctx.args.collective, 50)
|
||||
case StoreMode.WhenMissing =>
|
||||
RAttachment.findWithoutPreview(ctx.args.collective, 50)
|
||||
}
|
||||
|
||||
private def createJobs[F[_]: Sync](
|
||||
ctx: Context[F, Args]
|
||||
)(ras: Chunk[RAttachment]): Stream[F, RJob] = {
|
||||
val collectiveOrSystem = ctx.args.collective.getOrElse(DocspellSystem.taskGroup)
|
||||
|
||||
def mkJob(ra: RAttachment): F[RJob] =
|
||||
for {
|
||||
id <- Ident.randomId[F]
|
||||
now <- Timestamp.current[F]
|
||||
} yield RJob.newJob(
|
||||
id,
|
||||
MakePreviewArgs.taskName,
|
||||
collectiveOrSystem,
|
||||
MakePreviewArgs(ra.id, ctx.args.storeMode),
|
||||
s"Create preview ${ra.id.id}/${ra.name.getOrElse("-")}",
|
||||
now,
|
||||
collectiveOrSystem,
|
||||
Priority.Low,
|
||||
Some(MakePreviewArgs.taskName / ra.id)
|
||||
)
|
||||
|
||||
val jobs = ras.traverse(mkJob)
|
||||
Stream.evalUnChunk(jobs)
|
||||
}
|
||||
|
||||
def job[F[_]: Sync](storeMode: MakePreviewArgs.StoreMode, cid: Option[Ident]): F[RJob] =
|
||||
JobFactory.allPreviews(AllPreviewsArgs(cid, storeMode), None)
|
||||
|
||||
}
|
@ -0,0 +1,61 @@
|
||||
package docspell.joex.preview
|
||||
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.common._
|
||||
import docspell.convert.ConvertConfig
|
||||
import docspell.extract.pdfbox.PdfboxPreview
|
||||
import docspell.extract.pdfbox.PreviewConfig
|
||||
import docspell.joex.process.AttachmentPreview
|
||||
import docspell.joex.scheduler.Context
|
||||
import docspell.joex.scheduler.Task
|
||||
import docspell.store.records.RAttachment
|
||||
import docspell.store.records.RAttachmentPreview
|
||||
|
||||
object MakePreviewTask {
|
||||
|
||||
type Args = MakePreviewArgs
|
||||
|
||||
def apply[F[_]: Sync](cfg: ConvertConfig, pcfg: PreviewConfig): Task[F, Args, Unit] =
|
||||
Task { ctx =>
|
||||
for {
|
||||
exists <- previewExists(ctx)
|
||||
preview <- PdfboxPreview(pcfg)
|
||||
_ <-
|
||||
if (exists)
|
||||
ctx.logger.info(
|
||||
s"Preview already exists for attachment ${ctx.args.attachment}. Skipping."
|
||||
)
|
||||
else
|
||||
ctx.logger.info(
|
||||
s"Generating preview image for attachment ${ctx.args.attachment}"
|
||||
) *> generatePreview(ctx, preview, cfg)
|
||||
} yield ()
|
||||
}
|
||||
|
||||
def onCancel[F[_]: Sync]: Task[F, Args, Unit] =
|
||||
Task.log(_.warn("Cancelling make-preview task"))
|
||||
|
||||
private def generatePreview[F[_]: Sync](
|
||||
ctx: Context[F, Args],
|
||||
preview: PdfboxPreview[F],
|
||||
cfg: ConvertConfig
|
||||
): F[Unit] =
|
||||
for {
|
||||
ra <- ctx.store.transact(RAttachment.findById(ctx.args.attachment))
|
||||
_ <- ra
|
||||
.map(AttachmentPreview.createPreview(ctx, preview, cfg.chunkSize))
|
||||
.getOrElse(
|
||||
ctx.logger.warn(s"No attachment found with id: ${ctx.args.attachment}")
|
||||
)
|
||||
} yield ()
|
||||
|
||||
private def previewExists[F[_]: Sync](ctx: Context[F, Args]): F[Boolean] =
|
||||
if (ctx.args.store == MakePreviewArgs.StoreMode.WhenMissing)
|
||||
ctx.store.transact(
|
||||
RAttachmentPreview.findById(ctx.args.attachment).map(_.isDefined)
|
||||
)
|
||||
else
|
||||
false.pure[F]
|
||||
}
|
@ -0,0 +1,106 @@
|
||||
package docspell.joex.process
|
||||
|
||||
import cats.Functor
|
||||
import cats.data.OptionT
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
import fs2.Stream
|
||||
|
||||
import docspell.common._
|
||||
import docspell.convert._
|
||||
import docspell.extract.pdfbox.PdfboxPreview
|
||||
import docspell.extract.pdfbox.PreviewConfig
|
||||
import docspell.joex.scheduler._
|
||||
import docspell.store.queries.QAttachment
|
||||
import docspell.store.records.RAttachment
|
||||
import docspell.store.records._
|
||||
import docspell.store.syntax.MimeTypes._
|
||||
|
||||
import bitpeace.{Mimetype, MimetypeHint, RangeDef}
|
||||
|
||||
/** Goes through all attachments that must be already converted into a
|
||||
* pdf. If it is a pdf, the first page is converted into a small
|
||||
* preview png image and linked to the attachment.
|
||||
*/
|
||||
object AttachmentPreview {
|
||||
|
||||
def apply[F[_]: Sync: ContextShift](cfg: ConvertConfig, pcfg: PreviewConfig)(
|
||||
item: ItemData
|
||||
): Task[F, ProcessItemArgs, ItemData] =
|
||||
Task { ctx =>
|
||||
for {
|
||||
_ <- ctx.logger.info(
|
||||
s"Creating preview images for ${item.attachments.size} files…"
|
||||
)
|
||||
preview <- PdfboxPreview(pcfg)
|
||||
_ <- item.attachments
|
||||
.traverse(createPreview(ctx, preview, cfg.chunkSize))
|
||||
.attempt
|
||||
.flatMap {
|
||||
case Right(_) => ().pure[F]
|
||||
case Left(ex) =>
|
||||
ctx.logger.error(ex)(
|
||||
s"Creating preview images failed, continuing without it."
|
||||
)
|
||||
}
|
||||
} yield item
|
||||
}
|
||||
|
||||
def createPreview[F[_]: Sync](
|
||||
ctx: Context[F, _],
|
||||
preview: PdfboxPreview[F],
|
||||
chunkSize: Int
|
||||
)(
|
||||
ra: RAttachment
|
||||
): F[Option[RAttachmentPreview]] =
|
||||
findMime[F](ctx)(ra).flatMap {
|
||||
case MimeType.PdfMatch(_) =>
|
||||
preview.previewPNG(loadFile(ctx)(ra)).flatMap {
|
||||
case Some(out) =>
|
||||
createRecord(ctx, out, ra, chunkSize).map(_.some)
|
||||
case None =>
|
||||
(None: Option[RAttachmentPreview]).pure[F]
|
||||
}
|
||||
|
||||
case _ =>
|
||||
(None: Option[RAttachmentPreview]).pure[F]
|
||||
}
|
||||
|
||||
private def createRecord[F[_]: Sync](
|
||||
ctx: Context[F, _],
|
||||
png: Stream[F, Byte],
|
||||
ra: RAttachment,
|
||||
chunkSize: Int
|
||||
): F[RAttachmentPreview] = {
|
||||
val name = ra.name
|
||||
.map(FileName.apply)
|
||||
.map(_.withPart("preview", '_').withExtension("png"))
|
||||
for {
|
||||
fileMeta <- ctx.store.bitpeace
|
||||
.saveNew(
|
||||
png,
|
||||
chunkSize,
|
||||
MimetypeHint(name.map(_.fullName), Some("image/png"))
|
||||
)
|
||||
.compile
|
||||
.lastOrError
|
||||
now <- Timestamp.current[F]
|
||||
rp = RAttachmentPreview(ra.id, Ident.unsafe(fileMeta.id), name.map(_.fullName), now)
|
||||
_ <- QAttachment.deletePreview(ctx.store)(ra.id)
|
||||
_ <- ctx.store.transact(RAttachmentPreview.insert(rp))
|
||||
} yield rp
|
||||
}
|
||||
|
||||
def findMime[F[_]: Functor](ctx: Context[F, _])(ra: RAttachment): F[MimeType] =
|
||||
OptionT(ctx.store.transact(RFileMeta.findById(ra.fileId)))
|
||||
.map(_.mimetype)
|
||||
.getOrElse(Mimetype.`application/octet-stream`)
|
||||
.map(_.toLocal)
|
||||
|
||||
def loadFile[F[_]](ctx: Context[F, _])(ra: RAttachment): Stream[F, Byte] =
|
||||
ctx.store.bitpeace
|
||||
.get(ra.fileId.id)
|
||||
.unNoneTerminate
|
||||
.through(ctx.store.bitpeace.fetchData2(RangeDef.all))
|
||||
|
||||
}
|
@ -135,7 +135,11 @@ object ConvertPdf {
|
||||
) = {
|
||||
val hint =
|
||||
MimeTypeHint.advertised(MimeType.pdf).withName(ra.name.getOrElse("file.pdf"))
|
||||
val newName = ra.name.map(n => s"$n.pdf")
|
||||
val newName =
|
||||
ra.name
|
||||
.map(FileName.apply)
|
||||
.map(_.withExtension("pdf").withPart(cfg.convertedFilenamePart, '.'))
|
||||
.map(_.fullName)
|
||||
ctx.store.bitpeace
|
||||
.saveNew(pdf, cfg.chunkSize, MimetypeHint(hint.filename, hint.advertised))
|
||||
.compile
|
||||
|
@ -54,6 +54,7 @@ object ProcessItem {
|
||||
ConvertPdf(cfg.convert, item)
|
||||
.flatMap(Task.setProgress(progress._1))
|
||||
.flatMap(TextExtraction(cfg.extraction, fts))
|
||||
.flatMap(AttachmentPreview(cfg.convert, cfg.extraction.preview))
|
||||
.flatMap(Task.setProgress(progress._2))
|
||||
.flatMap(analysisOnly[F](cfg, analyser, regexNer))
|
||||
.flatMap(Task.setProgress(progress._3))
|
||||
|
@ -1069,6 +1069,29 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicResult"
|
||||
|
||||
/sec/collective/previews:
|
||||
post:
|
||||
tags: [ Collective ]
|
||||
summary: Starts the generate previews task
|
||||
description: |
|
||||
Submits a task that re-generates preview images of all
|
||||
attachments of the current collective. Each existing preview
|
||||
image will be replaced.
|
||||
|
||||
This can be used after changing the `preview` settings.
|
||||
|
||||
If only preview images of selected attachments should be
|
||||
regenerated, see the `/sec/attachment/{id}/preview` endpoint.
|
||||
security:
|
||||
- authTokenHeader: []
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicResult"
|
||||
|
||||
/sec/user:
|
||||
get:
|
||||
tags: [ Collective ]
|
||||
@ -1847,6 +1870,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:
|
||||
@ -2446,6 +2510,63 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
/sec/attachment/{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 attachment is
|
||||
available. If not available, a 404 is returned.
|
||||
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 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.
|
||||
security:
|
||||
- authTokenHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/id"
|
||||
- $ref: "#/components/parameters/withFallback"
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
post:
|
||||
tags: [ Attachment ]
|
||||
summary: (Re)generate a preview image.
|
||||
description: |
|
||||
Submits a task that generates a preview image for this
|
||||
attachment. The existing preview will be replaced.
|
||||
security:
|
||||
- authTokenHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/id"
|
||||
responses:
|
||||
200:
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicResult"
|
||||
|
||||
/sec/attachment/{id}/meta:
|
||||
get:
|
||||
tags: [ Attachment ]
|
||||
@ -4822,3 +4943,10 @@ components:
|
||||
One of the available contact kinds.
|
||||
schema:
|
||||
type: string
|
||||
withFallback:
|
||||
name: withFallback
|
||||
in: query
|
||||
description: Whether to provide a fallback or not.
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
|
@ -0,0 +1,128 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="210mm"
|
||||
height="297mm"
|
||||
viewBox="0 0 210 297"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
|
||||
sodipodi:docname="no-preview.svg">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="0.7"
|
||||
inkscape:cx="638.19656"
|
||||
inkscape:cy="138.48596"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
inkscape:window-width="1896"
|
||||
inkscape:window-height="2101"
|
||||
inkscape:window-x="3844"
|
||||
inkscape:window-y="39"
|
||||
inkscape:window-maximized="0" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1">
|
||||
<rect
|
||||
style="opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1.50112426;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke fill markers"
|
||||
id="rect4518"
|
||||
width="209.3988"
|
||||
height="297.08929"
|
||||
x="0.37797618"
|
||||
y="0.28869045" />
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:4.23650599px;line-height:2;font-family:'DejaVu Sans';-inkscape-font-specification:'DejaVu Sans Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:center;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:middle;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.26478162"
|
||||
x="101.91397"
|
||||
y="163.31726"
|
||||
id="text4554"
|
||||
transform="scale(0.99565662,1.0043623)"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan4552"
|
||||
x="101.91397"
|
||||
y="163.31726"
|
||||
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:22.59469986px;line-height:2;font-family:'DejaVu Sans';-inkscape-font-specification:'DejaVu Sans Bold';text-align:center;text-anchor:middle;stroke-width:0.26478162">Preview</tspan><tspan
|
||||
sodipodi:role="line"
|
||||
x="101.91397"
|
||||
y="208.50665"
|
||||
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:22.59469986px;line-height:2;font-family:'DejaVu Sans';-inkscape-font-specification:'DejaVu Sans Bold';text-align:center;text-anchor:middle;stroke-width:0.26478162"
|
||||
id="tspan4556">not</tspan><tspan
|
||||
sodipodi:role="line"
|
||||
x="101.91397"
|
||||
y="253.69606"
|
||||
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:22.59469986px;line-height:2;font-family:'DejaVu Sans';-inkscape-font-specification:'DejaVu Sans Bold';text-align:center;text-anchor:middle;stroke-width:0.26478162"
|
||||
id="tspan4558">available</tspan></text>
|
||||
<path
|
||||
style="opacity:1;fill:#d7e3f4;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.64033973;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke fill markers"
|
||||
id="path4581"
|
||||
sodipodi:type="arc"
|
||||
sodipodi:cx="103.00598"
|
||||
sodipodi:cy="78.360054"
|
||||
sodipodi:rx="34.460411"
|
||||
sodipodi:ry="34.761723"
|
||||
sodipodi:start="0.34567468"
|
||||
sodipodi:end="0.34365009"
|
||||
sodipodi:open="true"
|
||||
d="M 135.42796,90.138421 A 34.460411,34.761723 0 0 1 91.34612,111.07148 34.460411,34.761723 0 0 1 70.572202,66.6148 34.460411,34.761723 0 0 1 114.63301,45.636742 a 34.460411,34.761723 0 0 1 20.81852,44.43544" />
|
||||
<rect
|
||||
style="opacity:1;fill:#000055;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.78938425;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke fill markers"
|
||||
id="rect4583"
|
||||
width="35.846756"
|
||||
height="4.1953807"
|
||||
x="84.785538"
|
||||
y="90.746422" />
|
||||
<path
|
||||
style="opacity:1;fill:#000055;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.78938425;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke fill markers"
|
||||
id="path4585"
|
||||
sodipodi:type="arc"
|
||||
sodipodi:cx="117.95863"
|
||||
sodipodi:cy="66.872711"
|
||||
sodipodi:rx="5.8424263"
|
||||
sodipodi:ry="5.8935103"
|
||||
sodipodi:start="0.34567468"
|
||||
sodipodi:end="0.34365009"
|
||||
sodipodi:open="true"
|
||||
d="m 123.45546,68.869618 a 5.8424263,5.8935103 0 0 1 -7.47364,3.548995 5.8424263,5.8935103 0 0 1 -3.52202,-7.537195 5.8424263,5.8935103 0 0 1 7.47008,-3.556625 5.8424263,5.8935103 0 0 1 3.52958,7.533595" />
|
||||
<path
|
||||
style="opacity:1;fill:#000055;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.78938425;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke fill markers"
|
||||
id="path4585-8"
|
||||
sodipodi:type="arc"
|
||||
sodipodi:cx="87.558212"
|
||||
sodipodi:cy="67.172394"
|
||||
sodipodi:rx="5.8424263"
|
||||
sodipodi:ry="5.8935103"
|
||||
sodipodi:start="0.34567468"
|
||||
sodipodi:end="0.34365009"
|
||||
sodipodi:open="true"
|
||||
d="m 93.055042,69.169301 a 5.8424263,5.8935103 0 0 1 -7.473645,3.548995 5.8424263,5.8935103 0 0 1 -3.522015,-7.537195 5.8424263,5.8935103 0 0 1 7.47008,-3.556625 5.8424263,5.8935103 0 0 1 3.529577,7.533595" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 6.3 KiB |
@ -33,7 +33,7 @@ object RestServer {
|
||||
"/api/info" -> routes.InfoRoutes(),
|
||||
"/api/v1/open/" -> openRoutes(cfg, restApp),
|
||||
"/api/v1/sec/" -> Authenticate(restApp.backend.login, cfg.auth) { token =>
|
||||
securedRoutes(cfg, restApp, token)
|
||||
securedRoutes(cfg, pools, restApp, token)
|
||||
},
|
||||
"/api/doc" -> templates.doc,
|
||||
"/app/assets" -> WebjarRoutes.appRoutes[F](pools.blocker),
|
||||
@ -57,8 +57,9 @@ object RestServer {
|
||||
)
|
||||
}.drain
|
||||
|
||||
def securedRoutes[F[_]: Effect](
|
||||
def securedRoutes[F[_]: Effect: ContextShift](
|
||||
cfg: Config,
|
||||
pools: Pools,
|
||||
restApp: RestApp[F],
|
||||
token: AuthToken
|
||||
): HttpRoutes[F] =
|
||||
@ -72,9 +73,9 @@ object RestServer {
|
||||
"user" -> UserRoutes(restApp.backend, token),
|
||||
"collective" -> CollectiveRoutes(restApp.backend, token),
|
||||
"queue" -> JobQueueRoutes(restApp.backend, token),
|
||||
"item" -> ItemRoutes(cfg, restApp.backend, token),
|
||||
"item" -> ItemRoutes(cfg, pools.blocker, restApp.backend, token),
|
||||
"items" -> ItemMultiRoutes(restApp.backend, token),
|
||||
"attachment" -> AttachmentRoutes(restApp.backend, token),
|
||||
"attachment" -> AttachmentRoutes(pools.blocker, restApp.backend, token),
|
||||
"upload" -> UploadRoutes.secured(restApp.backend, cfg, token),
|
||||
"checkfile" -> CheckFileRoutes.secured(restApp.backend, token),
|
||||
"email/send" -> MailSendRoutes(restApp.backend, token),
|
||||
|
@ -0,0 +1,67 @@
|
||||
package docspell.restserver.http4s
|
||||
|
||||
import cats.data.NonEmptyList
|
||||
import cats.data.OptionT
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.backend.ops._
|
||||
|
||||
import bitpeace.FileMeta
|
||||
import org.http4s._
|
||||
import org.http4s.circe.CirceEntityEncoder._
|
||||
import org.http4s.dsl.Http4sDsl
|
||||
import org.http4s.headers.ETag.EntityTag
|
||||
import org.http4s.headers._
|
||||
|
||||
object BinaryUtil {
|
||||
|
||||
def withResponseHeaders[F[_]: Sync](dsl: Http4sDsl[F], resp: F[Response[F]])(
|
||||
data: OItemSearch.BinaryData[F]
|
||||
): F[Response[F]] = {
|
||||
import dsl._
|
||||
|
||||
val mt = MediaType.unsafeParse(data.meta.mimetype.asString)
|
||||
val ctype = `Content-Type`(mt)
|
||||
val cntLen: Header = `Content-Length`.unsafeFromLong(data.meta.length)
|
||||
val eTag: Header = ETag(data.meta.checksum)
|
||||
val disp: Header =
|
||||
`Content-Disposition`("inline", Map("filename" -> data.name.getOrElse("")))
|
||||
|
||||
resp.map(r =>
|
||||
if (r.status == NotModified) r.withHeaders(ctype, eTag, disp)
|
||||
else r.withHeaders(ctype, cntLen, eTag, disp)
|
||||
)
|
||||
}
|
||||
|
||||
def makeByteResp[F[_]: Sync](
|
||||
dsl: Http4sDsl[F]
|
||||
)(data: OItemSearch.BinaryData[F]): F[Response[F]] = {
|
||||
import dsl._
|
||||
withResponseHeaders(dsl, Ok(data.data.take(data.meta.length)))(data)
|
||||
}
|
||||
|
||||
def matchETag[F[_]](
|
||||
fileData: Option[FileMeta],
|
||||
noneMatch: Option[NonEmptyList[EntityTag]]
|
||||
): Boolean =
|
||||
(fileData, noneMatch) match {
|
||||
case (Some(meta), Some(nm)) =>
|
||||
meta.checksum == nm.head.tag
|
||||
case _ =>
|
||||
false
|
||||
}
|
||||
|
||||
def noPreview[F[_]: Sync: ContextShift](
|
||||
blocker: Blocker,
|
||||
req: Option[Request[F]]
|
||||
): OptionT[F, Response[F]] =
|
||||
StaticFile.fromResource(
|
||||
name = "/docspell/restserver/no-preview.svg",
|
||||
blocker = blocker,
|
||||
req = req,
|
||||
preferGzipped = true,
|
||||
classloader = getClass.getClassLoader().some
|
||||
)
|
||||
|
||||
}
|
@ -29,4 +29,6 @@ object QueryParam {
|
||||
object ContactKindOpt extends OptionalQueryParamDecoderMatcher[ContactKind]("kind")
|
||||
|
||||
object QueryOpt extends OptionalQueryParamDecoderMatcher[QueryString]("q")
|
||||
|
||||
object WithFallback extends OptionalQueryParamDecoderMatcher[Boolean]("withFallback")
|
||||
}
|
||||
|
@ -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`())
|
||||
)
|
||||
)
|
||||
|
||||
}
|
||||
|
@ -1,6 +1,5 @@
|
||||
package docspell.restserver.routes
|
||||
|
||||
import cats.data.NonEmptyList
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
@ -8,42 +7,36 @@ import docspell.backend.BackendApp
|
||||
import docspell.backend.auth.AuthToken
|
||||
import docspell.backend.ops._
|
||||
import docspell.common.Ident
|
||||
import docspell.common.MakePreviewArgs
|
||||
import docspell.restapi.model._
|
||||
import docspell.restserver.conv.Conversions
|
||||
import docspell.restserver.http4s.BinaryUtil
|
||||
import docspell.restserver.http4s.{QueryParam => QP}
|
||||
import docspell.restserver.webapp.Webjars
|
||||
|
||||
import bitpeace.FileMeta
|
||||
import org.http4s._
|
||||
import org.http4s.circe.CirceEntityDecoder._
|
||||
import org.http4s.circe.CirceEntityEncoder._
|
||||
import org.http4s.dsl.Http4sDsl
|
||||
import org.http4s.headers.ETag.EntityTag
|
||||
import org.http4s.headers._
|
||||
|
||||
object AttachmentRoutes {
|
||||
|
||||
def apply[F[_]: Effect](backend: BackendApp[F], user: AuthToken): HttpRoutes[F] = {
|
||||
def apply[F[_]: Effect: ContextShift](
|
||||
blocker: Blocker,
|
||||
backend: BackendApp[F],
|
||||
user: AuthToken
|
||||
): HttpRoutes[F] = {
|
||||
val dsl = new Http4sDsl[F] {}
|
||||
import dsl._
|
||||
|
||||
def withResponseHeaders(
|
||||
resp: F[Response[F]]
|
||||
)(data: OItemSearch.BinaryData[F]): F[Response[F]] = {
|
||||
val mt = MediaType.unsafeParse(data.meta.mimetype.asString)
|
||||
val ctype = `Content-Type`(mt)
|
||||
val cntLen: Header = `Content-Length`.unsafeFromLong(data.meta.length)
|
||||
val eTag: Header = ETag(data.meta.checksum)
|
||||
val disp: Header =
|
||||
`Content-Disposition`("inline", Map("filename" -> data.name.getOrElse("")))
|
||||
|
||||
resp.map(r =>
|
||||
if (r.status == NotModified) r.withHeaders(ctype, eTag, disp)
|
||||
else r.withHeaders(ctype, cntLen, eTag, disp)
|
||||
)
|
||||
}
|
||||
def withResponseHeaders(resp: F[Response[F]])(
|
||||
data: OItemSearch.BinaryData[F]
|
||||
): F[Response[F]] =
|
||||
BinaryUtil.withResponseHeaders[F](dsl, resp)(data)
|
||||
|
||||
def makeByteResp(data: OItemSearch.BinaryData[F]): F[Response[F]] =
|
||||
withResponseHeaders(Ok(data.data.take(data.meta.length)))(data)
|
||||
BinaryUtil.makeByteResp(dsl)(data)
|
||||
|
||||
HttpRoutes.of {
|
||||
case HEAD -> Root / Ident(id) =>
|
||||
@ -59,7 +52,7 @@ object AttachmentRoutes {
|
||||
for {
|
||||
fileData <- backend.itemSearch.findAttachment(id, user.account.collective)
|
||||
inm = req.headers.get(`If-None-Match`).flatMap(_.tags)
|
||||
matches = matchETag(fileData.map(_.meta), inm)
|
||||
matches = BinaryUtil.matchETag(fileData.map(_.meta), inm)
|
||||
resp <-
|
||||
fileData
|
||||
.map { data =>
|
||||
@ -82,7 +75,7 @@ object AttachmentRoutes {
|
||||
for {
|
||||
fileData <- backend.itemSearch.findAttachmentSource(id, user.account.collective)
|
||||
inm = req.headers.get(`If-None-Match`).flatMap(_.tags)
|
||||
matches = matchETag(fileData.map(_.meta), inm)
|
||||
matches = BinaryUtil.matchETag(fileData.map(_.meta), inm)
|
||||
resp <-
|
||||
fileData
|
||||
.map { data =>
|
||||
@ -107,7 +100,7 @@ object AttachmentRoutes {
|
||||
fileData <-
|
||||
backend.itemSearch.findAttachmentArchive(id, user.account.collective)
|
||||
inm = req.headers.get(`If-None-Match`).flatMap(_.tags)
|
||||
matches = matchETag(fileData.map(_.meta), inm)
|
||||
matches = BinaryUtil.matchETag(fileData.map(_.meta), inm)
|
||||
resp <-
|
||||
fileData
|
||||
.map { data =>
|
||||
@ -117,6 +110,49 @@ object AttachmentRoutes {
|
||||
.getOrElse(NotFound(BasicResult(false, "Not found")))
|
||||
} yield resp
|
||||
|
||||
case req @ GET -> Root / Ident(id) / "preview" :? QP.WithFallback(flag) =>
|
||||
def notFound =
|
||||
NotFound(BasicResult(false, "Not found"))
|
||||
for {
|
||||
fileData <-
|
||||
backend.itemSearch.findAttachmentPreview(id, user.account.collective)
|
||||
inm = req.headers.get(`If-None-Match`).flatMap(_.tags)
|
||||
matches = BinaryUtil.matchETag(fileData.map(_.meta), inm)
|
||||
fallback = flag.getOrElse(false)
|
||||
resp <-
|
||||
fileData
|
||||
.map { data =>
|
||||
if (matches) withResponseHeaders(NotModified())(data)
|
||||
else makeByteResp(data)
|
||||
}
|
||||
.getOrElse(
|
||||
if (fallback) BinaryUtil.noPreview(blocker, req.some).getOrElseF(notFound)
|
||||
else notFound
|
||||
)
|
||||
} yield resp
|
||||
|
||||
case HEAD -> Root / Ident(id) / "preview" =>
|
||||
for {
|
||||
fileData <-
|
||||
backend.itemSearch.findAttachmentPreview(id, user.account.collective)
|
||||
resp <-
|
||||
fileData
|
||||
.map(data => withResponseHeaders(Ok())(data))
|
||||
.getOrElse(NotFound(BasicResult(false, "Not found")))
|
||||
} yield resp
|
||||
|
||||
case POST -> Root / Ident(id) / "preview" =>
|
||||
for {
|
||||
res <- backend.item.generatePreview(
|
||||
MakePreviewArgs.replace(id),
|
||||
user.account,
|
||||
true
|
||||
)
|
||||
resp <- Ok(
|
||||
Conversions.basicResult(res, "Generating preview image task submitted.")
|
||||
)
|
||||
} yield resp
|
||||
|
||||
case GET -> Root / Ident(id) / "view" =>
|
||||
// this route exists to provide a stable url
|
||||
// it redirects currently to viewerjs
|
||||
@ -148,16 +184,4 @@ object AttachmentRoutes {
|
||||
} yield resp
|
||||
}
|
||||
}
|
||||
|
||||
private def matchETag[F[_]](
|
||||
fileData: Option[FileMeta],
|
||||
noneMatch: Option[NonEmptyList[EntityTag]]
|
||||
): Boolean =
|
||||
(fileData, noneMatch) match {
|
||||
case (Some(meta), Some(nm)) =>
|
||||
meta.checksum == nm.head.tag
|
||||
case _ =>
|
||||
false
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import cats.implicits._
|
||||
import docspell.backend.BackendApp
|
||||
import docspell.backend.auth.AuthToken
|
||||
import docspell.backend.ops.OCollective
|
||||
import docspell.common.MakePreviewArgs
|
||||
import docspell.restapi.model._
|
||||
import docspell.restserver.conv.Conversions
|
||||
import docspell.restserver.http4s._
|
||||
@ -94,6 +95,18 @@ object CollectiveRoutes {
|
||||
resp <- Ok(BasicResult(true, "Task submitted"))
|
||||
} yield resp
|
||||
|
||||
case POST -> Root / "previews" =>
|
||||
for {
|
||||
res <- backend.collective.generatePreviews(
|
||||
MakePreviewArgs.StoreMode.Replace,
|
||||
user.account,
|
||||
true
|
||||
)
|
||||
resp <- Ok(
|
||||
Conversions.basicResult(res, "Generate all previews task submitted.")
|
||||
)
|
||||
} yield resp
|
||||
|
||||
case GET -> Root =>
|
||||
for {
|
||||
collDb <- backend.collective.find(user.account.collective)
|
||||
|
@ -13,18 +13,23 @@ 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 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.log4s._
|
||||
|
||||
object ItemRoutes {
|
||||
private[this] val logger = getLogger
|
||||
|
||||
def apply[F[_]: Effect](
|
||||
def apply[F[_]: Effect: ContextShift](
|
||||
cfg: Config,
|
||||
blocker: Blocker,
|
||||
backend: BackendApp[F],
|
||||
user: AuthToken
|
||||
): HttpRoutes[F] = {
|
||||
@ -315,6 +320,35 @@ object ItemRoutes {
|
||||
resp <- Ok(Conversions.basicResult(res, "Attachment moved."))
|
||||
} yield resp
|
||||
|
||||
case req @ GET -> Root / Ident(id) / "preview" :? QP.WithFallback(flag) =>
|
||||
def notFound =
|
||||
NotFound(BasicResult(false, "Not found"))
|
||||
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)
|
||||
fallback = flag.getOrElse(false)
|
||||
resp <-
|
||||
preview
|
||||
.map { data =>
|
||||
if (matches) BinaryUtil.withResponseHeaders(dsl, NotModified())(data)
|
||||
else BinaryUtil.makeByteResp(dsl)(data).map(Responses.noCache)
|
||||
}
|
||||
.getOrElse(
|
||||
if (fallback) BinaryUtil.noPreview(blocker, req.some).getOrElseF(notFound)
|
||||
else notFound
|
||||
)
|
||||
} 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]
|
||||
|
@ -0,0 +1,8 @@
|
||||
CREATE TABLE "attachment_preview" (
|
||||
"id" varchar(254) not null primary key,
|
||||
"file_id" varchar(254) not null,
|
||||
"filename" varchar(254),
|
||||
"created" timestamp not null,
|
||||
foreign key ("file_id") references "filemeta"("id"),
|
||||
foreign key ("id") references "attachment"("attachid")
|
||||
);
|
@ -0,0 +1,8 @@
|
||||
CREATE TABLE `attachment_preview` (
|
||||
`id` varchar(254) not null primary key,
|
||||
`file_id` varchar(254) not null,
|
||||
`filename` varchar(254),
|
||||
`created` timestamp not null,
|
||||
foreign key (`file_id`) references `filemeta`(`id`),
|
||||
foreign key (`id`) references `attachment`(`attachid`)
|
||||
);
|
@ -0,0 +1,8 @@
|
||||
CREATE TABLE "attachment_preview" (
|
||||
"id" varchar(254) not null primary key,
|
||||
"file_id" varchar(254) not null,
|
||||
"filename" varchar(254),
|
||||
"created" timestamp not null,
|
||||
foreign key ("file_id") references "filemeta"("id"),
|
||||
foreign key ("id") references "attachment"("attachid")
|
||||
);
|
@ -17,6 +17,22 @@ import doobie.implicits._
|
||||
object QAttachment {
|
||||
private[this] val logger = org.log4s.getLogger
|
||||
|
||||
def deletePreview[F[_]: Sync](store: Store[F])(attachId: Ident): F[Int] = {
|
||||
val findPreview =
|
||||
for {
|
||||
rp <- RAttachmentPreview.findById(attachId)
|
||||
} yield rp.toSeq
|
||||
|
||||
Stream
|
||||
.evalSeq(store.transact(findPreview))
|
||||
.map(_.fileId.id)
|
||||
.evalTap(_ => store.transact(RAttachmentPreview.delete(attachId)))
|
||||
.flatMap(store.bitpeace.delete)
|
||||
.map(flag => if (flag) 1 else 0)
|
||||
.compile
|
||||
.foldMonoid
|
||||
}
|
||||
|
||||
/** Deletes an attachment, its related source and meta data records.
|
||||
* It will only delete an related archive file, if this is the last
|
||||
* attachment in that archive.
|
||||
@ -27,18 +43,19 @@ object QAttachment {
|
||||
val loadFiles = for {
|
||||
ra <- RAttachment.findByIdAndCollective(attachId, coll).map(_.map(_.fileId))
|
||||
rs <- RAttachmentSource.findByIdAndCollective(attachId, coll).map(_.map(_.fileId))
|
||||
rp <- RAttachmentPreview.findByIdAndCollective(attachId, coll).map(_.map(_.fileId))
|
||||
ne <- RAttachmentArchive.countEntries(attachId)
|
||||
} yield (ra, rs, ne)
|
||||
} yield (ra.toSeq ++ rs.toSeq ++ rp.toSeq, ne)
|
||||
|
||||
for {
|
||||
files <- store.transact(loadFiles)
|
||||
k <-
|
||||
if (files._3 == 1) deleteArchive(store)(attachId)
|
||||
if (files._2 == 1) deleteArchive(store)(attachId)
|
||||
else store.transact(RAttachmentArchive.delete(attachId))
|
||||
n <- store.transact(RAttachment.delete(attachId))
|
||||
f <-
|
||||
Stream
|
||||
.emits(files._1.toSeq ++ files._2.toSeq)
|
||||
.emits(files._1)
|
||||
.map(_.id)
|
||||
.flatMap(store.bitpeace.delete)
|
||||
.map(flag => if (flag) 1 else 0)
|
||||
@ -55,13 +72,14 @@ object QAttachment {
|
||||
for {
|
||||
_ <- logger.fdebug[F](s"Deleting attachment: ${ra.id.id}")
|
||||
s <- store.transact(RAttachmentSource.findById(ra.id))
|
||||
p <- store.transact(RAttachmentPreview.findById(ra.id))
|
||||
n <- store.transact(RAttachment.delete(ra.id))
|
||||
_ <- logger.fdebug[F](
|
||||
s"Deleted $n meta records (source, meta, archive). Deleting binaries now."
|
||||
s"Deleted $n meta records (source, meta, preview, archive). Deleting binaries now."
|
||||
)
|
||||
f <-
|
||||
Stream
|
||||
.emits(ra.fileId.id +: (s.map(_.fileId.id).toSeq))
|
||||
.emits(ra.fileId.id +: (s.map(_.fileId.id).toSeq ++ p.map(_.fileId.id).toSeq))
|
||||
.flatMap(store.bitpeace.delete)
|
||||
.map(flag => if (flag) 1 else 0)
|
||||
.compile
|
||||
|
@ -61,6 +61,9 @@ object QCollective {
|
||||
select a.file_id,m.length from attachment_source a
|
||||
inner join filemeta m on m.id = a.file_id where a.id in (select aid from attachs)
|
||||
union distinct
|
||||
select p.file_id,m.length from attachment_preview p
|
||||
inner join filemeta m on m.id = p.file_id where p.id in (select aid from attachs)
|
||||
union distinct
|
||||
select a.file_id,m.length from attachment_archive a
|
||||
inner join filemeta m on m.id = a.file_id where a.id in (select aid from attachs)
|
||||
) as t""".query[Option[Long]].unique
|
||||
|
@ -224,12 +224,69 @@ object RAttachment {
|
||||
for {
|
||||
n0 <- RAttachmentMeta.delete(attachId)
|
||||
n1 <- RAttachmentSource.delete(attachId)
|
||||
n2 <- deleteFrom(table, id.is(attachId)).update.run
|
||||
} yield n0 + n1 + n2
|
||||
n2 <- RAttachmentPreview.delete(attachId)
|
||||
n3 <- deleteFrom(table, id.is(attachId)).update.run
|
||||
} yield n0 + n1 + n2 + n3
|
||||
|
||||
def findItemId(attachId: Ident): ConnectionIO[Option[Ident]] =
|
||||
selectSimple(Seq(itemId), table, id.is(attachId)).query[Ident].option
|
||||
|
||||
def findAll(
|
||||
coll: Option[Ident],
|
||||
chunkSize: Int
|
||||
): Stream[ConnectionIO, RAttachment] = {
|
||||
val aItem = Columns.itemId.prefix("a")
|
||||
val iId = RItem.Columns.id.prefix("i")
|
||||
val iColl = RItem.Columns.cid.prefix("i")
|
||||
|
||||
val cols = all.map(_.prefix("a"))
|
||||
|
||||
coll match {
|
||||
case Some(cid) =>
|
||||
val join = table ++ fr"a INNER JOIN" ++ RItem.table ++ fr"i ON" ++ iId.is(aItem)
|
||||
val cond = iColl.is(cid)
|
||||
selectSimple(cols, join, cond)
|
||||
.query[RAttachment]
|
||||
.streamWithChunkSize(chunkSize)
|
||||
case None =>
|
||||
selectSimple(cols, table, Fragment.empty)
|
||||
.query[RAttachment]
|
||||
.streamWithChunkSize(chunkSize)
|
||||
}
|
||||
}
|
||||
|
||||
def findWithoutPreview(
|
||||
coll: Option[Ident],
|
||||
chunkSize: Int
|
||||
): Stream[ConnectionIO, RAttachment] = {
|
||||
val aId = Columns.id.prefix("a")
|
||||
val aItem = Columns.itemId.prefix("a")
|
||||
val pId = RAttachmentPreview.Columns.id.prefix("p")
|
||||
val iId = RItem.Columns.id.prefix("i")
|
||||
val iColl = RItem.Columns.cid.prefix("i")
|
||||
|
||||
val cols = all.map(_.prefix("a"))
|
||||
val baseJoin =
|
||||
table ++ fr"a LEFT OUTER JOIN" ++
|
||||
RAttachmentPreview.table ++ fr"p ON" ++ pId.is(aId)
|
||||
|
||||
val baseCond =
|
||||
Seq(pId.isNull)
|
||||
|
||||
coll match {
|
||||
case Some(cid) =>
|
||||
val join = baseJoin ++ fr"INNER JOIN" ++ RItem.table ++ fr"i ON" ++ iId.is(aItem)
|
||||
val cond = and(baseCond ++ Seq(iColl.is(cid)))
|
||||
selectSimple(cols, join, cond)
|
||||
.query[RAttachment]
|
||||
.streamWithChunkSize(chunkSize)
|
||||
case None =>
|
||||
selectSimple(cols, baseJoin, and(baseCond))
|
||||
.query[RAttachment]
|
||||
.streamWithChunkSize(chunkSize)
|
||||
}
|
||||
}
|
||||
|
||||
def findNonConvertedPdf(
|
||||
coll: Option[Ident],
|
||||
chunkSize: Int
|
||||
|
@ -0,0 +1,123 @@
|
||||
package docspell.store.records
|
||||
|
||||
import docspell.common._
|
||||
import docspell.store.impl.Implicits._
|
||||
import docspell.store.impl._
|
||||
|
||||
import bitpeace.FileMeta
|
||||
import doobie._
|
||||
import doobie.implicits._
|
||||
|
||||
/** A preview image of an attachment. The `id` is shared with the
|
||||
* attachment, to create a 1-1 (or 0..1-1) relationship.
|
||||
*/
|
||||
case class RAttachmentPreview(
|
||||
id: Ident, //same as RAttachment.id
|
||||
fileId: Ident,
|
||||
name: Option[String],
|
||||
created: Timestamp
|
||||
)
|
||||
|
||||
object RAttachmentPreview {
|
||||
|
||||
val table = fr"attachment_preview"
|
||||
|
||||
object Columns {
|
||||
val id = Column("id")
|
||||
val fileId = Column("file_id")
|
||||
val name = Column("filename")
|
||||
val created = Column("created")
|
||||
|
||||
val all = List(id, fileId, name, created)
|
||||
}
|
||||
|
||||
import Columns._
|
||||
|
||||
def insert(v: RAttachmentPreview): ConnectionIO[Int] =
|
||||
insertRow(table, all, fr"${v.id},${v.fileId},${v.name},${v.created}").update.run
|
||||
|
||||
def findById(attachId: Ident): ConnectionIO[Option[RAttachmentPreview]] =
|
||||
selectSimple(all, table, id.is(attachId)).query[RAttachmentPreview].option
|
||||
|
||||
def delete(attachId: Ident): ConnectionIO[Int] =
|
||||
deleteFrom(table, id.is(attachId)).update.run
|
||||
|
||||
def findByIdAndCollective(
|
||||
attachId: Ident,
|
||||
collective: Ident
|
||||
): ConnectionIO[Option[RAttachmentPreview]] = {
|
||||
val bId = RAttachment.Columns.id.prefix("b")
|
||||
val aId = Columns.id.prefix("a")
|
||||
val bItem = RAttachment.Columns.itemId.prefix("b")
|
||||
val iId = RItem.Columns.id.prefix("i")
|
||||
val iColl = RItem.Columns.cid.prefix("i")
|
||||
|
||||
val from = table ++ fr"a INNER JOIN" ++
|
||||
RAttachment.table ++ fr"b ON" ++ aId.is(bId) ++
|
||||
fr"INNER JOIN" ++ RItem.table ++ fr"i ON" ++ bItem.is(iId)
|
||||
|
||||
val where = and(aId.is(attachId), bId.is(attachId), iColl.is(collective))
|
||||
|
||||
selectSimple(all.map(_.prefix("a")), from, where).query[RAttachmentPreview].option
|
||||
}
|
||||
|
||||
def findByItem(itemId: Ident): ConnectionIO[Vector[RAttachmentPreview]] = {
|
||||
val sId = Columns.id.prefix("s")
|
||||
val aId = RAttachment.Columns.id.prefix("a")
|
||||
val aItem = RAttachment.Columns.itemId.prefix("a")
|
||||
|
||||
val from = table ++ fr"s INNER JOIN" ++ RAttachment.table ++ fr"a ON" ++ sId.is(aId)
|
||||
selectSimple(all.map(_.prefix("s")), from, aItem.is(itemId))
|
||||
.query[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)]] = {
|
||||
import bitpeace.sql._
|
||||
|
||||
val aId = Columns.id.prefix("a")
|
||||
val afileMeta = fileId.prefix("a")
|
||||
val bPos = RAttachment.Columns.position.prefix("b")
|
||||
val bId = RAttachment.Columns.id.prefix("b")
|
||||
val bItem = RAttachment.Columns.itemId.prefix("b")
|
||||
val mId = RFileMeta.Columns.id.prefix("m")
|
||||
|
||||
val cols = all.map(_.prefix("a")) ++ RFileMeta.Columns.all.map(_.prefix("m"))
|
||||
val from = table ++ fr"a INNER JOIN" ++
|
||||
RFileMeta.table ++ fr"m ON" ++ afileMeta.is(mId) ++ fr"INNER JOIN" ++
|
||||
RAttachment.table ++ fr"b ON" ++ aId.is(bId)
|
||||
val where = bItem.is(id)
|
||||
|
||||
(selectSimple(cols, from, where) ++ orderBy(bPos.asc))
|
||||
.query[(RAttachmentPreview, FileMeta)]
|
||||
.to[Vector]
|
||||
}
|
||||
|
||||
}
|
@ -30,6 +30,7 @@ module Api exposing
|
||||
, deleteSource
|
||||
, deleteTag
|
||||
, deleteUser
|
||||
, fileURL
|
||||
, getAttachmentMeta
|
||||
, getCollective
|
||||
, getCollectiveSettings
|
||||
@ -59,6 +60,7 @@ module Api exposing
|
||||
, getUsers
|
||||
, itemDetail
|
||||
, itemIndexSearch
|
||||
, itemPreviewURL
|
||||
, itemSearch
|
||||
, login
|
||||
, loginSession
|
||||
@ -1501,6 +1503,16 @@ deleteAllItems flags ids receive =
|
||||
--- Item
|
||||
|
||||
|
||||
itemPreviewURL : String -> String
|
||||
itemPreviewURL itemId =
|
||||
"/api/v1/sec/item/" ++ itemId ++ "/preview?withFallback=true"
|
||||
|
||||
|
||||
fileURL : String -> String
|
||||
fileURL attachId =
|
||||
"/api/v1/sec/attachment/" ++ attachId
|
||||
|
||||
|
||||
setAttachmentName :
|
||||
Flags
|
||||
-> String
|
||||
|
40
modules/webapp/src/main/elm/Comp/BasicSizeField.elm
Normal file
40
modules/webapp/src/main/elm/Comp/BasicSizeField.elm
Normal file
@ -0,0 +1,40 @@
|
||||
module Comp.BasicSizeField exposing (Msg, update, view)
|
||||
|
||||
import Data.BasicSize exposing (BasicSize)
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (onCheck)
|
||||
|
||||
|
||||
type Msg
|
||||
= Toggle BasicSize
|
||||
|
||||
|
||||
update : Msg -> Maybe BasicSize
|
||||
update msg =
|
||||
case msg of
|
||||
Toggle bs ->
|
||||
Just bs
|
||||
|
||||
|
||||
view : String -> BasicSize -> Html Msg
|
||||
view labelTxt current =
|
||||
div [ class "grouped fields" ]
|
||||
(label [] [ text labelTxt ]
|
||||
:: List.map (makeField current) Data.BasicSize.all
|
||||
)
|
||||
|
||||
|
||||
makeField : BasicSize -> BasicSize -> Html Msg
|
||||
makeField current element =
|
||||
div [ class "field" ]
|
||||
[ div [ class "ui radio checkbox" ]
|
||||
[ input
|
||||
[ type_ "radio"
|
||||
, checked (current == element)
|
||||
, onCheck (\_ -> Toggle element)
|
||||
]
|
||||
[]
|
||||
, label [] [ text (Data.BasicSize.label element) ]
|
||||
]
|
||||
]
|
@ -10,6 +10,7 @@ module Comp.ItemCardList exposing
|
||||
, view
|
||||
)
|
||||
|
||||
import Api
|
||||
import Api.Model.HighlightEntry exposing (HighlightEntry)
|
||||
import Api.Model.ItemLight exposing (ItemLight)
|
||||
import Api.Model.ItemLightGroup exposing (ItemLightGroup)
|
||||
@ -230,7 +231,19 @@ viewItem cfg settings item =
|
||||
]
|
||||
++ DD.draggable ItemDDMsg item.id
|
||||
)
|
||||
[ div [ class "content" ]
|
||||
[ if fieldHidden Data.Fields.PreviewImage then
|
||||
span [ class "invisible" ] []
|
||||
|
||||
else
|
||||
div [ class "image" ]
|
||||
[ img
|
||||
[ class "preview-image"
|
||||
, src (Api.itemPreviewURL item.id)
|
||||
, Data.UiSettings.cardPreviewSize settings
|
||||
]
|
||||
[]
|
||||
]
|
||||
, div [ class "content" ]
|
||||
[ case cfg.selection of
|
||||
Data.ItemSelection.Active ids ->
|
||||
div [ class "header" ]
|
||||
|
@ -1464,6 +1464,9 @@ resetField flags item tagger field =
|
||||
Data.Fields.Direction ->
|
||||
Cmd.none
|
||||
|
||||
Data.Fields.PreviewImage ->
|
||||
Cmd.none
|
||||
|
||||
|
||||
resetHiddenFields :
|
||||
UiSettings
|
||||
|
@ -1,5 +1,6 @@
|
||||
module Comp.ItemDetail.View exposing (view)
|
||||
|
||||
import Api
|
||||
import Api.Model.Attachment exposing (Attachment)
|
||||
import Comp.AttachmentMeta
|
||||
import Comp.DatePicker
|
||||
@ -320,7 +321,7 @@ renderAttachmentView : UiSettings -> Model -> Int -> Attachment -> Html Msg
|
||||
renderAttachmentView settings model pos attach =
|
||||
let
|
||||
fileUrl =
|
||||
"/api/v1/sec/attachment/" ++ attach.id
|
||||
Api.fileURL attach.id
|
||||
|
||||
attachName =
|
||||
Maybe.withDefault "No name" attach.name
|
||||
|
@ -8,9 +8,11 @@ module Comp.UiSettingsForm exposing
|
||||
|
||||
import Api
|
||||
import Api.Model.TagList exposing (TagList)
|
||||
import Comp.BasicSizeField
|
||||
import Comp.ColorTagger
|
||||
import Comp.FieldListSelect
|
||||
import Comp.IntField
|
||||
import Data.BasicSize exposing (BasicSize)
|
||||
import Data.Color exposing (Color)
|
||||
import Data.Fields exposing (Field)
|
||||
import Data.Flags exposing (Flags)
|
||||
@ -42,6 +44,7 @@ type alias Model =
|
||||
, itemDetailShortcuts : Bool
|
||||
, searchMenuVisible : Bool
|
||||
, editMenuVisible : Bool
|
||||
, cardPreviewSize : BasicSize
|
||||
}
|
||||
|
||||
|
||||
@ -93,6 +96,7 @@ init flags settings =
|
||||
, itemDetailShortcuts = settings.itemDetailShortcuts
|
||||
, searchMenuVisible = settings.searchMenuVisible
|
||||
, editMenuVisible = settings.editMenuVisible
|
||||
, cardPreviewSize = settings.cardPreviewSize
|
||||
}
|
||||
, Api.getTags flags "" GetTagsResp
|
||||
)
|
||||
@ -112,6 +116,7 @@ type Msg
|
||||
| ToggleItemDetailShortcuts
|
||||
| ToggleSearchMenuVisible
|
||||
| ToggleEditMenuVisible
|
||||
| CardPreviewSizeMsg Comp.BasicSizeField.Msg
|
||||
|
||||
|
||||
|
||||
@ -297,6 +302,23 @@ update sett msg model =
|
||||
, Just { sett | editMenuVisible = flag }
|
||||
)
|
||||
|
||||
CardPreviewSizeMsg lm ->
|
||||
let
|
||||
next =
|
||||
Comp.BasicSizeField.update lm
|
||||
|> Maybe.withDefault model.cardPreviewSize
|
||||
|
||||
newSettings =
|
||||
if next /= model.cardPreviewSize then
|
||||
Just { sett | cardPreviewSize = next }
|
||||
|
||||
else
|
||||
Nothing
|
||||
in
|
||||
( { model | cardPreviewSize = next }
|
||||
, newSettings
|
||||
)
|
||||
|
||||
|
||||
|
||||
--- View
|
||||
@ -329,6 +351,9 @@ view flags _ model =
|
||||
"field"
|
||||
model.searchPageSizeModel
|
||||
)
|
||||
, div [ class "ui dividing header" ]
|
||||
[ text "Item Cards"
|
||||
]
|
||||
, Html.map NoteLengthMsg
|
||||
(Comp.IntField.viewWithInfo
|
||||
("Maximum size of the item notes to display in card view. Between 0 - "
|
||||
@ -339,6 +364,11 @@ view flags _ model =
|
||||
"field"
|
||||
model.searchNoteLengthModel
|
||||
)
|
||||
, Html.map CardPreviewSizeMsg
|
||||
(Comp.BasicSizeField.view
|
||||
"Size of item preview"
|
||||
model.cardPreviewSize
|
||||
)
|
||||
, div [ class "ui dividing header" ]
|
||||
[ text "Search Menu" ]
|
||||
, div [ class "field" ]
|
||||
|
55
modules/webapp/src/main/elm/Data/BasicSize.elm
Normal file
55
modules/webapp/src/main/elm/Data/BasicSize.elm
Normal file
@ -0,0 +1,55 @@
|
||||
module Data.BasicSize exposing
|
||||
( BasicSize(..)
|
||||
, all
|
||||
, asString
|
||||
, fromString
|
||||
, label
|
||||
)
|
||||
|
||||
|
||||
type BasicSize
|
||||
= Small
|
||||
| Medium
|
||||
| Large
|
||||
|
||||
|
||||
all : List BasicSize
|
||||
all =
|
||||
[ Small
|
||||
, Medium
|
||||
, Large
|
||||
]
|
||||
|
||||
|
||||
fromString : String -> Maybe BasicSize
|
||||
fromString str =
|
||||
case String.toLower str of
|
||||
"small" ->
|
||||
Just Small
|
||||
|
||||
"medium" ->
|
||||
Just Medium
|
||||
|
||||
"large" ->
|
||||
Just Large
|
||||
|
||||
_ ->
|
||||
Nothing
|
||||
|
||||
|
||||
asString : BasicSize -> String
|
||||
asString size =
|
||||
label size |> String.toLower
|
||||
|
||||
|
||||
label : BasicSize -> String
|
||||
label size =
|
||||
case size of
|
||||
Small ->
|
||||
"Small"
|
||||
|
||||
Medium ->
|
||||
"Medium"
|
||||
|
||||
Large ->
|
||||
"Large"
|
@ -19,6 +19,7 @@ type Field
|
||||
| Date
|
||||
| DueDate
|
||||
| Direction
|
||||
| PreviewImage
|
||||
|
||||
|
||||
all : List Field
|
||||
@ -33,6 +34,7 @@ all =
|
||||
, Date
|
||||
, DueDate
|
||||
, Direction
|
||||
, PreviewImage
|
||||
]
|
||||
|
||||
|
||||
@ -71,6 +73,9 @@ fromString str =
|
||||
"direction" ->
|
||||
Just Direction
|
||||
|
||||
"preview" ->
|
||||
Just PreviewImage
|
||||
|
||||
_ ->
|
||||
Nothing
|
||||
|
||||
@ -105,6 +110,9 @@ toString field =
|
||||
Direction ->
|
||||
"direction"
|
||||
|
||||
PreviewImage ->
|
||||
"preview"
|
||||
|
||||
|
||||
label : Field -> String
|
||||
label field =
|
||||
@ -136,6 +144,9 @@ label field =
|
||||
Direction ->
|
||||
"Direction"
|
||||
|
||||
PreviewImage ->
|
||||
"Preview Image"
|
||||
|
||||
|
||||
fromList : List String -> List Field
|
||||
fromList strings =
|
||||
|
@ -2,6 +2,7 @@ module Data.UiSettings exposing
|
||||
( Pos(..)
|
||||
, StoredUiSettings
|
||||
, UiSettings
|
||||
, cardPreviewSize
|
||||
, catColor
|
||||
, catColorString
|
||||
, defaults
|
||||
@ -17,9 +18,12 @@ module Data.UiSettings exposing
|
||||
)
|
||||
|
||||
import Api.Model.Tag exposing (Tag)
|
||||
import Data.BasicSize exposing (BasicSize)
|
||||
import Data.Color exposing (Color)
|
||||
import Data.Fields exposing (Field)
|
||||
import Dict exposing (Dict)
|
||||
import Html exposing (Attribute)
|
||||
import Html.Attributes as HA
|
||||
|
||||
|
||||
{-| Settings for the web ui. All fields should be optional, since it
|
||||
@ -43,6 +47,7 @@ type alias StoredUiSettings =
|
||||
, itemDetailShortcuts : Bool
|
||||
, searchMenuVisible : Bool
|
||||
, editMenuVisible : Bool
|
||||
, cardPreviewSize : Maybe String
|
||||
}
|
||||
|
||||
|
||||
@ -66,6 +71,7 @@ type alias UiSettings =
|
||||
, itemDetailShortcuts : Bool
|
||||
, searchMenuVisible : Bool
|
||||
, editMenuVisible : Bool
|
||||
, cardPreviewSize : BasicSize
|
||||
}
|
||||
|
||||
|
||||
@ -111,6 +117,7 @@ defaults =
|
||||
, itemDetailShortcuts = False
|
||||
, searchMenuVisible = False
|
||||
, editMenuVisible = False
|
||||
, cardPreviewSize = Data.BasicSize.Medium
|
||||
}
|
||||
|
||||
|
||||
@ -146,6 +153,10 @@ merge given fallback =
|
||||
, itemDetailShortcuts = given.itemDetailShortcuts
|
||||
, searchMenuVisible = given.searchMenuVisible
|
||||
, editMenuVisible = given.editMenuVisible
|
||||
, cardPreviewSize =
|
||||
given.cardPreviewSize
|
||||
|> Maybe.andThen Data.BasicSize.fromString
|
||||
|> Maybe.withDefault fallback.cardPreviewSize
|
||||
}
|
||||
|
||||
|
||||
@ -172,6 +183,10 @@ toStoredUiSettings settings =
|
||||
, itemDetailShortcuts = settings.itemDetailShortcuts
|
||||
, searchMenuVisible = settings.searchMenuVisible
|
||||
, editMenuVisible = settings.editMenuVisible
|
||||
, cardPreviewSize =
|
||||
settings.cardPreviewSize
|
||||
|> Data.BasicSize.asString
|
||||
|> Just
|
||||
}
|
||||
|
||||
|
||||
@ -209,6 +224,19 @@ fieldHidden settings field =
|
||||
fieldVisible settings field |> not
|
||||
|
||||
|
||||
cardPreviewSize : UiSettings -> Attribute msg
|
||||
cardPreviewSize settings =
|
||||
case settings.cardPreviewSize of
|
||||
Data.BasicSize.Small ->
|
||||
HA.style "max-width" "80px"
|
||||
|
||||
Data.BasicSize.Medium ->
|
||||
HA.style "max-width" "160px"
|
||||
|
||||
Data.BasicSize.Large ->
|
||||
HA.style "max-width" "none"
|
||||
|
||||
|
||||
|
||||
--- Helpers
|
||||
|
||||
|
@ -93,6 +93,11 @@
|
||||
padding: 0.8em;
|
||||
}
|
||||
|
||||
.default-layout img.preview-image {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.default-layout .menu .item.active a.right-tab-icon-link {
|
||||
position: relative;
|
||||
right: -8px;
|
||||
|
29
tools/preview/regenerate-previews.sh
Executable file
29
tools/preview/regenerate-previews.sh
Executable file
@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# This script submits a job to regenerate all preview images. This may
|
||||
# be necessary if you change the dpi setting that affects the size of
|
||||
# the preview.
|
||||
|
||||
set -e
|
||||
|
||||
BASE_URL="${1:-http://localhost:7880}"
|
||||
LOGIN_URL="$BASE_URL/api/v1/open/auth/login"
|
||||
TRIGGER_URL="$BASE_URL/api/v1/sec/collective/previews"
|
||||
|
||||
echo "Login to trigger regenerating preview images."
|
||||
echo "Using url: $BASE_URL"
|
||||
echo -n "Account: "
|
||||
read USER
|
||||
echo -n "Password: "
|
||||
read -s PASS
|
||||
echo
|
||||
|
||||
auth=$(curl --fail -XPOST --silent --data-binary "{\"account\":\"$USER\", \"password\":\"$PASS\"}" "$LOGIN_URL")
|
||||
|
||||
if [ "$(echo $auth | jq .success)" == "true" ]; then
|
||||
echo "Login successful"
|
||||
auth_token=$(echo $auth | jq -r .token)
|
||||
curl --fail -XPOST -H "X-Docspell-Auth: $auth_token" "$TRIGGER_URL"
|
||||
else
|
||||
echo "Login failed."
|
||||
fi
|
Loading…
x
Reference in New Issue
Block a user