Merge pull request #130 from eikek/add-attachment

Add attachment
This commit is contained in:
eikek 2020-05-24 15:46:24 +02:00 committed by GitHub
commit 324476c1a9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 1057 additions and 348 deletions

View File

@ -8,18 +8,21 @@
periodically to import your mails. periodically to import your mails.
- New feature "Integration Endpoint". Allows an admin to upload files - New feature "Integration Endpoint". Allows an admin to upload files
to any collective using a separate endpoint. to any collective using a separate endpoint.
- New feature: add files to existing items.
- The document list on the front-page has been rewritten. The table is
removed and documents are now presented in a “card view”.
- Amend the mail-to-pdf conversion to include the e-mail date.
- When processing e-mails, set the item date automatically from the
received-date in the mail.
- Fixes regarding character encodings when reading e-mails.
- Fix the `find-by-checksum` route that, given a sha256 checksum, - Fix the `find-by-checksum` route that, given a sha256 checksum,
returns whether there is such a file in docspell. It falsely returns whether there is such a file in docspell. It falsely
returned `false` although documents existed. returned `false` although documents existed.
- Amend the mail-to-pdf conversion to include the e-mail date.
- Fix webapp for mobile devices. - Fix webapp for mobile devices.
- The document list on the front-page has been rewritten. The table is
removed and documents are now presented in a “card view”.
- Fix the search menu to remember dates in fields. When going back - Fix the search menu to remember dates in fields. When going back
from an item detail to the front-page, the search menu remembers the from an item detail to the front-page, the search menu remembers the
last state, but dates were cleared. last state, but dates were cleared.
- More fixes regarding character encodings when reading e-mails. - Fix redirecting `/` only to `/app`.
- Fix redirecting `/` to `/app`.
### Configuration Changes ### Configuration Changes

View File

@ -19,6 +19,15 @@ private[analysis] object Tld {
".edu", ".edu",
".gov", ".gov",
".mil", ".mil",
".info",
".app",
".bar",
".biz",
".club",
".coop",
".icu",
".name",
".xyz",
".ad", ".ad",
".ae", ".ae",
".al", ".al",

View File

@ -1,15 +1,17 @@
package docspell.backend.ops package docspell.backend.ops
import bitpeace.MimetypeHint import bitpeace.MimetypeHint
import cats.implicits._ import cats.Functor
import cats.data.{EitherT, OptionT}
import cats.effect._ import cats.effect._
import cats.implicits._
import docspell.backend.Config import docspell.backend.Config
import fs2.Stream import fs2.Stream
import docspell.common._ import docspell.common._
import docspell.common.syntax.all._ import docspell.common.syntax.all._
import docspell.store.Store import docspell.store.Store
import docspell.store.queue.JobQueue import docspell.store.queue.JobQueue
import docspell.store.records.{RCollective, RJob, RSource} import docspell.store.records._
import org.log4s._ import org.log4s._
trait OUpload[F[_]] { trait OUpload[F[_]] {
@ -17,14 +19,29 @@ trait OUpload[F[_]] {
def submit( def submit(
data: OUpload.UploadData[F], data: OUpload.UploadData[F],
account: AccountId, account: AccountId,
notifyJoex: Boolean notifyJoex: Boolean,
itemId: Option[Ident]
): F[OUpload.UploadResult] ): F[OUpload.UploadResult]
def submit( def submit(
data: OUpload.UploadData[F], data: OUpload.UploadData[F],
sourceId: Ident, sourceId: Ident,
notifyJoex: Boolean notifyJoex: Boolean,
itemId: Option[Ident]
): F[OUpload.UploadResult] ): F[OUpload.UploadResult]
final def submitEither(
data: OUpload.UploadData[F],
accOrSrc: Either[Ident, AccountId],
notifyJoex: Boolean,
itemId: Option[Ident]
): F[OUpload.UploadResult] =
accOrSrc match {
case Right(acc) =>
submit(data, acc, notifyJoex, itemId)
case Left(srcId) =>
submit(data, srcId, notifyJoex, itemId)
}
} }
object OUpload { object OUpload {
@ -52,11 +69,32 @@ object OUpload {
sealed trait UploadResult sealed trait UploadResult
object UploadResult { object UploadResult {
/** File(s) have been successfully submitted. */
case object Success extends UploadResult case object Success extends UploadResult
def success: UploadResult = Success
/** There were no files to submit. */
case object NoFiles extends UploadResult case object NoFiles extends UploadResult
def noFiles: UploadResult = NoFiles
/** A source (`RSource') could not be found for a given source-id. */
case object NoSource extends UploadResult case object NoSource extends UploadResult
def noSource: UploadResult = NoSource
/** When adding files to an item, no item was found using the given
* item-id. */
case object NoItem extends UploadResult
def noItem: UploadResult = NoItem
} }
private def right[F[_]: Functor, A](a: F[A]): EitherT[F, UploadResult, A] =
EitherT.right(a)
def apply[F[_]: Sync]( def apply[F[_]: Sync](
store: Store[F], store: Store[F],
queue: JobQueue[F], queue: JobQueue[F],
@ -68,14 +106,17 @@ object OUpload {
def submit( def submit(
data: OUpload.UploadData[F], data: OUpload.UploadData[F],
account: AccountId, account: AccountId,
notifyJoex: Boolean notifyJoex: Boolean,
itemId: Option[Ident]
): F[OUpload.UploadResult] = ): F[OUpload.UploadResult] =
for { (for {
files <- data.files.traverse(saveFile).map(_.flatten) _ <- checkExistingItem(itemId, account.collective)
pred <- checkFileList(files) files <- right(data.files.traverse(saveFile).map(_.flatten))
lang <- store.transact(RCollective.findLanguage(account.collective)) _ <- checkFileList(files)
lang <- right(store.transact(RCollective.findLanguage(account.collective)))
meta = ProcessItemArgs.ProcessMeta( meta = ProcessItemArgs.ProcessMeta(
account.collective, account.collective,
itemId,
lang.getOrElse(Language.German), lang.getOrElse(Language.German),
data.meta.direction, data.meta.direction,
data.meta.sourceAbbrev, data.meta.sourceAbbrev,
@ -84,29 +125,31 @@ object OUpload {
args = args =
if (data.multiple) files.map(f => ProcessItemArgs(meta, List(f))) if (data.multiple) files.map(f => ProcessItemArgs(meta, List(f)))
else Vector(ProcessItemArgs(meta, files.toList)) else Vector(ProcessItemArgs(meta, files.toList))
job <- pred.traverse(_ => makeJobs(args, account, data.priority, data.tracker)) jobs <- right(makeJobs(args, account, data.priority, data.tracker))
_ <- logger.fdebug(s"Storing jobs: $job") _ <- right(logger.fdebug(s"Storing jobs: $jobs"))
res <- job.traverse(submitJobs(notifyJoex)) res <- right(submitJobs(notifyJoex)(jobs))
_ <- store.transact( _ <- right(
store.transact(
RSource.incrementCounter(data.meta.sourceAbbrev, account.collective) RSource.incrementCounter(data.meta.sourceAbbrev, account.collective)
) )
} yield res.fold(identity, identity) )
} yield res).fold(identity, identity)
def submit( def submit(
data: OUpload.UploadData[F], data: OUpload.UploadData[F],
sourceId: Ident, sourceId: Ident,
notifyJoex: Boolean notifyJoex: Boolean,
itemId: Option[Ident]
): F[OUpload.UploadResult] = ): F[OUpload.UploadResult] =
for { (for {
sOpt <- src <- OptionT(store.transact(RSource.find(sourceId)))
store updata = data.copy(
.transact(RSource.find(sourceId)) meta = data.meta.copy(sourceAbbrev = src.abbrev),
.map(_.toRight(UploadResult.NoSource)) priority = src.priority
abbrev = sOpt.map(_.abbrev).toOption.getOrElse(data.meta.sourceAbbrev) )
updata = data.copy(meta = data.meta.copy(sourceAbbrev = abbrev)) accId = AccountId(src.cid, src.sid)
accId = sOpt.map(source => AccountId(source.cid, source.sid)) result <- OptionT.liftF(submit(updata, accId, notifyJoex, itemId))
result <- accId.traverse(acc => submit(updata, acc, notifyJoex)) } yield result).getOrElse(UploadResult.noSource)
} yield result.fold(identity, identity)
private def submitJobs( private def submitJobs(
notifyJoex: Boolean notifyJoex: Boolean
@ -117,6 +160,7 @@ object OUpload {
_ <- if (notifyJoex) joex.notifyAllNodes else ().pure[F] _ <- if (notifyJoex) joex.notifyAllNodes else ().pure[F]
} yield UploadResult.Success } yield UploadResult.Success
/** Saves the file into the database. */
private def saveFile(file: File[F]): F[Option[ProcessItemArgs.File]] = private def saveFile(file: File[F]): F[Option[ProcessItemArgs.File]] =
logger.finfo(s"Receiving file $file") *> logger.finfo(s"Receiving file $file") *>
store.bitpeace store.bitpeace
@ -135,10 +179,24 @@ object OUpload {
) )
) )
private def checkExistingItem(
itemId: Option[Ident],
coll: Ident
): EitherT[F, UploadResult, Unit] =
itemId match {
case None =>
right(().pure[F])
case Some(id) =>
OptionT(store.transact(RItem.findByIdAndCollective(id, coll)))
.toRight(UploadResult.noItem)
.map(_ => ())
}
private def checkFileList( private def checkFileList(
files: Seq[ProcessItemArgs.File] files: Seq[ProcessItemArgs.File]
): F[Either[UploadResult, Unit]] = ): EitherT[F, UploadResult, Unit] =
Sync[F].pure(if (files.isEmpty) Left(UploadResult.NoFiles) else Right(())) if (files.isEmpty) EitherT.left(UploadResult.noFiles.pure[F])
else right(().pure[F])
private def makeJobs( private def makeJobs(
args: Vector[ProcessItemArgs], args: Vector[ProcessItemArgs],

View File

@ -1,11 +1,18 @@
package docspell.common package docspell.common
import io.circe.{Decoder, Encoder} import io.circe.{Decoder, Encoder}
import cats.data.NonEmptyList
sealed trait ItemState { self: Product => sealed trait ItemState { self: Product =>
final def name: String = final def name: String =
productPrefix.toLowerCase productPrefix.toLowerCase
def isValid: Boolean =
ItemState.validStates.exists(_ == this)
def isInvalid: Boolean =
ItemState.invalidStates.exists(_ == this)
} }
object ItemState { object ItemState {
@ -24,8 +31,11 @@ object ItemState {
case _ => Left(s"Invalid item state: $str") case _ => Left(s"Invalid item state: $str")
} }
val validStates: Seq[ItemState] = val validStates: NonEmptyList[ItemState] =
Seq(Created, Confirmed) NonEmptyList.of(Created, Confirmed)
val invalidStates: NonEmptyList[ItemState] =
NonEmptyList.of(Premature, Processing)
def unsafe(str: String): ItemState = def unsafe(str: String): ItemState =
fromString(str).fold(sys.error, identity) fromString(str).fold(sys.error, identity)

View File

@ -4,6 +4,14 @@ import io.circe._, io.circe.generic.semiauto._
import docspell.common.syntax.all._ import docspell.common.syntax.all._
import ProcessItemArgs._ import ProcessItemArgs._
/** Arguments to the process-item task.
*
* This task is run for each new file to create a new item from it or
* to add this file as an attachment to an existing item.
*
* If the `itemId' is set to some value, the item is tried to load to
* ammend with the given files. Otherwise a new item is created.
*/
case class ProcessItemArgs(meta: ProcessMeta, files: List[File]) { case class ProcessItemArgs(meta: ProcessMeta, files: List[File]) {
def makeSubject: String = def makeSubject: String =
@ -22,6 +30,7 @@ object ProcessItemArgs {
case class ProcessMeta( case class ProcessMeta(
collective: Ident, collective: Ident,
itemId: Option[Ident],
language: Language, language: Language,
direction: Option[Direction], direction: Option[Direction],
sourceAbbrev: String, sourceAbbrev: String,

View File

@ -77,7 +77,7 @@ object JoexAppImpl {
.withTask( .withTask(
JobTask.json( JobTask.json(
ProcessItemArgs.taskName, ProcessItemArgs.taskName,
ItemHandler[F](cfg), ItemHandler.newItem[F](cfg),
ItemHandler.onCancel[F] ItemHandler.onCancel[F]
) )
) )

View File

@ -71,7 +71,7 @@ object NotifyDueItemsTask {
QItem.Query QItem.Query
.empty(ctx.args.account.collective) .empty(ctx.args.account.collective)
.copy( .copy(
states = ItemState.validStates, states = ItemState.validStates.toList,
tagsInclude = ctx.args.tagsInclude, tagsInclude = ctx.args.tagsInclude,
tagsExclude = ctx.args.tagsExclude, tagsExclude = ctx.args.tagsExclude,
dueDateFrom = ctx.args.daysBack.map(back => now - Duration.days(back.toLong)), dueDateFrom = ctx.args.daysBack.map(back => now - Duration.days(back.toLong)),

View File

@ -62,7 +62,7 @@ object ConvertPdf {
Conversion.create[F](cfg, sanitizeHtml, ctx.blocker, ctx.logger).use { conv => Conversion.create[F](cfg, sanitizeHtml, ctx.blocker, ctx.logger).use { conv =>
mime match { mime match {
case mt if mt.baseEqual(Mimetype.`application/pdf`) => case mt if mt.baseEqual(Mimetype.`application/pdf`) =>
ctx.logger.info("Not going to convert a PDF file into a PDF.") *> ctx.logger.debug(s"Not going to convert a PDF file ${ra.name} into a PDF.") *>
(ra, None: Option[RAttachmentMeta]).pure[F] (ra, None: Option[RAttachmentMeta]).pure[F]
case _ => case _ =>

View File

@ -31,6 +31,9 @@ object CreateItem {
.contains(fm.mimetype.baseType) .contains(fm.mimetype.baseType)
def fileMetas(itemId: Ident, now: Timestamp) = def fileMetas(itemId: Ident, now: Timestamp) =
Stream
.eval(ctx.store.transact(RAttachment.nextPosition(itemId)))
.flatMap { offset =>
Stream Stream
.emits(ctx.args.files) .emits(ctx.args.files)
.flatMap(f => ctx.store.bitpeace.get(f.fileMetaId.id).map(fm => (f, fm))) .flatMap(f => ctx.store.bitpeace.get(f.fileMetaId.id).map(fm => (f, fm)))
@ -41,35 +44,63 @@ object CreateItem {
Ident Ident
.randomId[F] .randomId[F]
.map(id => .map(id =>
RAttachment(id, itemId, f.fileMetaId, index.toInt, now, f.name) RAttachment(
id,
itemId,
f.fileMetaId,
index.toInt + offset,
now,
f.name
)
) )
}) })
}
.compile .compile
.toVector .toVector
val item = RItem.newItem[F]( val loadItemOrInsertNew =
ctx.args.meta.itemId match {
case Some(id) =>
(for {
_ <- OptionT.liftF(
ctx.logger.info(
s"Loading item with id ${id.id} to ammend"
)
)
item <- OptionT(
ctx.store
.transact(RItem.findByIdAndCollective(id, ctx.args.meta.collective))
)
} yield (1, item))
.getOrElseF(Sync[F].raiseError(new Exception(s"Item not found.")))
case None =>
for {
_ <- ctx.logger.info(
s"Creating new item with ${ctx.args.files.size} attachment(s)"
)
item <- RItem.newItem[F](
ctx.args.meta.collective, ctx.args.meta.collective,
ctx.args.makeSubject, ctx.args.makeSubject,
ctx.args.meta.sourceAbbrev, ctx.args.meta.sourceAbbrev,
ctx.args.meta.direction.getOrElse(Direction.Incoming), ctx.args.meta.direction.getOrElse(Direction.Incoming),
ItemState.Premature ItemState.Premature
) )
n <- ctx.store.transact(RItem.insert(item))
} yield (n, item)
}
for { for {
_ <- ctx.logger.info(
s"Creating new item with ${ctx.args.files.size} attachment(s)"
)
time <- Duration.stopTime[F] time <- Duration.stopTime[F]
it <- item it <- loadItemOrInsertNew
n <- ctx.store.transact(RItem.insert(it)) _ <- if (it._1 != 1) storeItemError[F](ctx) else ().pure[F]
_ <- if (n != 1) storeItemError[F](ctx) else ().pure[F] now <- Timestamp.current[F]
fm <- fileMetas(it.id, it.created) fm <- fileMetas(it._2.id, now)
k <- fm.traverse(insertAttachment(ctx)) k <- fm.traverse(insertAttachment(ctx))
_ <- logDifferences(ctx, fm, k.sum) _ <- logDifferences(ctx, fm, k.sum)
dur <- time dur <- time
_ <- ctx.logger.info(s"Creating item finished in ${dur.formatExact}") _ <- ctx.logger.info(s"Creating item finished in ${dur.formatExact}")
} yield ItemData( } yield ItemData(
it, it._2,
fm, fm,
Vector.empty, Vector.empty,
Vector.empty, Vector.empty,
@ -86,10 +117,11 @@ object CreateItem {
} yield n) } yield n)
} }
def findExisting[F[_]: Sync]: Task[F, ProcessItemArgs, Option[ItemData]] = private def findExisting[F[_]: Sync]: Task[F, ProcessItemArgs, Option[ItemData]] =
Task { ctx => Task { ctx =>
val fileMetaIds = ctx.args.files.map(_.fileMetaId).toSet
for { for {
cand <- ctx.store.transact(QItem.findByFileIds(ctx.args.files.map(_.fileMetaId))) cand <- ctx.store.transact(QItem.findByFileIds(fileMetaIds.toSeq))
_ <- _ <-
if (cand.nonEmpty) ctx.logger.warn("Found existing item with these files.") if (cand.nonEmpty) ctx.logger.warn("Found existing item with these files.")
else ().pure[F] else ().pure[F]
@ -99,8 +131,11 @@ object CreateItem {
ctx.logger.warn(s"Removed ${ht.sum} items with same attachments") ctx.logger.warn(s"Removed ${ht.sum} items with same attachments")
else ().pure[F] else ().pure[F]
rms <- OptionT( rms <- OptionT(
//load attachments but only those mentioned in the task's arguments
cand.headOption.traverse(ri => cand.headOption.traverse(ri =>
ctx.store.transact(RAttachment.findByItemAndCollective(ri.id, ri.cid)) ctx.store
.transact(RAttachment.findByItemAndCollective(ri.id, ri.cid))
.map(_.filter(r => fileMetaIds.contains(r.fileId)))
) )
).getOrElse(Vector.empty) ).getOrElse(Vector.empty)
orig <- rms.traverse(a => orig <- rms.traverse(a =>

View File

@ -13,6 +13,8 @@ import docspell.store.records._
import docspell.files.Zip import docspell.files.Zip
import cats.kernel.Monoid import cats.kernel.Monoid
import emil.Mail import emil.Mail
import cats.kernel.Order
import cats.data.NonEmptyList
/** Goes through all attachments and extracts archive files, like zip /** Goes through all attachments and extracts archive files, like zip
* files. The process is recursive, until all archives have been * files. The process is recursive, until all archives have been
@ -46,22 +48,37 @@ object ExtractArchive {
archive: Option[RAttachmentArchive] archive: Option[RAttachmentArchive]
): Task[F, ProcessItemArgs, (Option[RAttachmentArchive], ItemData)] = ): Task[F, ProcessItemArgs, (Option[RAttachmentArchive], ItemData)] =
Task { ctx => Task { ctx =>
def extract(ra: RAttachment) = def extract(ra: RAttachment, pos: Int): F[Extracted] =
findMime(ctx)(ra).flatMap(m => extractSafe(ctx, archive)(ra, m)) findMime(ctx)(ra).flatMap(m => extractSafe(ctx, archive)(ra, pos, m))
for { for {
ras <- item.attachments.traverse(extract) lastPos <- ctx.store.transact(RAttachment.nextPosition(item.item.id))
nra = ras.flatMap(_.files).zipWithIndex.map(t => t._1.copy(position = t._2)) extracts <-
_ <- nra.traverse(storeAttachment(ctx)) item.attachments.zipWithIndex
naa = ras.flatMap(_.archives) .traverse(t => extract(t._1, lastPos + t._2))
.map(Monoid[Extracted].combineAll)
.map(fixPositions)
nra = extracts.files
_ <- extracts.files.traverse(storeAttachment(ctx))
naa = extracts.archives
_ <- naa.traverse(storeArchive(ctx)) _ <- naa.traverse(storeArchive(ctx))
} yield naa.headOption -> item.copy( } yield naa.headOption -> item.copy(
attachments = nra, attachments = nra,
originFile = item.originFile ++ nra.map(a => a.id -> a.fileId).toMap, originFile = item.originFile ++ nra.map(a => a.id -> a.fileId).toMap,
givenMeta = item.givenMeta.fillEmptyFrom(Monoid[Extracted].combineAll(ras).meta) givenMeta = item.givenMeta.fillEmptyFrom(extracts.meta)
) )
} }
/** After all files have been extracted, the `extract' contains the
* whole (combined) result. This fixes positions of the attachments
* such that the elements of an archive are "spliced" into the
* attachment list at the position of the archive. If there is no
* archive, positions don't need to be fixed.
*/
private def fixPositions(extract: Extracted): Extracted =
if (extract.archives.isEmpty) extract
else extract.updatePositions
def findMime[F[_]: Functor](ctx: Context[F, _])(ra: RAttachment): F[Mimetype] = def findMime[F[_]: Functor](ctx: Context[F, _])(ra: RAttachment): F[Mimetype] =
OptionT(ctx.store.transact(RFileMeta.findById(ra.fileId))) OptionT(ctx.store.transact(RFileMeta.findById(ra.fileId)))
.map(_.mimetype) .map(_.mimetype)
@ -70,21 +87,21 @@ object ExtractArchive {
def extractSafe[F[_]: ConcurrentEffect: ContextShift]( def extractSafe[F[_]: ConcurrentEffect: ContextShift](
ctx: Context[F, ProcessItemArgs], ctx: Context[F, ProcessItemArgs],
archive: Option[RAttachmentArchive] archive: Option[RAttachmentArchive]
)(ra: RAttachment, mime: Mimetype): F[Extracted] = )(ra: RAttachment, pos: Int, mime: Mimetype): F[Extracted] =
mime match { mime match {
case Mimetype("application", "zip", _) if ra.name.exists(_.endsWith(".zip")) => case Mimetype("application", "zip", _) if ra.name.exists(_.endsWith(".zip")) =>
ctx.logger.info(s"Extracting zip archive ${ra.name.getOrElse("<noname>")}.") *> ctx.logger.info(s"Extracting zip archive ${ra.name.getOrElse("<noname>")}.") *>
extractZip(ctx, archive)(ra) extractZip(ctx, archive)(ra, pos)
.flatTap(_ => cleanupParents(ctx, ra, archive)) .flatTap(_ => cleanupParents(ctx, ra, archive))
case Mimetype("message", "rfc822", _) => case Mimetype("message", "rfc822", _) =>
ctx.logger.info(s"Reading e-mail ${ra.name.getOrElse("<noname>")}") *> ctx.logger.info(s"Reading e-mail ${ra.name.getOrElse("<noname>")}") *>
extractMail(ctx, archive)(ra) extractMail(ctx, archive)(ra, pos)
.flatTap(_ => cleanupParents(ctx, ra, archive)) .flatTap(_ => cleanupParents(ctx, ra, archive))
case _ => case _ =>
ctx.logger.debug(s"Not an archive: ${mime.asString}") *> ctx.logger.debug(s"Not an archive: ${mime.asString}") *>
Extracted.noArchive(ra).pure[F] Extracted.noArchive(ra, pos, 0).pure[F]
} }
def cleanupParents[F[_]: Sync]( def cleanupParents[F[_]: Sync](
@ -114,7 +131,7 @@ object ExtractArchive {
def extractZip[F[_]: ConcurrentEffect: ContextShift]( def extractZip[F[_]: ConcurrentEffect: ContextShift](
ctx: Context[F, _], ctx: Context[F, _],
archive: Option[RAttachmentArchive] archive: Option[RAttachmentArchive]
)(ra: RAttachment): F[Extracted] = { )(ra: RAttachment, pos: Int): F[Extracted] = {
val zipData = ctx.store.bitpeace val zipData = ctx.store.bitpeace
.get(ra.fileId.id) .get(ra.fileId.id)
.unNoneTerminate .unNoneTerminate
@ -122,7 +139,8 @@ object ExtractArchive {
zipData zipData
.through(Zip.unzipP[F](8192, ctx.blocker)) .through(Zip.unzipP[F](8192, ctx.blocker))
.flatMap(handleEntry(ctx, ra, archive, None)) .zipWithIndex
.flatMap(handleEntry(ctx, ra, pos, archive, None))
.foldMonoid .foldMonoid
.compile .compile
.lastOrError .lastOrError
@ -131,7 +149,7 @@ object ExtractArchive {
def extractMail[F[_]: ConcurrentEffect: ContextShift]( def extractMail[F[_]: ConcurrentEffect: ContextShift](
ctx: Context[F, _], ctx: Context[F, _],
archive: Option[RAttachmentArchive] archive: Option[RAttachmentArchive]
)(ra: RAttachment): F[Extracted] = { )(ra: RAttachment, pos: Int): F[Extracted] = {
val email: Stream[F, Byte] = ctx.store.bitpeace val email: Stream[F, Byte] = ctx.store.bitpeace
.get(ra.fileId.id) .get(ra.fileId.id)
.unNoneTerminate .unNoneTerminate
@ -149,7 +167,8 @@ object ExtractArchive {
ReadMail ReadMail
.mailToEntries(ctx.logger)(mail) .mailToEntries(ctx.logger)(mail)
.flatMap(handleEntry(ctx, ra, archive, mId)) ++ Stream.eval(givenMeta) .zipWithIndex
.flatMap(handleEntry(ctx, ra, pos, archive, mId)) ++ Stream.eval(givenMeta)
} }
.foldMonoid .foldMonoid
.compile .compile
@ -165,11 +184,13 @@ object ExtractArchive {
def handleEntry[F[_]: Sync]( def handleEntry[F[_]: Sync](
ctx: Context[F, _], ctx: Context[F, _],
ra: RAttachment, ra: RAttachment,
pos: Int,
archive: Option[RAttachmentArchive], archive: Option[RAttachmentArchive],
messageId: Option[String] messageId: Option[String]
)( )(
entry: Binary[F] tentry: (Binary[F], Long)
): Stream[F, Extracted] = { ): Stream[F, Extracted] = {
val (entry, subPos) = tentry
val mimeHint = MimetypeHint.filename(entry.name).withAdvertised(entry.mime.asString) val mimeHint = MimetypeHint.filename(entry.name).withAdvertised(entry.mime.asString)
val fileMeta = ctx.store.bitpeace.saveNew(entry.data, 8192, mimeHint) val fileMeta = ctx.store.bitpeace.saveNew(entry.data, 8192, mimeHint)
Stream.eval(ctx.logger.debug(s"Extracted ${entry.name}. Storing as attachment.")) >> Stream.eval(ctx.logger.debug(s"Extracted ${entry.name}. Storing as attachment.")) >>
@ -179,12 +200,12 @@ object ExtractArchive {
id, id,
ra.itemId, ra.itemId,
Ident.unsafe(fm.id), Ident.unsafe(fm.id),
0, //position is updated afterwards pos,
ra.created, ra.created,
Option(entry.name).map(_.trim).filter(_.nonEmpty) Option(entry.name).map(_.trim).filter(_.nonEmpty)
) )
val aa = archive.getOrElse(RAttachmentArchive.of(ra, messageId)).copy(id = id) val aa = archive.getOrElse(RAttachmentArchive.of(ra, messageId)).copy(id = id)
Extracted.of(nra, aa) Extracted.of(nra, aa, pos, subPos.toInt)
} }
} }
@ -204,28 +225,67 @@ object ExtractArchive {
case class Extracted( case class Extracted(
files: Vector[RAttachment], files: Vector[RAttachment],
archives: Vector[RAttachmentArchive], archives: Vector[RAttachmentArchive],
meta: MetaProposalList meta: MetaProposalList,
positions: List[Extracted.Pos]
) { ) {
def ++(e: Extracted) = def ++(e: Extracted) =
Extracted(files ++ e.files, archives ++ e.archives, meta.fillEmptyFrom(e.meta)) Extracted(
files ++ e.files,
archives ++ e.archives,
meta.fillEmptyFrom(e.meta),
positions ++ e.positions
)
def setMeta(m: MetaProposal): Extracted = def setMeta(m: MetaProposal): Extracted =
setMeta(MetaProposalList.of(m)) setMeta(MetaProposalList.of(m))
def setMeta(ml: MetaProposalList): Extracted = def setMeta(ml: MetaProposalList): Extracted =
Extracted(files, archives, meta.fillEmptyFrom(ml)) Extracted(files, archives, meta.fillEmptyFrom(ml), positions)
def updatePositions: Extracted =
NonEmptyList.fromList(positions) match {
case None =>
this
case Some(nel) =>
val sorted = nel.sorted
println(s"---------------------------- $sorted ")
val offset = sorted.head.first
val pos =
sorted.zipWithIndex.map({ case (p, i) => p.id -> (i + offset) }).toList.toMap
val nf =
files.map(f => pos.get(f.id).map(n => f.copy(position = n)).getOrElse(f))
copy(files = nf)
}
} }
object Extracted { object Extracted {
val empty = Extracted(Vector.empty, Vector.empty, MetaProposalList.empty) val empty =
Extracted(Vector.empty, Vector.empty, MetaProposalList.empty, Nil)
def noArchive(ra: RAttachment): Extracted = def noArchive(ra: RAttachment, pos: Int, subPos: Int): Extracted =
Extracted(Vector(ra), Vector.empty, MetaProposalList.empty) Extracted(
Vector(ra),
Vector.empty,
MetaProposalList.empty,
List(Pos(ra.id, pos, subPos))
)
def of(ra: RAttachment, aa: RAttachmentArchive): Extracted = def of(ra: RAttachment, aa: RAttachmentArchive, pos: Int, subPos: Int): Extracted =
Extracted(Vector(ra), Vector(aa), MetaProposalList.empty) Extracted(
Vector(ra),
Vector(aa),
MetaProposalList.empty,
List(Pos(ra.id, pos, subPos))
)
implicit val extractedMonoid: Monoid[Extracted] = implicit val extractedMonoid: Monoid[Extracted] =
Monoid.instance(empty, _ ++ _) Monoid.instance(empty, _ ++ _)
case class Pos(id: Ident, first: Int, second: Int)
object Pos {
implicit val ordering: Order[Pos] =
Order.whenEqual(Order.by(_.first), Order.by(_.second))
}
} }
} }

View File

@ -2,19 +2,20 @@ package docspell.joex.process
import cats.implicits._ import cats.implicits._
import cats.effect._ import cats.effect._
import fs2.Stream
import docspell.common.{ItemState, ProcessItemArgs} import docspell.common.{ItemState, ProcessItemArgs}
import docspell.joex.Config import docspell.joex.Config
import docspell.joex.scheduler.{Context, Task} import docspell.joex.scheduler.Task
import docspell.store.queries.QItem import docspell.store.queries.QItem
import docspell.store.records.{RItem, RJob} import docspell.store.records.RItem
object ItemHandler { object ItemHandler {
def onCancel[F[_]: Sync: ContextShift]: Task[F, ProcessItemArgs, Unit] = def onCancel[F[_]: Sync: ContextShift]: Task[F, ProcessItemArgs, Unit] =
logWarn("Now cancelling. Deleting potentially created data.").flatMap(_ => logWarn("Now cancelling. Deleting potentially created data.").flatMap(_ =>
deleteByFileIds deleteByFileIds.flatMap(_ => deleteFiles)
) )
def apply[F[_]: ConcurrentEffect: ContextShift]( def newItem[F[_]: ConcurrentEffect: ContextShift](
cfg: Config cfg: Config
): Task[F, ProcessItemArgs, Unit] = ): Task[F, ProcessItemArgs, Unit] =
CreateItem[F] CreateItem[F]
@ -25,18 +26,19 @@ object ItemHandler {
def itemStateTask[F[_]: Sync, A]( def itemStateTask[F[_]: Sync, A](
state: ItemState state: ItemState
)(data: ItemData): Task[F, A, ItemData] = )(data: ItemData): Task[F, A, ItemData] =
Task(ctx => ctx.store.transact(RItem.updateState(data.item.id, state)).map(_ => data)) Task(ctx =>
ctx.store
.transact(RItem.updateState(data.item.id, state, ItemState.invalidStates))
.map(_ => data)
)
def isLastRetry[F[_]: Sync, A](ctx: Context[F, A]): F[Boolean] = def isLastRetry[F[_]: Sync]: Task[F, ProcessItemArgs, Boolean] =
for { Task(_.isLastRetry)
current <- ctx.store.transact(RJob.getRetries(ctx.jobId))
last = ctx.config.retries == current.getOrElse(0)
} yield last
def safeProcess[F[_]: ConcurrentEffect: ContextShift]( def safeProcess[F[_]: ConcurrentEffect: ContextShift](
cfg: Config cfg: Config
)(data: ItemData): Task[F, ProcessItemArgs, ItemData] = )(data: ItemData): Task[F, ProcessItemArgs, ItemData] =
Task(isLastRetry[F, ProcessItemArgs] _).flatMap { isLastRetry[F].flatMap {
case true => case true =>
ProcessItem[F](cfg)(data).attempt.flatMap({ ProcessItem[F](cfg)(data).attempt.flatMap({
case Right(d) => case Right(d) =>
@ -60,6 +62,15 @@ object ItemHandler {
} yield () } yield ()
} }
private def deleteFiles[F[_]: Sync]: Task[F, ProcessItemArgs, Unit] =
Task(ctx =>
Stream
.emits(ctx.args.files.map(_.fileMetaId.id))
.flatMap(id => ctx.store.bitpeace.delete(id).attempt.drain)
.compile
.drain
)
private def logWarn[F[_]](msg: => String): Task[F, ProcessItemArgs, Unit] = private def logWarn[F[_]](msg: => String): Task[F, ProcessItemArgs, Unit] =
Task(_.logger.warn(msg)) Task(_.logger.warn(msg))
} }

View File

@ -9,6 +9,11 @@ import docspell.store.records.RItem
object LinkProposal { object LinkProposal {
def apply[F[_]: Sync](data: ItemData): Task[F, ProcessItemArgs, ItemData] = def apply[F[_]: Sync](data: ItemData): Task[F, ProcessItemArgs, ItemData] =
if (data.item.state.isValid)
Task
.log[F, ProcessItemArgs](_.debug(s"Not linking proposals on existing item"))
.map(_ => data)
else
Task { ctx => Task { ctx =>
// sort by weight; order of equal weights is not important, just // sort by weight; order of equal weights is not important, just
// choose one others are then suggestions // choose one others are then suggestions
@ -40,8 +45,9 @@ object LinkProposal {
Result.single(mpt) Result.single(mpt)
) )
case Some(a) => case Some(a) =>
val ids = a.values.map(_.ref.id.id)
ctx.logger.info( ctx.logger.info(
s"Found many (${a.size}, ${a.values.map(_.ref.id.id)}) candidates for ${a.proposalType}. Setting first." s"Found many (${a.size}, ${ids}) candidates for ${a.proposalType}. Setting first."
) *> ) *>
setItemMeta(data.item.id, ctx, a.proposalType, a.values.head.ref.id).map(_ => setItemMeta(data.item.id, ctx, a.proposalType, a.values.head.ref.id).map(_ =>
Result.multiple(mpt) Result.multiple(mpt)

View File

@ -12,11 +12,13 @@ object ProcessItem {
cfg: Config cfg: Config
)(item: ItemData): Task[F, ProcessItemArgs, ItemData] = )(item: ItemData): Task[F, ProcessItemArgs, ItemData] =
ExtractArchive(item) ExtractArchive(item)
.flatMap(Task.setProgress(20))
.flatMap(ConvertPdf(cfg.convert, _)) .flatMap(ConvertPdf(cfg.convert, _))
.flatMap(Task.setProgress(40))
.flatMap(TextExtraction(cfg.extraction, _)) .flatMap(TextExtraction(cfg.extraction, _))
.flatMap(Task.setProgress(50)) .flatMap(Task.setProgress(60))
.flatMap(analysisOnly[F](cfg.textAnalysis)) .flatMap(analysisOnly[F](cfg.textAnalysis))
.flatMap(Task.setProgress(75)) .flatMap(Task.setProgress(80))
.flatMap(LinkProposal[F]) .flatMap(LinkProposal[F])
.flatMap(Task.setProgress(99)) .flatMap(Task.setProgress(99))

View File

@ -60,7 +60,7 @@ object TextExtraction {
rm => rm.setContentIfEmpty(txt.map(_.trim).filter(_.nonEmpty)) rm => rm.setContentIfEmpty(txt.map(_.trim).filter(_.nonEmpty))
) )
est <- dst est <- dst
_ <- ctx.logger.debug( _ <- ctx.logger.info(
s"Extracting text for attachment ${stripAttachmentName(ra)} finished in ${est.formatExact}" s"Extracting text for attachment ${stripAttachmentName(ra)} finished in ${est.formatExact}"
) )
} yield meta } yield meta

View File

@ -259,7 +259,7 @@ object ScanMailboxTask {
priority = Priority.Low, priority = Priority.Low,
tracker = None tracker = None
) )
res <- upload.submit(data, ctx.args.account, false) res <- upload.submit(data, ctx.args.account, false, None)
} yield res } yield res
} }

View File

@ -1,7 +1,7 @@
package docspell.joex.scheduler package docspell.joex.scheduler
import cats.Functor import cats.{Applicative, Functor}
import cats.effect.{Blocker, Concurrent} import cats.effect._
import cats.implicits._ import cats.implicits._
import docspell.common._ import docspell.common._
import docspell.store.Store import docspell.store.Store
@ -23,6 +23,12 @@ trait Context[F[_], A] { self =>
def store: Store[F] def store: Store[F]
final def isLastRetry(implicit ev: Applicative[F]): F[Boolean] =
for {
current <- store.transact(RJob.getRetries(jobId))
last = config.retries == current.getOrElse(0)
} yield last
def blocker: Blocker def blocker: Blocker
def map[C](f: A => C)(implicit F: Functor[F]): Context[F, C] = def map[C](f: A => C)(implicit F: Functor[F]): Context[F, C] =

View File

@ -87,12 +87,6 @@ paths:
The upload meta data can be used to tell, whether multiple The upload meta data can be used to tell, whether multiple
files are one item, or if each file should become a single files are one item, or if each file should become a single
item. By default, each file will be a one item. item. By default, each file will be a one item.
Only certain file types are supported:
* application/pdf
Support for more types might be added.
parameters: parameters:
- $ref: "#/components/parameters/id" - $ref: "#/components/parameters/id"
requestBody: requestBody:
@ -115,6 +109,48 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/BasicResult" $ref: "#/components/schemas/BasicResult"
/open/upload/item/{itemId}/{id}:
post:
tags: [ Upload ]
summary: Upload files to docspell.
description: |
Upload a file to docspell for processing. The id is a *source
id* configured by a collective. Files are submitted for
processing which eventually resuts in an item in the inbox of
the corresponding collective. This endpoint associates the
files to an existing item identified by its `itemId`.
The request must be a `multipart/form-data` request, where the
first part has name `meta`, is optional and may contain upload
metadata as JSON. Checkout the structure `ItemUploadMeta` at
the end if it is not shown here. Other parts specify the
files. Multiple files can be specified, but at least on is
required.
Upload meta data is ignored.
parameters:
- $ref: "#/components/parameters/id"
- $ref: "#/components/parameters/itemId"
requestBody:
content:
multipart/form-data:
schema:
type: object
properties:
meta:
$ref: "#/components/schemas/ItemUploadMeta"
file:
type: array
items:
type: string
format: binary
responses:
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/BasicResult"
/sec/checkfile/{checksum}: /sec/checkfile/{checksum}:
get: get:
tags: [ Upload ] tags: [ Upload ]
@ -155,12 +191,6 @@ paths:
The upload meta data can be used to tell, whether multiple The upload meta data can be used to tell, whether multiple
files are one item, or if each file should become a single files are one item, or if each file should become a single
item. By default, each file will be a one item. item. By default, each file will be a one item.
Only certain file types are supported:
* application/pdf
Support for more types might be added.
security: security:
- authTokenHeader: [] - authTokenHeader: []
requestBody: requestBody:
@ -183,6 +213,50 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/BasicResult" $ref: "#/components/schemas/BasicResult"
/sec/upload/{itemId}:
post:
tags: [ Upload ]
summary: Upload files to docspell.
description: |
Upload files to docspell for processing. This route is meant
for authenticated users that upload files to their account.
This endpoint will associate the files to an existing item
identified by its `itemId`.
Everything else is the same as with the
`/open/upload/item/{itemId}/{id}` endpoint.
The request must be a "multipart/form-data" request, where the
first part is optional and may contain upload metadata as
JSON. Other parts specify the files. Multiple files can be
specified, but at least on is required.
The upload meta data is ignored, since the item already
exists.
security:
- authTokenHeader: []
parameters:
- $ref: "#/components/parameters/itemId"
requestBody:
content:
multipart/form-data:
schema:
type: object
properties:
meta:
$ref: "#/components/schemas/ItemUploadMeta"
file:
type: array
items:
type: string
format: binary
responses:
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/BasicResult"
/open/signup/register: /open/signup/register:
post: post:
tags: [ Registration ] tags: [ Registration ]
@ -3156,6 +3230,13 @@ components:
required: true required: true
schema: schema:
type: string type: string
itemId:
name: itemId
in: path
description: An identifier for an item
required: true
schema:
type: string
full: full:
name: full name: full
in: query in: query

View File

@ -109,7 +109,7 @@ trait Conversions {
coll, coll,
m.name, m.name,
if (m.inbox) Seq(ItemState.Created) if (m.inbox) Seq(ItemState.Created)
else ItemState.validStates, else ItemState.validStates.toList,
m.direction, m.direction,
m.corrPerson, m.corrPerson,
m.corrOrg, m.corrOrg,
@ -470,6 +470,7 @@ trait Conversions {
case UploadResult.Success => BasicResult(true, "Files submitted.") case UploadResult.Success => BasicResult(true, "Files submitted.")
case UploadResult.NoFiles => BasicResult(false, "There were no files to submit.") case UploadResult.NoFiles => BasicResult(false, "There were no files to submit.")
case UploadResult.NoSource => BasicResult(false, "The source id is not valid.") case UploadResult.NoSource => BasicResult(false, "The source id is not valid.")
case UploadResult.NoItem => BasicResult(false, "The item could not be found.")
} }
def basicResult(cr: PassChangeResult): BasicResult = def basicResult(cr: PassChangeResult): BasicResult =

View File

@ -80,7 +80,7 @@ object IntegrationEndpointRoutes {
cfg.backend.files.validMimeTypes cfg.backend.files.validMimeTypes
) )
account = AccountId(coll, Ident.unsafe("docspell-system")) account = AccountId(coll, Ident.unsafe("docspell-system"))
result <- backend.upload.submit(updata, account, true) result <- backend.upload.submit(updata, account, true, None)
res <- Ok(basicResult(result)) res <- Ok(basicResult(result))
} yield res } yield res
} }

View File

@ -2,13 +2,13 @@ package docspell.restserver.routes
import cats.effect._ import cats.effect._
import cats.implicits._ import cats.implicits._
import docspell.common._
import docspell.backend.BackendApp import docspell.backend.BackendApp
import docspell.backend.auth.AuthToken import docspell.backend.auth.AuthToken
import docspell.common.{Ident, Priority}
import docspell.restserver.Config import docspell.restserver.Config
import docspell.restserver.conv.Conversions._ import docspell.restserver.conv.Conversions._
import docspell.restserver.http4s.ResponseGenerator import docspell.restserver.http4s.ResponseGenerator
import org.http4s.HttpRoutes import org.http4s._
import org.http4s.circe.CirceEntityEncoder._ import org.http4s.circe.CirceEntityEncoder._
import org.http4s.EntityDecoder._ import org.http4s.EntityDecoder._
import org.http4s.dsl.Http4sDsl import org.http4s.dsl.Http4sDsl
@ -26,19 +26,14 @@ object UploadRoutes {
val dsl = new Http4sDsl[F] with ResponseGenerator[F] {} val dsl = new Http4sDsl[F] with ResponseGenerator[F] {}
import dsl._ import dsl._
val submitting = submitFiles[F](backend, cfg, Right(user.account)) _
HttpRoutes.of { HttpRoutes.of {
case req @ POST -> Root / "item" => case req @ POST -> Root / "item" =>
for { submitting(req, None, Priority.High, dsl)
multipart <- req.as[Multipart[F]]
updata <- readMultipart( case req @ POST -> Root / "item" / Ident(itemId) =>
multipart, submitting(req, Some(itemId), Priority.High, dsl)
logger,
Priority.High,
cfg.backend.files.validMimeTypes
)
result <- backend.upload.submit(updata, user.account, true)
res <- Ok(basicResult(result))
} yield res
} }
} }
@ -48,17 +43,35 @@ object UploadRoutes {
HttpRoutes.of { HttpRoutes.of {
case req @ POST -> Root / "item" / Ident(id) => case req @ POST -> Root / "item" / Ident(id) =>
submitFiles(backend, cfg, Left(id))(req, None, Priority.Low, dsl)
case req @ POST -> Root / "item" / Ident(itemId) / Ident(id) =>
submitFiles(backend, cfg, Left(id))(req, Some(itemId), Priority.Low, dsl)
}
}
private def submitFiles[F[_]: Effect](
backend: BackendApp[F],
cfg: Config,
accOrSrc: Either[Ident, AccountId]
)(
req: Request[F],
itemId: Option[Ident],
prio: Priority,
dsl: Http4sDsl[F]
): F[Response[F]] = {
import dsl._
for { for {
multipart <- req.as[Multipart[F]] multipart <- req.as[Multipart[F]]
updata <- readMultipart( updata <- readMultipart(
multipart, multipart,
logger, logger,
Priority.Low, prio,
cfg.backend.files.validMimeTypes cfg.backend.files.validMimeTypes
) )
result <- backend.upload.submit(updata, id, true) result <- backend.upload.submitEither(updata, accOrSrc, true, itemId)
res <- Ok(basicResult(result)) res <- Ok(basicResult(result))
} yield res } yield res
} }
}
} }

View File

@ -101,4 +101,6 @@ case class Column(name: String, ns: String = "", alias: String = "") {
def asc: Fragment = def asc: Fragment =
f ++ fr"asc" f ++ fr"asc"
def max: Fragment =
fr"MAX(" ++ f ++ fr")"
} }

View File

@ -287,7 +287,7 @@ object QItem {
n <- store.transact(RItem.deleteByIdAndCollective(itemId, collective)) n <- store.transact(RItem.deleteByIdAndCollective(itemId, collective))
} yield tn + rn + n } yield tn + rn + n
def findByFileIds(fileMetaIds: List[Ident]): ConnectionIO[Vector[RItem]] = { def findByFileIds(fileMetaIds: Seq[Ident]): ConnectionIO[Vector[RItem]] = {
val IC = RItem.Columns val IC = RItem.Columns
val AC = RAttachment.Columns val AC = RAttachment.Columns
val q = val q =

View File

@ -38,6 +38,11 @@ object RAttachment {
fr"${v.id},${v.itemId},${v.fileId.id},${v.position},${v.created},${v.name}" fr"${v.id},${v.itemId},${v.fileId.id},${v.position},${v.created},${v.name}"
).update.run ).update.run
def nextPosition(id: Ident): ConnectionIO[Int] =
for {
max <- selectSimple(position.max, table, itemId.is(id)).query[Option[Int]].unique
} yield max.map(_ + 1).getOrElse(0)
def updateFileIdAndName( def updateFileIdAndName(
attachId: Ident, attachId: Ident,
fId: Ident, fId: Ident,

View File

@ -1,7 +1,8 @@
package docspell.store.records package docspell.store.records
import cats.implicits._ import cats.data.NonEmptyList
import cats.effect.Sync import cats.effect.Sync
import cats.implicits._
import doobie._ import doobie._
import doobie.implicits._ import doobie.implicits._
import docspell.common._ import docspell.common._
@ -110,12 +111,16 @@ object RItem {
def getCollective(itemId: Ident): ConnectionIO[Option[Ident]] = def getCollective(itemId: Ident): ConnectionIO[Option[Ident]] =
selectSimple(List(cid), table, id.is(itemId)).query[Ident].option selectSimple(List(cid), table, id.is(itemId)).query[Ident].option
def updateState(itemId: Ident, itemState: ItemState): ConnectionIO[Int] = def updateState(
itemId: Ident,
itemState: ItemState,
existing: NonEmptyList[ItemState]
): ConnectionIO[Int] =
for { for {
t <- currentTime t <- currentTime
n <- updateRow( n <- updateRow(
table, table,
id.is(itemId), and(id.is(itemId), state.isIn(existing)),
commas(state.setTo(itemState), updated.setTo(t)) commas(state.setTo(itemState), updated.setTo(t))
).update.run ).update.run
} yield n } yield n
@ -285,4 +290,7 @@ object RItem {
def existsById(itemId: Ident): ConnectionIO[Boolean] = def existsById(itemId: Ident): ConnectionIO[Boolean] =
selectCount(id, table, id.is(itemId)).query[Int].unique.map(_ > 0) selectCount(id, table, id.is(itemId)).query[Int].unique.map(_ > 0)
def findByIdAndCollective(itemId: Ident, coll: Ident): ConnectionIO[Option[RItem]] =
selectSimple(all, table, and(id.is(itemId), cid.is(coll))).query[RItem].option
} }

View File

@ -71,6 +71,7 @@ module Api exposing
, submitNotifyDueItems , submitNotifyDueItems
, updateScanMailbox , updateScanMailbox
, upload , upload
, uploadAmend
, uploadSingle , uploadSingle
, versionInfo , versionInfo
) )
@ -429,7 +430,42 @@ createImapSettings flags mname ems receive =
--- Upload --- Upload
upload : Flags -> Maybe String -> ItemUploadMeta -> List File -> (String -> Result Http.Error BasicResult -> msg) -> List (Cmd msg) uploadAmend :
Flags
-> String
-> List File
-> (String -> Result Http.Error BasicResult -> msg)
-> List (Cmd msg)
uploadAmend flags itemId files receive =
let
mkReq file =
let
fid =
Util.File.makeFileId file
path =
"/api/v1/sec/upload/item/" ++ itemId
in
Http2.authPostTrack
{ url = flags.config.baseUrl ++ path
, account = getAccount flags
, body =
Http.multipartBody <|
[ Http.filePart "file[]" file ]
, expect = Http.expectJson (receive fid) Api.Model.BasicResult.decoder
, tracker = fid
}
in
List.map mkReq files
upload :
Flags
-> Maybe String
-> ItemUploadMeta
-> List File
-> (String -> Result Http.Error BasicResult -> msg)
-> List (Cmd msg)
upload flags sourceId meta files receive = upload flags sourceId meta files receive =
let let
metaStr = metaStr =
@ -457,7 +493,14 @@ upload flags sourceId meta files receive =
List.map mkReq files List.map mkReq files
uploadSingle : Flags -> Maybe String -> ItemUploadMeta -> String -> List File -> (Result Http.Error BasicResult -> msg) -> Cmd msg uploadSingle :
Flags
-> Maybe String
-> ItemUploadMeta
-> String
-> List File
-> (Result Http.Error BasicResult -> msg)
-> Cmd msg
uploadSingle flags sourceId meta track files receive = uploadSingle flags sourceId meta track files receive =
let let
metaStr = metaStr =

View File

@ -74,7 +74,7 @@ updateWithSub msg model =
updateNewInvite m model |> noSub updateNewInvite m model |> noSub
ItemDetailMsg m -> ItemDetailMsg m ->
updateItemDetail m model |> noSub updateItemDetail m model
VersionResp (Ok info) -> VersionResp (Ok info) ->
( { model | version = info }, Cmd.none ) |> noSub ( { model | version = info }, Cmd.none ) |> noSub
@ -172,17 +172,20 @@ updateWithSub msg model =
( { model | navMenuOpen = not model.navMenuOpen }, Cmd.none, Sub.none ) ( { model | navMenuOpen = not model.navMenuOpen }, Cmd.none, Sub.none )
updateItemDetail : Page.ItemDetail.Data.Msg -> Model -> ( Model, Cmd Msg ) updateItemDetail : Page.ItemDetail.Data.Msg -> Model -> ( Model, Cmd Msg, Sub Msg )
updateItemDetail lmsg model = updateItemDetail lmsg model =
let let
inav = inav =
Page.Home.Data.itemNav model.itemDetailModel.detail.item.id model.homeModel Page.Home.Data.itemNav model.itemDetailModel.detail.item.id model.homeModel
( lm, lc ) = ( lm, lc, ls ) =
Page.ItemDetail.Update.update model.key model.flags inav.next lmsg model.itemDetailModel Page.ItemDetail.Update.update model.key model.flags inav.next lmsg model.itemDetailModel
in in
( { model | itemDetailModel = lm } ( { model
| itemDetailModel = lm
}
, Cmd.map ItemDetailMsg lc , Cmd.map ItemDetailMsg lc
, Sub.map ItemDetailMsg ls
) )
@ -341,7 +344,19 @@ initPage model page =
updateQueue Page.Queue.Data.StopRefresh model updateQueue Page.Queue.Data.StopRefresh model
ItemDetailPage id -> ItemDetailPage id ->
updateItemDetail (Page.ItemDetail.Data.Init id) model let
updateDetail m__ =
let
( m, c, s ) =
updateItemDetail (Page.ItemDetail.Data.Init id) m__
in
( { m | subs = Sub.batch [ m.subs, s ] }, c )
in
Util.Update.andThen1
[ updateDetail
, updateQueue Page.Queue.Data.StopRefresh
]
model
noSub : ( Model, Cmd Msg ) -> ( Model, Cmd Msg, Sub Msg ) noSub : ( Model, Cmd Msg ) -> ( Model, Cmd Msg, Sub Msg )

View File

@ -136,6 +136,12 @@ view model =
[ i [ class "folder open icon" ] [] [ i [ class "folder open icon" ] []
, text "Select ..." , text "Select ..."
] ]
, div [ class "ui center aligned text container" ]
[ span [ class "small-info" ]
[ text "Choose document files (pdf, docx, txt, html, ). "
, text "Archives (zip and eml) are extracted."
]
]
] ]

View File

@ -25,6 +25,7 @@ import Browser.Navigation as Nav
import Comp.AttachmentMeta import Comp.AttachmentMeta
import Comp.DatePicker import Comp.DatePicker
import Comp.Dropdown exposing (isDropdownChangeMsg) import Comp.Dropdown exposing (isDropdownChangeMsg)
import Comp.Dropzone
import Comp.ItemMail import Comp.ItemMail
import Comp.MarkdownInput import Comp.MarkdownInput
import Comp.SentMails import Comp.SentMails
@ -34,12 +35,16 @@ import Data.Flags exposing (Flags)
import Data.Icons as Icons import Data.Icons as Icons
import DatePicker exposing (DatePicker) import DatePicker exposing (DatePicker)
import Dict exposing (Dict) import Dict exposing (Dict)
import File exposing (File)
import Html exposing (..) import Html exposing (..)
import Html.Attributes exposing (..) import Html.Attributes exposing (..)
import Html.Events exposing (onCheck, onClick, onInput) import Html.Events exposing (onCheck, onClick, onInput)
import Http import Http
import Markdown import Markdown
import Page exposing (Page(..)) import Page exposing (Page(..))
import Ports
import Set exposing (Set)
import Util.File exposing (makeFileId)
import Util.Http import Util.Http
import Util.List import Util.List
import Util.Maybe import Util.Maybe
@ -77,6 +82,12 @@ type alias Model =
, attachMetaOpen : Bool , attachMetaOpen : Bool
, pdfNativeView : Bool , pdfNativeView : Bool
, deleteAttachConfirm : Comp.YesNoDimmer.Model , deleteAttachConfirm : Comp.YesNoDimmer.Model
, addFilesOpen : Bool
, addFilesModel : Comp.Dropzone.Model
, selectedFiles : List File
, completed : Set String
, errored : Set String
, loading : Set String
} }
@ -165,6 +176,12 @@ emptyModel =
, attachMetaOpen = False , attachMetaOpen = False
, pdfNativeView = False , pdfNativeView = False
, deleteAttachConfirm = Comp.YesNoDimmer.emptyModel , deleteAttachConfirm = Comp.YesNoDimmer.emptyModel
, addFilesOpen = False
, addFilesModel = Comp.Dropzone.init Comp.Dropzone.defaultSettings
, selectedFiles = []
, completed = Set.empty
, errored = Set.empty
, loading = Set.empty
} }
@ -221,6 +238,12 @@ type Msg
| RequestDeleteAttachment String | RequestDeleteAttachment String
| DeleteAttachConfirm String Comp.YesNoDimmer.Msg | DeleteAttachConfirm String Comp.YesNoDimmer.Msg
| DeleteAttachResp (Result Http.Error BasicResult) | DeleteAttachResp (Result Http.Error BasicResult)
| AddFilesToggle
| AddFilesMsg Comp.Dropzone.Msg
| AddFilesSubmitUpload
| AddFilesUploadResp String (Result Http.Error BasicResult)
| AddFilesProgress String Http.Progress
| AddFilesReset
@ -334,7 +357,48 @@ setDueDate flags model date =
Api.setItemDueDate flags model.item.id (OptionalDate date) SaveResp Api.setItemDueDate flags model.item.id (OptionalDate date) SaveResp
update : Nav.Key -> Flags -> Maybe String -> Msg -> Model -> ( Model, Cmd Msg ) isLoading : Model -> File -> Bool
isLoading model file =
Set.member (makeFileId file) model.loading
isCompleted : Model -> File -> Bool
isCompleted model file =
Set.member (makeFileId file) model.completed
isError : Model -> File -> Bool
isError model file =
Set.member (makeFileId file) model.errored
isIdle : Model -> File -> Bool
isIdle model file =
not (isLoading model file || isCompleted model file || isError model file)
setCompleted : Model -> String -> Set String
setCompleted model fileid =
Set.insert fileid model.completed
setErrored : Model -> String -> Set String
setErrored model fileid =
Set.insert fileid model.errored
isSuccessAll : Model -> Bool
isSuccessAll model =
List.map makeFileId model.selectedFiles
|> List.all (\id -> Set.member id model.completed)
noSub : ( Model, Cmd Msg ) -> ( Model, Cmd Msg, Sub Msg )
noSub ( m, c ) =
( m, c, Sub.none )
update : Nav.Key -> Flags -> Maybe String -> Msg -> Model -> ( Model, Cmd Msg, Sub Msg )
update key flags next msg model = update key flags next msg model =
case msg of case msg of
Init -> Init ->
@ -345,6 +409,7 @@ update key flags next msg model =
( im, ic ) = ( im, ic ) =
Comp.ItemMail.init flags Comp.ItemMail.init flags
in in
noSub
( { model | itemDatePicker = dp, dueDatePicker = dp, itemMail = im, visibleAttach = 0 } ( { model | itemDatePicker = dp, dueDatePicker = dp, itemMail = im, visibleAttach = 0 }
, Cmd.batch , Cmd.batch
[ getOptions flags [ getOptions flags
@ -357,10 +422,10 @@ update key flags next msg model =
SetItem item -> SetItem item ->
let let
( m1, c1 ) = ( m1, c1, s1 ) =
update key flags next (TagDropdownMsg (Comp.Dropdown.SetSelection item.tags)) model update key flags next (TagDropdownMsg (Comp.Dropdown.SetSelection item.tags)) model
( m2, c2 ) = ( m2, c2, s2 ) =
update key update key
flags flags
next next
@ -374,7 +439,7 @@ update key flags next msg model =
) )
m1 m1
( m3, c3 ) = ( m3, c3, s3 ) =
update key update key
flags flags
next next
@ -388,7 +453,7 @@ update key flags next msg model =
) )
m2 m2
( m4, c4 ) = ( m4, c4, s4 ) =
update key update key
flags flags
next next
@ -402,7 +467,7 @@ update key flags next msg model =
) )
m3 m3
( m5, c5 ) = ( m5, c5, s5 ) =
update key update key
flags flags
next next
@ -416,7 +481,7 @@ update key flags next msg model =
) )
m4 m4
( m6, c6 ) = ( m6, c6, s6 ) =
update key update key
flags flags
next next
@ -430,6 +495,9 @@ update key flags next msg model =
) )
m5 m5
( m7, c7, s7 ) =
update key flags next AddFilesReset m6
proposalCmd = proposalCmd =
if item.state == "created" then if item.state == "created" then
Api.getItemProposals flags item.id GetProposalResp Api.getItemProposals flags item.id GetProposalResp
@ -437,7 +505,7 @@ update key flags next msg model =
else else
Cmd.none Cmd.none
in in
( { m6 ( { m7
| item = item | item = item
, nameModel = item.name , nameModel = item.name
, notesModel = item.notes , notesModel = item.notes
@ -453,24 +521,26 @@ update key flags next msg model =
, c4 , c4
, c5 , c5
, c6 , c6
, c7
, getOptions flags , getOptions flags
, proposalCmd , proposalCmd
, Api.getSentMails flags item.id SentMailsResp , Api.getSentMails flags item.id SentMailsResp
] ]
, Sub.batch [ s1, s2, s3, s4, s5, s6, s7 ]
) )
SetActiveAttachment pos -> SetActiveAttachment pos ->
( { model | visibleAttach = pos, sentMailsOpen = False }, Cmd.none ) noSub ( { model | visibleAttach = pos, sentMailsOpen = False }, Cmd.none )
ToggleMenu -> ToggleMenu ->
( { model | menuOpen = not model.menuOpen }, Cmd.none ) noSub ( { model | menuOpen = not model.menuOpen }, Cmd.none )
ReloadItem -> ReloadItem ->
if model.item.id == "" then if model.item.id == "" then
( model, Cmd.none ) noSub ( model, Cmd.none )
else else
( model, Api.itemDetail flags model.item.id GetItemResp ) noSub ( model, Api.itemDetail flags model.item.id GetItemResp )
TagDropdownMsg m -> TagDropdownMsg m ->
let let
@ -487,7 +557,7 @@ update key flags next msg model =
else else
Cmd.none Cmd.none
in in
( newModel, Cmd.batch [ save, Cmd.map TagDropdownMsg c2 ] ) noSub ( newModel, Cmd.batch [ save, Cmd.map TagDropdownMsg c2 ] )
DirDropdownMsg m -> DirDropdownMsg m ->
let let
@ -504,7 +574,7 @@ update key flags next msg model =
else else
Cmd.none Cmd.none
in in
( newModel, Cmd.batch [ save, Cmd.map DirDropdownMsg c2 ] ) noSub ( newModel, Cmd.batch [ save, Cmd.map DirDropdownMsg c2 ] )
OrgDropdownMsg m -> OrgDropdownMsg m ->
let let
@ -524,7 +594,7 @@ update key flags next msg model =
else else
Cmd.none Cmd.none
in in
( newModel, Cmd.batch [ save, Cmd.map OrgDropdownMsg c2 ] ) noSub ( newModel, Cmd.batch [ save, Cmd.map OrgDropdownMsg c2 ] )
CorrPersonMsg m -> CorrPersonMsg m ->
let let
@ -544,7 +614,7 @@ update key flags next msg model =
else else
Cmd.none Cmd.none
in in
( newModel, Cmd.batch [ save, Cmd.map CorrPersonMsg c2 ] ) noSub ( newModel, Cmd.batch [ save, Cmd.map CorrPersonMsg c2 ] )
ConcPersonMsg m -> ConcPersonMsg m ->
let let
@ -564,7 +634,7 @@ update key flags next msg model =
else else
Cmd.none Cmd.none
in in
( newModel, Cmd.batch [ save, Cmd.map ConcPersonMsg c2 ] ) noSub ( newModel, Cmd.batch [ save, Cmd.map ConcPersonMsg c2 ] )
ConcEquipMsg m -> ConcEquipMsg m ->
let let
@ -584,20 +654,22 @@ update key flags next msg model =
else else
Cmd.none Cmd.none
in in
( newModel, Cmd.batch [ save, Cmd.map ConcEquipMsg c2 ] ) noSub ( newModel, Cmd.batch [ save, Cmd.map ConcEquipMsg c2 ] )
SetName str -> SetName str ->
( { model | nameModel = str }, Cmd.none ) noSub ( { model | nameModel = str }, Cmd.none )
SaveName -> SaveName ->
( model, setName flags model ) noSub ( model, setName flags model )
SetNotes str -> SetNotes str ->
noSub
( { model | notesModel = Util.Maybe.fromString str } ( { model | notesModel = Util.Maybe.fromString str }
, Cmd.none , Cmd.none
) )
ToggleNotes -> ToggleNotes ->
noSub
( { model ( { model
| notesField = | notesField =
if model.notesField == ViewNotes then if model.notesField == ViewNotes then
@ -610,6 +682,7 @@ update key flags next msg model =
) )
ToggleEditNotes -> ToggleEditNotes ->
noSub
( { model ( { model
| notesField = | notesField =
if isEditNotes model.notesField then if isEditNotes model.notesField then
@ -628,21 +701,25 @@ update key flags next msg model =
( lm2, str ) = ( lm2, str ) =
Comp.MarkdownInput.update (Maybe.withDefault "" model.notesModel) lm em Comp.MarkdownInput.update (Maybe.withDefault "" model.notesModel) lm em
in in
noSub
( { model | notesField = EditNotes lm2, notesModel = Util.Maybe.fromString str } ( { model | notesField = EditNotes lm2, notesModel = Util.Maybe.fromString str }
, Cmd.none , Cmd.none
) )
_ -> HideNotes ->
( model, Cmd.none ) noSub ( model, Cmd.none )
ViewNotes ->
noSub ( model, Cmd.none )
SaveNotes -> SaveNotes ->
( model, setNotes flags model ) noSub ( model, setNotes flags model )
ConfirmItem -> ConfirmItem ->
( model, Api.setConfirmed flags model.item.id SaveResp ) noSub ( model, Api.setConfirmed flags model.item.id SaveResp )
UnconfirmItem -> UnconfirmItem ->
( model, Api.setUnconfirmed flags model.item.id SaveResp ) noSub ( model, Api.setUnconfirmed flags model.item.id SaveResp )
ItemDatePickerMsg m -> ItemDatePickerMsg m ->
let let
@ -655,13 +732,13 @@ update key flags next msg model =
newModel = newModel =
{ model | itemDatePicker = dp, itemDate = Just (Comp.DatePicker.midOfDay date) } { model | itemDatePicker = dp, itemDate = Just (Comp.DatePicker.midOfDay date) }
in in
( newModel, setDate flags newModel newModel.itemDate ) noSub ( newModel, setDate flags newModel newModel.itemDate )
_ -> _ ->
( { model | itemDatePicker = dp }, Cmd.none ) noSub ( { model | itemDatePicker = dp }, Cmd.none )
RemoveDate -> RemoveDate ->
( { model | itemDate = Nothing }, setDate flags model Nothing ) noSub ( { model | itemDate = Nothing }, setDate flags model Nothing )
DueDatePickerMsg m -> DueDatePickerMsg m ->
let let
@ -674,13 +751,13 @@ update key flags next msg model =
newModel = newModel =
{ model | dueDatePicker = dp, dueDate = Just (Comp.DatePicker.midOfDay date) } { model | dueDatePicker = dp, dueDate = Just (Comp.DatePicker.midOfDay date) }
in in
( newModel, setDueDate flags newModel newModel.dueDate ) noSub ( newModel, setDueDate flags newModel newModel.dueDate )
_ -> _ ->
( { model | dueDatePicker = dp }, Cmd.none ) noSub ( { model | dueDatePicker = dp }, Cmd.none )
RemoveDueDate -> RemoveDueDate ->
( { model | dueDate = Nothing }, setDueDate flags model Nothing ) noSub ( { model | dueDate = Nothing }, setDueDate flags model Nothing )
DeleteItemConfirm m -> DeleteItemConfirm m ->
let let
@ -694,41 +771,41 @@ update key flags next msg model =
else else
Cmd.none Cmd.none
in in
( { model | deleteItemConfirm = cm }, cmd ) noSub ( { model | deleteItemConfirm = cm }, cmd )
RequestDelete -> RequestDelete ->
update key flags next (DeleteItemConfirm Comp.YesNoDimmer.activate) model update key flags next (DeleteItemConfirm Comp.YesNoDimmer.activate) model
SetCorrOrgSuggestion idname -> SetCorrOrgSuggestion idname ->
( model, setCorrOrg flags model (Just idname) ) noSub ( model, setCorrOrg flags model (Just idname) )
SetCorrPersonSuggestion idname -> SetCorrPersonSuggestion idname ->
( model, setCorrPerson flags model (Just idname) ) noSub ( model, setCorrPerson flags model (Just idname) )
SetConcPersonSuggestion idname -> SetConcPersonSuggestion idname ->
( model, setConcPerson flags model (Just idname) ) noSub ( model, setConcPerson flags model (Just idname) )
SetConcEquipSuggestion idname -> SetConcEquipSuggestion idname ->
( model, setConcEquip flags model (Just idname) ) noSub ( model, setConcEquip flags model (Just idname) )
SetItemDateSuggestion date -> SetItemDateSuggestion date ->
( model, setDate flags model (Just date) ) noSub ( model, setDate flags model (Just date) )
SetDueDateSuggestion date -> SetDueDateSuggestion date ->
( model, setDueDate flags model (Just date) ) noSub ( model, setDueDate flags model (Just date) )
GetTagsResp (Ok tags) -> GetTagsResp (Ok tags) ->
let let
tagList = tagList =
Comp.Dropdown.SetOptions tags.items Comp.Dropdown.SetOptions tags.items
( m1, c1 ) = ( m1, c1, s1 ) =
update key flags next (TagDropdownMsg tagList) model update key flags next (TagDropdownMsg tagList) model
in in
( m1, c1 ) ( m1, c1, s1 )
GetTagsResp (Err _) -> GetTagsResp (Err _) ->
( model, Cmd.none ) noSub ( model, Cmd.none )
GetOrgResp (Ok orgs) -> GetOrgResp (Ok orgs) ->
let let
@ -738,23 +815,23 @@ update key flags next msg model =
update key flags next (OrgDropdownMsg opts) model update key flags next (OrgDropdownMsg opts) model
GetOrgResp (Err _) -> GetOrgResp (Err _) ->
( model, Cmd.none ) noSub ( model, Cmd.none )
GetPersonResp (Ok ps) -> GetPersonResp (Ok ps) ->
let let
opts = opts =
Comp.Dropdown.SetOptions ps.items Comp.Dropdown.SetOptions ps.items
( m1, c1 ) = ( m1, c1, s1 ) =
update key flags next (CorrPersonMsg opts) model update key flags next (CorrPersonMsg opts) model
( m2, c2 ) = ( m2, c2, s2 ) =
update key flags next (ConcPersonMsg opts) m1 update key flags next (ConcPersonMsg opts) m1
in in
( m2, Cmd.batch [ c1, c2 ] ) ( m2, Cmd.batch [ c1, c2 ], Sub.batch [ s1, s2 ] )
GetPersonResp (Err _) -> GetPersonResp (Err _) ->
( model, Cmd.none ) noSub ( model, Cmd.none )
GetEquipResp (Ok equips) -> GetEquipResp (Ok equips) ->
let let
@ -767,44 +844,44 @@ update key flags next msg model =
update key flags next (ConcEquipMsg opts) model update key flags next (ConcEquipMsg opts) model
GetEquipResp (Err _) -> GetEquipResp (Err _) ->
( model, Cmd.none ) noSub ( model, Cmd.none )
SaveResp (Ok res) -> SaveResp (Ok res) ->
if res.success then if res.success then
( model, Api.itemDetail flags model.item.id GetItemResp ) noSub ( model, Api.itemDetail flags model.item.id GetItemResp )
else else
( model, Cmd.none ) noSub ( model, Cmd.none )
SaveResp (Err _) -> SaveResp (Err _) ->
( model, Cmd.none ) noSub ( model, Cmd.none )
DeleteResp (Ok res) -> DeleteResp (Ok res) ->
if res.success then if res.success then
case next of case next of
Just id -> Just id ->
( model, Page.set key (ItemDetailPage id) ) noSub ( model, Page.set key (ItemDetailPage id) )
Nothing -> Nothing ->
( model, Page.set key HomePage ) noSub ( model, Page.set key HomePage )
else else
( model, Cmd.none ) noSub ( model, Cmd.none )
DeleteResp (Err _) -> DeleteResp (Err _) ->
( model, Cmd.none ) noSub ( model, Cmd.none )
GetItemResp (Ok item) -> GetItemResp (Ok item) ->
update key flags next (SetItem item) model update key flags next (SetItem item) model
GetItemResp (Err _) -> GetItemResp (Err _) ->
( model, Cmd.none ) noSub ( model, Cmd.none )
GetProposalResp (Ok ip) -> GetProposalResp (Ok ip) ->
( { model | itemProposals = ip }, Cmd.none ) noSub ( { model | itemProposals = ip }, Cmd.none )
GetProposalResp (Err _) -> GetProposalResp (Err _) ->
( model, Cmd.none ) noSub ( model, Cmd.none )
ItemMailMsg m -> ItemMailMsg m ->
let let
@ -813,9 +890,10 @@ update key flags next msg model =
in in
case fa of case fa of
Comp.ItemMail.FormNone -> Comp.ItemMail.FormNone ->
( { model | itemMail = im }, Cmd.map ItemMailMsg ic ) noSub ( { model | itemMail = im }, Cmd.map ItemMailMsg ic )
Comp.ItemMail.FormCancel -> Comp.ItemMail.FormCancel ->
noSub
( { model ( { model
| itemMail = Comp.ItemMail.clear im | itemMail = Comp.ItemMail.clear im
, mailOpen = False , mailOpen = False
@ -832,6 +910,7 @@ update key flags next msg model =
, conn = sm.conn , conn = sm.conn
} }
in in
noSub
( { model | mailSending = True } ( { model | mailSending = True }
, Cmd.batch , Cmd.batch
[ Cmd.map ItemMailMsg ic [ Cmd.map ItemMailMsg ic
@ -851,6 +930,7 @@ update key flags next msg model =
else else
Nothing Nothing
in in
noSub
( { model ( { model
| mailOpen = newOpen | mailOpen = newOpen
, mailSendResult = sendResult , mailSendResult = sendResult
@ -867,6 +947,7 @@ update key flags next msg model =
else else
model.itemMail model.itemMail
in in
noSub
( { model ( { model
| itemMail = mm | itemMail = mm
, mailSending = False , mailSending = False
@ -884,6 +965,7 @@ update key flags next msg model =
errmsg = errmsg =
Util.Http.errorToString err Util.Http.errorToString err
in in
noSub
( { model ( { model
| mailSendResult = Just (BasicResult False errmsg) | mailSendResult = Just (BasicResult False errmsg)
, mailSending = False , mailSending = False
@ -896,24 +978,25 @@ update key flags next msg model =
sm = sm =
Comp.SentMails.update m model.sentMails Comp.SentMails.update m model.sentMails
in in
( { model | sentMails = sm }, Cmd.none ) noSub ( { model | sentMails = sm }, Cmd.none )
ToggleSentMails -> ToggleSentMails ->
( { model | sentMailsOpen = not model.sentMailsOpen, visibleAttach = -1 }, Cmd.none ) noSub ( { model | sentMailsOpen = not model.sentMailsOpen, visibleAttach = -1 }, Cmd.none )
SentMailsResp (Ok list) -> SentMailsResp (Ok list) ->
let let
sm = sm =
Comp.SentMails.initMails list.items Comp.SentMails.initMails list.items
in in
( { model | sentMails = sm }, Cmd.none ) noSub ( { model | sentMails = sm }, Cmd.none )
SentMailsResp (Err _) -> SentMailsResp (Err _) ->
( model, Cmd.none ) noSub ( model, Cmd.none )
AttachMetaClick id -> AttachMetaClick id ->
case Dict.get id model.attachMeta of case Dict.get id model.attachMeta of
Just _ -> Just _ ->
noSub
( { model | attachMetaOpen = not model.attachMetaOpen } ( { model | attachMetaOpen = not model.attachMetaOpen }
, Cmd.none , Cmd.none
) )
@ -926,6 +1009,7 @@ update key flags next msg model =
nextMeta = nextMeta =
Dict.insert id am model.attachMeta Dict.insert id am model.attachMeta
in in
noSub
( { model | attachMeta = nextMeta, attachMetaOpen = True } ( { model | attachMeta = nextMeta, attachMetaOpen = True }
, Cmd.map (AttachMetaMsg id) ac , Cmd.map (AttachMetaMsg id) ac
) )
@ -937,14 +1021,16 @@ update key flags next msg model =
am = am =
Comp.AttachmentMeta.update lmsg cm Comp.AttachmentMeta.update lmsg cm
in in
noSub
( { model | attachMeta = Dict.insert id am model.attachMeta } ( { model | attachMeta = Dict.insert id am model.attachMeta }
, Cmd.none , Cmd.none
) )
Nothing -> Nothing ->
( model, Cmd.none ) noSub ( model, Cmd.none )
TogglePdfNativeView -> TogglePdfNativeView ->
noSub
( { model | pdfNativeView = not model.pdfNativeView } ( { model | pdfNativeView = not model.pdfNativeView }
, Cmd.none , Cmd.none
) )
@ -961,17 +1047,17 @@ update key flags next msg model =
else else
Cmd.none Cmd.none
in in
( { model | deleteAttachConfirm = cm }, cmd ) noSub ( { model | deleteAttachConfirm = cm }, cmd )
DeleteAttachResp (Ok res) -> DeleteAttachResp (Ok res) ->
if res.success then if res.success then
update key flags next ReloadItem model update key flags next ReloadItem model
else else
( model, Cmd.none ) noSub ( model, Cmd.none )
DeleteAttachResp (Err _) -> DeleteAttachResp (Err _) ->
( model, Cmd.none ) noSub ( model, Cmd.none )
RequestDeleteAttachment id -> RequestDeleteAttachment id ->
update key update key
@ -980,6 +1066,114 @@ update key flags next msg model =
(DeleteAttachConfirm id Comp.YesNoDimmer.activate) (DeleteAttachConfirm id Comp.YesNoDimmer.activate)
model model
AddFilesToggle ->
noSub
( { model | addFilesOpen = not model.addFilesOpen }
, Cmd.none
)
AddFilesMsg lm ->
let
( dm, dc, df ) =
Comp.Dropzone.update lm model.addFilesModel
nextFiles =
model.selectedFiles ++ df
in
noSub
( { model | addFilesModel = dm, selectedFiles = nextFiles }
, Cmd.map AddFilesMsg dc
)
AddFilesReset ->
noSub
( { model
| selectedFiles = []
, addFilesModel = Comp.Dropzone.init Comp.Dropzone.defaultSettings
, completed = Set.empty
, errored = Set.empty
, loading = Set.empty
}
, Cmd.none
)
AddFilesSubmitUpload ->
let
fileids =
List.map makeFileId model.selectedFiles
uploads =
Cmd.batch (Api.uploadAmend flags model.item.id model.selectedFiles AddFilesUploadResp)
tracker =
Sub.batch <| List.map (\id -> Http.track id (AddFilesProgress id)) fileids
( cm2, _, _ ) =
Comp.Dropzone.update (Comp.Dropzone.setActive False) model.addFilesModel
in
( { model | loading = Set.fromList fileids, addFilesModel = cm2 }
, uploads
, tracker
)
AddFilesUploadResp fileid (Ok res) ->
let
compl =
if res.success then
setCompleted model fileid
else
model.completed
errs =
if not res.success then
setErrored model fileid
else
model.errored
load =
Set.remove fileid model.loading
newModel =
{ model | completed = compl, errored = errs, loading = load }
in
noSub
( newModel
, Ports.setProgress ( fileid, 100 )
)
AddFilesUploadResp fileid (Err _) ->
let
errs =
setErrored model fileid
load =
Set.remove fileid model.loading
in
noSub ( { model | errored = errs, loading = load }, Cmd.none )
AddFilesProgress fileid progress ->
let
percent =
case progress of
Http.Sending p ->
Http.fractionSent p
|> (*) 100
|> round
_ ->
0
updateBars =
if percent == 0 then
Cmd.none
else
Ports.setProgress ( fileid, percent )
in
noSub ( model, updateBars )
-- view -- view
@ -1001,7 +1195,11 @@ view inav model =
, div , div
[ classList [ classList
[ ( "ui ablue-comp menu", True ) [ ( "ui ablue-comp menu", True )
, ( "top attached", model.mailOpen ) , ( "top attached"
, model.mailOpen
|| model.addFilesOpen
|| isEditNotes model.notesField
)
] ]
] ]
[ a [ class "item", Page.href HomePage ] [ a [ class "item", Page.href HomePage ]
@ -1066,8 +1264,25 @@ view inav model =
] ]
[ Icons.editNotesIcon [ Icons.editNotesIcon
] ]
, a
[ classList
[ ( "toggle item", True )
, ( "active", model.addFilesOpen )
]
, if model.addFilesOpen then
title "Close"
else
title "Add Files"
, onClick AddFilesToggle
, href "#"
]
[ Icons.addFilesIcon
]
] ]
, renderMailForm model , renderMailForm model
, renderAddFilesForm model
, renderNotes model
, div [ class "ui grid" ] , div [ class "ui grid" ]
[ Html.map DeleteItemConfirm (Comp.YesNoDimmer.view model.deleteItemConfirm) [ Html.map DeleteItemConfirm (Comp.YesNoDimmer.view model.deleteItemConfirm)
, div , div
@ -1091,8 +1306,7 @@ view inav model =
] ]
<| <|
List.concat List.concat
[ renderNotes model [ [ renderAttachmentsTabMenu model
, [ renderAttachmentsTabMenu model
] ]
, renderAttachmentsTabBody model , renderAttachmentsTabBody model
, renderIdInfo model , renderIdInfo model
@ -1117,16 +1331,16 @@ renderIdInfo model =
] ]
renderNotes : Model -> List (Html Msg) renderNotes : Model -> Html Msg
renderNotes model = renderNotes model =
case model.notesField of case model.notesField of
HideNotes -> HideNotes ->
case model.item.notes of case model.item.notes of
Nothing -> Nothing ->
[] span [ class "invisible hidden" ] []
Just _ -> Just _ ->
[ div [ class "ui segment" ] div [ class "ui segment" ]
[ a [ a
[ class "ui top left attached label" [ class "ui top left attached label"
, onClick ToggleNotes , onClick ToggleNotes
@ -1136,15 +1350,14 @@ renderNotes model =
, text "Show notes" , text "Show notes"
] ]
] ]
]
ViewNotes -> ViewNotes ->
case model.item.notes of case model.item.notes of
Nothing -> Nothing ->
[] span [ class "hidden invisible" ] []
Just str -> Just str ->
[ div [ class "ui segment" ] div [ class "ui raised segment item-notes-display" ]
[ Markdown.toHtml [ class "item-notes" ] str [ Markdown.toHtml [ class "item-notes" ] str
, a , a
[ class "ui left corner label" [ class "ui left corner label"
@ -1154,10 +1367,9 @@ renderNotes model =
[ i [ class "eye slash icon" ] [] [ i [ class "eye slash icon" ] []
] ]
] ]
]
EditNotes mm -> EditNotes mm ->
[ div [ class "ui segment" ] div [ class "ui bottom attached segment" ]
[ Html.map NotesEditMsg (Comp.MarkdownInput.view (Maybe.withDefault "" model.notesModel) mm) [ Html.map NotesEditMsg (Comp.MarkdownInput.view (Maybe.withDefault "" model.notesModel) mm)
, div [ class "ui secondary menu" ] , div [ class "ui secondary menu" ]
[ a [ a
@ -1178,7 +1390,6 @@ renderNotes model =
] ]
] ]
] ]
]
attachmentVisible : Model -> Int -> Bool attachmentVisible : Model -> Int -> Bool
@ -1722,7 +1933,10 @@ renderMailForm model =
, ( "invisible hidden", not model.mailOpen ) , ( "invisible hidden", not model.mailOpen )
] ]
] ]
[ div [ h4 [ class "ui header" ]
[ text "Send this item via E-Mail"
]
, div
[ classList [ classList
[ ( "ui dimmer", True ) [ ( "ui dimmer", True )
, ( "active", model.mailSending ) , ( "active", model.mailSending )
@ -1732,9 +1946,6 @@ renderMailForm model =
[ text "Sending " [ text "Sending "
] ]
] ]
, h4 [ class "ui header" ]
[ text "Send this item via E-Mail"
]
, Html.map ItemMailMsg (Comp.ItemMail.view model.itemMail) , Html.map ItemMailMsg (Comp.ItemMail.view model.itemMail)
, div , div
[ classList [ classList
@ -1756,3 +1967,94 @@ renderMailForm model =
|> text |> text
] ]
] ]
renderAddFilesForm : Model -> Html Msg
renderAddFilesForm model =
div
[ classList
[ ( "ui bottom attached segment", True )
, ( "invisible hidden", not model.addFilesOpen )
]
]
[ h4 [ class "ui header" ]
[ text "Add more files to this item"
]
, Html.map AddFilesMsg (Comp.Dropzone.view model.addFilesModel)
, button
[ class "ui primary button"
, href "#"
, onClick AddFilesSubmitUpload
]
[ text "Submit"
]
, button
[ class "ui secondary button"
, href "#"
, onClick AddFilesReset
]
[ text "Reset"
]
, div
[ classList
[ ( "ui success message", True )
, ( "invisible hidden", model.selectedFiles == [] || not (isSuccessAll model) )
]
]
[ text "All files have been uploaded. They are being processed, some data "
, text "may not be available immediately. "
, a
[ class "link"
, href "#"
, onClick ReloadItem
]
[ text "Refresh now"
]
]
, div [ class "ui items" ]
(List.map (renderFileItem model) model.selectedFiles)
]
renderFileItem : Model -> File -> Html Msg
renderFileItem model file =
let
name =
File.name file
size =
File.size file
|> toFloat
|> Util.Size.bytesReadable Util.Size.B
in
div [ class "item" ]
[ i
[ classList
[ ( "large", True )
, ( "file outline icon", isIdle model file )
, ( "loading spinner icon", isLoading model file )
, ( "green check icon", isCompleted model file )
, ( "red bolt icon", isError model file )
]
]
[]
, div [ class "middle aligned content" ]
[ div [ class "header" ]
[ text name
]
, div [ class "right floated meta" ]
[ text size
]
, div [ class "description" ]
[ div
[ classList
[ ( "ui small indicating progress", True )
]
, id (makeFileId file)
]
[ div [ class "bar" ]
[]
]
]
]
]

View File

@ -1,5 +1,7 @@
module Data.Icons exposing module Data.Icons exposing
( concerned ( addFiles
, addFilesIcon
, concerned
, concernedIcon , concernedIcon
, correspondent , correspondent
, correspondentIcon , correspondentIcon
@ -51,3 +53,13 @@ editNotes =
editNotesIcon : Html msg editNotesIcon : Html msg
editNotesIcon = editNotesIcon =
i [ class editNotes ] [] i [ class editNotes ] []
addFiles : String
addFiles =
"file plus icon"
addFilesIcon : Html msg
addFilesIcon =
i [ class addFiles ] []

View File

@ -58,7 +58,9 @@ init flags url key =
Nothing -> Nothing ->
Cmd.none Cmd.none
in in
( m, Cmd.batch [ cmd, Api.versionInfo flags VersionResp, sessionCheck ] ) ( m
, Cmd.batch [ cmd, Api.versionInfo flags VersionResp, sessionCheck ]
)
viewDoc : Model -> Document Msg viewDoc : Model -> Document Msg

View File

@ -7,25 +7,27 @@ import Data.Flags exposing (Flags)
import Page.ItemDetail.Data exposing (Model, Msg(..)) import Page.ItemDetail.Data exposing (Model, Msg(..))
update : Nav.Key -> Flags -> Maybe String -> Msg -> Model -> ( Model, Cmd Msg ) update : Nav.Key -> Flags -> Maybe String -> Msg -> Model -> ( Model, Cmd Msg, Sub Msg )
update key flags next msg model = update key flags next msg model =
case msg of case msg of
Init id -> Init id ->
let let
( lm, lc ) = ( lm, lc, ls ) =
Comp.ItemDetail.update key flags next Comp.ItemDetail.Init model.detail Comp.ItemDetail.update key flags next Comp.ItemDetail.Init model.detail
in in
( { model | detail = lm } ( { model | detail = lm }
, Cmd.batch [ Api.itemDetail flags id ItemResp, Cmd.map ItemDetailMsg lc ] , Cmd.batch [ Api.itemDetail flags id ItemResp, Cmd.map ItemDetailMsg lc ]
, Sub.map ItemDetailMsg ls
) )
ItemDetailMsg lmsg -> ItemDetailMsg lmsg ->
let let
( lm, lc ) = ( lm, lc, ls ) =
Comp.ItemDetail.update key flags next lmsg model.detail Comp.ItemDetail.update key flags next lmsg model.detail
in in
( { model | detail = lm } ( { model | detail = lm }
, Cmd.map ItemDetailMsg lc , Cmd.map ItemDetailMsg lc
, Sub.map ItemDetailMsg ls
) )
ItemResp (Ok item) -> ItemResp (Ok item) ->
@ -36,4 +38,4 @@ update key flags next msg model =
update key flags next (ItemDetailMsg lmsg) model update key flags next (ItemDetailMsg lmsg) model
ItemResp (Err _) -> ItemResp (Err _) ->
( model, Cmd.none ) ( model, Cmd.none, Sub.none )

View File

@ -41,7 +41,12 @@ update sourceId flags msg model =
uploads = uploads =
if model.singleItem then if model.singleItem then
Api.uploadSingle flags sourceId meta uploadAllTracker model.files (SingleUploadResp uploadAllTracker) Api.uploadSingle flags
sourceId
meta
uploadAllTracker
model.files
(SingleUploadResp uploadAllTracker)
else else
Cmd.batch (Api.upload flags sourceId meta model.files SingleUploadResp) Cmd.batch (Api.upload flags sourceId meta model.files SingleUploadResp)

View File

@ -70,6 +70,9 @@
.default-layout .ui.segment .item-notes { .default-layout .ui.segment .item-notes {
padding: 0 1em; padding: 0 1em;
} }
.default-layout .ui.segment.item-notes-display {
background: rgba(246, 255, 158, 0.4);
}
.default-layout .extracted-text { .default-layout .extracted-text {
font-family: monospace; font-family: monospace;