mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-03-28 17:55:06 +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(
|
def classify(
|
||||||
logger: Logger[F],
|
logger: Logger[F],
|
||||||
model: ClassifierModel,
|
model: ClassifierModel,
|
||||||
text: String
|
txt: String
|
||||||
): F[Option[String]] =
|
): F[Option[String]] =
|
||||||
Sync[F].delay {
|
Option(txt).map(_.trim).filter(_.nonEmpty) match {
|
||||||
val cls = ColumnDataClassifier.getClassifier(
|
case Some(text) =>
|
||||||
model.model.normalize().toAbsolutePath().toString()
|
Sync[F].delay {
|
||||||
)
|
val cls = ColumnDataClassifier.getClassifier(
|
||||||
val cat = cls.classOf(cls.makeDatumFromLine("\t\t" + normalisedText(text)))
|
model.model.normalize().toAbsolutePath().toString()
|
||||||
Option(cat)
|
)
|
||||||
|
val cat = cls.classOf(cls.makeDatumFromLine("\t\t" + normalisedText(text)))
|
||||||
|
Option(cat)
|
||||||
|
}
|
||||||
|
case None =>
|
||||||
|
(None: Option[String]).pure[F]
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- helpers
|
// --- helpers
|
||||||
|
@ -8,6 +8,45 @@ import docspell.store.records.RJob
|
|||||||
|
|
||||||
object JobFactory {
|
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](
|
def convertAllPdfs[F[_]: Sync](
|
||||||
collective: Option[Ident],
|
collective: Option[Ident],
|
||||||
account: AccountId,
|
account: AccountId,
|
||||||
|
@ -4,9 +4,11 @@ import cats.effect.{Effect, Resource}
|
|||||||
import cats.implicits._
|
import cats.implicits._
|
||||||
import fs2.Stream
|
import fs2.Stream
|
||||||
|
|
||||||
|
import docspell.backend.JobFactory
|
||||||
import docspell.backend.PasswordCrypt
|
import docspell.backend.PasswordCrypt
|
||||||
import docspell.backend.ops.OCollective._
|
import docspell.backend.ops.OCollective._
|
||||||
import docspell.common._
|
import docspell.common._
|
||||||
|
import docspell.store.UpdateResult
|
||||||
import docspell.store.queries.QCollective
|
import docspell.store.queries.QCollective
|
||||||
import docspell.store.queue.JobQueue
|
import docspell.store.queue.JobQueue
|
||||||
import docspell.store.records._
|
import docspell.store.records._
|
||||||
@ -51,6 +53,15 @@ trait OCollective[F[_]] {
|
|||||||
def findEnabledSource(sourceId: Ident): F[Option[RSource]]
|
def findEnabledSource(sourceId: Ident): F[Option[RSource]]
|
||||||
|
|
||||||
def startLearnClassifier(collective: Ident): F[Unit]
|
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 {
|
object OCollective {
|
||||||
@ -210,5 +221,20 @@ object OCollective {
|
|||||||
|
|
||||||
def findEnabledSource(sourceId: Ident): F[Option[RSource]] =
|
def findEnabledSource(sourceId: Ident): F[Option[RSource]] =
|
||||||
store.transact(RSource.findEnabled(sourceId))
|
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,
|
account: AccountId,
|
||||||
notifyJoex: Boolean
|
notifyJoex: Boolean
|
||||||
): F[UpdateResult]
|
): 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 {
|
object OItem {
|
||||||
@ -656,6 +665,17 @@ object OItem {
|
|||||||
_ <- if (notifyJoex) joex.notifyAllNodes else ().pure[F]
|
_ <- if (notifyJoex) joex.notifyAllNodes else ().pure[F]
|
||||||
} yield UpdateResult.success
|
} 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] =
|
private def onSuccessIgnoreError(update: F[Unit])(ar: UpdateResult): F[Unit] =
|
||||||
ar match {
|
ar match {
|
||||||
case UpdateResult.Success =>
|
case UpdateResult.Success =>
|
||||||
|
@ -36,6 +36,13 @@ trait OItemSearch[F[_]] {
|
|||||||
collective: Ident
|
collective: Ident
|
||||||
): F[Option[AttachmentArchiveData[F]]]
|
): 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 findAttachmentMeta(id: Ident, collective: Ident): F[Option[RAttachmentMeta]]
|
||||||
|
|
||||||
def findByFileCollective(checksum: String, collective: Ident): F[Vector[RItem]]
|
def findByFileCollective(checksum: String, collective: Ident): F[Vector[RItem]]
|
||||||
@ -82,6 +89,15 @@ object OItemSearch {
|
|||||||
val fileId = rs.fileId
|
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[_]](
|
case class AttachmentArchiveData[F[_]](
|
||||||
rs: RAttachmentArchive,
|
rs: RAttachmentArchive,
|
||||||
meta: FileMeta,
|
meta: FileMeta,
|
||||||
@ -154,6 +170,46 @@ object OItemSearch {
|
|||||||
(None: Option[AttachmentSourceData[F]]).pure[F]
|
(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(
|
def findAttachmentArchive(
|
||||||
id: Ident,
|
id: Ident,
|
||||||
collective: 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 {
|
object DocspellSystem {
|
||||||
|
|
||||||
val user = Ident.unsafe("docspell-system")
|
val user = Ident.unsafe("docspell-system")
|
||||||
val taskGroup = user
|
val taskGroup = user
|
||||||
val migrationTaskTracker = Ident.unsafe("full-text-index-tracker")
|
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(
|
case class ConvertConfig(
|
||||||
chunkSize: Int,
|
chunkSize: Int,
|
||||||
|
convertedFilenamePart: String,
|
||||||
maxImageSize: Int,
|
maxImageSize: Int,
|
||||||
markdown: MarkdownConfig,
|
markdown: MarkdownConfig,
|
||||||
wkhtmlpdf: WkHtmlPdfConfig,
|
wkhtmlpdf: WkHtmlPdfConfig,
|
||||||
|
@ -23,6 +23,7 @@ object ConversionTest extends SimpleTestSuite with FileChecks {
|
|||||||
|
|
||||||
val convertConfig = ConvertConfig(
|
val convertConfig = ConvertConfig(
|
||||||
8192,
|
8192,
|
||||||
|
"converted",
|
||||||
3000 * 3000,
|
3000 * 3000,
|
||||||
MarkdownConfig("body { padding: 2em 5em; }"),
|
MarkdownConfig("body { padding: 2em 5em; }"),
|
||||||
WkHtmlPdfConfig(
|
WkHtmlPdfConfig(
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package docspell.extract
|
package docspell.extract
|
||||||
|
|
||||||
import docspell.extract.ocr.OcrConfig
|
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
|
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
|
# Extracting text using OCR works for image and pdf files. It will
|
||||||
# first run ghostscript to create a gray image from a pdf. Then
|
# first run ghostscript to create a gray image from a pdf. Then
|
||||||
# unpaper is run to optimize the image for the upcoming ocr, which
|
# unpaper is run to optimize the image for the upcoming ocr, which
|
||||||
@ -328,6 +340,11 @@ docspell.joex {
|
|||||||
# as used with the rest server.
|
# as used with the rest server.
|
||||||
chunk-size = 524288
|
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
|
# When reading images, this is the maximum size. Images that are
|
||||||
# larger are not processed.
|
# larger are not processed.
|
||||||
max-image-size = ${docspell.joex.extraction.ocr.max-image-size}
|
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.notify._
|
||||||
import docspell.joex.pdfconv.ConvertAllPdfTask
|
import docspell.joex.pdfconv.ConvertAllPdfTask
|
||||||
import docspell.joex.pdfconv.PdfConvTask
|
import docspell.joex.pdfconv.PdfConvTask
|
||||||
|
import docspell.joex.preview._
|
||||||
import docspell.joex.process.ItemHandler
|
import docspell.joex.process.ItemHandler
|
||||||
import docspell.joex.process.ReProcessItem
|
import docspell.joex.process.ReProcessItem
|
||||||
import docspell.joex.scanmailbox._
|
import docspell.joex.scanmailbox._
|
||||||
@ -68,7 +69,10 @@ final class JoexAppImpl[F[_]: ConcurrentEffect: ContextShift: Timer](
|
|||||||
HouseKeepingTask
|
HouseKeepingTask
|
||||||
.periodicTask[F](cfg.houseKeeping.schedule)
|
.periodicTask[F](cfg.houseKeeping.schedule)
|
||||||
.flatMap(pstore.insert) *>
|
.flatMap(pstore.insert) *>
|
||||||
MigrationTask.job.flatMap(queue.insertIfNew)
|
MigrationTask.job.flatMap(queue.insertIfNew) *>
|
||||||
|
AllPreviewsTask
|
||||||
|
.job(MakePreviewArgs.StoreMode.WhenMissing, None)
|
||||||
|
.flatMap(queue.insertIfNew)
|
||||||
}
|
}
|
||||||
|
|
||||||
object JoexAppImpl {
|
object JoexAppImpl {
|
||||||
@ -167,6 +171,20 @@ object JoexAppImpl {
|
|||||||
LearnClassifierTask.onCancel[F]
|
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
|
.resource
|
||||||
psch <- PeriodicScheduler.create(
|
psch <- PeriodicScheduler.create(
|
||||||
cfg.periodicScheduler,
|
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 =
|
val hint =
|
||||||
MimeTypeHint.advertised(MimeType.pdf).withName(ra.name.getOrElse("file.pdf"))
|
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
|
ctx.store.bitpeace
|
||||||
.saveNew(pdf, cfg.chunkSize, MimetypeHint(hint.filename, hint.advertised))
|
.saveNew(pdf, cfg.chunkSize, MimetypeHint(hint.filename, hint.advertised))
|
||||||
.compile
|
.compile
|
||||||
|
@ -54,6 +54,7 @@ object ProcessItem {
|
|||||||
ConvertPdf(cfg.convert, item)
|
ConvertPdf(cfg.convert, item)
|
||||||
.flatMap(Task.setProgress(progress._1))
|
.flatMap(Task.setProgress(progress._1))
|
||||||
.flatMap(TextExtraction(cfg.extraction, fts))
|
.flatMap(TextExtraction(cfg.extraction, fts))
|
||||||
|
.flatMap(AttachmentPreview(cfg.convert, cfg.extraction.preview))
|
||||||
.flatMap(Task.setProgress(progress._2))
|
.flatMap(Task.setProgress(progress._2))
|
||||||
.flatMap(analysisOnly[F](cfg, analyser, regexNer))
|
.flatMap(analysisOnly[F](cfg, analyser, regexNer))
|
||||||
.flatMap(Task.setProgress(progress._3))
|
.flatMap(Task.setProgress(progress._3))
|
||||||
|
@ -1069,6 +1069,29 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/BasicResult"
|
$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:
|
/sec/user:
|
||||||
get:
|
get:
|
||||||
tags: [ Collective ]
|
tags: [ Collective ]
|
||||||
@ -1847,6 +1870,47 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ItemProposals"
|
$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:
|
/sec/item/{itemId}/reprocess:
|
||||||
post:
|
post:
|
||||||
@ -2446,6 +2510,63 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
format: binary
|
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:
|
/sec/attachment/{id}/meta:
|
||||||
get:
|
get:
|
||||||
tags: [ Attachment ]
|
tags: [ Attachment ]
|
||||||
@ -4822,3 +4943,10 @@ components:
|
|||||||
One of the available contact kinds.
|
One of the available contact kinds.
|
||||||
schema:
|
schema:
|
||||||
type: string
|
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/info" -> routes.InfoRoutes(),
|
||||||
"/api/v1/open/" -> openRoutes(cfg, restApp),
|
"/api/v1/open/" -> openRoutes(cfg, restApp),
|
||||||
"/api/v1/sec/" -> Authenticate(restApp.backend.login, cfg.auth) { token =>
|
"/api/v1/sec/" -> Authenticate(restApp.backend.login, cfg.auth) { token =>
|
||||||
securedRoutes(cfg, restApp, token)
|
securedRoutes(cfg, pools, restApp, token)
|
||||||
},
|
},
|
||||||
"/api/doc" -> templates.doc,
|
"/api/doc" -> templates.doc,
|
||||||
"/app/assets" -> WebjarRoutes.appRoutes[F](pools.blocker),
|
"/app/assets" -> WebjarRoutes.appRoutes[F](pools.blocker),
|
||||||
@ -57,8 +57,9 @@ object RestServer {
|
|||||||
)
|
)
|
||||||
}.drain
|
}.drain
|
||||||
|
|
||||||
def securedRoutes[F[_]: Effect](
|
def securedRoutes[F[_]: Effect: ContextShift](
|
||||||
cfg: Config,
|
cfg: Config,
|
||||||
|
pools: Pools,
|
||||||
restApp: RestApp[F],
|
restApp: RestApp[F],
|
||||||
token: AuthToken
|
token: AuthToken
|
||||||
): HttpRoutes[F] =
|
): HttpRoutes[F] =
|
||||||
@ -72,9 +73,9 @@ object RestServer {
|
|||||||
"user" -> UserRoutes(restApp.backend, token),
|
"user" -> UserRoutes(restApp.backend, token),
|
||||||
"collective" -> CollectiveRoutes(restApp.backend, token),
|
"collective" -> CollectiveRoutes(restApp.backend, token),
|
||||||
"queue" -> JobQueueRoutes(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),
|
"items" -> ItemMultiRoutes(restApp.backend, token),
|
||||||
"attachment" -> AttachmentRoutes(restApp.backend, token),
|
"attachment" -> AttachmentRoutes(pools.blocker, restApp.backend, token),
|
||||||
"upload" -> UploadRoutes.secured(restApp.backend, cfg, token),
|
"upload" -> UploadRoutes.secured(restApp.backend, cfg, token),
|
||||||
"checkfile" -> CheckFileRoutes.secured(restApp.backend, token),
|
"checkfile" -> CheckFileRoutes.secured(restApp.backend, token),
|
||||||
"email/send" -> MailSendRoutes(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 ContactKindOpt extends OptionalQueryParamDecoderMatcher[ContactKind]("kind")
|
||||||
|
|
||||||
object QueryOpt extends OptionalQueryParamDecoderMatcher[QueryString]("q")
|
object QueryOpt extends OptionalQueryParamDecoderMatcher[QueryString]("q")
|
||||||
|
|
||||||
|
object WithFallback extends OptionalQueryParamDecoderMatcher[Boolean]("withFallback")
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package docspell.restserver.http4s
|
package docspell.restserver.http4s
|
||||||
|
|
||||||
|
import cats.data.NonEmptyList
|
||||||
import fs2.text.utf8Encode
|
import fs2.text.utf8Encode
|
||||||
import fs2.{Pure, Stream}
|
import fs2.{Pure, Stream}
|
||||||
|
|
||||||
@ -27,4 +28,12 @@ object Responses {
|
|||||||
|
|
||||||
def unauthorized[F[_]]: Response[F] =
|
def unauthorized[F[_]]: Response[F] =
|
||||||
pureUnauthorized.copy(body = pureUnauthorized.body.covary[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
|
package docspell.restserver.routes
|
||||||
|
|
||||||
import cats.data.NonEmptyList
|
|
||||||
import cats.effect._
|
import cats.effect._
|
||||||
import cats.implicits._
|
import cats.implicits._
|
||||||
|
|
||||||
@ -8,42 +7,36 @@ import docspell.backend.BackendApp
|
|||||||
import docspell.backend.auth.AuthToken
|
import docspell.backend.auth.AuthToken
|
||||||
import docspell.backend.ops._
|
import docspell.backend.ops._
|
||||||
import docspell.common.Ident
|
import docspell.common.Ident
|
||||||
|
import docspell.common.MakePreviewArgs
|
||||||
import docspell.restapi.model._
|
import docspell.restapi.model._
|
||||||
import docspell.restserver.conv.Conversions
|
import docspell.restserver.conv.Conversions
|
||||||
|
import docspell.restserver.http4s.BinaryUtil
|
||||||
|
import docspell.restserver.http4s.{QueryParam => QP}
|
||||||
import docspell.restserver.webapp.Webjars
|
import docspell.restserver.webapp.Webjars
|
||||||
|
|
||||||
import bitpeace.FileMeta
|
|
||||||
import org.http4s._
|
import org.http4s._
|
||||||
import org.http4s.circe.CirceEntityDecoder._
|
import org.http4s.circe.CirceEntityDecoder._
|
||||||
import org.http4s.circe.CirceEntityEncoder._
|
import org.http4s.circe.CirceEntityEncoder._
|
||||||
import org.http4s.dsl.Http4sDsl
|
import org.http4s.dsl.Http4sDsl
|
||||||
import org.http4s.headers.ETag.EntityTag
|
|
||||||
import org.http4s.headers._
|
import org.http4s.headers._
|
||||||
|
|
||||||
object AttachmentRoutes {
|
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] {}
|
val dsl = new Http4sDsl[F] {}
|
||||||
import dsl._
|
import dsl._
|
||||||
|
|
||||||
def withResponseHeaders(
|
def withResponseHeaders(resp: F[Response[F]])(
|
||||||
resp: F[Response[F]]
|
data: OItemSearch.BinaryData[F]
|
||||||
)(data: OItemSearch.BinaryData[F]): F[Response[F]] = {
|
): F[Response[F]] =
|
||||||
val mt = MediaType.unsafeParse(data.meta.mimetype.asString)
|
BinaryUtil.withResponseHeaders[F](dsl, resp)(data)
|
||||||
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(data: OItemSearch.BinaryData[F]): F[Response[F]] =
|
def makeByteResp(data: OItemSearch.BinaryData[F]): F[Response[F]] =
|
||||||
withResponseHeaders(Ok(data.data.take(data.meta.length)))(data)
|
BinaryUtil.makeByteResp(dsl)(data)
|
||||||
|
|
||||||
HttpRoutes.of {
|
HttpRoutes.of {
|
||||||
case HEAD -> Root / Ident(id) =>
|
case HEAD -> Root / Ident(id) =>
|
||||||
@ -59,7 +52,7 @@ object AttachmentRoutes {
|
|||||||
for {
|
for {
|
||||||
fileData <- backend.itemSearch.findAttachment(id, user.account.collective)
|
fileData <- backend.itemSearch.findAttachment(id, user.account.collective)
|
||||||
inm = req.headers.get(`If-None-Match`).flatMap(_.tags)
|
inm = req.headers.get(`If-None-Match`).flatMap(_.tags)
|
||||||
matches = matchETag(fileData.map(_.meta), inm)
|
matches = BinaryUtil.matchETag(fileData.map(_.meta), inm)
|
||||||
resp <-
|
resp <-
|
||||||
fileData
|
fileData
|
||||||
.map { data =>
|
.map { data =>
|
||||||
@ -82,7 +75,7 @@ object AttachmentRoutes {
|
|||||||
for {
|
for {
|
||||||
fileData <- backend.itemSearch.findAttachmentSource(id, user.account.collective)
|
fileData <- backend.itemSearch.findAttachmentSource(id, user.account.collective)
|
||||||
inm = req.headers.get(`If-None-Match`).flatMap(_.tags)
|
inm = req.headers.get(`If-None-Match`).flatMap(_.tags)
|
||||||
matches = matchETag(fileData.map(_.meta), inm)
|
matches = BinaryUtil.matchETag(fileData.map(_.meta), inm)
|
||||||
resp <-
|
resp <-
|
||||||
fileData
|
fileData
|
||||||
.map { data =>
|
.map { data =>
|
||||||
@ -107,7 +100,7 @@ object AttachmentRoutes {
|
|||||||
fileData <-
|
fileData <-
|
||||||
backend.itemSearch.findAttachmentArchive(id, user.account.collective)
|
backend.itemSearch.findAttachmentArchive(id, user.account.collective)
|
||||||
inm = req.headers.get(`If-None-Match`).flatMap(_.tags)
|
inm = req.headers.get(`If-None-Match`).flatMap(_.tags)
|
||||||
matches = matchETag(fileData.map(_.meta), inm)
|
matches = BinaryUtil.matchETag(fileData.map(_.meta), inm)
|
||||||
resp <-
|
resp <-
|
||||||
fileData
|
fileData
|
||||||
.map { data =>
|
.map { data =>
|
||||||
@ -117,6 +110,49 @@ object AttachmentRoutes {
|
|||||||
.getOrElse(NotFound(BasicResult(false, "Not found")))
|
.getOrElse(NotFound(BasicResult(false, "Not found")))
|
||||||
} yield resp
|
} 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" =>
|
case GET -> Root / Ident(id) / "view" =>
|
||||||
// this route exists to provide a stable url
|
// this route exists to provide a stable url
|
||||||
// it redirects currently to viewerjs
|
// it redirects currently to viewerjs
|
||||||
@ -148,16 +184,4 @@ object AttachmentRoutes {
|
|||||||
} yield resp
|
} 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.BackendApp
|
||||||
import docspell.backend.auth.AuthToken
|
import docspell.backend.auth.AuthToken
|
||||||
import docspell.backend.ops.OCollective
|
import docspell.backend.ops.OCollective
|
||||||
|
import docspell.common.MakePreviewArgs
|
||||||
import docspell.restapi.model._
|
import docspell.restapi.model._
|
||||||
import docspell.restserver.conv.Conversions
|
import docspell.restserver.conv.Conversions
|
||||||
import docspell.restserver.http4s._
|
import docspell.restserver.http4s._
|
||||||
@ -94,6 +95,18 @@ object CollectiveRoutes {
|
|||||||
resp <- Ok(BasicResult(true, "Task submitted"))
|
resp <- Ok(BasicResult(true, "Task submitted"))
|
||||||
} yield resp
|
} 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 =>
|
case GET -> Root =>
|
||||||
for {
|
for {
|
||||||
collDb <- backend.collective.find(user.account.collective)
|
collDb <- backend.collective.find(user.account.collective)
|
||||||
|
@ -13,18 +13,23 @@ import docspell.common.{Ident, ItemState}
|
|||||||
import docspell.restapi.model._
|
import docspell.restapi.model._
|
||||||
import docspell.restserver.Config
|
import docspell.restserver.Config
|
||||||
import docspell.restserver.conv.Conversions
|
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.HttpRoutes
|
||||||
import org.http4s.circe.CirceEntityDecoder._
|
import org.http4s.circe.CirceEntityDecoder._
|
||||||
import org.http4s.circe.CirceEntityEncoder._
|
import org.http4s.circe.CirceEntityEncoder._
|
||||||
import org.http4s.dsl.Http4sDsl
|
import org.http4s.dsl.Http4sDsl
|
||||||
|
import org.http4s.headers._
|
||||||
import org.log4s._
|
import org.log4s._
|
||||||
|
|
||||||
object ItemRoutes {
|
object ItemRoutes {
|
||||||
private[this] val logger = getLogger
|
private[this] val logger = getLogger
|
||||||
|
|
||||||
def apply[F[_]: Effect](
|
def apply[F[_]: Effect: ContextShift](
|
||||||
cfg: Config,
|
cfg: Config,
|
||||||
|
blocker: Blocker,
|
||||||
backend: BackendApp[F],
|
backend: BackendApp[F],
|
||||||
user: AuthToken
|
user: AuthToken
|
||||||
): HttpRoutes[F] = {
|
): HttpRoutes[F] = {
|
||||||
@ -315,6 +320,35 @@ object ItemRoutes {
|
|||||||
resp <- Ok(Conversions.basicResult(res, "Attachment moved."))
|
resp <- Ok(Conversions.basicResult(res, "Attachment moved."))
|
||||||
} yield resp
|
} 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" =>
|
case req @ POST -> Root / Ident(id) / "reprocess" =>
|
||||||
for {
|
for {
|
||||||
data <- req.as[IdList]
|
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 {
|
object QAttachment {
|
||||||
private[this] val logger = org.log4s.getLogger
|
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.
|
/** Deletes an attachment, its related source and meta data records.
|
||||||
* It will only delete an related archive file, if this is the last
|
* It will only delete an related archive file, if this is the last
|
||||||
* attachment in that archive.
|
* attachment in that archive.
|
||||||
@ -27,18 +43,19 @@ object QAttachment {
|
|||||||
val loadFiles = for {
|
val loadFiles = for {
|
||||||
ra <- RAttachment.findByIdAndCollective(attachId, coll).map(_.map(_.fileId))
|
ra <- RAttachment.findByIdAndCollective(attachId, coll).map(_.map(_.fileId))
|
||||||
rs <- RAttachmentSource.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)
|
ne <- RAttachmentArchive.countEntries(attachId)
|
||||||
} yield (ra, rs, ne)
|
} yield (ra.toSeq ++ rs.toSeq ++ rp.toSeq, ne)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
files <- store.transact(loadFiles)
|
files <- store.transact(loadFiles)
|
||||||
k <-
|
k <-
|
||||||
if (files._3 == 1) deleteArchive(store)(attachId)
|
if (files._2 == 1) deleteArchive(store)(attachId)
|
||||||
else store.transact(RAttachmentArchive.delete(attachId))
|
else store.transact(RAttachmentArchive.delete(attachId))
|
||||||
n <- store.transact(RAttachment.delete(attachId))
|
n <- store.transact(RAttachment.delete(attachId))
|
||||||
f <-
|
f <-
|
||||||
Stream
|
Stream
|
||||||
.emits(files._1.toSeq ++ files._2.toSeq)
|
.emits(files._1)
|
||||||
.map(_.id)
|
.map(_.id)
|
||||||
.flatMap(store.bitpeace.delete)
|
.flatMap(store.bitpeace.delete)
|
||||||
.map(flag => if (flag) 1 else 0)
|
.map(flag => if (flag) 1 else 0)
|
||||||
@ -55,13 +72,14 @@ object QAttachment {
|
|||||||
for {
|
for {
|
||||||
_ <- logger.fdebug[F](s"Deleting attachment: ${ra.id.id}")
|
_ <- logger.fdebug[F](s"Deleting attachment: ${ra.id.id}")
|
||||||
s <- store.transact(RAttachmentSource.findById(ra.id))
|
s <- store.transact(RAttachmentSource.findById(ra.id))
|
||||||
|
p <- store.transact(RAttachmentPreview.findById(ra.id))
|
||||||
n <- store.transact(RAttachment.delete(ra.id))
|
n <- store.transact(RAttachment.delete(ra.id))
|
||||||
_ <- logger.fdebug[F](
|
_ <- 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 <-
|
f <-
|
||||||
Stream
|
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)
|
.flatMap(store.bitpeace.delete)
|
||||||
.map(flag => if (flag) 1 else 0)
|
.map(flag => if (flag) 1 else 0)
|
||||||
.compile
|
.compile
|
||||||
|
@ -61,6 +61,9 @@ object QCollective {
|
|||||||
select a.file_id,m.length from attachment_source a
|
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)
|
inner join filemeta m on m.id = a.file_id where a.id in (select aid from attachs)
|
||||||
union distinct
|
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
|
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)
|
inner join filemeta m on m.id = a.file_id where a.id in (select aid from attachs)
|
||||||
) as t""".query[Option[Long]].unique
|
) as t""".query[Option[Long]].unique
|
||||||
|
@ -224,12 +224,69 @@ object RAttachment {
|
|||||||
for {
|
for {
|
||||||
n0 <- RAttachmentMeta.delete(attachId)
|
n0 <- RAttachmentMeta.delete(attachId)
|
||||||
n1 <- RAttachmentSource.delete(attachId)
|
n1 <- RAttachmentSource.delete(attachId)
|
||||||
n2 <- deleteFrom(table, id.is(attachId)).update.run
|
n2 <- RAttachmentPreview.delete(attachId)
|
||||||
} yield n0 + n1 + n2
|
n3 <- deleteFrom(table, id.is(attachId)).update.run
|
||||||
|
} yield n0 + n1 + n2 + n3
|
||||||
|
|
||||||
def findItemId(attachId: Ident): ConnectionIO[Option[Ident]] =
|
def findItemId(attachId: Ident): ConnectionIO[Option[Ident]] =
|
||||||
selectSimple(Seq(itemId), table, id.is(attachId)).query[Ident].option
|
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(
|
def findNonConvertedPdf(
|
||||||
coll: Option[Ident],
|
coll: Option[Ident],
|
||||||
chunkSize: Int
|
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
|
, deleteSource
|
||||||
, deleteTag
|
, deleteTag
|
||||||
, deleteUser
|
, deleteUser
|
||||||
|
, fileURL
|
||||||
, getAttachmentMeta
|
, getAttachmentMeta
|
||||||
, getCollective
|
, getCollective
|
||||||
, getCollectiveSettings
|
, getCollectiveSettings
|
||||||
@ -59,6 +60,7 @@ module Api exposing
|
|||||||
, getUsers
|
, getUsers
|
||||||
, itemDetail
|
, itemDetail
|
||||||
, itemIndexSearch
|
, itemIndexSearch
|
||||||
|
, itemPreviewURL
|
||||||
, itemSearch
|
, itemSearch
|
||||||
, login
|
, login
|
||||||
, loginSession
|
, loginSession
|
||||||
@ -1501,6 +1503,16 @@ deleteAllItems flags ids receive =
|
|||||||
--- Item
|
--- 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 :
|
setAttachmentName :
|
||||||
Flags
|
Flags
|
||||||
-> String
|
-> 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
|
, view
|
||||||
)
|
)
|
||||||
|
|
||||||
|
import Api
|
||||||
import Api.Model.HighlightEntry exposing (HighlightEntry)
|
import Api.Model.HighlightEntry exposing (HighlightEntry)
|
||||||
import Api.Model.ItemLight exposing (ItemLight)
|
import Api.Model.ItemLight exposing (ItemLight)
|
||||||
import Api.Model.ItemLightGroup exposing (ItemLightGroup)
|
import Api.Model.ItemLightGroup exposing (ItemLightGroup)
|
||||||
@ -230,7 +231,19 @@ viewItem cfg settings item =
|
|||||||
]
|
]
|
||||||
++ DD.draggable ItemDDMsg item.id
|
++ 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
|
[ case cfg.selection of
|
||||||
Data.ItemSelection.Active ids ->
|
Data.ItemSelection.Active ids ->
|
||||||
div [ class "header" ]
|
div [ class "header" ]
|
||||||
|
@ -1464,6 +1464,9 @@ resetField flags item tagger field =
|
|||||||
Data.Fields.Direction ->
|
Data.Fields.Direction ->
|
||||||
Cmd.none
|
Cmd.none
|
||||||
|
|
||||||
|
Data.Fields.PreviewImage ->
|
||||||
|
Cmd.none
|
||||||
|
|
||||||
|
|
||||||
resetHiddenFields :
|
resetHiddenFields :
|
||||||
UiSettings
|
UiSettings
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
module Comp.ItemDetail.View exposing (view)
|
module Comp.ItemDetail.View exposing (view)
|
||||||
|
|
||||||
|
import Api
|
||||||
import Api.Model.Attachment exposing (Attachment)
|
import Api.Model.Attachment exposing (Attachment)
|
||||||
import Comp.AttachmentMeta
|
import Comp.AttachmentMeta
|
||||||
import Comp.DatePicker
|
import Comp.DatePicker
|
||||||
@ -320,7 +321,7 @@ renderAttachmentView : UiSettings -> Model -> Int -> Attachment -> Html Msg
|
|||||||
renderAttachmentView settings model pos attach =
|
renderAttachmentView settings model pos attach =
|
||||||
let
|
let
|
||||||
fileUrl =
|
fileUrl =
|
||||||
"/api/v1/sec/attachment/" ++ attach.id
|
Api.fileURL attach.id
|
||||||
|
|
||||||
attachName =
|
attachName =
|
||||||
Maybe.withDefault "No name" attach.name
|
Maybe.withDefault "No name" attach.name
|
||||||
|
@ -8,9 +8,11 @@ module Comp.UiSettingsForm exposing
|
|||||||
|
|
||||||
import Api
|
import Api
|
||||||
import Api.Model.TagList exposing (TagList)
|
import Api.Model.TagList exposing (TagList)
|
||||||
|
import Comp.BasicSizeField
|
||||||
import Comp.ColorTagger
|
import Comp.ColorTagger
|
||||||
import Comp.FieldListSelect
|
import Comp.FieldListSelect
|
||||||
import Comp.IntField
|
import Comp.IntField
|
||||||
|
import Data.BasicSize exposing (BasicSize)
|
||||||
import Data.Color exposing (Color)
|
import Data.Color exposing (Color)
|
||||||
import Data.Fields exposing (Field)
|
import Data.Fields exposing (Field)
|
||||||
import Data.Flags exposing (Flags)
|
import Data.Flags exposing (Flags)
|
||||||
@ -42,6 +44,7 @@ type alias Model =
|
|||||||
, itemDetailShortcuts : Bool
|
, itemDetailShortcuts : Bool
|
||||||
, searchMenuVisible : Bool
|
, searchMenuVisible : Bool
|
||||||
, editMenuVisible : Bool
|
, editMenuVisible : Bool
|
||||||
|
, cardPreviewSize : BasicSize
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -93,6 +96,7 @@ init flags settings =
|
|||||||
, itemDetailShortcuts = settings.itemDetailShortcuts
|
, itemDetailShortcuts = settings.itemDetailShortcuts
|
||||||
, searchMenuVisible = settings.searchMenuVisible
|
, searchMenuVisible = settings.searchMenuVisible
|
||||||
, editMenuVisible = settings.editMenuVisible
|
, editMenuVisible = settings.editMenuVisible
|
||||||
|
, cardPreviewSize = settings.cardPreviewSize
|
||||||
}
|
}
|
||||||
, Api.getTags flags "" GetTagsResp
|
, Api.getTags flags "" GetTagsResp
|
||||||
)
|
)
|
||||||
@ -112,6 +116,7 @@ type Msg
|
|||||||
| ToggleItemDetailShortcuts
|
| ToggleItemDetailShortcuts
|
||||||
| ToggleSearchMenuVisible
|
| ToggleSearchMenuVisible
|
||||||
| ToggleEditMenuVisible
|
| ToggleEditMenuVisible
|
||||||
|
| CardPreviewSizeMsg Comp.BasicSizeField.Msg
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -297,6 +302,23 @@ update sett msg model =
|
|||||||
, Just { sett | editMenuVisible = flag }
|
, 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
|
--- View
|
||||||
@ -329,6 +351,9 @@ view flags _ model =
|
|||||||
"field"
|
"field"
|
||||||
model.searchPageSizeModel
|
model.searchPageSizeModel
|
||||||
)
|
)
|
||||||
|
, div [ class "ui dividing header" ]
|
||||||
|
[ text "Item Cards"
|
||||||
|
]
|
||||||
, Html.map NoteLengthMsg
|
, Html.map NoteLengthMsg
|
||||||
(Comp.IntField.viewWithInfo
|
(Comp.IntField.viewWithInfo
|
||||||
("Maximum size of the item notes to display in card view. Between 0 - "
|
("Maximum size of the item notes to display in card view. Between 0 - "
|
||||||
@ -339,6 +364,11 @@ view flags _ model =
|
|||||||
"field"
|
"field"
|
||||||
model.searchNoteLengthModel
|
model.searchNoteLengthModel
|
||||||
)
|
)
|
||||||
|
, Html.map CardPreviewSizeMsg
|
||||||
|
(Comp.BasicSizeField.view
|
||||||
|
"Size of item preview"
|
||||||
|
model.cardPreviewSize
|
||||||
|
)
|
||||||
, div [ class "ui dividing header" ]
|
, div [ class "ui dividing header" ]
|
||||||
[ text "Search Menu" ]
|
[ text "Search Menu" ]
|
||||||
, div [ class "field" ]
|
, 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
|
| Date
|
||||||
| DueDate
|
| DueDate
|
||||||
| Direction
|
| Direction
|
||||||
|
| PreviewImage
|
||||||
|
|
||||||
|
|
||||||
all : List Field
|
all : List Field
|
||||||
@ -33,6 +34,7 @@ all =
|
|||||||
, Date
|
, Date
|
||||||
, DueDate
|
, DueDate
|
||||||
, Direction
|
, Direction
|
||||||
|
, PreviewImage
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -71,6 +73,9 @@ fromString str =
|
|||||||
"direction" ->
|
"direction" ->
|
||||||
Just Direction
|
Just Direction
|
||||||
|
|
||||||
|
"preview" ->
|
||||||
|
Just PreviewImage
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
Nothing
|
Nothing
|
||||||
|
|
||||||
@ -105,6 +110,9 @@ toString field =
|
|||||||
Direction ->
|
Direction ->
|
||||||
"direction"
|
"direction"
|
||||||
|
|
||||||
|
PreviewImage ->
|
||||||
|
"preview"
|
||||||
|
|
||||||
|
|
||||||
label : Field -> String
|
label : Field -> String
|
||||||
label field =
|
label field =
|
||||||
@ -136,6 +144,9 @@ label field =
|
|||||||
Direction ->
|
Direction ->
|
||||||
"Direction"
|
"Direction"
|
||||||
|
|
||||||
|
PreviewImage ->
|
||||||
|
"Preview Image"
|
||||||
|
|
||||||
|
|
||||||
fromList : List String -> List Field
|
fromList : List String -> List Field
|
||||||
fromList strings =
|
fromList strings =
|
||||||
|
@ -2,6 +2,7 @@ module Data.UiSettings exposing
|
|||||||
( Pos(..)
|
( Pos(..)
|
||||||
, StoredUiSettings
|
, StoredUiSettings
|
||||||
, UiSettings
|
, UiSettings
|
||||||
|
, cardPreviewSize
|
||||||
, catColor
|
, catColor
|
||||||
, catColorString
|
, catColorString
|
||||||
, defaults
|
, defaults
|
||||||
@ -17,9 +18,12 @@ module Data.UiSettings exposing
|
|||||||
)
|
)
|
||||||
|
|
||||||
import Api.Model.Tag exposing (Tag)
|
import Api.Model.Tag exposing (Tag)
|
||||||
|
import Data.BasicSize exposing (BasicSize)
|
||||||
import Data.Color exposing (Color)
|
import Data.Color exposing (Color)
|
||||||
import Data.Fields exposing (Field)
|
import Data.Fields exposing (Field)
|
||||||
import Dict exposing (Dict)
|
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
|
{-| Settings for the web ui. All fields should be optional, since it
|
||||||
@ -43,6 +47,7 @@ type alias StoredUiSettings =
|
|||||||
, itemDetailShortcuts : Bool
|
, itemDetailShortcuts : Bool
|
||||||
, searchMenuVisible : Bool
|
, searchMenuVisible : Bool
|
||||||
, editMenuVisible : Bool
|
, editMenuVisible : Bool
|
||||||
|
, cardPreviewSize : Maybe String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -66,6 +71,7 @@ type alias UiSettings =
|
|||||||
, itemDetailShortcuts : Bool
|
, itemDetailShortcuts : Bool
|
||||||
, searchMenuVisible : Bool
|
, searchMenuVisible : Bool
|
||||||
, editMenuVisible : Bool
|
, editMenuVisible : Bool
|
||||||
|
, cardPreviewSize : BasicSize
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -111,6 +117,7 @@ defaults =
|
|||||||
, itemDetailShortcuts = False
|
, itemDetailShortcuts = False
|
||||||
, searchMenuVisible = False
|
, searchMenuVisible = False
|
||||||
, editMenuVisible = False
|
, editMenuVisible = False
|
||||||
|
, cardPreviewSize = Data.BasicSize.Medium
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -146,6 +153,10 @@ merge given fallback =
|
|||||||
, itemDetailShortcuts = given.itemDetailShortcuts
|
, itemDetailShortcuts = given.itemDetailShortcuts
|
||||||
, searchMenuVisible = given.searchMenuVisible
|
, searchMenuVisible = given.searchMenuVisible
|
||||||
, editMenuVisible = given.editMenuVisible
|
, editMenuVisible = given.editMenuVisible
|
||||||
|
, cardPreviewSize =
|
||||||
|
given.cardPreviewSize
|
||||||
|
|> Maybe.andThen Data.BasicSize.fromString
|
||||||
|
|> Maybe.withDefault fallback.cardPreviewSize
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -172,6 +183,10 @@ toStoredUiSettings settings =
|
|||||||
, itemDetailShortcuts = settings.itemDetailShortcuts
|
, itemDetailShortcuts = settings.itemDetailShortcuts
|
||||||
, searchMenuVisible = settings.searchMenuVisible
|
, searchMenuVisible = settings.searchMenuVisible
|
||||||
, editMenuVisible = settings.editMenuVisible
|
, editMenuVisible = settings.editMenuVisible
|
||||||
|
, cardPreviewSize =
|
||||||
|
settings.cardPreviewSize
|
||||||
|
|> Data.BasicSize.asString
|
||||||
|
|> Just
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -209,6 +224,19 @@ fieldHidden settings field =
|
|||||||
fieldVisible settings field |> not
|
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
|
--- Helpers
|
||||||
|
|
||||||
|
@ -93,6 +93,11 @@
|
|||||||
padding: 0.8em;
|
padding: 0.8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.default-layout img.preview-image {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.default-layout .menu .item.active a.right-tab-icon-link {
|
.default-layout .menu .item.active a.right-tab-icon-link {
|
||||||
position: relative;
|
position: relative;
|
||||||
right: -8px;
|
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