diff --git a/build.sbt b/build.sbt index 333b7121..e7b0a853 100644 --- a/build.sbt +++ b/build.sbt @@ -124,9 +124,8 @@ val buildInfoSettings = Seq( val openapiScalaSettings = Seq( openapiScalaConfig := ScalaConfig() .withJson(ScalaJson.circeSemiauto) - .addMapping(CustomMapping.forType({ - case TypeDef("LocalDateTime", _) => - TypeDef("Timestamp", Imports("docspell.common.Timestamp")) + .addMapping(CustomMapping.forType({ case TypeDef("LocalDateTime", _) => + TypeDef("Timestamp", Imports("docspell.common.Timestamp")) })) .addMapping(CustomMapping.forFormatType({ case "ident" => @@ -182,6 +181,8 @@ val openapiScalaSettings = Seq( ) ) ) + case "glob" => + field => field.copy(typeDef = TypeDef("Glob", Imports("docspell.common.Glob"))) })) ) @@ -595,11 +596,10 @@ def copyWebjarResources( src.flatMap { dir => if (dir.isDirectory) { val files = (dir ** "*").filter(_.isFile).get.pair(Path.relativeTo(dir)) - files.flatMap { - case (f, name) => - val target = targetDir / name - IO.createDirectories(Seq(target.getParentFile)) - copyWithGZ(f, target) + files.flatMap { case (f, name) => + val target = targetDir / name + IO.createDirectories(Seq(target.getParentFile)) + copyWithGZ(f, target) } } else { val target = targetDir / dir.name @@ -633,11 +633,13 @@ def compileElm( } def createWebjarSource(wj: Seq[ModuleID], out: File): Seq[File] = { - val target = out / "Webjars.scala" + val target = out / "Webjars.scala" val badChars = "-.".toSet val fields = wj .map(m => - s"""val ${m.name.toLowerCase.filter(c => !badChars.contains(c))} = "/${m.name}/${m.revision}" """ + s"""val ${m.name.toLowerCase.filter(c => + !badChars.contains(c) + )} = "/${m.name}/${m.revision}" """ ) .mkString("\n\n") val content = s"""package docspell.restserver.webapp diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OSource.scala b/modules/backend/src/main/scala/docspell/backend/ops/OSource.scala index 1208e987..cd7f3bda 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OSource.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OSource.scala @@ -4,44 +4,50 @@ import cats.effect.{Effect, Resource} import cats.implicits._ import docspell.common.{AccountId, Ident} +import docspell.store.UpdateResult import docspell.store.records.RSource +import docspell.store.records.SourceData import docspell.store.{AddResult, Store} trait OSource[F[_]] { - def findAll(account: AccountId): F[Vector[RSource]] + def findAll(account: AccountId): F[Vector[SourceData]] - def add(s: RSource): F[AddResult] + def add(s: RSource, tags: List[String]): F[AddResult] - def update(s: RSource): F[AddResult] + def update(s: RSource, tags: List[String]): F[AddResult] - def delete(id: Ident, collective: Ident): F[AddResult] + def delete(id: Ident, collective: Ident): F[UpdateResult] } object OSource { def apply[F[_]: Effect](store: Store[F]): Resource[F, OSource[F]] = Resource.pure[F, OSource[F]](new OSource[F] { - def findAll(account: AccountId): F[Vector[RSource]] = - store.transact(RSource.findAll(account.collective, _.abbrev)) + def findAll(account: AccountId): F[Vector[SourceData]] = + store + .transact(SourceData.findAll(account.collective, _.abbrev)) + .compile + .to(Vector) - def add(s: RSource): F[AddResult] = { - def insert = RSource.insert(s) + def add(s: RSource, tags: List[String]): F[AddResult] = { + def insert = SourceData.insert(s, tags) def exists = RSource.existsByAbbrev(s.cid, s.abbrev) val msg = s"A source with abbrev '${s.abbrev}' already exists" store.add(insert, exists).map(_.fold(identity, _.withMsg(msg), identity)) } - def update(s: RSource): F[AddResult] = { - def insert = RSource.updateNoCounter(s) + def update(s: RSource, tags: List[String]): F[AddResult] = { + def insert = SourceData.update(s, tags) def exists = RSource.existsByAbbrev(s.cid, s.abbrev) val msg = s"A source with abbrev '${s.abbrev}' already exists" store.add(insert, exists).map(_.fold(identity, _.withMsg(msg), identity)) } - def delete(id: Ident, collective: Ident): F[AddResult] = - store.transact(RSource.delete(id, collective)).attempt.map(AddResult.fromUpdate) + def delete(id: Ident, collective: Ident): F[UpdateResult] = + UpdateResult.fromUpdate(store.transact(SourceData.delete(id, collective))) + }) } diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OTag.scala b/modules/backend/src/main/scala/docspell/backend/ops/OTag.scala index 2a805546..a4e0c937 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OTag.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OTag.scala @@ -4,6 +4,7 @@ import cats.effect.{Effect, Resource} import cats.implicits._ import docspell.common.{AccountId, Ident} +import docspell.store.records.RTagSource import docspell.store.records.{RTag, RTagItem} import docspell.store.{AddResult, Store} @@ -49,8 +50,9 @@ object OTag { val io = for { optTag <- RTag.findByIdAndCollective(id, collective) n0 <- optTag.traverse(t => RTagItem.deleteTag(t.tagId)) - n1 <- optTag.traverse(t => RTag.delete(t.tagId, collective)) - } yield n0.getOrElse(0) + n1.getOrElse(0) + n1 <- optTag.traverse(t => RTagSource.deleteTag(t.tagId)) + n2 <- optTag.traverse(t => RTag.delete(t.tagId, collective)) + } yield (n0 |+| n1 |+| n2).getOrElse(0) store.transact(io).attempt.map(AddResult.fromUpdate) } diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OUpload.scala b/modules/backend/src/main/scala/docspell/backend/ops/OUpload.scala index e71a131f..8a0fc672 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OUpload.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OUpload.scala @@ -25,6 +25,11 @@ trait OUpload[F[_]] { itemId: Option[Ident] ): F[OUpload.UploadResult] + /** Submit files via a given source identifier. The source is looked + * up to identify the collective the files belong to. Metadata + * defined in the source is used as a fallback to those specified + * here (in UploadData). + */ def submit( data: OUpload.UploadData[F], sourceId: Ident, @@ -60,7 +65,9 @@ object OUpload { sourceAbbrev: String, folderId: Option[Ident], validFileTypes: Seq[MimeType], - skipDuplicates: Boolean + skipDuplicates: Boolean, + fileFilter: Glob, + tags: List[String] ) case class UploadData[F[_]]( @@ -127,7 +134,9 @@ object OUpload { data.meta.sourceAbbrev, data.meta.folderId, data.meta.validFileTypes, - data.meta.skipDuplicates + data.meta.skipDuplicates, + data.meta.fileFilter.some, + data.meta.tags.some ) args = if (data.multiple) files.map(f => ProcessItemArgs(meta, List(f))) @@ -149,15 +158,19 @@ object OUpload { itemId: Option[Ident] ): F[OUpload.UploadResult] = (for { - src <- OptionT(store.transact(RSource.findEnabled(sourceId))) + src <- OptionT(store.transact(SourceData.findEnabled(sourceId))) updata = data.copy( meta = data.meta.copy( - sourceAbbrev = src.abbrev, - folderId = data.meta.folderId.orElse(src.folderId) + sourceAbbrev = src.source.abbrev, + folderId = data.meta.folderId.orElse(src.source.folderId), + fileFilter = + if (data.meta.fileFilter == Glob.all) src.source.fileFilterOrAll + else data.meta.fileFilter, + tags = (data.meta.tags ++ src.tags.map(_.tagId.id)).distinct ), - priority = src.priority + priority = src.source.priority ) - accId = AccountId(src.cid, src.sid) + accId = AccountId(src.source.cid, src.source.sid) result <- OptionT.liftF(submit(updata, accId, notifyJoex, itemId)) } yield result).getOrElse(UploadResult.noSource) diff --git a/modules/common/src/main/scala/docspell/common/Glob.scala b/modules/common/src/main/scala/docspell/common/Glob.scala new file mode 100644 index 00000000..afa04a02 --- /dev/null +++ b/modules/common/src/main/scala/docspell/common/Glob.scala @@ -0,0 +1,168 @@ +package docspell.common + +import cats.data.NonEmptyList +import cats.implicits._ + +import io.circe.{Decoder, Encoder} + +trait Glob { + + /** Matches the input string against this glob. */ + def matches(in: String): Boolean + + /** If this glob consists of multiple segments, it is the same as + * `matches`. If it is only a single segment, it is matched against + * the last segment of the input string that is assumed to be a + * pathname separated by slash. + * + * Example: + * test.* <> "/a/b/test.txt" => true + * /test.* <> "/a/b/test.txt" => false + */ + def matchFilenameOrPath(in: String): Boolean + + def asString: String +} + +object Glob { + private val separator = '/' + private val anyChar = '|' + + val all = new Glob { + def matches(in: String) = true + def matchFilenameOrPath(in: String) = true + val asString = "*" + } + + def pattern(pattern: Pattern): Glob = + PatternGlob(pattern) + + /** A simple glob supporting `*` and `?`. */ + final private case class PatternGlob(pattern: Pattern) extends Glob { + def matches(in: String): Boolean = + pattern.parts + .zipWith(Glob.split(in, Glob.separator))(_.matches(_)) + .forall(identity) + + def matchFilenameOrPath(in: String): Boolean = + if (pattern.parts.tail.isEmpty) matches(split(in, separator).last) + else matches(in) + + def asString: String = + pattern.asString + } + + final private case class AnyGlob(globs: NonEmptyList[Glob]) extends Glob { + def matches(in: String) = + globs.exists(_.matches(in)) + def matchFilenameOrPath(in: String) = + globs.exists(_.matchFilenameOrPath(in)) + def asString = + globs.toList.map(_.asString).mkString(anyChar.toString) + } + + def apply(in: String): Glob = { + def single(str: String) = + PatternGlob(Pattern(split(str, separator).map(makeSegment))) + + if (in == "*") all + else + split(in, anyChar) match { + case NonEmptyList(_, Nil) => + single(in) + case nel => + AnyGlob(nel.map(_.trim).map(single)) + } + } + + case class Pattern(parts: NonEmptyList[Segment]) { + def asString = + parts.map(_.asString).toList.mkString(separator.toString) + } + + object Pattern { + def apply(s0: Segment, sm: Segment*): Pattern = + Pattern(NonEmptyList.of(s0, sm: _*)) + } + + case class Segment(tokens: NonEmptyList[Token]) { + def matches(in: String): Boolean = + consume(in).exists(_.isEmpty) + + def consume(in: String): Option[String] = + tokens.foldLeft(in.some) { (rem, token) => + rem.flatMap(token.consume) + } + + def asString: String = + tokens.toList.map(_.asString).mkString + } + object Segment { + def apply(t0: Token, ts: Token*): Segment = + Segment(NonEmptyList.of(t0, ts: _*)) + } + + sealed trait Token { + def consume(str: String): Option[String] + + def asString: String + } + object Token { + case class Literal(asString: String) extends Token { + def consume(str: String): Option[String] = + if (str.startsWith(asString)) str.drop(asString.length).some + else None + } + case class Until(value: String) extends Token { + def consume(str: String): Option[String] = + if (value.isEmpty) Some("") + else + str.indexOf(value) match { + case -1 => None + case n => str.substring(n + value.length).some + } + val asString = + s"*$value" + } + case object Single extends Token { + def consume(str: String): Option[String] = + if (str.isEmpty()) None + else Some(str.drop(1)) + + val asString = "?" + } + } + + private def split(str: String, sep: Char): NonEmptyList[String] = + NonEmptyList + .fromList(str.split(sep).toList) + .getOrElse(NonEmptyList.of(str)) + + private def makeSegment(str: String): Segment = { + def loop(rem: String, res: List[Token]): List[Token] = + if (rem.isEmpty) res + else + rem.charAt(0) match { + case '*' => + val stop = rem.drop(1).takeWhile(c => c != '*' && c != '?') + loop(rem.drop(1 + stop.length), Token.Until(stop) :: res) + case '?' => + loop(rem.drop(1), Token.Single :: res) + case _ => + val lit = rem.takeWhile(c => c != '*' && c != '?') + loop(rem.drop(lit.length), Token.Literal(lit) :: res) + } + + val fixed = str.replaceAll("\\*+", "*") + NonEmptyList + .fromList(loop(fixed, Nil).reverse) + .map(Segment.apply) + .getOrElse(Segment(Token.Literal(str))) + } + + implicit val jsonEncoder: Encoder[Glob] = + Encoder.encodeString.contramap(_.asString) + + implicit val jsonDecoder: Decoder[Glob] = + Decoder.decodeString.map(Glob.apply) +} diff --git a/modules/common/src/main/scala/docspell/common/ProcessItemArgs.scala b/modules/common/src/main/scala/docspell/common/ProcessItemArgs.scala index 6e5427be..aba6974e 100644 --- a/modules/common/src/main/scala/docspell/common/ProcessItemArgs.scala +++ b/modules/common/src/main/scala/docspell/common/ProcessItemArgs.scala @@ -38,7 +38,9 @@ object ProcessItemArgs { sourceAbbrev: String, folderId: Option[Ident], validFileTypes: Seq[MimeType], - skipDuplicate: Boolean + skipDuplicate: Boolean, + fileFilter: Option[Glob], + tags: Option[List[String]] ) object ProcessMeta { diff --git a/modules/common/src/main/scala/docspell/common/ScanMailboxArgs.scala b/modules/common/src/main/scala/docspell/common/ScanMailboxArgs.scala index fa86b903..fc73d616 100644 --- a/modules/common/src/main/scala/docspell/common/ScanMailboxArgs.scala +++ b/modules/common/src/main/scala/docspell/common/ScanMailboxArgs.scala @@ -29,7 +29,11 @@ case class ScanMailboxArgs( // set the direction when submitting direction: Option[Direction], // set a folder for items - itemFolder: Option[Ident] + itemFolder: Option[Ident], + // set a filter for files when importing archives + fileFilter: Option[Glob], + // set a list of tags to apply to new item + tags: Option[List[String]] ) object ScanMailboxArgs { diff --git a/modules/common/src/test/scala/docspell/common/GlobTest.scala b/modules/common/src/test/scala/docspell/common/GlobTest.scala new file mode 100644 index 00000000..8f228851 --- /dev/null +++ b/modules/common/src/test/scala/docspell/common/GlobTest.scala @@ -0,0 +1,111 @@ +package docspell.common + +import minitest._ +import Glob._ + +object GlobTest extends SimpleTestSuite { + + test("literals") { + assert(Glob.pattern(Pattern(Segment(Token.Literal("hello")))).matches("hello")) + assert(!Glob.pattern(Pattern(Segment(Token.Literal("hello")))).matches("hello1")) + } + + test("single wildcards 1") { + val glob = + Glob.pattern( + Pattern(Segment(Token.Literal("s"), Token.Until("p"), Token.Until("t"))) + ) + + assert(glob.matches("snapshot")) + assert(!glob.matches("snapshots")) + } + + test("single wildcards 2") { + val glob = + Glob.pattern(Pattern(Segment(Token.Literal("test."), Token.Until("")))) + + assert(glob.matches("test.txt")) + assert(glob.matches("test.pdf")) + assert(glob.matches("test.converted.pdf")) + assert(!glob.matches("test1.txt")) + assert(!glob.matches("atest.txt")) + } + + test("single parsing") { + assertEquals( + Glob("s*p*t"), + Glob.pattern( + Pattern(Segment(Token.Literal("s"), Token.Until("p"), Token.Until("t"))) + ) + ) + assertEquals( + Glob("s***p*t"), + Glob.pattern( + Pattern(Segment(Token.Literal("s"), Token.Until("p"), Token.Until("t"))) + ) + ) + assertEquals( + Glob("test.*"), + Glob.pattern(Pattern(Segment(Token.Literal("test."), Token.Until("")))) + ) + assertEquals( + Glob("stop"), + Glob.pattern(Pattern(Segment(Token.Literal("stop")))) + ) + assertEquals( + Glob("*stop"), + Glob.pattern(Pattern(Segment(Token.Until("stop")))) + ) + assertEquals(Glob("*"), Glob.all) + } + + test("with splitting") { + assert(Glob("a/b/*").matches("a/b/hello")) + assert(!Glob("a/b/*").matches("/a/b/hello")) + assert(Glob("/a/b/*").matches("/a/b/hello")) + assert(!Glob("/a/b/*").matches("a/b/hello")) + assert(!Glob("*/a/b/*").matches("a/b/hello")) + assert(Glob("*/a/b/*").matches("test/a/b/hello")) + } + + test("asString") { + assertEquals(Glob("test.*").asString, "test.*") + assertEquals(Glob("s***p*t").asString, "s*p*t") + assertEquals(Glob("stop").asString, "stop") + assertEquals(Glob("*stop").asString, "*stop") + assertEquals(Glob("/a/b/*").asString, "/a/b/*") + assertEquals(Glob("*").asString, "*") + assertEquals(Glob.all.asString, "*") + } + + test("simple matches") { + assert(Glob("/test.*").matches("/test.pdf")) + assert(!Glob("/test.*").matches("test.pdf")) + assert(!Glob("test.*").matches("/test.pdf")) + } + + test("matchFilenameOrPath") { + assert(Glob("test.*").matchFilenameOrPath("/a/b/test.pdf")) + assert(!Glob("/test.*").matchFilenameOrPath("/a/b/test.pdf")) + assert(Glob("s*p*t").matchFilenameOrPath("snapshot")) + assert(Glob("s*p*t").matchFilenameOrPath("/tmp/snapshot")) + assert(Glob("/tmp/s*p*t").matchFilenameOrPath("/tmp/snapshot")) + + assert(Glob("a/b/*").matchFilenameOrPath("a/b/hello")) + assert(!Glob("a/b/*").matchFilenameOrPath("/a/b/hello")) + assert(Glob("/a/b/*").matchFilenameOrPath("/a/b/hello")) + assert(!Glob("/a/b/*").matchFilenameOrPath("a/b/hello")) + assert(!Glob("*/a/b/*").matchFilenameOrPath("a/b/hello")) + assert(Glob("*/a/b/*").matchFilenameOrPath("test/a/b/hello")) + } + + test("anyglob") { + assert(Glob("*.pdf|*.txt").matches("test.pdf")) + assert(Glob("*.pdf|*.txt").matches("test.txt")) + assert(!Glob("*.pdf|*.txt").matches("test.xls")) + assert(Glob("*.pdf | *.txt").matches("test.pdf")) + assert(Glob("*.pdf | mail.html").matches("test.pdf")) + assert(Glob("*.pdf | mail.html").matches("mail.html")) + assert(!Glob("*.pdf | mail.html").matches("test.docx")) + } +} diff --git a/modules/files/src/main/scala/docspell/files/Zip.scala b/modules/files/src/main/scala/docspell/files/Zip.scala index f09bd66d..5450cbf7 100644 --- a/modules/files/src/main/scala/docspell/files/Zip.scala +++ b/modules/files/src/main/scala/docspell/files/Zip.scala @@ -9,24 +9,33 @@ import cats.implicits._ import fs2.{Pipe, Stream} import docspell.common.Binary +import docspell.common.Glob object Zip { def unzipP[F[_]: ConcurrentEffect: ContextShift]( chunkSize: Int, - blocker: Blocker + blocker: Blocker, + glob: Glob ): Pipe[F, Byte, Binary[F]] = - s => unzip[F](chunkSize, blocker)(s) + s => unzip[F](chunkSize, blocker, glob)(s) - def unzip[F[_]: ConcurrentEffect: ContextShift](chunkSize: Int, blocker: Blocker)( + def unzip[F[_]: ConcurrentEffect: ContextShift]( + chunkSize: Int, + blocker: Blocker, + glob: Glob + )( data: Stream[F, Byte] ): Stream[F, Binary[F]] = - data.through(fs2.io.toInputStream[F]).flatMap(in => unzipJava(in, chunkSize, blocker)) + data + .through(fs2.io.toInputStream[F]) + .flatMap(in => unzipJava(in, chunkSize, blocker, glob)) def unzipJava[F[_]: Sync: ContextShift]( in: InputStream, chunkSize: Int, - blocker: Blocker + blocker: Blocker, + glob: Glob ): Stream[F, Binary[F]] = { val zin = new ZipInputStream(in) @@ -39,6 +48,7 @@ object Zip { .resource(nextEntry) .repeat .unNoneTerminate + .filter(ze => glob.matchFilenameOrPath(ze.getName())) .map { ze => val name = Paths.get(ze.getName()).getFileName.toString val data = diff --git a/modules/files/src/test/scala/docspell/files/ZipTest.scala b/modules/files/src/test/scala/docspell/files/ZipTest.scala index 7fce9d50..05cf1866 100644 --- a/modules/files/src/test/scala/docspell/files/ZipTest.scala +++ b/modules/files/src/test/scala/docspell/files/ZipTest.scala @@ -4,6 +4,7 @@ import minitest._ import cats.effect._ import cats.implicits._ import scala.concurrent.ExecutionContext +import docspell.common.Glob object ZipTest extends SimpleTestSuite { @@ -12,7 +13,7 @@ object ZipTest extends SimpleTestSuite { test("unzip") { val zipFile = ExampleFiles.letters_zip.readURL[IO](8192, blocker) - val uncomp = zipFile.through(Zip.unzip(8192, blocker)) + val uncomp = zipFile.through(Zip.unzip(8192, blocker, Glob.all)) uncomp .evalMap { entry => diff --git a/modules/joex/src/main/scala/docspell/joex/mail/ReadMail.scala b/modules/joex/src/main/scala/docspell/joex/mail/ReadMail.scala index 0f144ab7..8242df94 100644 --- a/modules/joex/src/main/scala/docspell/joex/mail/ReadMail.scala +++ b/modules/joex/src/main/scala/docspell/joex/mail/ReadMail.scala @@ -16,9 +16,10 @@ import emil.{MimeType => _, _} object ReadMail { def readBytesP[F[_]: ConcurrentEffect: ContextShift]( - logger: Logger[F] + logger: Logger[F], + glob: Glob ): Pipe[F, Byte, Binary[F]] = - _.through(bytesToMail(logger)).flatMap(mailToEntries[F](logger)) + _.through(bytesToMail(logger)).flatMap(mailToEntries[F](logger, glob)) def bytesToMail[F[_]: Sync](logger: Logger[F]): Pipe[F, Byte, Mail[F]] = s => @@ -26,7 +27,8 @@ object ReadMail { s.through(Mail.readBytes[F]) def mailToEntries[F[_]: ConcurrentEffect: ContextShift]( - logger: Logger[F] + logger: Logger[F], + glob: Glob )(mail: Mail[F]): Stream[F, Binary[F]] = { val bodyEntry: F[Option[Binary[F]]] = if (mail.body.isEmpty) (None: Option[Binary[F]]).pure[F] @@ -48,10 +50,12 @@ object ReadMail { ) >> (Stream .eval(bodyEntry) - .flatMap(e => Stream.emits(e.toSeq)) ++ + .flatMap(e => Stream.emits(e.toSeq)) + .filter(a => glob.matches(a.name)) ++ Stream .eval(TnefExtract.replace(mail)) .flatMap(m => Stream.emits(m.attachments.all)) + .filter(a => a.filename.exists(glob.matches)) .map(a => Binary(a.filename.getOrElse("noname"), a.mimeType.toLocal, a.content) )) diff --git a/modules/joex/src/main/scala/docspell/joex/process/ExtractArchive.scala b/modules/joex/src/main/scala/docspell/joex/process/ExtractArchive.scala index f489ae3c..b684ded9 100644 --- a/modules/joex/src/main/scala/docspell/joex/process/ExtractArchive.scala +++ b/modules/joex/src/main/scala/docspell/joex/process/ExtractArchive.scala @@ -95,12 +95,12 @@ object ExtractArchive { case MimeType.ZipMatch(_) if ra.name.exists(_.endsWith(".zip")) => ctx.logger.info(s"Extracting zip archive ${ra.name.getOrElse("")}.") *> extractZip(ctx, archive)(ra, pos) - .flatTap(_ => cleanupParents(ctx, ra, archive)) + .flatMap(cleanupParents(ctx, ra, archive)) case MimeType.EmailMatch(_) => ctx.logger.info(s"Reading e-mail ${ra.name.getOrElse("")}") *> extractMail(ctx, archive)(ra, pos) - .flatTap(_ => cleanupParents(ctx, ra, archive)) + .flatMap(cleanupParents(ctx, ra, archive)) case _ => ctx.logger.debug(s"Not an archive: ${mime.asString}") *> @@ -111,7 +111,7 @@ object ExtractArchive { ctx: Context[F, _], ra: RAttachment, archive: Option[RAttachmentArchive] - ): F[Unit] = + )(extracted: Extracted): F[Extracted] = archive match { case Some(_) => for { @@ -121,36 +121,37 @@ object ExtractArchive { _ <- ctx.store.transact(RAttachmentArchive.delete(ra.id)) _ <- ctx.store.transact(RAttachment.delete(ra.id)) _ <- ctx.store.bitpeace.delete(ra.fileId.id).compile.drain - } yield () + } yield extracted case None => for { _ <- ctx.logger.debug( s"Extracted attachment ${ra.name}. Remove it from the item." ) _ <- ctx.store.transact(RAttachment.delete(ra.id)) - } yield () + } yield extracted.copy(files = extracted.files.filter(_.id != ra.id)) } def extractZip[F[_]: ConcurrentEffect: ContextShift]( - ctx: Context[F, _], + ctx: Context[F, ProcessItemArgs], archive: Option[RAttachmentArchive] )(ra: RAttachment, pos: Int): F[Extracted] = { val zipData = ctx.store.bitpeace .get(ra.fileId.id) .unNoneTerminate .through(ctx.store.bitpeace.fetchData2(RangeDef.all)) - - zipData - .through(Zip.unzipP[F](8192, ctx.blocker)) - .zipWithIndex - .flatMap(handleEntry(ctx, ra, pos, archive, None)) - .foldMonoid - .compile - .lastOrError + val glob = ctx.args.meta.fileFilter.getOrElse(Glob.all) + ctx.logger.debug(s"Filtering zip entries with '${glob.asString}'") *> + zipData + .through(Zip.unzipP[F](8192, ctx.blocker, glob)) + .zipWithIndex + .flatMap(handleEntry(ctx, ra, pos, archive, None)) + .foldMonoid + .compile + .lastOrError } def extractMail[F[_]: ConcurrentEffect: ContextShift]( - ctx: Context[F, _], + ctx: Context[F, ProcessItemArgs], archive: Option[RAttachmentArchive] )(ra: RAttachment, pos: Int): F[Extracted] = { val email: Stream[F, Byte] = ctx.store.bitpeace @@ -158,24 +159,26 @@ object ExtractArchive { .unNoneTerminate .through(ctx.store.bitpeace.fetchData2(RangeDef.all)) - email - .through(ReadMail.bytesToMail[F](ctx.logger)) - .flatMap { mail => - val mId = mail.header.messageId - val givenMeta = - for { - _ <- ctx.logger.debug(s"Use mail date for item date: ${mail.header.date}") - s <- Sync[F].delay(extractMailMeta(mail)) - } yield s + val glob = ctx.args.meta.fileFilter.getOrElse(Glob.all) + ctx.logger.debug(s"Filtering email attachments with '${glob.asString}'") *> + email + .through(ReadMail.bytesToMail[F](ctx.logger)) + .flatMap { mail => + val mId = mail.header.messageId + val givenMeta = + for { + _ <- ctx.logger.debug(s"Use mail date for item date: ${mail.header.date}") + s <- Sync[F].delay(extractMailMeta(mail)) + } yield s - ReadMail - .mailToEntries(ctx.logger)(mail) - .zipWithIndex - .flatMap(handleEntry(ctx, ra, pos, archive, mId)) ++ Stream.eval(givenMeta) - } - .foldMonoid - .compile - .lastOrError + ReadMail + .mailToEntries(ctx.logger, glob)(mail) + .zipWithIndex + .flatMap(handleEntry(ctx, ra, pos, archive, mId)) ++ Stream.eval(givenMeta) + } + .foldMonoid + .compile + .lastOrError } def extractMailMeta[F[_]](mail: Mail[F]): Extracted = @@ -239,6 +242,9 @@ object ExtractArchive { positions ++ e.positions ) + def filterNames(filter: Glob): Extracted = + copy(files = files.filter(ra => filter.matches(ra.name.getOrElse("")))) + def setMeta(m: MetaProposal): Extracted = setMeta(MetaProposalList.of(m)) diff --git a/modules/joex/src/main/scala/docspell/joex/process/ProcessItem.scala b/modules/joex/src/main/scala/docspell/joex/process/ProcessItem.scala index 56f3cd33..b6cc493e 100644 --- a/modules/joex/src/main/scala/docspell/joex/process/ProcessItem.scala +++ b/modules/joex/src/main/scala/docspell/joex/process/ProcessItem.scala @@ -25,6 +25,7 @@ object ProcessItem { .flatMap(LinkProposal[F]) .flatMap(SetGivenData[F](itemOps)) .flatMap(Task.setProgress(99)) + .flatMap(RemoveEmptyItem(itemOps)) def processAttachments[F[_]: ConcurrentEffect: ContextShift]( cfg: Config, diff --git a/modules/joex/src/main/scala/docspell/joex/process/ReProcessItem.scala b/modules/joex/src/main/scala/docspell/joex/process/ReProcessItem.scala index dd7747db..07fb2901 100644 --- a/modules/joex/src/main/scala/docspell/joex/process/ReProcessItem.scala +++ b/modules/joex/src/main/scala/docspell/joex/process/ReProcessItem.scala @@ -91,7 +91,9 @@ object ReProcessItem { "", //source-id None, //folder Seq.empty, - false + false, + None, + None ), Nil ).pure[F] diff --git a/modules/joex/src/main/scala/docspell/joex/process/RemoveEmptyItem.scala b/modules/joex/src/main/scala/docspell/joex/process/RemoveEmptyItem.scala new file mode 100644 index 00000000..75751672 --- /dev/null +++ b/modules/joex/src/main/scala/docspell/joex/process/RemoveEmptyItem.scala @@ -0,0 +1,26 @@ +package docspell.joex.process + +import cats.effect._ +import cats.implicits._ + +import docspell.backend.ops.OItem +import docspell.common._ +import docspell.joex.scheduler.Task + +object RemoveEmptyItem { + + def apply[F[_]: Sync]( + ops: OItem[F] + )(data: ItemData): Task[F, ProcessItemArgs, ItemData] = + if (data.item.state.isInvalid && data.attachments.isEmpty) + Task { ctx => + for { + _ <- ctx.logger.warn(s"Removing item as it doesn't have any attachments!") + n <- ops.deleteItem(data.item.id, data.item.cid) + _ <- ctx.logger.warn(s"Removed item ($n). No item has been created!") + } yield data + } + else + Task.pure(data) + +} diff --git a/modules/joex/src/main/scala/docspell/joex/process/SetGivenData.scala b/modules/joex/src/main/scala/docspell/joex/process/SetGivenData.scala index b0c279e7..99348419 100644 --- a/modules/joex/src/main/scala/docspell/joex/process/SetGivenData.scala +++ b/modules/joex/src/main/scala/docspell/joex/process/SetGivenData.scala @@ -45,9 +45,10 @@ object SetGivenData { Task { ctx => val itemId = data.item.id val collective = ctx.args.meta.collective + val tags = (ctx.args.meta.tags.getOrElse(Nil) ++ data.tags).distinct for { - _ <- ctx.logger.info(s"Set tags from given data: ${data.tags}") - e <- ops.linkTags(itemId, data.tags, collective).attempt + _ <- ctx.logger.info(s"Set tags from given data: ${tags}") + e <- ops.linkTags(itemId, tags, collective).attempt _ <- e.fold( ex => ctx.logger.warn(s"Error setting tags: ${ex.getMessage}"), _ => ().pure[F] diff --git a/modules/joex/src/main/scala/docspell/joex/process/TextExtraction.scala b/modules/joex/src/main/scala/docspell/joex/process/TextExtraction.scala index 112034a4..db2988b8 100644 --- a/modules/joex/src/main/scala/docspell/joex/process/TextExtraction.scala +++ b/modules/joex/src/main/scala/docspell/joex/process/TextExtraction.scala @@ -47,7 +47,9 @@ object TextExtraction { _ <- fts.indexData(ctx.logger, (idxItem +: txt.map(_.td)).toSeq: _*) dur <- start _ <- ctx.logger.info(s"Text extraction finished in ${dur.formatExact}") - } yield item.copy(metas = txt.map(_.am), tags = txt.flatMap(_.tags).distinct.toList) + } yield item + .copy(metas = txt.map(_.am)) + .appendTags(txt.flatMap(_.tags).distinct.toList) } // -- helpers diff --git a/modules/joex/src/main/scala/docspell/joex/scanmailbox/ScanMailboxTask.scala b/modules/joex/src/main/scala/docspell/joex/scanmailbox/ScanMailboxTask.scala index 0fee001a..7a746b9f 100644 --- a/modules/joex/src/main/scala/docspell/joex/scanmailbox/ScanMailboxTask.scala +++ b/modules/joex/src/main/scala/docspell/joex/scanmailbox/ScanMailboxTask.scala @@ -255,7 +255,9 @@ object ScanMailboxTask { s"mailbox-${ctx.args.account.user.id}", args.itemFolder, Seq.empty, - true + true, + args.fileFilter.getOrElse(Glob.all), + args.tags.getOrElse(Nil) ) data = OUpload.UploadData( multiple = false, diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index fbba6e89..70816521 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -1211,7 +1211,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/Source" + $ref: "#/components/schemas/SourceTagIn" responses: 200: description: Ok @@ -1231,7 +1231,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/Source" + $ref: "#/components/schemas/SourceTagIn" responses: 200: description: Ok @@ -3499,6 +3499,14 @@ components: The folder id that is applied to items resulting from importing mails. If the folder id is not valid when the task executes, items have no folder set. + tags: + $ref: "#/components/schemas/StringList" + fileFilter: + description: | + A glob to filter attachments to import. + type: string + format: glob + ImapSettingsList: description: | A list of user email settings. @@ -4256,6 +4264,18 @@ components: A folderId can be given, the item is placed into this folder after creation. + The `fileFilter` is an optional glob for filtering files to + import. Only applicable if archive files are uploaded. It + applies to all of them. For example, to only import pdf files + when uploading e-mails, use `*.pdf`. If the pattern doesn't + contain a slash `/`, then it is applied to all file names. + Otherwise it is applied to the complete path in the archive + (useful for zip files). Note that the archive file itself is + always saved completely, too. + + The `tags` input allows to provide tags that should be applied + to the item being created. This only works if the tags already + exist. It is possible to specify their ids or names. required: - multiple properties: @@ -4271,6 +4291,11 @@ components: skipDuplicates: type: boolean default: false + tags: + $ref: "#/components/schemas/StringList" + fileFilter: + type: string + format: glob Collective: description: | @@ -4341,7 +4366,7 @@ components: items: type: array items: - $ref: "#/components/schemas/Source" + $ref: "#/components/schemas/SourceAndTags" Source: description: | Data about a Source. A source defines the endpoint where @@ -4375,10 +4400,38 @@ components: folder: type: string format: ident + fileFilter: + type: string + format: glob created: description: DateTime type: integer format: date-time + SourceTagIn: + description: | + A source and optional tags (ids or names) for updating/adding. + required: + - source + - tags + properties: + source: + $ref: "#/components/schema/Source" + tags: + type: array + items: + type: string + SourceAndTags: + description: | + A source and optional tags. + required: + - source + - tags + properties: + source: + $ref: "#/components/schema/Source" + tags: + $ref: "#/components/schema/TagList" + EquipmentList: description: | A list of equipments. diff --git a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala index aba61555..8c889f6b 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala @@ -310,13 +310,16 @@ trait Conversions { sourceName, m.folder, validFileTypes, - m.skipDuplicates.getOrElse(false) + m.skipDuplicates.getOrElse(false), + m.fileFilter.getOrElse(Glob.all), + m.tags.map(_.items).getOrElse(Nil) ) ) ) ) .getOrElse( - (true, UploadMeta(None, sourceName, None, validFileTypes, false)).pure[F] + (true, UploadMeta(None, sourceName, None, validFileTypes, false, Glob.all, Nil)) + .pure[F] ) val files = mp.parts @@ -521,21 +524,36 @@ trait Conversions { // sources - def mkSource(s: RSource): Source = - Source( - s.sid, - s.abbrev, - s.description, - s.counter, - s.enabled, - s.priority, - s.folderId, - s.created + def mkSource(s: SourceData): SourceAndTags = + SourceAndTags( + Source( + s.source.sid, + s.source.abbrev, + s.source.description, + s.source.counter, + s.source.enabled, + s.source.priority, + s.source.folderId, + s.source.fileFilter, + s.source.created + ), + TagList(s.tags.length, s.tags.map(mkTag).toList) ) def newSource[F[_]: Sync](s: Source, cid: Ident): F[RSource] = timeId.map({ case (id, now) => - RSource(id, cid, s.abbrev, s.description, 0, s.enabled, s.priority, now, s.folder) + RSource( + id, + cid, + s.abbrev, + s.description, + 0, + s.enabled, + s.priority, + now, + s.folder, + s.fileFilter + ) }) def changeSource[F[_]: Sync](s: Source, coll: Ident): RSource = @@ -548,7 +566,8 @@ trait Conversions { s.enabled, s.priority, s.created, - s.folder + s.folder, + s.fileFilter ) // equipment diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ScanMailboxRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ScanMailboxRoutes.scala index 4a1a738c..983f491d 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ScanMailboxRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ScanMailboxRoutes.scala @@ -113,7 +113,9 @@ object ScanMailboxRoutes { settings.targetFolder, settings.deleteMail, settings.direction, - settings.itemFolder + settings.itemFolder, + settings.fileFilter, + settings.tags.map(_.items) ) ) ) @@ -141,6 +143,8 @@ object ScanMailboxRoutes { task.args.targetFolder, task.args.deleteMail, task.args.direction, - task.args.itemFolder + task.args.itemFolder, + task.args.tags.map(StringList.apply), + task.args.fileFilter ) } diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/SourceRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/SourceRoutes.scala index aa9034e9..fdda7e76 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/SourceRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/SourceRoutes.scala @@ -30,17 +30,17 @@ object SourceRoutes { case req @ POST -> Root => for { - data <- req.as[Source] - src <- newSource(data, user.account.collective) - added <- backend.source.add(src) + data <- req.as[SourceTagIn] + src <- newSource(data.source, user.account.collective) + added <- backend.source.add(src, data.tags) resp <- Ok(basicResult(added, "Source added.")) } yield resp case req @ PUT -> Root => for { - data <- req.as[Source] - src = changeSource(data, user.account.collective) - updated <- backend.source.update(src) + data <- req.as[SourceTagIn] + src = changeSource(data.source, user.account.collective) + updated <- backend.source.update(src, data.tags) resp <- Ok(basicResult(updated, "Source updated.")) } yield resp diff --git a/modules/store/src/main/resources/db/migration/h2/V1.12.0__upload_data.sql b/modules/store/src/main/resources/db/migration/h2/V1.12.0__upload_data.sql new file mode 100644 index 00000000..15689fc7 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/h2/V1.12.0__upload_data.sql @@ -0,0 +1,11 @@ +ALTER TABLE "source" +ADD COLUMN "file_filter" varchar(254) NULL; + +CREATE TABLE "tagsource" ( + "id" varchar(254) not null primary key, + "source_id" varchar(254) not null, + "tag_id" varchar(254) not null, + unique ("source_id", "tag_id"), + foreign key ("source_id") references "source"("sid"), + foreign key ("tag_id") references "tag"("tid") +); diff --git a/modules/store/src/main/resources/db/migration/mariadb/V1.12.0__upload_data.sql b/modules/store/src/main/resources/db/migration/mariadb/V1.12.0__upload_data.sql new file mode 100644 index 00000000..236fa64b --- /dev/null +++ b/modules/store/src/main/resources/db/migration/mariadb/V1.12.0__upload_data.sql @@ -0,0 +1,11 @@ +ALTER TABLE `source` +ADD COLUMN `file_filter` varchar(254) NULL; + +CREATE TABLE `tagsource` ( + `id` varchar(254) not null primary key, + `source_id` varchar(254) not null, + `tag_id` varchar(254) not null, + unique (`source_id`, `tag_id`), + foreign key (`source_id`) references `source`(`sid`), + foreign key (`tag_id`) references `tag`(`tid`) +); diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.12.0__upload_data.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.12.0__upload_data.sql new file mode 100644 index 00000000..15689fc7 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/postgresql/V1.12.0__upload_data.sql @@ -0,0 +1,11 @@ +ALTER TABLE "source" +ADD COLUMN "file_filter" varchar(254) NULL; + +CREATE TABLE "tagsource" ( + "id" varchar(254) not null primary key, + "source_id" varchar(254) not null, + "tag_id" varchar(254) not null, + unique ("source_id", "tag_id"), + foreign key ("source_id") references "source"("sid"), + foreign key ("tag_id") references "tag"("tid") +); diff --git a/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala b/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala index 338cdc69..0e2ed027 100644 --- a/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala +++ b/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala @@ -91,6 +91,9 @@ trait DoobieMeta extends EmilDoobieMeta { implicit val metaCalEvent: Meta[CalEvent] = Meta[String].timap(CalEvent.unsafe)(_.asString) + + implicit val metaGlob: Meta[Glob] = + Meta[String].timap(Glob.apply)(_.asString) } object DoobieMeta extends DoobieMeta { diff --git a/modules/store/src/main/scala/docspell/store/records/RSource.scala b/modules/store/src/main/scala/docspell/store/records/RSource.scala index 3d339861..ea7a0c60 100644 --- a/modules/store/src/main/scala/docspell/store/records/RSource.scala +++ b/modules/store/src/main/scala/docspell/store/records/RSource.scala @@ -16,8 +16,13 @@ case class RSource( enabled: Boolean, priority: Priority, created: Timestamp, - folderId: Option[Ident] -) {} + folderId: Option[Ident], + fileFilter: Option[Glob] +) { + + def fileFilterOrAll: Glob = + fileFilter.getOrElse(Glob.all) +} object RSource { @@ -34,9 +39,21 @@ object RSource { val priority = Column("priority") val created = Column("created") val folder = Column("folder_id") + val fileFilter = Column("file_filter") val all = - List(sid, cid, abbrev, description, counter, enabled, priority, created, folder) + List( + sid, + cid, + abbrev, + description, + counter, + enabled, + priority, + created, + folder, + fileFilter + ) } import Columns._ @@ -45,7 +62,7 @@ object RSource { val sql = insertRow( table, all, - fr"${v.sid},${v.cid},${v.abbrev},${v.description},${v.counter},${v.enabled},${v.priority},${v.created},${v.folderId}" + fr"${v.sid},${v.cid},${v.abbrev},${v.description},${v.counter},${v.enabled},${v.priority},${v.created},${v.folderId},${v.fileFilter}" ) sql.update.run } @@ -60,7 +77,8 @@ object RSource { description.setTo(v.description), enabled.setTo(v.enabled), priority.setTo(v.priority), - folder.setTo(v.folderId) + folder.setTo(v.folderId), + fileFilter.setTo(v.fileFilter) ) ) sql.update.run @@ -83,10 +101,11 @@ object RSource { sql.query[Int].unique.map(_ > 0) } - def findEnabled(id: Ident): ConnectionIO[Option[RSource]] = { - val sql = selectSimple(all, table, and(sid.is(id), enabled.is(true))) - sql.query[RSource].option - } + def findEnabled(id: Ident): ConnectionIO[Option[RSource]] = + findEnabledSql(id).query[RSource].option + + private[records] def findEnabledSql(id: Ident): Fragment = + selectSimple(all, table, and(sid.is(id), enabled.is(true))) def findCollective(sourceId: Ident): ConnectionIO[Option[Ident]] = selectSimple(List(cid), table, sid.is(sourceId)).query[Ident].option @@ -94,10 +113,11 @@ object RSource { def findAll( coll: Ident, order: Columns.type => Column - ): ConnectionIO[Vector[RSource]] = { - val sql = selectSimple(all, table, cid.is(coll)) ++ orderBy(order(Columns).f) - sql.query[RSource].to[Vector] - } + ): ConnectionIO[Vector[RSource]] = + findAllSql(coll, order).query[RSource].to[Vector] + + private[records] def findAllSql(coll: Ident, order: Columns.type => Column): Fragment = + selectSimple(all, table, cid.is(coll)) ++ orderBy(order(Columns).f) def delete(sourceId: Ident, coll: Ident): ConnectionIO[Int] = deleteFrom(table, and(sid.is(sourceId), cid.is(coll))).update.run diff --git a/modules/store/src/main/scala/docspell/store/records/RTag.scala b/modules/store/src/main/scala/docspell/store/records/RTag.scala index e0c1a56a..cc206d39 100644 --- a/modules/store/src/main/scala/docspell/store/records/RTag.scala +++ b/modules/store/src/main/scala/docspell/store/records/RTag.scala @@ -104,6 +104,18 @@ object RTag { ) ++ orderBy(name.prefix("t").asc)).query[RTag].to[Vector] } + def findBySource(source: Ident): ConnectionIO[Vector[RTag]] = { + val rcol = all.map(_.prefix("t")) + (selectSimple( + rcol, + table ++ fr"t," ++ RTagSource.table ++ fr"s", + and( + RTagSource.Columns.sourceId.prefix("s").is(source), + RTagSource.Columns.tagId.prefix("s").is(tid.prefix("t")) + ) + ) ++ orderBy(name.prefix("t").asc)).query[RTag].to[Vector] + } + def findAllByNameOrId( nameOrIds: List[String], coll: Ident diff --git a/modules/store/src/main/scala/docspell/store/records/RTagSource.scala b/modules/store/src/main/scala/docspell/store/records/RTagSource.scala new file mode 100644 index 00000000..f94cc88e --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RTagSource.scala @@ -0,0 +1,56 @@ +package docspell.store.records + +import cats.effect.Sync +import cats.implicits._ + +import docspell.common._ +import docspell.store.impl.Implicits._ +import docspell.store.impl._ + +import doobie._ +import doobie.implicits._ + +case class RTagSource(id: Ident, sourceId: Ident, tagId: Ident) {} + +object RTagSource { + + val table = fr"tagsource" + + object Columns { + val id = Column("id") + val sourceId = Column("source_id") + val tagId = Column("tag_id") + val all = List(id, sourceId, tagId) + } + import Columns._ + + def createNew[F[_]: Sync](source: Ident, tag: Ident): F[RTagSource] = + Ident.randomId[F].map(id => RTagSource(id, source, tag)) + + def insert(v: RTagSource): ConnectionIO[Int] = + insertRow(table, all, fr"${v.id},${v.sourceId},${v.tagId}").update.run + + def deleteSourceTags(source: Ident): ConnectionIO[Int] = + deleteFrom(table, sourceId.is(source)).update.run + + def deleteTag(tid: Ident): ConnectionIO[Int] = + deleteFrom(table, tagId.is(tid)).update.run + + def findBySource(source: Ident): ConnectionIO[Vector[RTagSource]] = + selectSimple(all, table, sourceId.is(source)).query[RTagSource].to[Vector] + + def setAllTags(source: Ident, tags: Seq[Ident]): ConnectionIO[Int] = + if (tags.isEmpty) 0.pure[ConnectionIO] + else + for { + entities <- tags.toList.traverse(tagId => + Ident.randomId[ConnectionIO].map(id => RTagSource(id, source, tagId)) + ) + n <- insertRows( + table, + all, + entities.map(v => fr"${v.id},${v.sourceId},${v.tagId}") + ).update.run + } yield n + +} diff --git a/modules/store/src/main/scala/docspell/store/records/SourceData.scala b/modules/store/src/main/scala/docspell/store/records/SourceData.scala new file mode 100644 index 00000000..8ce65f33 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/SourceData.scala @@ -0,0 +1,87 @@ +package docspell.store.records + +import cats.effect.concurrent.Ref +import cats.implicits._ +import fs2.Stream + +import docspell.common._ +import docspell.store.impl.Implicits._ +import docspell.store.impl._ + +import doobie._ +import doobie.implicits._ + +/** Combines a source record (RSource) and a list of associated tags. + */ +case class SourceData(source: RSource, tags: Vector[RTag]) + +object SourceData { + + def fromSource(s: RSource): SourceData = + SourceData(s, Vector.empty) + + def findAll( + coll: Ident, + order: RSource.Columns.type => Column + ): Stream[ConnectionIO, SourceData] = + findAllWithTags(RSource.findAllSql(coll, order).query[RSource].stream) + + private def findAllWithTags( + select: Stream[ConnectionIO, RSource] + ): Stream[ConnectionIO, SourceData] = { + def findTag( + cache: Ref[ConnectionIO, Map[Ident, RTag]], + tagSource: RTagSource + ): ConnectionIO[Option[RTag]] = + for { + cc <- cache.get + fromCache = cc.get(tagSource.tagId) + orFromDB <- + if (fromCache.isDefined) fromCache.pure[ConnectionIO] + else RTag.findById(tagSource.tagId) + _ <- + if (fromCache.isDefined) ().pure[ConnectionIO] + else + orFromDB match { + case Some(t) => cache.update(tmap => tmap.updated(t.tagId, t)) + case None => ().pure[ConnectionIO] + } + } yield orFromDB + + for { + resolvedTags <- Stream.eval(Ref.of[ConnectionIO, Map[Ident, RTag]](Map.empty)) + source <- select + tagSources <- Stream.eval(RTagSource.findBySource(source.sid)) + tags <- Stream.eval(tagSources.traverse(ti => findTag(resolvedTags, ti))) + } yield SourceData(source, tags.flatten) + } + + def findEnabled(id: Ident): ConnectionIO[Option[SourceData]] = + findAllWithTags(RSource.findEnabledSql(id).query[RSource].stream).head.compile.last + + def insert(data: RSource, tags: List[String]): ConnectionIO[Int] = + for { + n0 <- RSource.insert(data) + tags <- RTag.findAllByNameOrId(tags, data.cid) + n1 <- tags.traverse(tag => + RTagSource.createNew[ConnectionIO](data.sid, tag.tagId).flatMap(RTagSource.insert) + ) + } yield n0 + n1.sum + + def update(data: RSource, tags: List[String]): ConnectionIO[Int] = + for { + n0 <- RSource.updateNoCounter(data) + tags <- RTag.findAllByNameOrId(tags, data.cid) + _ <- RTagSource.deleteSourceTags(data.sid) + n1 <- tags.traverse(tag => + RTagSource.createNew[ConnectionIO](data.sid, tag.tagId).flatMap(RTagSource.insert) + ) + } yield n0 + n1.sum + + def delete(source: Ident, coll: Ident): ConnectionIO[Int] = + for { + n0 <- RTagSource.deleteSourceTags(source) + n1 <- RSource.delete(source, coll) + } yield n0 + n1 + +} diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index c9935f49..5af07dc1 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -175,8 +175,9 @@ import Api.Model.ScanMailboxSettings exposing (ScanMailboxSettings) import Api.Model.ScanMailboxSettingsList exposing (ScanMailboxSettingsList) import Api.Model.SentMails exposing (SentMails) import Api.Model.SimpleMail exposing (SimpleMail) -import Api.Model.Source exposing (Source) +import Api.Model.SourceAndTags exposing (SourceAndTags) import Api.Model.SourceList exposing (SourceList) +import Api.Model.SourceTagIn exposing (SourceTagIn) import Api.Model.StringList exposing (StringList) import Api.Model.Tag exposing (Tag) import Api.Model.TagCloud exposing (TagCloud) @@ -1144,17 +1145,22 @@ getSources flags receive = } -postSource : Flags -> Source -> (Result Http.Error BasicResult -> msg) -> Cmd msg +postSource : Flags -> SourceAndTags -> (Result Http.Error BasicResult -> msg) -> Cmd msg postSource flags source receive = let + st = + { source = source.source + , tags = List.map .id source.tags.items + } + params = { url = flags.config.baseUrl ++ "/api/v1/sec/source" , account = getAccount flags - , body = Http.jsonBody (Api.Model.Source.encode source) + , body = Http.jsonBody (Api.Model.SourceTagIn.encode st) , expect = Http.expectJson receive Api.Model.BasicResult.decoder } in - if source.id == "" then + if source.source.id == "" then Http2.authPost params else diff --git a/modules/webapp/src/main/elm/Comp/ScanMailboxForm.elm b/modules/webapp/src/main/elm/Comp/ScanMailboxForm.elm index 0ceeee73..2a4544f8 100644 --- a/modules/webapp/src/main/elm/Comp/ScanMailboxForm.elm +++ b/modules/webapp/src/main/elm/Comp/ScanMailboxForm.elm @@ -15,6 +15,9 @@ import Api.Model.FolderList exposing (FolderList) import Api.Model.IdName exposing (IdName) import Api.Model.ImapSettingsList exposing (ImapSettingsList) import Api.Model.ScanMailboxSettings exposing (ScanMailboxSettings) +import Api.Model.StringList exposing (StringList) +import Api.Model.Tag exposing (Tag) +import Api.Model.TagList exposing (TagList) import Comp.CalEventInput import Comp.Dropdown exposing (isDropdownChangeMsg) import Comp.IntField @@ -34,6 +37,7 @@ import Util.Folder exposing (mkFolderOption) import Util.Http import Util.List import Util.Maybe +import Util.Tag import Util.Update @@ -56,6 +60,9 @@ type alias Model = , folderModel : Comp.Dropdown.Model IdName , allFolders : List FolderItem , itemFolderId : Maybe String + , tagModel : Comp.Dropdown.Model Tag + , existingTags : List String + , fileFilter : Maybe String } @@ -84,6 +91,9 @@ type Msg | YesNoDeleteMsg Comp.YesNoDimmer.Msg | GetFolderResp (Result Http.Error FolderList) | FolderDropdownMsg (Comp.Dropdown.Msg IdName) + | GetTagResp (Result Http.Error TagList) + | TagDropdownMsg (Comp.Dropdown.Msg Tag) + | SetFileFilter String initWith : Flags -> ScanMailboxSettings -> ( Model, Cmd Msg ) @@ -120,12 +130,18 @@ initWith flags s = , formMsg = Nothing , yesNoDelete = Comp.YesNoDimmer.emptyModel , itemFolderId = s.itemFolder + , tagModel = Util.Tag.makeDropdownModel + , existingTags = + Maybe.map .items s.tags + |> Maybe.withDefault [] + , fileFilter = s.fileFilter } , Cmd.batch [ Api.getImapSettings flags "" ConnResp , nc , Cmd.map CalEventMsg sc , Api.getFolders flags "" False GetFolderResp + , Api.getTags flags "" GetTagResp ] ) @@ -134,7 +150,7 @@ init : Flags -> ( Model, Cmd Msg ) init flags = let initialSchedule = - Data.Validated.Unknown Data.CalEvent.everyMonth + Data.Validated.Valid Data.CalEvent.everyMonth sm = Comp.CalEventInput.initDefault @@ -156,7 +172,7 @@ init flags = , schedule = initialSchedule , scheduleModel = sm , formMsg = Nothing - , loading = 2 + , loading = 3 , yesNoDelete = Comp.YesNoDimmer.emptyModel , folderModel = Comp.Dropdown.makeSingle @@ -165,10 +181,14 @@ init flags = } , allFolders = [] , itemFolderId = Nothing + , tagModel = Util.Tag.makeDropdownModel + , existingTags = [] + , fileFilter = Nothing } , Cmd.batch [ Api.getImapSettings flags "" ConnResp , Api.getFolders flags "" False GetFolderResp + , Api.getTags flags "" GetTagResp ] ) @@ -196,9 +216,9 @@ makeSettings model = else Valid model.folders - make smtp timer folders = + make imap timer folders = { prev - | imapConnection = smtp + | imapConnection = imap , enabled = model.enabled , receivedSinceHours = model.receivedHours , deleteMail = model.deleteMail @@ -207,6 +227,16 @@ makeSettings model = , direction = Maybe.map Data.Direction.toString model.direction , schedule = Data.CalEvent.makeEvent timer , itemFolder = model.itemFolderId + , fileFilter = model.fileFilter + , tags = + case Comp.Dropdown.getSelected model.tagModel of + [] -> + Nothing + + els -> + List.map .id els + |> StringList + |> Just } in Data.Validated.map3 make @@ -501,6 +531,61 @@ update flags msg model = in ( model_, NoAction, Cmd.map FolderDropdownMsg c2 ) + GetTagResp (Ok list) -> + let + contains el = + List.member el model.existingTags + + isExistingTag t = + contains t.id || contains t.name + + selected = + List.filter isExistingTag list.items + |> Comp.Dropdown.SetSelection + + opts = + Comp.Dropdown.SetOptions list.items + + ( tagModel_, tagcmd ) = + Util.Update.andThen1 + [ Comp.Dropdown.update selected + , Comp.Dropdown.update opts + ] + model.tagModel + + nextModel = + { model + | loading = model.loading - 1 + , tagModel = tagModel_ + } + in + ( nextModel + , NoAction + , Cmd.map TagDropdownMsg tagcmd + ) + + GetTagResp (Err _) -> + ( { model | loading = model.loading - 1 } + , NoAction + , Cmd.none + ) + + TagDropdownMsg lm -> + let + ( m2, c2 ) = + Comp.Dropdown.update lm model.tagModel + + newModel = + { model | tagModel = m2 } + in + ( newModel, NoAction, Cmd.map TagDropdownMsg c2 ) + + SetFileFilter str -> + ( { model | fileFilter = Util.Maybe.fromString str } + , NoAction + , Cmd.none + ) + --- View @@ -603,6 +688,9 @@ view extraClasses settings model = , text " is not set." ] ] + , div [ class "ui dividing header" ] + [ text "Metadata" + ] , div [ class "required field" ] [ label [] [ text "Item direction" ] , div [ class "grouped fields" ] @@ -668,6 +756,49 @@ disappear then. """ ] ] + , div [ class "field" ] + [ label [] [ text "Tags" ] + , Html.map TagDropdownMsg (Comp.Dropdown.view settings model.tagModel) + , div [ class "small-info" ] + [ text "Choose tags that should be applied to items." + ] + ] + , div + [ class "field" + ] + [ label [] [ text "File Filter" ] + , input + [ type_ "text" + , onInput SetFileFilter + , placeholder "File Filter" + , model.fileFilter + |> Maybe.withDefault "" + |> value + ] + [] + , div [ class "small-info" ] + [ text "Specify a file glob to filter attachments. For example, to only extract pdf files: " + , code [] + [ text "*.pdf" + ] + , text ". If you want to include the mail body, allow html files or " + , code [] + [ text "mail.html" + ] + , text ". Globs can be combined via OR, like this: " + , code [] + [ text "*.pdf|mail.html" + ] + , text "No file filter defaults to " + , code [] + [ text "*" + ] + , text " that includes all" + ] + ] + , div [ class "ui dividing header" ] + [ text "Schedule" + ] , div [ class "required field" ] [ label [] [ text "Schedule" diff --git a/modules/webapp/src/main/elm/Comp/SourceForm.elm b/modules/webapp/src/main/elm/Comp/SourceForm.elm index 6d156ce8..05a52c26 100644 --- a/modules/webapp/src/main/elm/Comp/SourceForm.elm +++ b/modules/webapp/src/main/elm/Comp/SourceForm.elm @@ -12,7 +12,9 @@ import Api import Api.Model.FolderItem exposing (FolderItem) import Api.Model.FolderList exposing (FolderList) import Api.Model.IdName exposing (IdName) -import Api.Model.Source exposing (Source) +import Api.Model.SourceAndTags exposing (SourceAndTags) +import Api.Model.Tag exposing (Tag) +import Api.Model.TagList exposing (TagList) import Comp.Dropdown exposing (isDropdownChangeMsg) import Comp.FixedDropdown import Data.Flags exposing (Flags) @@ -24,10 +26,13 @@ import Html.Events exposing (onCheck, onInput) import Http import Markdown import Util.Folder exposing (mkFolderOption) +import Util.Maybe +import Util.Tag +import Util.Update type alias Model = - { source : Source + { source : SourceAndTags , abbrev : String , description : Maybe String , priorityModel : Comp.FixedDropdown.Model Priority @@ -36,12 +41,14 @@ type alias Model = , folderModel : Comp.Dropdown.Model IdName , allFolders : List FolderItem , folderId : Maybe String + , tagModel : Comp.Dropdown.Model Tag + , fileFilter : Maybe String } emptyModel : Model emptyModel = - { source = Api.Model.Source.empty + { source = Api.Model.SourceAndTags.empty , abbrev = "" , description = Nothing , priorityModel = @@ -57,13 +64,18 @@ emptyModel = } , allFolders = [] , folderId = Nothing + , tagModel = Util.Tag.makeDropdownModel + , fileFilter = Nothing } init : Flags -> ( Model, Cmd Msg ) init flags = ( emptyModel - , Api.getFolders flags "" False GetFolderResp + , Cmd.batch + [ Api.getFolders flags "" False GetFolderResp + , Api.getTags flags "" GetTagResp + ] ) @@ -72,29 +84,42 @@ isValid model = model.abbrev /= "" -getSource : Model -> Source +getSource : Model -> SourceAndTags getSource model = let - s = + st = model.source + + s = + st.source + + tags = + Comp.Dropdown.getSelected model.tagModel + + n = + { s + | abbrev = model.abbrev + , description = model.description + , enabled = model.enabled + , priority = Data.Priority.toName model.priority + , folder = model.folderId + , fileFilter = model.fileFilter + } in - { s - | abbrev = model.abbrev - , description = model.description - , enabled = model.enabled - , priority = Data.Priority.toName model.priority - , folder = model.folderId - } + { st | source = n, tags = TagList (List.length tags) tags } type Msg = SetAbbrev String - | SetSource Source + | SetSource SourceAndTags | SetDescr String | ToggleEnabled | PrioDropdownMsg (Comp.FixedDropdown.Msg Priority) | GetFolderResp (Result Http.Error FolderList) | FolderDropdownMsg (Comp.Dropdown.Msg IdName) + | GetTagResp (Result Http.Error TagList) + | TagDropdownMsg (Comp.Dropdown.Msg Tag) + | SetFileFilter String @@ -106,29 +131,34 @@ update flags msg model = case msg of SetSource t -> let - post = + stpost = model.source + post = + stpost.source + np = { post - | id = t.id - , abbrev = t.abbrev - , description = t.description - , priority = t.priority - , enabled = t.enabled - , folder = t.folder + | id = t.source.id + , abbrev = t.source.abbrev + , description = t.source.description + , priority = t.source.priority + , enabled = t.source.enabled + , folder = t.source.folder + , fileFilter = t.source.fileFilter } newModel = { model - | source = np - , abbrev = t.abbrev - , description = t.description + | source = { stpost | source = np } + , abbrev = t.source.abbrev + , description = t.source.description , priority = - Data.Priority.fromString t.priority + Data.Priority.fromString t.source.priority |> Maybe.withDefault Data.Priority.Low - , enabled = t.enabled - , folderId = t.folder + , enabled = t.source.enabled + , folderId = t.source.folder + , fileFilter = t.source.fileFilter } mkIdName id = @@ -143,14 +173,21 @@ update flags msg model = model.allFolders sel = - case Maybe.map mkIdName t.folder of + case Maybe.map mkIdName t.source.folder of Just idref -> idref Nothing -> [] + + tags = + Comp.Dropdown.SetSelection t.tags.items in - update flags (FolderDropdownMsg (Comp.Dropdown.SetSelection sel)) newModel + Util.Update.andThen1 + [ update flags (FolderDropdownMsg (Comp.Dropdown.SetSelection sel)) + , update flags (TagDropdownMsg tags) + ] + newModel ToggleEnabled -> ( { model | enabled = not model.enabled }, Cmd.none ) @@ -159,14 +196,7 @@ update flags msg model = ( { model | abbrev = n }, Cmd.none ) SetDescr d -> - ( { model - | description = - if d /= "" then - Just d - - else - Nothing - } + ( { model | description = Util.Maybe.fromString d } , Cmd.none ) @@ -226,6 +256,31 @@ update flags msg model = in ( model_, Cmd.map FolderDropdownMsg c2 ) + GetTagResp (Ok list) -> + let + opts = + Comp.Dropdown.SetOptions list.items + in + update flags (TagDropdownMsg opts) model + + GetTagResp (Err _) -> + ( model, Cmd.none ) + + TagDropdownMsg lm -> + let + ( m2, c2 ) = + Comp.Dropdown.update lm model.tagModel + + newModel = + { model | tagModel = m2 } + in + ( newModel, Cmd.map TagDropdownMsg c2 ) + + SetFileFilter d -> + ( { model | fileFilter = Util.Maybe.fromString d } + , Cmd.none + ) + --- View @@ -260,6 +315,7 @@ view flags settings model = , textarea [ onInput SetDescr , model.description |> Maybe.withDefault "" |> value + , rows 3 ] [] ] @@ -281,12 +337,26 @@ view flags settings model = (Just priorityItem) model.priorityModel ) + , div [ class "small-info" ] + [ text "The priority used by the scheduler when processing uploaded files." + ] + ] + , div [ class "ui dividing header" ] + [ text "Metadata" + ] + , div [ class "ui message" ] + [ text "Metadata specified here is automatically attached to each item uploaded " + , text "through this source, unless it is overriden in the upload request meta data. " + , text "Tags from the request are added to those defined here." ] , div [ class "field" ] [ label [] [ text "Folder" ] , Html.map FolderDropdownMsg (Comp.Dropdown.view settings model.folderModel) + , div [ class "small-info" ] + [ text "Choose a folder to automatically put items into." + ] , div [ classList [ ( "ui warning message", True ) @@ -301,6 +371,38 @@ disappear then. """ ] ] + , div [ class "field" ] + [ label [] [ text "Tags" ] + , Html.map TagDropdownMsg (Comp.Dropdown.view settings model.tagModel) + , div [ class "small-info" ] + [ text "Choose tags that should be applied to items." + ] + ] + , div + [ class "field" + ] + [ label [] [ text "File Filter" ] + , input + [ type_ "text" + , onInput SetFileFilter + , placeholder "File Filter" + , model.fileFilter + |> Maybe.withDefault "" + |> value + ] + [] + , div [ class "small-info" ] + [ text "Specify a file glob to filter files when uploading archives " + , text "(e.g. for email and zip). For example, to only extract pdf files: " + , code [] + [ text "*.pdf" + ] + , text ". Globs can be combined via OR, like this: " + , code [] + [ text "*.pdf|mail.html" + ] + ] + ] ] diff --git a/modules/webapp/src/main/elm/Comp/SourceManage.elm b/modules/webapp/src/main/elm/Comp/SourceManage.elm index 70da2a06..4e79cd8c 100644 --- a/modules/webapp/src/main/elm/Comp/SourceManage.elm +++ b/modules/webapp/src/main/elm/Comp/SourceManage.elm @@ -8,8 +8,9 @@ module Comp.SourceManage exposing import Api import Api.Model.BasicResult exposing (BasicResult) -import Api.Model.Source exposing (Source) +import Api.Model.SourceAndTags exposing (SourceAndTags) import Api.Model.SourceList exposing (SourceList) +import Api.Model.SourceTagIn exposing (SourceTagIn) import Comp.SourceForm import Comp.SourceTable exposing (SelectMode(..)) import Comp.YesNoDimmer @@ -31,7 +32,7 @@ type alias Model = , formError : Maybe String , loading : Bool , deleteConfirm : Comp.YesNoDimmer.Model - , sources : List Source + , sources : List SourceAndTags } @@ -145,7 +146,7 @@ update flags msg model = InitNewSource -> let source = - Api.Model.Source.empty + Api.Model.SourceAndTags.empty nm = { model | viewMode = Edit source, formError = Nothing } @@ -196,7 +197,7 @@ update flags msg model = cmd = if confirmed then - Api.deleteSource flags src.id SubmitResp + Api.deleteSource flags src.source.id SubmitResp else Cmd.none @@ -248,22 +249,22 @@ viewTable model = ] -viewLinks : Flags -> UiSettings -> Source -> Html Msg +viewLinks : Flags -> UiSettings -> SourceAndTags -> Html Msg viewLinks flags _ source = let appUrl = - flags.config.baseUrl ++ "/app/upload/" ++ source.id + flags.config.baseUrl ++ "/app/upload/" ++ source.source.id apiUrl = - flags.config.baseUrl ++ "/api/v1/open/upload/item/" ++ source.id + flags.config.baseUrl ++ "/api/v1/open/upload/item/" ++ source.source.id in div [] [ h3 [ class "ui dividing header" ] [ text "Public Uploads: " - , text source.abbrev + , text source.source.abbrev , div [ class "sub header" ] - [ text source.id + [ text source.source.id ] ] , p [] @@ -273,7 +274,7 @@ viewLinks flags _ source = ] , p [] [ text "There have been " - , String.fromInt source.counter |> text + , String.fromInt source.source.counter |> text , text " items created through this source." ] , h4 [ class "ui header" ] @@ -358,7 +359,7 @@ viewForm : Flags -> UiSettings -> Model -> List (Html Msg) viewForm flags settings model = let newSource = - model.formModel.source.id == "" + model.formModel.source.source.id == "" in [ if newSource then h3 [ class "ui top attached header" ] @@ -367,10 +368,10 @@ viewForm flags settings model = else h3 [ class "ui top attached header" ] - [ text ("Edit: " ++ model.formModel.source.abbrev) + [ text ("Edit: " ++ model.formModel.source.source.abbrev) , div [ class "sub header" ] [ text "Id: " - , text model.formModel.source.id + , text model.formModel.source.source.id ] ] , Html.form [ class "ui attached segment", onSubmit Submit ] diff --git a/modules/webapp/src/main/elm/Comp/SourceTable.elm b/modules/webapp/src/main/elm/Comp/SourceTable.elm index 5e9d0ec8..f3a14351 100644 --- a/modules/webapp/src/main/elm/Comp/SourceTable.elm +++ b/modules/webapp/src/main/elm/Comp/SourceTable.elm @@ -6,7 +6,7 @@ module Comp.SourceTable exposing , view ) -import Api.Model.Source exposing (Source) +import Api.Model.SourceAndTags exposing (SourceAndTags) import Data.Flags exposing (Flags) import Data.Priority import Html exposing (..) @@ -15,8 +15,8 @@ import Html.Events exposing (onClick) type SelectMode - = Edit Source - | Display Source + = Edit SourceAndTags + | Display SourceAndTags | None @@ -34,8 +34,8 @@ isEdit m = type Msg - = Select Source - | Show Source + = Select SourceAndTags + | Show SourceAndTags update : Flags -> Msg -> ( Cmd Msg, SelectMode ) @@ -48,7 +48,7 @@ update _ msg = ( Cmd.none, Display source ) -view : List Source -> Html Msg +view : List SourceAndTags -> Html Msg view sources = table [ class "ui table" ] [ thead [] @@ -66,7 +66,7 @@ view sources = ] -renderSourceLine : Source -> Html Msg +renderSourceLine : SourceAndTags -> Html Msg renderSourceLine source = tr [] @@ -82,10 +82,10 @@ renderSourceLine source = , a [ classList [ ( "ui basic tiny primary button", True ) - , ( "disabled", not source.enabled ) + , ( "disabled", not source.source.enabled ) ] , href "#" - , disabled (not source.enabled) + , disabled (not source.source.enabled) , onClick (Show source) ] [ i [ class "eye icon" ] [] @@ -93,25 +93,25 @@ renderSourceLine source = ] ] , td [ class "collapsing" ] - [ text source.abbrev + [ text source.source.abbrev ] , td [ class "collapsing" ] - [ if source.enabled then + [ if source.source.enabled then i [ class "check square outline icon" ] [] else i [ class "minus square outline icon" ] [] ] , td [ class "collapsing" ] - [ source.counter |> String.fromInt |> text + [ source.source.counter |> String.fromInt |> text ] , td [ class "collapsing" ] - [ Data.Priority.fromString source.priority + [ Data.Priority.fromString source.source.priority |> Maybe.map Data.Priority.toName - |> Maybe.withDefault source.priority + |> Maybe.withDefault source.source.priority |> text ] , td [] - [ text source.id + [ text source.source.id ] ]