mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-04-04 10:29:34 +00:00
Experiment with addons
Addons allow to execute external programs in some context inside docspell. Currently it is possible to run them after processing files. Addons are provided by URLs to zip files.
This commit is contained in:
parent
e04a76faa4
commit
7fdd78ad06
44
build.sbt
44
build.sbt
@ -293,6 +293,15 @@ val openapiScalaSettings = Seq(
|
||||
field.copy(typeDef =
|
||||
TypeDef("DownloadState", Imports("docspell.common.DownloadState"))
|
||||
)
|
||||
case "addon-trigger-type" =>
|
||||
field =>
|
||||
field.copy(typeDef =
|
||||
TypeDef("AddonTriggerType", Imports("docspell.addons.AddonTriggerType"))
|
||||
)
|
||||
case "addon-runner-type" =>
|
||||
field =>
|
||||
field
|
||||
.copy(typeDef = TypeDef("RunnerType", Imports("docspell.addons.RunnerType")))
|
||||
})
|
||||
)
|
||||
|
||||
@ -325,6 +334,7 @@ val common = project
|
||||
libraryDependencies ++=
|
||||
Dependencies.fs2 ++
|
||||
Dependencies.circe ++
|
||||
Dependencies.circeGenericExtra ++
|
||||
Dependencies.calevCore ++
|
||||
Dependencies.calevCirce
|
||||
)
|
||||
@ -351,7 +361,7 @@ val files = project
|
||||
.in(file("modules/files"))
|
||||
.disablePlugins(RevolverPlugin)
|
||||
.settings(sharedSettings)
|
||||
.withTestSettings
|
||||
.withTestSettingsDependsOn(loggingScribe)
|
||||
.settings(
|
||||
name := "docspell-files",
|
||||
libraryDependencies ++=
|
||||
@ -448,6 +458,19 @@ val notificationApi = project
|
||||
)
|
||||
.dependsOn(common, loggingScribe)
|
||||
|
||||
val addonlib = project
|
||||
.in(file("modules/addonlib"))
|
||||
.disablePlugins(RevolverPlugin)
|
||||
.settings(sharedSettings)
|
||||
.withTestSettingsDependsOn(loggingScribe)
|
||||
.settings(
|
||||
libraryDependencies ++=
|
||||
Dependencies.fs2 ++
|
||||
Dependencies.circe ++
|
||||
Dependencies.circeYaml
|
||||
)
|
||||
.dependsOn(common, files, loggingScribe)
|
||||
|
||||
val store = project
|
||||
.in(file("modules/store"))
|
||||
.disablePlugins(RevolverPlugin)
|
||||
@ -469,7 +492,16 @@ val store = project
|
||||
libraryDependencies ++=
|
||||
Dependencies.testContainer.map(_ % Test)
|
||||
)
|
||||
.dependsOn(common, query.jvm, totp, files, notificationApi, jsonminiq, loggingScribe)
|
||||
.dependsOn(
|
||||
common,
|
||||
addonlib,
|
||||
query.jvm,
|
||||
totp,
|
||||
files,
|
||||
notificationApi,
|
||||
jsonminiq,
|
||||
loggingScribe
|
||||
)
|
||||
|
||||
val notificationImpl = project
|
||||
.in(file("modules/notification/impl"))
|
||||
@ -647,7 +679,7 @@ val restapi = project
|
||||
openapiSpec := (Compile / resourceDirectory).value / "docspell-openapi.yml",
|
||||
openapiStaticGen := OpenApiDocGenerator.Redoc
|
||||
)
|
||||
.dependsOn(common, query.jvm, notificationApi, jsonminiq)
|
||||
.dependsOn(common, query.jvm, notificationApi, jsonminiq, addonlib)
|
||||
|
||||
val joexapi = project
|
||||
.in(file("modules/joexapi"))
|
||||
@ -667,7 +699,7 @@ val joexapi = project
|
||||
openapiSpec := (Compile / resourceDirectory).value / "joex-openapi.yml",
|
||||
openapiStaticGen := OpenApiDocGenerator.Redoc
|
||||
)
|
||||
.dependsOn(common, loggingScribe)
|
||||
.dependsOn(common, loggingScribe, addonlib)
|
||||
|
||||
val backend = project
|
||||
.in(file("modules/backend"))
|
||||
@ -683,6 +715,7 @@ val backend = project
|
||||
Dependencies.emil
|
||||
)
|
||||
.dependsOn(
|
||||
addonlib,
|
||||
store,
|
||||
notificationApi,
|
||||
joexapi,
|
||||
@ -739,7 +772,7 @@ val config = project
|
||||
Dependencies.fs2 ++
|
||||
Dependencies.pureconfig
|
||||
)
|
||||
.dependsOn(common, loggingApi, ftspsql, store)
|
||||
.dependsOn(common, loggingApi, ftspsql, store, addonlib)
|
||||
|
||||
// --- Application(s)
|
||||
|
||||
@ -946,6 +979,7 @@ val root = project
|
||||
)
|
||||
.aggregate(
|
||||
common,
|
||||
addonlib,
|
||||
loggingApi,
|
||||
loggingScribe,
|
||||
config,
|
||||
|
@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.addons
|
||||
|
||||
import cats.effect._
|
||||
import cats.syntax.all._
|
||||
import fs2.Stream
|
||||
import fs2.io.file.{Files, Path}
|
||||
|
||||
import docspell.common._
|
||||
import docspell.files.Zip
|
||||
|
||||
final case class AddonArchive(url: LenientUri, name: String, version: String) {
|
||||
def nameAndVersion: String =
|
||||
s"$name-$version"
|
||||
|
||||
def extractTo[F[_]: Async](
|
||||
reader: UrlReader[F],
|
||||
directory: Path,
|
||||
withSubdir: Boolean = true,
|
||||
glob: Glob = Glob.all
|
||||
): F[Path] = {
|
||||
val logger = docspell.logging.getLogger[F]
|
||||
val target =
|
||||
if (withSubdir) directory.absolute / nameAndVersion
|
||||
else directory.absolute
|
||||
|
||||
Files[F]
|
||||
.exists(target)
|
||||
.flatMap {
|
||||
case true => target.pure[F]
|
||||
case false =>
|
||||
Files[F].createDirectories(target) *>
|
||||
reader(url)
|
||||
.through(Zip.unzip(8192, glob))
|
||||
.through(Zip.saveTo(logger, target, moveUp = true))
|
||||
.compile
|
||||
.drain
|
||||
.as(target)
|
||||
}
|
||||
}
|
||||
|
||||
/** Read meta either from the given directory or extract the url to find the metadata
|
||||
* file to read
|
||||
*/
|
||||
def readMeta[F[_]: Async](
|
||||
urlReader: UrlReader[F],
|
||||
directory: Option[Path] = None
|
||||
): F[AddonMeta] =
|
||||
directory
|
||||
.map(AddonMeta.findInDirectory[F])
|
||||
.getOrElse(AddonMeta.findInZip(urlReader(url)))
|
||||
}
|
||||
|
||||
object AddonArchive {
|
||||
def read[F[_]: Async](
|
||||
url: LenientUri,
|
||||
urlReader: UrlReader[F],
|
||||
extractDir: Option[Path] = None
|
||||
): F[AddonArchive] = {
|
||||
val addon = AddonArchive(url, "", "")
|
||||
addon
|
||||
.readMeta(urlReader, extractDir)
|
||||
.map(m => addon.copy(name = m.meta.name, version = m.meta.version))
|
||||
}
|
||||
|
||||
def dockerAndFlakeExists[F[_]: Async](
|
||||
archive: Either[Path, Stream[F, Byte]]
|
||||
): F[(Boolean, Boolean)] = {
|
||||
val files = Files[F]
|
||||
def forPath(path: Path): F[(Boolean, Boolean)] =
|
||||
(files.exists(path / "Dockerfile"), files.exists(path / "flake.nix")).tupled
|
||||
|
||||
def forZip(data: Stream[F, Byte]): F[(Boolean, Boolean)] =
|
||||
data
|
||||
.through(Zip.unzip(8192, Glob("Dockerfile|flake.nix")))
|
||||
.collect {
|
||||
case bin if bin.name == "Dockerfile" => (true, false)
|
||||
case bin if bin.name == "flake.nix" => (false, true)
|
||||
}
|
||||
.compile
|
||||
.fold((false, false))((r, e) => (r._1 || e._1, r._2 || e._2))
|
||||
|
||||
archive.fold(forPath, forZip)
|
||||
}
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.addons
|
||||
|
||||
import cats.Monoid
|
||||
import cats.syntax.all._
|
||||
|
||||
case class AddonExecutionResult(
|
||||
addonResults: List[AddonResult],
|
||||
pure: Boolean
|
||||
) {
|
||||
def addonResult: AddonResult = addonResults.combineAll
|
||||
def isFailure: Boolean = addonResult.isFailure
|
||||
def isSuccess: Boolean = addonResult.isSuccess
|
||||
}
|
||||
|
||||
object AddonExecutionResult {
|
||||
val empty: AddonExecutionResult =
|
||||
AddonExecutionResult(Nil, false)
|
||||
|
||||
def combine(a: AddonExecutionResult, b: AddonExecutionResult): AddonExecutionResult =
|
||||
AddonExecutionResult(
|
||||
a.addonResults ::: b.addonResults,
|
||||
a.pure && b.pure
|
||||
)
|
||||
|
||||
implicit val executionResultMonoid: Monoid[AddonExecutionResult] =
|
||||
Monoid.instance(empty, combine)
|
||||
}
|
@ -0,0 +1,121 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.addons
|
||||
|
||||
import cats.data.Kleisli
|
||||
import cats.effect._
|
||||
import cats.syntax.all._
|
||||
import fs2.Stream
|
||||
import fs2.io.file._
|
||||
|
||||
import docspell.common.UrlReader
|
||||
import docspell.common.exec.Env
|
||||
import docspell.logging.Logger
|
||||
|
||||
trait AddonExecutor[F[_]] {
|
||||
|
||||
def config: AddonExecutorConfig
|
||||
|
||||
def execute(logger: Logger[F]): AddonExec[F]
|
||||
|
||||
def execute(logger: Logger[F], in: InputEnv): F[AddonExecutionResult] =
|
||||
execute(logger).run(in)
|
||||
}
|
||||
|
||||
object AddonExecutor {
|
||||
|
||||
def apply[F[_]: Async](
|
||||
cfg: AddonExecutorConfig,
|
||||
urlReader: UrlReader[F]
|
||||
): AddonExecutor[F] =
|
||||
new AddonExecutor[F] with AddonLoggerExtension {
|
||||
val config = cfg
|
||||
|
||||
def execute(logger: Logger[F]): AddonExec[F] =
|
||||
Kleisli { in =>
|
||||
for {
|
||||
_ <- logger.info(s"About to run ${in.addons.size} addon(s) in ${in.baseDir}")
|
||||
ctx <- prepareDirectory(
|
||||
logger,
|
||||
in.baseDir,
|
||||
in.outputDir,
|
||||
in.cacheDir,
|
||||
in.addons
|
||||
)
|
||||
rs <- ctx.traverse(c => runAddon(logger.withAddon(c), in.env)(c))
|
||||
pure = ctx.foldl(true)((b, c) => b && c.meta.isPure)
|
||||
} yield AddonExecutionResult(rs, pure)
|
||||
}
|
||||
|
||||
private def prepareDirectory(
|
||||
logger: Logger[F],
|
||||
baseDir: Path,
|
||||
outDir: Path,
|
||||
cacheDir: Path,
|
||||
addons: List[AddonRef]
|
||||
): F[List[Context]] =
|
||||
for {
|
||||
addonsDir <- Directory.create(baseDir / "addons")
|
||||
_ <- Directory.createAll(Context.tempDir(baseDir), outDir, cacheDir)
|
||||
_ <- Context
|
||||
.userInputFile(baseDir)
|
||||
.parent
|
||||
.fold(().pure[F])(Files[F].createDirectories)
|
||||
archives = addons.map(_.archive).distinctBy(_.url)
|
||||
_ <- logger.info(s"Extract ${archives.size} addons to $addonsDir")
|
||||
mkCtxs <- archives.traverse { archive =>
|
||||
for {
|
||||
_ <- logger.debug(s"Extracting $archive")
|
||||
addonDir <- archive.extractTo(urlReader, addonsDir)
|
||||
meta <- AddonMeta.findInDirectory(addonDir)
|
||||
mkCtx = (ref: AddonRef) =>
|
||||
Context(ref, meta, baseDir, addonDir, outDir, cacheDir)
|
||||
} yield archive.url -> mkCtx
|
||||
}
|
||||
ctxFactory = mkCtxs.toMap
|
||||
res = addons.map(ref => ctxFactory(ref.archive.url)(ref))
|
||||
} yield res
|
||||
|
||||
private def runAddon(logger: Logger[F], env: Env)(
|
||||
ctx: Context
|
||||
): F[AddonResult] =
|
||||
for {
|
||||
_ <- logger.info(s"Executing addon ${ctx.meta.nameAndVersion}")
|
||||
_ <- logger.trace("Storing user input into file")
|
||||
_ <- Stream
|
||||
.emit(ctx.addon.args)
|
||||
.through(fs2.text.utf8.encode)
|
||||
.through(Files[F].writeAll(ctx.userInputFile, Flags.Write))
|
||||
.compile
|
||||
.drain
|
||||
|
||||
runner <- selectRunner(cfg, ctx.meta, ctx.addonDir)
|
||||
result <- runner.run(logger, env, ctx)
|
||||
} yield result
|
||||
}
|
||||
|
||||
def selectRunner[F[_]: Async](
|
||||
cfg: AddonExecutorConfig,
|
||||
meta: AddonMeta,
|
||||
addonDir: Path
|
||||
): F[AddonRunner[F]] =
|
||||
for {
|
||||
addonRunner <- meta.enabledTypes(Left(addonDir))
|
||||
// intersect on list retains order in first
|
||||
possibleRunner = cfg.runner
|
||||
.intersect(addonRunner)
|
||||
.map(AddonRunner.forType[F](cfg))
|
||||
runner = possibleRunner match {
|
||||
case Nil =>
|
||||
AddonRunner.failWith(
|
||||
s"No runner available for addon config ${meta.runner} and config ${cfg.runner}."
|
||||
)
|
||||
case list =>
|
||||
AddonRunner.firstSuccessful(list)
|
||||
}
|
||||
} yield runner
|
||||
}
|
@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.addons
|
||||
|
||||
import docspell.addons.AddonExecutorConfig._
|
||||
import docspell.common.Duration
|
||||
import docspell.common.exec.{Args, SysCmd}
|
||||
|
||||
case class AddonExecutorConfig(
|
||||
runner: List[RunnerType],
|
||||
runTimeout: Duration,
|
||||
nspawn: NSpawn,
|
||||
nixRunner: NixConfig,
|
||||
dockerRunner: DockerConfig
|
||||
)
|
||||
|
||||
object AddonExecutorConfig {
|
||||
|
||||
case class NSpawn(
|
||||
enabled: Boolean,
|
||||
sudoBinary: String,
|
||||
nspawnBinary: String,
|
||||
containerWait: Duration
|
||||
) {
|
||||
val nspawnVersion =
|
||||
SysCmd(nspawnBinary, Args.of("--version")).withTimeout(Duration.seconds(2))
|
||||
}
|
||||
|
||||
case class NixConfig(
|
||||
nixBinary: String,
|
||||
buildTimeout: Duration
|
||||
)
|
||||
|
||||
case class DockerConfig(
|
||||
dockerBinary: String,
|
||||
buildTimeout: Duration
|
||||
) {
|
||||
def dockerBuild(imageName: String): SysCmd =
|
||||
SysCmd(dockerBinary, "build", "-t", imageName, ".").withTimeout(buildTimeout)
|
||||
}
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.addons
|
||||
|
||||
import docspell.logging.Logger
|
||||
|
||||
trait AddonLoggerExtension {
|
||||
|
||||
implicit final class LoggerAddonOps[F[_]](self: Logger[F]) {
|
||||
private val addonName = "addon-name"
|
||||
private val addonVersion = "addon-version"
|
||||
|
||||
def withAddon(r: AddonArchive): Logger[F] =
|
||||
self.capture(addonName, r.name).capture(addonVersion, r.version)
|
||||
|
||||
def withAddon(r: Context): Logger[F] =
|
||||
withAddon(r.addon.archive)
|
||||
|
||||
def withAddon(m: AddonMeta): Logger[F] =
|
||||
self.capture(addonName, m.meta.name).capture(addonVersion, m.meta.version)
|
||||
}
|
||||
}
|
||||
|
||||
object AddonLoggerExtension extends AddonLoggerExtension
|
216
modules/addonlib/src/main/scala/docspell/addons/AddonMeta.scala
Normal file
216
modules/addonlib/src/main/scala/docspell/addons/AddonMeta.scala
Normal file
@ -0,0 +1,216 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.addons
|
||||
|
||||
import java.io.FileNotFoundException
|
||||
|
||||
import cats.data.OptionT
|
||||
import cats.effect._
|
||||
import cats.syntax.all._
|
||||
import fs2.Stream
|
||||
import fs2.io.file.{Files, Path}
|
||||
|
||||
import docspell.common.Glob
|
||||
import docspell.files.Zip
|
||||
|
||||
import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder}
|
||||
import io.circe.yaml.{parser => YamlParser}
|
||||
import io.circe.{Decoder, Encoder}
|
||||
import io.circe.{parser => JsonParser}
|
||||
|
||||
case class AddonMeta(
|
||||
meta: AddonMeta.Meta,
|
||||
triggers: Option[Set[AddonTriggerType]],
|
||||
args: Option[List[String]],
|
||||
runner: Option[AddonMeta.Runner],
|
||||
options: Option[AddonMeta.Options]
|
||||
) {
|
||||
|
||||
def nameAndVersion: String =
|
||||
s"${meta.name}-${meta.version}"
|
||||
|
||||
def parseResult: Boolean =
|
||||
options.exists(_.collectOutput)
|
||||
|
||||
def ignoreResult: Boolean =
|
||||
!parseResult
|
||||
|
||||
def isImpure: Boolean =
|
||||
options.exists(_.isImpure)
|
||||
|
||||
def isPure: Boolean =
|
||||
options.forall(_.isPure)
|
||||
|
||||
/** Returns a list of runner types that are possible to use for this addon. This is also
|
||||
* inspecting the archive to return defaults when the addon isn't declaring it in the
|
||||
* descriptor.
|
||||
*/
|
||||
def enabledTypes[F[_]: Async](
|
||||
archive: Either[Path, Stream[F, Byte]]
|
||||
): F[List[RunnerType]] =
|
||||
for {
|
||||
filesExists <- AddonArchive.dockerAndFlakeExists(archive)
|
||||
(dockerFileExists, flakeFileExists) = filesExists
|
||||
|
||||
nixEnabled = runner.flatMap(_.nix).map(_.enable) match {
|
||||
case Some(flag) => flag
|
||||
case None => flakeFileExists
|
||||
}
|
||||
|
||||
dockerEnabled = runner.flatMap(_.docker).map(_.enable) match {
|
||||
case Some(flag) => flag
|
||||
case None => dockerFileExists
|
||||
}
|
||||
|
||||
trivialEnabled = runner.flatMap(_.trivial).exists(_.enable)
|
||||
|
||||
result = RunnerType.all.filter(_.fold(nixEnabled, dockerEnabled, trivialEnabled))
|
||||
} yield result
|
||||
|
||||
}
|
||||
|
||||
object AddonMeta {
|
||||
|
||||
def empty(name: String, version: String): AddonMeta =
|
||||
AddonMeta(Meta(name, version, None), None, None, None, None)
|
||||
|
||||
case class Meta(name: String, version: String, description: Option[String])
|
||||
case class Runner(
|
||||
nix: Option[NixRunner],
|
||||
docker: Option[DockerRunner],
|
||||
trivial: Option[TrivialRunner]
|
||||
)
|
||||
case class NixRunner(enable: Boolean)
|
||||
case class DockerRunner(enable: Boolean, image: Option[String], build: Option[String])
|
||||
case class TrivialRunner(enable: Boolean, exec: String)
|
||||
case class Options(networking: Boolean, collectOutput: Boolean) {
|
||||
def isPure = !networking && collectOutput
|
||||
def isImpure = networking
|
||||
def isUseless = !networking && !collectOutput
|
||||
def isUseful = networking || collectOutput
|
||||
}
|
||||
|
||||
object NixRunner {
|
||||
implicit val jsonEncoder: Encoder[NixRunner] =
|
||||
deriveEncoder
|
||||
implicit val jsonDecoder: Decoder[NixRunner] =
|
||||
deriveDecoder
|
||||
}
|
||||
|
||||
object DockerRunner {
|
||||
implicit val jsonEncoder: Encoder[DockerRunner] =
|
||||
deriveEncoder
|
||||
implicit val jsonDecoder: Decoder[DockerRunner] =
|
||||
deriveDecoder
|
||||
}
|
||||
|
||||
object TrivialRunner {
|
||||
implicit val jsonEncoder: Encoder[TrivialRunner] =
|
||||
deriveEncoder
|
||||
implicit val jsonDecoder: Decoder[TrivialRunner] =
|
||||
deriveDecoder
|
||||
}
|
||||
|
||||
object Runner {
|
||||
implicit val jsonEncoder: Encoder[Runner] =
|
||||
deriveEncoder
|
||||
implicit val jsonDecoder: Decoder[Runner] =
|
||||
deriveDecoder
|
||||
}
|
||||
|
||||
object Options {
|
||||
implicit val jsonEncoder: Encoder[Options] =
|
||||
deriveEncoder
|
||||
implicit val jsonDecoder: Decoder[Options] =
|
||||
deriveDecoder
|
||||
}
|
||||
|
||||
object Meta {
|
||||
implicit val jsonEncoder: Encoder[Meta] =
|
||||
deriveEncoder
|
||||
implicit val jsonDecoder: Decoder[Meta] =
|
||||
deriveDecoder
|
||||
}
|
||||
|
||||
implicit val jsonEncoder: Encoder[AddonMeta] =
|
||||
deriveEncoder
|
||||
|
||||
implicit val jsonDecoder: Decoder[AddonMeta] =
|
||||
deriveDecoder
|
||||
|
||||
def fromJsonString(str: String): Either[Throwable, AddonMeta] =
|
||||
JsonParser.decode[AddonMeta](str)
|
||||
|
||||
def fromJsonBytes[F[_]: Sync](bytes: Stream[F, Byte]): F[AddonMeta] =
|
||||
bytes
|
||||
.through(fs2.text.utf8.decode)
|
||||
.compile
|
||||
.string
|
||||
.map(fromJsonString)
|
||||
.rethrow
|
||||
|
||||
def fromYamlString(str: String): Either[Throwable, AddonMeta] =
|
||||
YamlParser.parse(str).flatMap(_.as[AddonMeta])
|
||||
|
||||
def fromYamlBytes[F[_]: Sync](bytes: Stream[F, Byte]): F[AddonMeta] =
|
||||
bytes
|
||||
.through(fs2.text.utf8.decode)
|
||||
.compile
|
||||
.string
|
||||
.map(fromYamlString)
|
||||
.rethrow
|
||||
|
||||
def findInDirectory[F[_]: Sync: Files](dir: Path): F[AddonMeta] = {
|
||||
val logger = docspell.logging.getLogger[F]
|
||||
val jsonFile = dir / "docspell-addon.json"
|
||||
val yamlFile = dir / "docspell-addon.yaml"
|
||||
val yamlFile2 = dir / "docspell-addon.yml"
|
||||
|
||||
OptionT
|
||||
.liftF(Files[F].exists(jsonFile))
|
||||
.flatTap(OptionT.whenF(_)(logger.debug(s"Reading json addon file $jsonFile")))
|
||||
.flatMap(OptionT.whenF(_)(fromJsonBytes(Files[F].readAll(jsonFile))))
|
||||
.orElse(
|
||||
OptionT
|
||||
.liftF(Files[F].exists(yamlFile))
|
||||
.flatTap(OptionT.whenF(_)(logger.debug(s"Reading yaml addon file $yamlFile")))
|
||||
.flatMap(OptionT.whenF(_)(fromYamlBytes(Files[F].readAll(yamlFile))))
|
||||
)
|
||||
.orElse(
|
||||
OptionT
|
||||
.liftF(Files[F].exists(yamlFile2))
|
||||
.flatTap(OptionT.whenF(_)(logger.debug(s"Reading yaml addon file $yamlFile2")))
|
||||
.flatMap(OptionT.whenF(_)(fromYamlBytes(Files[F].readAll(yamlFile2))))
|
||||
)
|
||||
.getOrElseF(
|
||||
Sync[F].raiseError(
|
||||
new FileNotFoundException(s"No docspell-addon.{yaml|json} file found in $dir!")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
def findInZip[F[_]: Async](zipFile: Stream[F, Byte]): F[AddonMeta] = {
|
||||
val fail: F[AddonMeta] = Async[F].raiseError(
|
||||
new FileNotFoundException(
|
||||
s"No docspell-addon.{yaml|json} file found in zip!"
|
||||
)
|
||||
)
|
||||
zipFile
|
||||
.through(Zip.unzip(8192, Glob("**/docspell-addon.*")))
|
||||
.filter(bin => !bin.name.endsWith("/"))
|
||||
.flatMap { bin =>
|
||||
if (bin.extensionIn(Set("json"))) Stream.eval(AddonMeta.fromJsonBytes(bin.data))
|
||||
else if (bin.extensionIn(Set("yaml", "yml")))
|
||||
Stream.eval(AddonMeta.fromYamlBytes(bin.data))
|
||||
else Stream.empty
|
||||
}
|
||||
.take(1)
|
||||
.compile
|
||||
.last
|
||||
.flatMap(_.map(Sync[F].pure).getOrElse(fail))
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.addons
|
||||
|
||||
case class AddonRef(archive: AddonArchive, args: String)
|
@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.addons
|
||||
|
||||
import cats.Monoid
|
||||
|
||||
import docspell.addons.out.AddonOutput
|
||||
|
||||
import io.circe.generic.extras.Configuration
|
||||
import io.circe.generic.extras.semiauto.{deriveConfiguredDecoder, deriveConfiguredEncoder}
|
||||
import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder}
|
||||
import io.circe.{Codec, Decoder, Encoder}
|
||||
|
||||
sealed trait AddonResult {
|
||||
def toEither: Either[Throwable, AddonOutput]
|
||||
|
||||
def isSuccess: Boolean = toEither.isRight
|
||||
def isFailure: Boolean = !isSuccess
|
||||
|
||||
def cast: AddonResult = this
|
||||
}
|
||||
|
||||
object AddonResult {
|
||||
|
||||
/** The addon was run successful, but decoding its stdout failed. */
|
||||
case class DecodingError(message: String) extends AddonResult {
|
||||
def toEither = Left(new IllegalStateException(message))
|
||||
}
|
||||
object DecodingError {
|
||||
implicit val jsonEncoder: Encoder[DecodingError] = deriveEncoder
|
||||
implicit val jsonDecoder: Decoder[DecodingError] = deriveDecoder
|
||||
}
|
||||
|
||||
def decodingError(message: String): AddonResult =
|
||||
DecodingError(message)
|
||||
|
||||
def decodingError(ex: Throwable): AddonResult =
|
||||
DecodingError(ex.getMessage)
|
||||
|
||||
/** Running the addon resulted in an invalid return code (!= 0). */
|
||||
case class ExecutionError(rc: Int) extends AddonResult {
|
||||
def toEither = Left(new IllegalStateException(s"Exit code: $rc"))
|
||||
}
|
||||
|
||||
object ExecutionError {
|
||||
implicit val jsonEncoder: Encoder[ExecutionError] = deriveEncoder
|
||||
implicit val jsonDecoder: Decoder[ExecutionError] = deriveDecoder
|
||||
}
|
||||
|
||||
def executionError(rc: Int): AddonResult =
|
||||
ExecutionError(rc)
|
||||
|
||||
/** The execution of the addon failed with an exception. */
|
||||
case class ExecutionFailed(error: Throwable) extends AddonResult {
|
||||
def toEither = Left(error)
|
||||
}
|
||||
|
||||
object ExecutionFailed {
|
||||
implicit val throwableCodec: Codec[Throwable] =
|
||||
Codec.from(
|
||||
Decoder[String].emap(str => Right(ErrorMessageThrowable(str))),
|
||||
Encoder[String].contramap(_.getMessage)
|
||||
)
|
||||
|
||||
implicit val jsonEncoder: Encoder[ExecutionFailed] = deriveEncoder
|
||||
implicit val jsonDecoder: Decoder[ExecutionFailed] = deriveDecoder
|
||||
|
||||
private class ErrorMessageThrowable(msg: String) extends RuntimeException(msg) {
|
||||
override def fillInStackTrace() = this
|
||||
}
|
||||
private object ErrorMessageThrowable {
|
||||
def apply(str: String): Throwable = new ErrorMessageThrowable(str)
|
||||
}
|
||||
}
|
||||
|
||||
def executionFailed(error: Throwable): AddonResult =
|
||||
ExecutionFailed(error)
|
||||
|
||||
/** The addon was run successfully and its output was decoded (if any). */
|
||||
case class Success(output: AddonOutput) extends AddonResult {
|
||||
def toEither = Right(output)
|
||||
}
|
||||
|
||||
object Success {
|
||||
implicit val jsonEncoder: Encoder[Success] = deriveEncoder
|
||||
implicit val jsonDecoder: Decoder[Success] = deriveDecoder
|
||||
}
|
||||
|
||||
def success(output: AddonOutput): AddonResult =
|
||||
Success(output)
|
||||
|
||||
val empty: AddonResult = Success(AddonOutput.empty)
|
||||
|
||||
def combine(a: AddonResult, b: AddonResult): AddonResult =
|
||||
(a, b) match {
|
||||
case (Success(o1), Success(o2)) => Success(AddonOutput.combine(o1, o2))
|
||||
case (Success(_), e) => e
|
||||
case (e, Success(_)) => e
|
||||
case _ => a
|
||||
}
|
||||
|
||||
implicit val deriveConfig: Configuration =
|
||||
Configuration.default.withDiscriminator("result").withKebabCaseConstructorNames
|
||||
|
||||
implicit val jsonDecoder: Decoder[AddonResult] = deriveConfiguredDecoder
|
||||
implicit val jsonEncoder: Encoder[AddonResult] = deriveConfiguredEncoder
|
||||
|
||||
implicit val addonResultMonoid: Monoid[AddonResult] =
|
||||
Monoid.instance(empty, combine)
|
||||
}
|
@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.addons
|
||||
|
||||
import cats.Applicative
|
||||
import cats.effect._
|
||||
import cats.syntax.all._
|
||||
import fs2.Stream
|
||||
|
||||
import docspell.addons.runner._
|
||||
import docspell.common.exec.Env
|
||||
import docspell.logging.Logger
|
||||
|
||||
trait AddonRunner[F[_]] {
|
||||
def runnerType: List[RunnerType]
|
||||
|
||||
def run(
|
||||
logger: Logger[F],
|
||||
env: Env,
|
||||
ctx: Context
|
||||
): F[AddonResult]
|
||||
}
|
||||
|
||||
object AddonRunner {
|
||||
def forType[F[_]: Async](cfg: AddonExecutorConfig)(rt: RunnerType) =
|
||||
rt match {
|
||||
case RunnerType.NixFlake => NixFlakeRunner[F](cfg)
|
||||
case RunnerType.Docker => DockerRunner[F](cfg)
|
||||
case RunnerType.Trivial => TrivialRunner[F](cfg)
|
||||
}
|
||||
|
||||
def failWith[F[_]](errorMsg: String)(implicit F: Applicative[F]): AddonRunner[F] =
|
||||
pure(AddonResult.executionFailed(new Exception(errorMsg)))
|
||||
|
||||
def pure[F[_]: Applicative](result: AddonResult): AddonRunner[F] =
|
||||
new AddonRunner[F] {
|
||||
val runnerType = Nil
|
||||
|
||||
def run(logger: Logger[F], env: Env, ctx: Context) =
|
||||
Applicative[F].pure(result)
|
||||
}
|
||||
|
||||
def firstSuccessful[F[_]: Sync](runners: List[AddonRunner[F]]): AddonRunner[F] =
|
||||
runners match {
|
||||
case Nil => failWith("No runner available!")
|
||||
case a :: Nil => a
|
||||
case _ =>
|
||||
new AddonRunner[F] {
|
||||
val runnerType = runners.flatMap(_.runnerType).distinct
|
||||
|
||||
def run(logger: Logger[F], env: Env, ctx: Context) =
|
||||
Stream
|
||||
.emits(runners)
|
||||
.evalTap(r =>
|
||||
logger.info(
|
||||
s"Attempt to run addon ${ctx.meta.nameAndVersion} with runner ${r.runnerType}"
|
||||
)
|
||||
)
|
||||
.evalMap(_.run(logger, env, ctx))
|
||||
.flatMap {
|
||||
case r @ AddonResult.Success(_) => Stream.emit(r.cast.some)
|
||||
case r @ AddonResult.ExecutionFailed(ex) =>
|
||||
if (ctx.meta.isPure) {
|
||||
logger.stream
|
||||
.warn(ex)(s"Addon runner failed, try next.")
|
||||
.as(r.cast.some)
|
||||
} else {
|
||||
logger.stream.warn(ex)(s"Addon runner failed!").as(None)
|
||||
}
|
||||
case r @ AddonResult.ExecutionError(rc) =>
|
||||
if (ctx.meta.isPure) {
|
||||
logger.stream
|
||||
.warn(s"Addon runner returned non-zero: $rc. Try next.")
|
||||
.as(r.cast.some)
|
||||
} else {
|
||||
logger.stream.warn(s"Addon runner returned non-zero: $rc!").as(None)
|
||||
}
|
||||
case AddonResult.DecodingError(message) =>
|
||||
// Don't retry as it is very unlikely that the output differs using another runner
|
||||
// This is most likely a bug in the addon
|
||||
logger.stream
|
||||
.warn(
|
||||
s"Error decoding the output of the addon ${ctx.meta.nameAndVersion}: $message. Stopping here. This is likely a bug in the addon."
|
||||
)
|
||||
.as(None)
|
||||
}
|
||||
.unNoneTerminate
|
||||
.takeThrough(_.isFailure)
|
||||
.compile
|
||||
.last
|
||||
.flatMap {
|
||||
case Some(r) => r.pure[F]
|
||||
case None =>
|
||||
AddonResult
|
||||
.executionFailed(new NoSuchElementException("No runner left :("))
|
||||
.pure[F]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def firstSuccessful[F[_]: Sync](
|
||||
runner: AddonRunner[F],
|
||||
runners: AddonRunner[F]*
|
||||
): AddonRunner[F] =
|
||||
firstSuccessful(runner :: runners.toList)
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.addons
|
||||
|
||||
import cats.data.NonEmptyList
|
||||
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
sealed trait AddonTriggerType {
|
||||
def name: String
|
||||
}
|
||||
|
||||
object AddonTriggerType {
|
||||
|
||||
/** The final step when processing an item. */
|
||||
case object FinalProcessItem extends AddonTriggerType {
|
||||
val name = "final-process-item"
|
||||
}
|
||||
|
||||
/** The final step when reprocessing an item. */
|
||||
case object FinalReprocessItem extends AddonTriggerType {
|
||||
val name = "final-reprocess-item"
|
||||
}
|
||||
|
||||
/** Running periodically based on a schedule. */
|
||||
case object Scheduled extends AddonTriggerType {
|
||||
val name = "scheduled"
|
||||
}
|
||||
|
||||
/** Running (manually) on some existing item. */
|
||||
case object ExistingItem extends AddonTriggerType {
|
||||
val name = "existing-item"
|
||||
}
|
||||
|
||||
val all: NonEmptyList[AddonTriggerType] =
|
||||
NonEmptyList.of(FinalProcessItem, FinalReprocessItem, Scheduled, ExistingItem)
|
||||
|
||||
def fromString(str: String): Either[String, AddonTriggerType] =
|
||||
all
|
||||
.find(e => e.name.equalsIgnoreCase(str))
|
||||
.toRight(s"Invalid addon trigger type: $str")
|
||||
|
||||
def unsafeFromString(str: String): AddonTriggerType =
|
||||
fromString(str).fold(sys.error, identity)
|
||||
|
||||
implicit val jsonEncoder: Encoder[AddonTriggerType] =
|
||||
Encoder.encodeString.contramap(_.name)
|
||||
|
||||
implicit val jsonDecoder: Decoder[AddonTriggerType] =
|
||||
Decoder.decodeString.emap(fromString)
|
||||
}
|
@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.addons
|
||||
|
||||
import fs2.io.file.Path
|
||||
|
||||
import docspell.common._
|
||||
import docspell.common.exec.{Args, Env, SysCmd}
|
||||
|
||||
/** Context a list of addons is executed in.
|
||||
*
|
||||
* Each addon has its own `addonDir`, but all share the same `baseDir` in one run.
|
||||
*/
|
||||
case class Context(
|
||||
addon: AddonRef,
|
||||
meta: AddonMeta,
|
||||
baseDir: Path,
|
||||
addonDir: Path,
|
||||
outputDir: Path,
|
||||
cacheDir: Path
|
||||
) {
|
||||
def userInputFile = Context.userInputFile(baseDir)
|
||||
def tempDir = Context.tempDir(baseDir)
|
||||
|
||||
private[addons] def addonCommand(
|
||||
binary: String,
|
||||
timeout: Duration,
|
||||
relativeToBase: Boolean,
|
||||
outputDir: Option[String],
|
||||
cacheDir: Option[String]
|
||||
): SysCmd = {
|
||||
val execBin = Option
|
||||
.when(relativeToBase)(binary)
|
||||
.getOrElse((baseDir / binary).toString)
|
||||
|
||||
val input = Option
|
||||
.when(relativeToBase)(baseDir.relativize(userInputFile))
|
||||
.getOrElse(userInputFile)
|
||||
|
||||
val allArgs =
|
||||
Args(meta.args.getOrElse(Nil)).append(input)
|
||||
val envAddonDir = Option
|
||||
.when(relativeToBase)(baseDir.relativize(addonDir))
|
||||
.getOrElse(addonDir)
|
||||
val envTmpDir = Option
|
||||
.when(relativeToBase)(baseDir.relativize(tempDir))
|
||||
.getOrElse(tempDir)
|
||||
val outDir = outputDir.getOrElse(this.outputDir.toString)
|
||||
val cache = cacheDir.getOrElse(this.cacheDir.toString)
|
||||
val moreEnv =
|
||||
Env.of(
|
||||
"ADDON_DIR" -> envAddonDir.toString,
|
||||
"TMPDIR" -> envTmpDir.toString,
|
||||
"TMP_DIR" -> envTmpDir.toString,
|
||||
"OUTPUT_DIR" -> outDir,
|
||||
"CACHE_DIR" -> cache
|
||||
)
|
||||
|
||||
SysCmd(execBin, allArgs).withTimeout(timeout).addEnv(moreEnv)
|
||||
}
|
||||
}
|
||||
|
||||
object Context {
|
||||
def userInputFile(base: Path): Path =
|
||||
base / "arguments" / "user-input"
|
||||
def tempDir(base: Path): Path =
|
||||
base / "temp"
|
||||
}
|
@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.addons
|
||||
|
||||
import cats.effect._
|
||||
import cats.syntax.all._
|
||||
import cats.{Applicative, Monad}
|
||||
import fs2.io.file.{Files, Path, PosixPermissions}
|
||||
|
||||
object Directory {
|
||||
|
||||
def create[F[_]: Files: Applicative](dir: Path): F[Path] =
|
||||
Files[F]
|
||||
.createDirectories(dir, PosixPermissions.fromOctal("777"))
|
||||
.as(dir)
|
||||
|
||||
def createAll[F[_]: Files: Applicative](dir: Path, dirs: Path*): F[Unit] =
|
||||
(dir :: dirs.toList).traverse_(Files[F].createDirectories(_))
|
||||
|
||||
def nonEmpty[F[_]: Files: Sync](dir: Path): F[Boolean] =
|
||||
List(
|
||||
Files[F].isDirectory(dir),
|
||||
Files[F].list(dir).take(1).compile.last.map(_.isDefined)
|
||||
).sequence.map(_.forall(identity))
|
||||
|
||||
def isEmpty[F[_]: Files: Sync](dir: Path): F[Boolean] =
|
||||
nonEmpty(dir).map(b => !b)
|
||||
|
||||
def temp[F[_]: Files](parent: Path, prefix: String): Resource[F, Path] =
|
||||
for {
|
||||
_ <- Resource.eval(Files[F].createDirectories(parent))
|
||||
d <- mkTemp(parent, prefix)
|
||||
} yield d
|
||||
|
||||
def temp2[F[_]: Files](
|
||||
parent: Path,
|
||||
prefix1: String,
|
||||
prefix2: String
|
||||
): Resource[F, (Path, Path)] =
|
||||
for {
|
||||
_ <- Resource.eval(Files[F].createDirectories(parent))
|
||||
a <- mkTemp(parent, prefix1)
|
||||
b <- mkTemp(parent, prefix2)
|
||||
} yield (a, b)
|
||||
|
||||
def createTemp[F[_]: Files: Monad](
|
||||
parent: Path,
|
||||
prefix: String
|
||||
): F[Path] =
|
||||
for {
|
||||
_ <- Files[F].createDirectories(parent)
|
||||
d <- mkTemp_(parent, prefix)
|
||||
} yield d
|
||||
|
||||
private def mkTemp[F[_]: Files](parent: Path, prefix: String): Resource[F, Path] =
|
||||
Files[F]
|
||||
.tempDirectory(
|
||||
parent.some,
|
||||
prefix,
|
||||
PosixPermissions.fromOctal("777")
|
||||
)
|
||||
|
||||
private def mkTemp_[F[_]: Files](parent: Path, prefix: String): F[Path] =
|
||||
Files[F]
|
||||
.createTempDirectory(
|
||||
parent.some,
|
||||
prefix,
|
||||
PosixPermissions.fromOctal("777")
|
||||
)
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.addons
|
||||
|
||||
import cats.effect.Resource
|
||||
import fs2.io.file.{Files, Path}
|
||||
|
||||
import docspell.common.exec.Env
|
||||
|
||||
case class InputEnv(
|
||||
addons: List[AddonRef],
|
||||
baseDir: Path,
|
||||
outputDir: Path,
|
||||
cacheDir: Path,
|
||||
env: Env
|
||||
) {
|
||||
def addEnv(key: String, value: String): InputEnv =
|
||||
copy(env = env.add(key, value))
|
||||
|
||||
def addEnv(vp: (String, String)*): InputEnv =
|
||||
copy(env = env.addAll(vp.toMap))
|
||||
|
||||
def addEnv(vm: Map[String, String]): InputEnv =
|
||||
copy(env = env ++ Env(vm))
|
||||
|
||||
def withTempBase[F[_]: Files]: Resource[F, InputEnv] =
|
||||
Directory.temp(baseDir, "addon-").map(path => copy(baseDir = path))
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.addons
|
||||
|
||||
import cats.Monad
|
||||
import cats.data.Kleisli
|
||||
import cats.effect.kernel.Sync
|
||||
import cats.syntax.all._
|
||||
import fs2.io.file.Files
|
||||
|
||||
trait Middleware[F[_]] extends (AddonExec[F] => AddonExec[F]) { self =>
|
||||
|
||||
def >>(next: Middleware[F]): Middleware[F] =
|
||||
Middleware(self.andThen(next))
|
||||
}
|
||||
|
||||
object Middleware {
|
||||
def apply[F[_]](f: AddonExec[F] => AddonExec[F]): Middleware[F] =
|
||||
a => f(a)
|
||||
|
||||
def identity[F[_]]: Middleware[F] = Middleware(scala.Predef.identity)
|
||||
|
||||
/** Uses a temporary base dir that is removed after execution. Use this as the last
|
||||
* layer!
|
||||
*/
|
||||
def ephemeralRun[F[_]: Files: Sync]: Middleware[F] =
|
||||
Middleware(a => Kleisli(_.withTempBase.use(a.run)))
|
||||
|
||||
/** Prepare running an addon */
|
||||
def prepare[F[_]: Monad](
|
||||
prep: Kleisli[F, InputEnv, InputEnv]
|
||||
): Middleware[F] =
|
||||
Middleware(a => Kleisli(in => prep.run(in).flatMap(a.run)))
|
||||
|
||||
def postProcess[F[_]: Monad](
|
||||
post: Kleisli[F, AddonExecutionResult, Unit]
|
||||
): Middleware[F] =
|
||||
Middleware(_.flatMapF(r => post.map(_ => r).run(r)))
|
||||
}
|
@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.addons
|
||||
|
||||
import cats.data.NonEmptyList
|
||||
import cats.syntax.all._
|
||||
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
sealed trait RunnerType {
|
||||
def name: String
|
||||
|
||||
def fold[A](
|
||||
nixFlake: => A,
|
||||
docker: => A,
|
||||
trivial: => A
|
||||
): A
|
||||
}
|
||||
object RunnerType {
|
||||
case object NixFlake extends RunnerType {
|
||||
val name = "nix-flake"
|
||||
|
||||
def fold[A](
|
||||
nixFlake: => A,
|
||||
docker: => A,
|
||||
trivial: => A
|
||||
): A = nixFlake
|
||||
}
|
||||
case object Docker extends RunnerType {
|
||||
val name = "docker"
|
||||
|
||||
def fold[A](
|
||||
nixFlake: => A,
|
||||
docker: => A,
|
||||
trivial: => A
|
||||
): A = docker
|
||||
}
|
||||
case object Trivial extends RunnerType {
|
||||
val name = "trivial"
|
||||
|
||||
def fold[A](
|
||||
nixFlake: => A,
|
||||
docker: => A,
|
||||
trivial: => A
|
||||
): A = trivial
|
||||
}
|
||||
|
||||
val all: NonEmptyList[RunnerType] =
|
||||
NonEmptyList.of(NixFlake, Docker, Trivial)
|
||||
|
||||
def fromString(str: String): Either[String, RunnerType] =
|
||||
all.find(_.name.equalsIgnoreCase(str)).toRight(s"Invalid runner value: $str")
|
||||
|
||||
def unsafeFromString(str: String): RunnerType =
|
||||
fromString(str).fold(sys.error, identity)
|
||||
|
||||
def fromSeparatedString(str: String): Either[String, List[RunnerType]] =
|
||||
str.split("[\\s,]+").toList.map(_.trim).traverse(fromString)
|
||||
|
||||
implicit val jsonDecoder: Decoder[RunnerType] =
|
||||
Decoder[String].emap(RunnerType.fromString)
|
||||
|
||||
implicit val jsonEncoder: Encoder[RunnerType] =
|
||||
Encoder[String].contramap(_.name)
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.addons.out
|
||||
|
||||
import cats.kernel.Monoid
|
||||
|
||||
import docspell.common.bc.BackendCommand
|
||||
|
||||
import io.circe.generic.extras.Configuration
|
||||
import io.circe.generic.extras.semiauto.{deriveConfiguredDecoder, deriveConfiguredEncoder}
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
/** Decoded stdout result from executing an addon. */
|
||||
case class AddonOutput(
|
||||
commands: List[BackendCommand] = Nil,
|
||||
files: List[ItemFile] = Nil,
|
||||
newItems: List[NewItem] = Nil
|
||||
)
|
||||
|
||||
object AddonOutput {
|
||||
val empty: AddonOutput = AddonOutput()
|
||||
|
||||
def combine(a: AddonOutput, b: AddonOutput): AddonOutput =
|
||||
AddonOutput(a.commands ++ b.commands, a.files ++ b.files)
|
||||
|
||||
implicit val addonResultMonoid: Monoid[AddonOutput] =
|
||||
Monoid.instance(empty, combine)
|
||||
|
||||
implicit val jsonConfig: Configuration =
|
||||
Configuration.default.withDefaults
|
||||
|
||||
implicit val jsonDecoder: Decoder[AddonOutput] = deriveConfiguredDecoder
|
||||
implicit val jsonEncoder: Encoder[AddonOutput] = deriveConfiguredEncoder
|
||||
|
||||
def fromString(str: String): Either[Throwable, AddonOutput] =
|
||||
io.circe.parser.decode[AddonOutput](str)
|
||||
|
||||
def unsafeFromString(str: String): AddonOutput =
|
||||
fromString(str).fold(throw _, identity)
|
||||
}
|
@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.addons.out
|
||||
|
||||
import cats.data.OptionT
|
||||
import cats.effect._
|
||||
import cats.syntax.all._
|
||||
import fs2.io.file.{Files, Path}
|
||||
|
||||
import docspell.common._
|
||||
import docspell.files.FileSupport._
|
||||
import docspell.logging.Logger
|
||||
|
||||
import io.circe.generic.extras.Configuration
|
||||
import io.circe.generic.extras.semiauto.{deriveConfiguredDecoder, deriveConfiguredEncoder}
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
/** Addons can produce files in their output directory. These can be named here in order
|
||||
* to do something with them.
|
||||
*
|
||||
* - textFiles will replace the extracted text with the contents of the file
|
||||
* - pdfFiles will add/replace the converted pdf with the given file
|
||||
* - previewImages will add/replace preview images
|
||||
* - newFiles will be added as new attachments to the item
|
||||
*
|
||||
* Files must be referenced by attachment id.
|
||||
*/
|
||||
final case class ItemFile(
|
||||
itemId: Ident,
|
||||
textFiles: Map[String, String] = Map.empty,
|
||||
pdfFiles: Map[String, String] = Map.empty,
|
||||
previewImages: Map[String, String] = Map.empty,
|
||||
newFiles: List[NewFile] = Nil
|
||||
) {
|
||||
def isEmpty: Boolean =
|
||||
textFiles.isEmpty && pdfFiles.isEmpty && previewImages.isEmpty
|
||||
|
||||
def nonEmpty: Boolean = !isEmpty
|
||||
|
||||
def resolveTextFiles[F[_]: Files: Sync](
|
||||
logger: Logger[F],
|
||||
outputDir: Path
|
||||
): F[List[(String, Path)]] =
|
||||
resolveFiles(logger, outputDir, MimeType.text("*"), textFiles)
|
||||
|
||||
def resolvePdfFiles[F[_]: Files: Sync](
|
||||
logger: Logger[F],
|
||||
outputDir: Path
|
||||
): F[List[(String, Path)]] =
|
||||
resolveFiles(logger, outputDir, MimeType.pdf, pdfFiles)
|
||||
|
||||
def resolvePreviewFiles[F[_]: Files: Sync](
|
||||
logger: Logger[F],
|
||||
outputDir: Path
|
||||
): F[List[(String, Path)]] =
|
||||
resolveFiles(logger, outputDir, MimeType.image("*"), previewImages)
|
||||
|
||||
def resolveNewFiles[F[_]: Files: Sync](
|
||||
logger: Logger[F],
|
||||
outputDir: Path
|
||||
): F[List[(NewFile, Path)]] =
|
||||
newFiles.traverseFilter(nf =>
|
||||
nf.resolveFile(logger, outputDir).map(_.map(p => (nf, p)))
|
||||
)
|
||||
|
||||
private def resolveFiles[F[_]: Files: Sync](
|
||||
logger: Logger[F],
|
||||
outputDir: Path,
|
||||
mime: MimeType,
|
||||
files: Map[String, String]
|
||||
): F[List[(String, Path)]] = {
|
||||
val allFiles =
|
||||
files.toList.map(t => t._1 -> outputDir / t._2)
|
||||
|
||||
allFiles.traverseFilter { case (key, file) =>
|
||||
OptionT(file.detectMime)
|
||||
.flatMapF(fileType =>
|
||||
if (mime.matches(fileType)) (key -> file).some.pure[F]
|
||||
else
|
||||
logger
|
||||
.warn(
|
||||
s"File $file provided as ${mime.asString} file, but was recognized as ${fileType.asString}. Ignoring it."
|
||||
)
|
||||
.as(None: Option[(String, Path)])
|
||||
)
|
||||
.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object ItemFile {
|
||||
|
||||
implicit val jsonConfig: Configuration =
|
||||
Configuration.default.withDefaults
|
||||
|
||||
implicit val jsonEncoder: Encoder[ItemFile] = deriveConfiguredEncoder
|
||||
implicit val jsonDecoder: Decoder[ItemFile] = deriveConfiguredDecoder
|
||||
}
|
@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.addons.out
|
||||
|
||||
import cats.effect.Sync
|
||||
import cats.syntax.all._
|
||||
import fs2.io.file.{Files, Path}
|
||||
|
||||
import docspell.addons.out.NewFile.Meta
|
||||
import docspell.common.ProcessItemArgs.ProcessMeta
|
||||
import docspell.common.{Ident, Language}
|
||||
import docspell.logging.Logger
|
||||
|
||||
import io.circe.Codec
|
||||
import io.circe.generic.extras.Configuration
|
||||
import io.circe.generic.extras.semiauto.deriveConfiguredCodec
|
||||
import io.circe.generic.semiauto.deriveCodec
|
||||
|
||||
case class NewFile(metadata: Meta = Meta.empty, file: String) {
|
||||
|
||||
def resolveFile[F[_]: Files: Sync](
|
||||
logger: Logger[F],
|
||||
outputDir: Path
|
||||
): F[Option[Path]] = {
|
||||
val target = outputDir / file
|
||||
Files[F]
|
||||
.exists(target)
|
||||
.flatMap(flag =>
|
||||
if (flag) target.some.pure[F]
|
||||
else logger.warn(s"File not found: $file").as(Option.empty)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
object NewFile {
|
||||
|
||||
case class Meta(
|
||||
language: Option[Language],
|
||||
skipDuplicate: Option[Boolean],
|
||||
attachmentsOnly: Option[Boolean]
|
||||
) {
|
||||
|
||||
def toProcessMeta(
|
||||
cid: Ident,
|
||||
itemId: Ident,
|
||||
collLang: Option[Language],
|
||||
sourceAbbrev: String
|
||||
): ProcessMeta =
|
||||
ProcessMeta(
|
||||
collective = cid,
|
||||
itemId = Some(itemId),
|
||||
language = language.orElse(collLang).getOrElse(Language.English),
|
||||
direction = None,
|
||||
sourceAbbrev = sourceAbbrev,
|
||||
folderId = None,
|
||||
validFileTypes = Seq.empty,
|
||||
skipDuplicate = skipDuplicate.getOrElse(true),
|
||||
fileFilter = None,
|
||||
tags = None,
|
||||
reprocess = false,
|
||||
attachmentsOnly = attachmentsOnly
|
||||
)
|
||||
}
|
||||
|
||||
object Meta {
|
||||
val empty = Meta(None, None, None)
|
||||
implicit val jsonCodec: Codec[Meta] = deriveCodec
|
||||
}
|
||||
|
||||
implicit val jsonConfig: Configuration = Configuration.default.withDefaults
|
||||
|
||||
implicit val jsonCodec: Codec[NewFile] = deriveConfiguredCodec
|
||||
}
|
@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.addons.out
|
||||
|
||||
import cats.Monad
|
||||
import cats.syntax.all._
|
||||
import fs2.io.file.{Files, Path}
|
||||
|
||||
import docspell.addons.out.NewItem.Meta
|
||||
import docspell.common._
|
||||
import docspell.logging.Logger
|
||||
|
||||
import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder}
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
case class NewItem(metadata: Option[Meta], files: List[String]) {
|
||||
|
||||
def toProcessMeta(
|
||||
cid: Ident,
|
||||
collLang: Option[Language],
|
||||
sourceAbbrev: String
|
||||
): ProcessItemArgs.ProcessMeta =
|
||||
metadata
|
||||
.getOrElse(Meta(None, None, None, None, None, None, None))
|
||||
.toProcessArgs(cid, collLang, sourceAbbrev)
|
||||
|
||||
def resolveFiles[F[_]: Files: Monad](
|
||||
logger: Logger[F],
|
||||
outputDir: Path
|
||||
): F[List[Path]] = {
|
||||
val allFiles =
|
||||
files.map(name => outputDir / name)
|
||||
|
||||
allFiles.traverseFilter { file =>
|
||||
Files[F]
|
||||
.exists(file)
|
||||
.flatMap {
|
||||
case true => file.some.pure[F]
|
||||
case false =>
|
||||
logger
|
||||
.warn(s"File $file doesn't exist. Ignoring it.")
|
||||
.as(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object NewItem {
|
||||
|
||||
case class Meta(
|
||||
language: Option[Language],
|
||||
direction: Option[Direction],
|
||||
folderId: Option[Ident],
|
||||
source: Option[String],
|
||||
skipDuplicate: Option[Boolean],
|
||||
tags: Option[List[String]],
|
||||
attachmentsOnly: Option[Boolean]
|
||||
) {
|
||||
|
||||
def toProcessArgs(
|
||||
cid: Ident,
|
||||
collLang: Option[Language],
|
||||
sourceAbbrev: String
|
||||
): ProcessItemArgs.ProcessMeta =
|
||||
ProcessItemArgs.ProcessMeta(
|
||||
collective = cid,
|
||||
itemId = None,
|
||||
language = language.orElse(collLang).getOrElse(Language.English),
|
||||
direction = direction,
|
||||
sourceAbbrev = source.getOrElse(sourceAbbrev),
|
||||
folderId = folderId,
|
||||
validFileTypes = Seq.empty,
|
||||
skipDuplicate = skipDuplicate.getOrElse(true),
|
||||
fileFilter = None,
|
||||
tags = tags,
|
||||
reprocess = false,
|
||||
attachmentsOnly = attachmentsOnly
|
||||
)
|
||||
}
|
||||
|
||||
object Meta {
|
||||
implicit val jsonEncoder: Encoder[Meta] = deriveEncoder
|
||||
implicit val jsonDecoder: Decoder[Meta] = deriveDecoder
|
||||
}
|
||||
|
||||
implicit val jsonDecoder: Decoder[NewItem] = deriveDecoder
|
||||
implicit val jsonEncoder: Encoder[NewItem] = deriveEncoder
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell
|
||||
|
||||
import cats.data.Kleisli
|
||||
|
||||
package object addons {
|
||||
|
||||
type AddonExec[F[_]] = Kleisli[F, InputEnv, AddonExecutionResult]
|
||||
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.addons.runner
|
||||
|
||||
import cats.Applicative
|
||||
import cats.effect.{Ref, Sync}
|
||||
import cats.syntax.all._
|
||||
import fs2.Pipe
|
||||
|
||||
trait CollectOut[F[_]] {
|
||||
|
||||
def get: F[String]
|
||||
|
||||
def append: Pipe[F, String, String]
|
||||
}
|
||||
|
||||
object CollectOut {
|
||||
|
||||
def none[F[_]: Applicative]: CollectOut[F] =
|
||||
new CollectOut[F] {
|
||||
def get = "".pure[F]
|
||||
def append = identity
|
||||
}
|
||||
|
||||
def buffer[F[_]: Sync]: F[CollectOut[F]] =
|
||||
Ref
|
||||
.of[F, Vector[String]](Vector.empty)
|
||||
.map(buffer =>
|
||||
new CollectOut[F] {
|
||||
override def get =
|
||||
buffer.get.map(_.mkString("\n").trim)
|
||||
|
||||
override def append =
|
||||
_.evalTap(line =>
|
||||
if (line.trim.nonEmpty) buffer.update(_.appended(line)) else ().pure[F]
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.addons.runner
|
||||
|
||||
import fs2.io.file.Path
|
||||
|
||||
import docspell.common.Duration
|
||||
import docspell.common.exec.{Args, Env, SysCmd}
|
||||
|
||||
/** Builder for a docker system command. */
|
||||
case class DockerBuilder(
|
||||
dockerBinary: String,
|
||||
subCmd: String,
|
||||
timeout: Duration,
|
||||
containerName: Option[String] = None,
|
||||
env: Env = Env.empty,
|
||||
mounts: Args = Args.empty,
|
||||
network: Option[String] = Some("host"),
|
||||
workingDir: Option[String] = None,
|
||||
imageName: Option[String] = None,
|
||||
cntCmd: Args = Args.empty
|
||||
) {
|
||||
def containerCmd(args: Args): DockerBuilder =
|
||||
copy(cntCmd = args)
|
||||
def containerCmd(args: Seq[String]): DockerBuilder =
|
||||
copy(cntCmd = Args(args))
|
||||
|
||||
def imageName(name: String): DockerBuilder =
|
||||
copy(imageName = Some(name))
|
||||
|
||||
def workDirectory(dir: String): DockerBuilder =
|
||||
copy(workingDir = Some(dir))
|
||||
|
||||
def withDockerBinary(bin: String): DockerBuilder =
|
||||
copy(dockerBinary = bin)
|
||||
|
||||
def withSubCmd(cmd: String): DockerBuilder =
|
||||
copy(subCmd = cmd)
|
||||
|
||||
def withEnv(key: String, value: String): DockerBuilder =
|
||||
copy(env = env.add(key, value))
|
||||
|
||||
def withEnv(moreEnv: Env): DockerBuilder =
|
||||
copy(env = env ++ moreEnv)
|
||||
|
||||
def privateNetwork(flag: Boolean): DockerBuilder =
|
||||
if (flag) copy(network = Some("none"))
|
||||
else copy(network = Some("host"))
|
||||
|
||||
def mount(
|
||||
hostDir: Path,
|
||||
cntDir: Option[String] = None,
|
||||
readOnly: Boolean = true
|
||||
): DockerBuilder = {
|
||||
val target = cntDir.getOrElse(hostDir.toString)
|
||||
val ro = Option.when(readOnly)(",readonly").getOrElse("")
|
||||
val opt = s"type=bind,source=$hostDir,target=$target${ro}"
|
||||
copy(mounts = mounts.append("--mount", opt))
|
||||
}
|
||||
|
||||
def withName(containerName: String): DockerBuilder =
|
||||
copy(containerName = Some(containerName))
|
||||
|
||||
def build: SysCmd =
|
||||
SysCmd(dockerBinary, buildArgs).withTimeout(timeout)
|
||||
|
||||
private def buildArgs: Args =
|
||||
Args
|
||||
.of(subCmd)
|
||||
.append("--rm")
|
||||
.option("--name", containerName)
|
||||
.append(mounts)
|
||||
.option("--network", network)
|
||||
.append(env.mapConcat((k, v) => List("--env", s"${k}=${v}")))
|
||||
.option("-w", workingDir)
|
||||
.appendOpt(imageName)
|
||||
.append(cntCmd)
|
||||
}
|
@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.addons.runner
|
||||
|
||||
import cats.data.OptionT
|
||||
import cats.effect._
|
||||
import cats.syntax.all._
|
||||
|
||||
import docspell.addons.AddonExecutorConfig.DockerConfig
|
||||
import docspell.addons._
|
||||
import docspell.common.Duration
|
||||
import docspell.common.exec.{Env, SysCmd, SysExec}
|
||||
import docspell.common.util.Random
|
||||
import docspell.logging.Logger
|
||||
|
||||
final class DockerRunner[F[_]: Async](cfg: DockerRunner.Config) extends AddonRunner[F] {
|
||||
|
||||
val runnerType = List(RunnerType.Docker)
|
||||
|
||||
def run(
|
||||
logger: Logger[F],
|
||||
env: Env,
|
||||
ctx: Context
|
||||
) = for {
|
||||
_ <- OptionT.whenF(requireBuild(ctx))(build(logger, ctx)).value
|
||||
suffix <- Random[F].string(4)
|
||||
cmd = createDockerCommand(env, ctx, suffix)
|
||||
result <- RunnerUtil.runAddonCommand(logger, cmd, ctx)
|
||||
} yield result
|
||||
|
||||
def createDockerCommand(
|
||||
env: Env,
|
||||
ctx: Context,
|
||||
suffix: String
|
||||
): SysCmd = {
|
||||
val outputPath = "/mnt/output"
|
||||
val cachePath = "/mnt/cache"
|
||||
val addonArgs =
|
||||
ctx.addonCommand(
|
||||
"",
|
||||
Duration.zero,
|
||||
relativeToBase = true,
|
||||
outputPath.some,
|
||||
cachePath.some
|
||||
)
|
||||
|
||||
DockerBuilder(cfg.docker.dockerBinary, "run", cfg.timeout)
|
||||
.withName(ctx.meta.nameAndVersion + "-" + suffix)
|
||||
.withEnv(env)
|
||||
.withEnv(addonArgs.env)
|
||||
.mount(ctx.baseDir, "/mnt/work".some, readOnly = false)
|
||||
.mount(ctx.outputDir, outputPath.some, readOnly = false)
|
||||
.mount(ctx.cacheDir, cachePath.some, readOnly = false)
|
||||
.workDirectory("/mnt/work")
|
||||
.privateNetwork(ctx.meta.isPure)
|
||||
.imageName(imageName(ctx))
|
||||
.containerCmd(addonArgs.args)
|
||||
.build
|
||||
}
|
||||
|
||||
def build(logger: Logger[F], ctx: Context): F[Unit] =
|
||||
for {
|
||||
_ <- logger.info(s"Building docker image for addon ${ctx.meta.nameAndVersion}")
|
||||
cmd = cfg.docker.dockerBuild(imageName(ctx))
|
||||
_ <- SysExec(cmd, logger, ctx.addonDir.some)
|
||||
.flatMap(_.logOutputs(logger, "docker build"))
|
||||
.use(_.waitFor())
|
||||
_ <- logger.info(s"Docker image built successfully")
|
||||
} yield ()
|
||||
|
||||
private def requireBuild(ctx: Context) =
|
||||
ctx.meta.runner
|
||||
.flatMap(_.docker)
|
||||
.flatMap(_.image)
|
||||
.isEmpty
|
||||
|
||||
private def imageName(ctx: Context): String =
|
||||
ctx.meta.runner
|
||||
.flatMap(_.docker)
|
||||
.flatMap(_.image)
|
||||
.getOrElse(s"${ctx.meta.meta.name}:latest")
|
||||
}
|
||||
|
||||
object DockerRunner {
|
||||
def apply[F[_]: Async](cfg: AddonExecutorConfig): DockerRunner[F] =
|
||||
new DockerRunner[F](Config(cfg.dockerRunner, cfg.runTimeout))
|
||||
|
||||
case class Config(docker: DockerConfig, timeout: Duration)
|
||||
}
|
@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.addons.runner
|
||||
|
||||
import fs2.io.file.Path
|
||||
|
||||
import docspell.common.exec.{Args, Env, SysCmd}
|
||||
|
||||
case class NSpawnBuilder(
|
||||
child: SysCmd,
|
||||
chroot: Path,
|
||||
spawnBinary: String = "systemd-nspawn",
|
||||
sudoBinary: String = "sudo",
|
||||
args: Args = Args.empty,
|
||||
env: Env = Env.empty
|
||||
) {
|
||||
|
||||
def withNSpawnBinary(bin: String): NSpawnBuilder =
|
||||
copy(spawnBinary = bin)
|
||||
|
||||
def withSudoBinary(bin: String): NSpawnBuilder =
|
||||
copy(sudoBinary = bin)
|
||||
|
||||
def withEnv(key: String, value: String): NSpawnBuilder =
|
||||
copy(args = args.append(s"--setenv=$key=$value"))
|
||||
|
||||
def withEnvOpt(key: String, value: Option[String]): NSpawnBuilder =
|
||||
value.map(v => withEnv(key, v)).getOrElse(this)
|
||||
|
||||
def withName(containerName: String): NSpawnBuilder =
|
||||
copy(args = args.append(s"--machine=$containerName"))
|
||||
|
||||
def mount(
|
||||
hostDir: Path,
|
||||
cntDir: Option[String] = None,
|
||||
readOnly: Boolean = true
|
||||
): NSpawnBuilder = {
|
||||
val bind = if (readOnly) "--bind-ro" else "--bind"
|
||||
val target = cntDir.map(dir => s":$dir").getOrElse("")
|
||||
copy(args = args.append(s"${bind}=${hostDir}${target}"))
|
||||
}
|
||||
|
||||
def workDirectory(dir: String): NSpawnBuilder =
|
||||
copy(args = args.append(s"--chdir=$dir"))
|
||||
|
||||
def portMap(port: Int): NSpawnBuilder =
|
||||
copy(args = args.append("-p", port.toString))
|
||||
|
||||
def privateNetwork(flag: Boolean): NSpawnBuilder =
|
||||
if (flag) copy(args = args.append("--private-network"))
|
||||
else this
|
||||
|
||||
def build: SysCmd =
|
||||
SysCmd(
|
||||
program = if (sudoBinary.nonEmpty) sudoBinary else spawnBinary,
|
||||
args = buildArgs,
|
||||
timeout = child.timeout,
|
||||
env = env
|
||||
)
|
||||
|
||||
private def buildArgs: Args =
|
||||
Args
|
||||
.of("--private-users=identity") // can't use -U because need writeable bind mounts
|
||||
.append("--notify-ready=yes")
|
||||
.append("--ephemeral")
|
||||
.append("--as-pid2")
|
||||
.append("--console=pipe")
|
||||
.append("--no-pager")
|
||||
.append("--bind-ro=/bin")
|
||||
.append("--bind-ro=/usr/bin")
|
||||
.append("--bind-ro=/nix/store")
|
||||
.append(s"--directory=$chroot")
|
||||
.append(args)
|
||||
.append(child.env.map((n, v) => s"--setenv=$n=$v"))
|
||||
.prependWhen(sudoBinary.nonEmpty)(spawnBinary)
|
||||
.append("--")
|
||||
.append(child.program)
|
||||
.append(child.args)
|
||||
}
|
@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.addons.runner
|
||||
|
||||
import cats.effect._
|
||||
import cats.syntax.all._
|
||||
import fs2.Stream
|
||||
import fs2.io.file.{Files, Path}
|
||||
|
||||
import docspell.addons.AddonExecutorConfig.{NSpawn, NixConfig}
|
||||
import docspell.addons._
|
||||
import docspell.addons.runner.NixFlakeRunner.PreCtx
|
||||
import docspell.common.Duration
|
||||
import docspell.common.exec._
|
||||
import docspell.logging.Logger
|
||||
|
||||
final class NixFlakeRunner[F[_]: Async](cfg: NixFlakeRunner.Config)
|
||||
extends AddonRunner[F] {
|
||||
|
||||
val runnerType = List(RunnerType.NixFlake)
|
||||
|
||||
def run(
|
||||
logger: Logger[F],
|
||||
env: Env,
|
||||
ctx: Context
|
||||
): F[AddonResult] =
|
||||
prepare(logger, ctx)
|
||||
.flatMap { preCtx =>
|
||||
if (preCtx.nspawnEnabled) runInContainer(logger, env, preCtx, ctx)
|
||||
else runOnHost(logger, env, preCtx, ctx)
|
||||
}
|
||||
|
||||
def prepare(logger: Logger[F], ctx: Context): F[PreCtx] =
|
||||
for {
|
||||
_ <- logger.info(s"Prepare addon ${ctx.meta.nameAndVersion} for executing via nix")
|
||||
_ <- logger.debug(s"Building with nix build")
|
||||
_ <- SysExec(cfg.nixBuild, logger, workdir = ctx.addonDir.some)
|
||||
.flatMap(_.logOutputs(logger, "nix build"))
|
||||
.use(_.waitFor())
|
||||
bin <- findFile(ctx.addonDir / "result" / "bin", ctx.addonDir / "result")
|
||||
_ <- logger.debug(s"Build done, found binary: $bin")
|
||||
_ <- logger.debug(s"Checking for systemd-nspawn…")
|
||||
cnt <- checkContainer(logger)
|
||||
_ <-
|
||||
if (cnt)
|
||||
logger.debug(s"Using systemd-nspawn to run addon in a container.")
|
||||
else
|
||||
logger.info(s"Running via systemd-nspawn is disabled in the config file")
|
||||
} yield PreCtx(cnt, ctx.baseDir.relativize(bin))
|
||||
|
||||
private def checkContainer(logger: Logger[F]): F[Boolean] =
|
||||
if (!cfg.nspawn.enabled) false.pure[F]
|
||||
else RunnerUtil.checkContainer(logger, cfg.nspawn)
|
||||
|
||||
private def runOnHost(
|
||||
logger: Logger[F],
|
||||
env: Env,
|
||||
preCtx: PreCtx,
|
||||
ctx: Context
|
||||
): F[AddonResult] = {
|
||||
val cmd =
|
||||
SysCmd(preCtx.binary.toString, Args.empty).withTimeout(cfg.timeout).addEnv(env)
|
||||
RunnerUtil.runDirectly(logger, ctx)(cmd)
|
||||
}
|
||||
|
||||
private def runInContainer(
|
||||
logger: Logger[F],
|
||||
env: Env,
|
||||
preCtx: PreCtx,
|
||||
ctx: Context
|
||||
): F[AddonResult] = {
|
||||
val cmd = SysCmd(preCtx.binary.toString, Args.empty)
|
||||
.withTimeout(cfg.timeout)
|
||||
.addEnv(env)
|
||||
RunnerUtil.runInContainer(logger, cfg.nspawn, ctx)(cmd)
|
||||
}
|
||||
|
||||
/** Find first file, try directories in given order. */
|
||||
private def findFile(firstDir: Path, more: Path*): F[Path] = {
|
||||
val fail: F[Path] = Sync[F].raiseError(
|
||||
new NoSuchElementException(
|
||||
s"No file found to execute in ${firstDir :: more.toList}"
|
||||
)
|
||||
)
|
||||
|
||||
Stream
|
||||
.emits(more)
|
||||
.cons1(firstDir)
|
||||
.flatMap(dir =>
|
||||
Files[F]
|
||||
.list(dir)
|
||||
.evalFilter(p => Files[F].isDirectory(p).map(!_))
|
||||
.take(1)
|
||||
)
|
||||
.take(1)
|
||||
.compile
|
||||
.last
|
||||
.flatMap(_.fold(fail)(Sync[F].pure))
|
||||
}
|
||||
}
|
||||
|
||||
object NixFlakeRunner {
|
||||
def apply[F[_]: Async](cfg: AddonExecutorConfig): NixFlakeRunner[F] =
|
||||
new NixFlakeRunner[F](Config(cfg.nixRunner, cfg.nspawn, cfg.runTimeout))
|
||||
|
||||
case class Config(
|
||||
nix: NixConfig,
|
||||
nspawn: NSpawn,
|
||||
timeout: Duration
|
||||
) {
|
||||
|
||||
val nixBuild =
|
||||
SysCmd(nix.nixBinary, Args.of("build")).withTimeout(nix.buildTimeout)
|
||||
|
||||
val nspawnVersion = nspawn.nspawnVersion
|
||||
}
|
||||
|
||||
case class PreCtx(nspawnEnabled: Boolean, binary: Path)
|
||||
}
|
@ -0,0 +1,171 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.addons.runner
|
||||
|
||||
import cats.data.OptionT
|
||||
import cats.effect.{Async, Sync}
|
||||
import cats.syntax.all._
|
||||
import fs2.Pipe
|
||||
import fs2.io.file.Files
|
||||
|
||||
import docspell.addons._
|
||||
import docspell.addons.out.AddonOutput
|
||||
import docspell.common.exec.{SysCmd, SysExec}
|
||||
import docspell.common.util.Random
|
||||
import docspell.logging.Logger
|
||||
|
||||
import io.circe.{parser => JsonParser}
|
||||
|
||||
private[addons] object RunnerUtil {
|
||||
|
||||
/** Run the given `cmd` on this machine.
|
||||
*
|
||||
* The `cmd` is containing a template command to execute the addon. The path are
|
||||
* expected to be relative to the `ctx.baseDir`. Additional arguments and environment
|
||||
* variables are added as configured in the addon.
|
||||
*/
|
||||
def runDirectly[F[_]: Async](
|
||||
logger: Logger[F],
|
||||
ctx: Context
|
||||
)(cmd: SysCmd): F[AddonResult] = {
|
||||
val addonCmd = ctx
|
||||
.addonCommand(cmd.program, cmd.timeout, relativeToBase = false, None, None)
|
||||
.withArgs(_.append(cmd.args))
|
||||
.addEnv(cmd.env)
|
||||
runAddonCommand(logger, addonCmd, ctx)
|
||||
}
|
||||
|
||||
/** Run the given `cmd` inside a container via systemd-nspawn.
|
||||
*
|
||||
* The `cmd` is containing a template command to execute the addon. The path are
|
||||
* expected to be relative to the `ctx.baseDir`. Additional arguments and environment
|
||||
* variables are added as configured in the addon.
|
||||
*/
|
||||
def runInContainer[F[_]: Async](
|
||||
logger: Logger[F],
|
||||
cfg: AddonExecutorConfig.NSpawn,
|
||||
ctx: Context
|
||||
)(cmd: SysCmd): F[AddonResult] = {
|
||||
val outputPath = "/mnt/output"
|
||||
val cachePath = "/mnt/cache"
|
||||
val addonCmd = ctx
|
||||
.addonCommand(
|
||||
cmd.program,
|
||||
cmd.timeout,
|
||||
relativeToBase = true,
|
||||
outputPath.some,
|
||||
cachePath.some
|
||||
)
|
||||
.withArgs(_.append(cmd.args))
|
||||
.addEnv(cmd.env)
|
||||
|
||||
val chroot = ctx.baseDir / "cnt-root"
|
||||
val nspawn = NSpawnBuilder(addonCmd, chroot)
|
||||
.withNSpawnBinary(cfg.nspawnBinary)
|
||||
.withSudoBinary(cfg.sudoBinary)
|
||||
.mount(ctx.baseDir, "/mnt/work".some, readOnly = false)
|
||||
.mount(ctx.cacheDir, cachePath.some, readOnly = false)
|
||||
.mount(ctx.outputDir, outputPath.some, readOnly = false)
|
||||
.workDirectory("/mnt/work")
|
||||
.withEnv("XDG_RUNTIME_DIR", "/mnt/work")
|
||||
.privateNetwork(ctx.meta.isPure)
|
||||
|
||||
for {
|
||||
suffix <- Random[F].string(4)
|
||||
_ <- List(chroot).traverse_(Files[F].createDirectories)
|
||||
res <- runAddonCommand(
|
||||
logger,
|
||||
nspawn.withName(ctx.meta.nameAndVersion + "-" + suffix).build,
|
||||
ctx
|
||||
)
|
||||
// allow some time to unregister the current container
|
||||
// only important when same addons are called in sequence too fast
|
||||
_ <- Sync[F].sleep(cfg.containerWait.toScala)
|
||||
} yield res
|
||||
}
|
||||
|
||||
private def procPipe[F[_]](
|
||||
p: String,
|
||||
ctx: Context,
|
||||
collect: CollectOut[F],
|
||||
logger: Logger[F]
|
||||
): Pipe[F, String, Unit] =
|
||||
_.through(collect.append)
|
||||
.map(line => s">> [${ctx.meta.nameAndVersion} ($p)] $line")
|
||||
.evalMap(logger.debug(_))
|
||||
|
||||
/** Runs the external command that is executing the addon.
|
||||
*
|
||||
* If the addons specifies to collect its output, the stdout is parsed as json and
|
||||
* decoded into [[AddonOutput]].
|
||||
*/
|
||||
def runAddonCommand[F[_]: Async](
|
||||
logger: Logger[F],
|
||||
cmd: SysCmd,
|
||||
ctx: Context
|
||||
): F[AddonResult] =
|
||||
for {
|
||||
stdout <-
|
||||
if (ctx.meta.options.exists(_.collectOutput)) CollectOut.buffer[F]
|
||||
else CollectOut.none[F].pure[F]
|
||||
cmdResult <- SysExec(cmd, logger, ctx.baseDir.some)
|
||||
.flatMap(
|
||||
_.consumeOutputs(
|
||||
procPipe("out", ctx, stdout, logger),
|
||||
procPipe("err", ctx, CollectOut.none[F], logger)
|
||||
)
|
||||
)
|
||||
.use(_.waitFor())
|
||||
.attempt
|
||||
addonResult <- cmdResult match {
|
||||
case Right(rc) if rc != 0 =>
|
||||
for {
|
||||
_ <- logger.error(
|
||||
s"Addon ${ctx.meta.nameAndVersion} returned non-zero: $rc"
|
||||
)
|
||||
} yield AddonResult.executionError(rc)
|
||||
|
||||
case Right(_) =>
|
||||
for {
|
||||
_ <- logger.debug(s"Addon ${ctx.meta.nameAndVersion} executed successfully!")
|
||||
out <- stdout.get
|
||||
_ <- logger.debug(s"Addon stdout: $out")
|
||||
result = Option
|
||||
.when(ctx.meta.options.exists(_.collectOutput) && out.nonEmpty)(
|
||||
JsonParser
|
||||
.decode[AddonOutput](out)
|
||||
.fold(AddonResult.decodingError, AddonResult.success)
|
||||
)
|
||||
.getOrElse(AddonResult.empty)
|
||||
} yield result
|
||||
|
||||
case Left(ex) =>
|
||||
logger
|
||||
.error(ex)(s"Executing external command failed!")
|
||||
.as(AddonResult.executionFailed(ex))
|
||||
}
|
||||
} yield addonResult
|
||||
|
||||
/** Check whether `systemd-nspawn` is available on this machine. */
|
||||
def checkContainer[F[_]: Async](
|
||||
logger: Logger[F],
|
||||
cfg: AddonExecutorConfig.NSpawn
|
||||
): F[Boolean] =
|
||||
for {
|
||||
rc <- SysExec(cfg.nspawnVersion, logger)
|
||||
.flatMap(_.logOutputs(logger, "nspawn"))
|
||||
.use(_.waitFor())
|
||||
_ <-
|
||||
OptionT
|
||||
.whenF(rc != 0)(
|
||||
logger.warn(
|
||||
s"No systemd-nspawn found! Addon is not executed inside a container."
|
||||
)
|
||||
)
|
||||
.value
|
||||
} yield rc == 0
|
||||
}
|
@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.addons.runner
|
||||
|
||||
import cats.data.OptionT
|
||||
import cats.effect._
|
||||
import cats.kernel.Monoid
|
||||
import cats.syntax.all._
|
||||
import fs2.io.file.PosixPermission._
|
||||
import fs2.io.file.{Files, PosixPermissions}
|
||||
|
||||
import docspell.addons.AddonExecutorConfig.NSpawn
|
||||
import docspell.addons._
|
||||
import docspell.common.Duration
|
||||
import docspell.common.exec.{Args, Env, SysCmd}
|
||||
import docspell.logging.Logger
|
||||
|
||||
final class TrivialRunner[F[_]: Async](cfg: TrivialRunner.Config) extends AddonRunner[F] {
|
||||
private val sync = Async[F]
|
||||
private val files = Files[F]
|
||||
implicit val andMonoid: Monoid[Boolean] = Monoid.instance[Boolean](true, _ && _)
|
||||
|
||||
private val executeBits = PosixPermissions(
|
||||
OwnerExecute,
|
||||
OwnerRead,
|
||||
OwnerWrite,
|
||||
GroupExecute,
|
||||
GroupRead,
|
||||
OthersExecute,
|
||||
OthersRead
|
||||
)
|
||||
|
||||
val runnerType = List(RunnerType.Trivial)
|
||||
|
||||
def run(
|
||||
logger: Logger[F],
|
||||
env: Env,
|
||||
ctx: Context
|
||||
) = {
|
||||
val binaryPath = ctx.meta.runner
|
||||
.flatMap(_.trivial)
|
||||
.map(_.exec)
|
||||
.map(bin => ctx.addonDir / bin)
|
||||
|
||||
binaryPath match {
|
||||
case None =>
|
||||
sync.raiseError(new IllegalStateException("No executable specified in addon!"))
|
||||
|
||||
case Some(file) =>
|
||||
val bin = ctx.baseDir.relativize(file)
|
||||
val cmd = SysCmd(bin.toString, Args.empty).withTimeout(cfg.timeout).addEnv(env)
|
||||
|
||||
val withNSpawn =
|
||||
OptionT
|
||||
.whenF(cfg.nspawn.enabled)(RunnerUtil.checkContainer(logger, cfg.nspawn))
|
||||
.getOrElse(false)
|
||||
|
||||
files.setPosixPermissions(file, executeBits).attempt *>
|
||||
withNSpawn.flatMap {
|
||||
case true =>
|
||||
RunnerUtil.runInContainer(logger, cfg.nspawn, ctx)(cmd)
|
||||
case false =>
|
||||
RunnerUtil.runDirectly(logger, ctx)(cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object TrivialRunner {
|
||||
def apply[F[_]: Async](cfg: AddonExecutorConfig): TrivialRunner[F] =
|
||||
new TrivialRunner[F](Config(cfg.nspawn, cfg.runTimeout))
|
||||
|
||||
case class Config(nspawn: NSpawn, timeout: Duration)
|
||||
}
|
Binary file not shown.
@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.addons
|
||||
|
||||
import cats.effect._
|
||||
import cats.syntax.option._
|
||||
|
||||
import docspell.common.UrlReader
|
||||
import docspell.logging.TestLoggingConfig
|
||||
|
||||
import munit._
|
||||
|
||||
class AddonArchiveTest extends CatsEffectSuite with TestLoggingConfig with Fixtures {
|
||||
val logger = docspell.logging.getLogger[IO]
|
||||
|
||||
tempDir.test("Read archive from directory") { dir =>
|
||||
for {
|
||||
archive <- IO(AddonArchive(dummyAddonUrl, "", ""))
|
||||
path <- archive.extractTo[IO](UrlReader.defaultReader[IO], dir)
|
||||
|
||||
aa <- AddonArchive.read[IO](dummyAddonUrl, UrlReader.defaultReader[IO], path.some)
|
||||
_ = {
|
||||
assertEquals(aa.name, "dummy-addon")
|
||||
assertEquals(aa.version, "2.9")
|
||||
assertEquals(aa.url, dummyAddonUrl)
|
||||
}
|
||||
} yield ()
|
||||
}
|
||||
|
||||
test("Read archive from zip file") {
|
||||
for {
|
||||
archive <- AddonArchive.read[IO](dummyAddonUrl, UrlReader.defaultReader[IO])
|
||||
_ = {
|
||||
assertEquals(archive.name, "dummy-addon")
|
||||
assertEquals(archive.version, "2.9")
|
||||
assertEquals(archive.url, dummyAddonUrl)
|
||||
}
|
||||
} yield ()
|
||||
}
|
||||
}
|
@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.addons
|
||||
|
||||
import cats.effect._
|
||||
|
||||
import docspell.logging.{Level, TestLoggingConfig}
|
||||
|
||||
import munit._
|
||||
|
||||
class AddonExecutorTest extends CatsEffectSuite with Fixtures with TestLoggingConfig {
|
||||
val logger = docspell.logging.getLogger[IO]
|
||||
|
||||
override def docspellLogConfig =
|
||||
super.docspellLogConfig.copy(minimumLevel = Level.Trace)
|
||||
|
||||
tempDir.test("select docker if Dockerfile exists") { dir =>
|
||||
for {
|
||||
_ <- files.createFile(dir / "Dockerfile")
|
||||
cfg = testExecutorConfig(
|
||||
RunnerType.Docker,
|
||||
RunnerType.NixFlake,
|
||||
RunnerType.Trivial
|
||||
)
|
||||
meta = dummyAddonMeta.copy(runner = None)
|
||||
r <- AddonExecutor.selectRunner[IO](cfg, meta, dir)
|
||||
_ = assertEquals(r.runnerType, List(RunnerType.Docker))
|
||||
} yield ()
|
||||
}
|
||||
|
||||
tempDir.test("select nix-flake if flake.nix exists") { dir =>
|
||||
for {
|
||||
_ <- files.createFile(dir / "flake.nix")
|
||||
cfg = testExecutorConfig(
|
||||
RunnerType.Docker,
|
||||
RunnerType.NixFlake,
|
||||
RunnerType.Trivial
|
||||
)
|
||||
meta = dummyAddonMeta.copy(runner = None)
|
||||
r <- AddonExecutor.selectRunner[IO](cfg, meta, dir)
|
||||
_ = assertEquals(r.runnerType, List(RunnerType.NixFlake))
|
||||
} yield ()
|
||||
}
|
||||
|
||||
tempDir.test("select nix-flake and docker") { dir =>
|
||||
for {
|
||||
_ <- files.createFile(dir / "flake.nix")
|
||||
_ <- files.createFile(dir / "Dockerfile")
|
||||
cfg = testExecutorConfig(
|
||||
RunnerType.Docker,
|
||||
RunnerType.NixFlake,
|
||||
RunnerType.Trivial
|
||||
)
|
||||
meta = dummyAddonMeta.copy(runner = None)
|
||||
r <- AddonExecutor.selectRunner[IO](cfg, meta, dir)
|
||||
_ = assertEquals(r.runnerType, List(RunnerType.Docker, RunnerType.NixFlake))
|
||||
} yield ()
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.addons
|
||||
|
||||
import cats.effect._
|
||||
|
||||
import docspell.common.Glob
|
||||
import docspell.files.Zip
|
||||
import docspell.logging.TestLoggingConfig
|
||||
|
||||
import munit._
|
||||
|
||||
class AddonMetaTest extends CatsEffectSuite with TestLoggingConfig with Fixtures {
|
||||
val logger = docspell.logging.getLogger[IO]
|
||||
|
||||
test("read meta from zip file") {
|
||||
val meta = AddonMeta.findInZip(dummyAddonUrl.readURL[IO](8192))
|
||||
assertIO(meta, dummyAddonMeta)
|
||||
}
|
||||
|
||||
tempDir.test("read meta from directory") { dir =>
|
||||
for {
|
||||
_ <- dummyAddonUrl
|
||||
.readURL[IO](8192)
|
||||
.through(Zip.unzip(8192, Glob.all))
|
||||
.through(Zip.saveTo(logger, dir, moveUp = true))
|
||||
.compile
|
||||
.drain
|
||||
meta <- AddonMeta.findInDirectory[IO](dir)
|
||||
_ = assertEquals(meta, dummyAddonMeta)
|
||||
} yield ()
|
||||
}
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.addons
|
||||
|
||||
import docspell.addons.out.AddonOutput
|
||||
|
||||
import io.circe.parser.decode
|
||||
import munit.FunSuite
|
||||
|
||||
class AddonOutputTest extends FunSuite {
|
||||
|
||||
test("decode empty object") {
|
||||
val out = decode[AddonOutput]("{}")
|
||||
println(out)
|
||||
}
|
||||
|
||||
test("decode sample output") {
|
||||
val jsonStr =
|
||||
"""{ "files": [
|
||||
| {
|
||||
| "itemId": "qZDnyGIAJsXr",
|
||||
| "textFiles": {
|
||||
| "HPFvIDib6eA": "HPFvIDib6eA.txt"
|
||||
| },
|
||||
| "pdfFiles": {
|
||||
| "HPFvIDib6eA": "HPFvIDib6eA.pdf"
|
||||
| }
|
||||
| }
|
||||
| ]
|
||||
|}
|
||||
|""".stripMargin
|
||||
|
||||
val out = decode[AddonOutput](jsonStr)
|
||||
println(out)
|
||||
}
|
||||
}
|
@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.addons
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
import cats.effect.IO
|
||||
import fs2.io.file.Path
|
||||
|
||||
import docspell.common.LenientUri
|
||||
import docspell.common.exec.Env
|
||||
import docspell.logging.{Logger, TestLoggingConfig}
|
||||
|
||||
import munit._
|
||||
|
||||
class AddonRunnerTest extends CatsEffectSuite with TestLoggingConfig {
|
||||
|
||||
val logger = docspell.logging.getLogger[IO]
|
||||
|
||||
val dummyContext = Context(
|
||||
addon = AddonRef(AddonArchive(LenientUri.unsafe("http://test"), "", ""), ""),
|
||||
meta = AddonMeta.empty("test", "1.0"),
|
||||
baseDir = Path(""),
|
||||
addonDir = Path(""),
|
||||
outputDir = Path(""),
|
||||
cacheDir = Path("")
|
||||
)
|
||||
|
||||
test("firstSuccessful must stop on first success") {
|
||||
val counter = new AtomicInteger(0)
|
||||
val runner = new MockRunner(IO(counter.incrementAndGet()).void)
|
||||
val r = AddonRunner.firstSuccessful(runner, runner, runner)
|
||||
for {
|
||||
_ <- r.run(logger, Env.empty, dummyContext)
|
||||
_ = assertEquals(counter.get(), 1)
|
||||
} yield ()
|
||||
}
|
||||
|
||||
test("firstSuccessful must try with next on error") {
|
||||
val counter = new AtomicInteger(0)
|
||||
val fail = AddonRunner.failWith[IO]("failed")
|
||||
val runner: AddonRunner[IO] = new MockRunner(IO(counter.incrementAndGet()).void)
|
||||
val r = AddonRunner.firstSuccessful(fail, runner, runner)
|
||||
for {
|
||||
_ <- r.run(logger, Env.empty, dummyContext)
|
||||
_ = assertEquals(counter.get(), 1)
|
||||
} yield ()
|
||||
}
|
||||
|
||||
test("do not retry on decoding errors") {
|
||||
val counter = new AtomicInteger(0)
|
||||
val fail = AddonRunner.pure[IO](AddonResult.decodingError("Decoding failed"))
|
||||
val increment: AddonRunner[IO] = new MockRunner(IO(counter.incrementAndGet()).void)
|
||||
|
||||
val r = AddonRunner.firstSuccessful(fail, increment, increment)
|
||||
for {
|
||||
_ <- r.run(logger, Env.empty, dummyContext)
|
||||
_ = assertEquals(counter.get(), 0)
|
||||
} yield ()
|
||||
}
|
||||
|
||||
test("try on errors but stop on decoding error") {
|
||||
val counter = new AtomicInteger(0)
|
||||
val decodeFail = AddonRunner.pure[IO](AddonResult.decodingError("Decoding failed"))
|
||||
val incrementFail =
|
||||
new MockRunner(IO(counter.incrementAndGet()).void)
|
||||
.as(AddonResult.executionFailed(new Exception("fail")))
|
||||
val increment: AddonRunner[IO] = new MockRunner(IO(counter.incrementAndGet()).void)
|
||||
|
||||
val r = AddonRunner.firstSuccessful(
|
||||
incrementFail,
|
||||
incrementFail,
|
||||
decodeFail,
|
||||
increment,
|
||||
increment
|
||||
)
|
||||
for {
|
||||
_ <- r.run(logger, Env.empty, dummyContext)
|
||||
_ = assertEquals(counter.get(), 2)
|
||||
} yield ()
|
||||
}
|
||||
|
||||
final class MockRunner(run: IO[Unit], result: AddonResult = AddonResult.empty)
|
||||
extends AddonRunner[IO] {
|
||||
val runnerType = Nil
|
||||
def run(
|
||||
logger: Logger[IO],
|
||||
env: Env,
|
||||
ctx: Context
|
||||
) = run.as(result)
|
||||
|
||||
def as(r: AddonResult) = new MockRunner(run, r)
|
||||
}
|
||||
}
|
@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.addons
|
||||
|
||||
import cats.effect._
|
||||
import cats.syntax.all._
|
||||
import fs2.io.file.{Files, Path, PosixPermissions}
|
||||
|
||||
import docspell.addons.AddonExecutorConfig._
|
||||
import docspell.addons.AddonMeta._
|
||||
import docspell.addons.AddonTriggerType._
|
||||
import docspell.common.{Duration, LenientUri}
|
||||
import docspell.logging.TestLoggingConfig
|
||||
|
||||
import munit.CatsEffectSuite
|
||||
|
||||
trait Fixtures extends TestLoggingConfig { self: CatsEffectSuite =>
|
||||
|
||||
val files: Files[IO] = Files[IO]
|
||||
|
||||
val dummyAddonUrl =
|
||||
LenientUri.fromJava(getClass.getResource("/docspell-dummy-addon-master.zip"))
|
||||
|
||||
val dummyAddonMeta =
|
||||
AddonMeta(
|
||||
meta =
|
||||
AddonMeta.Meta("dummy-addon", "2.9", "Some dummy addon only for testing.\n".some),
|
||||
triggers = Some(
|
||||
Set(Scheduled, FinalProcessItem, FinalReprocessItem)
|
||||
),
|
||||
None,
|
||||
runner = Runner(
|
||||
nix = NixRunner(true).some,
|
||||
docker = DockerRunner(
|
||||
enable = true,
|
||||
image = None,
|
||||
build = "Dockerfile".some
|
||||
).some,
|
||||
trivial = TrivialRunner(true, "src/addon.sh").some
|
||||
).some,
|
||||
options = Options(networking = true, collectOutput = true).some
|
||||
)
|
||||
|
||||
def baseTempDir: Path =
|
||||
Path(s"/tmp/target/test-temp")
|
||||
|
||||
val tempDir =
|
||||
ResourceFixture[Path](
|
||||
Resource.eval(Files[IO].createDirectories(baseTempDir)) *>
|
||||
Files[IO]
|
||||
.tempDirectory(baseTempDir.some, "run-", PosixPermissions.fromOctal("777"))
|
||||
)
|
||||
|
||||
def testExecutorConfig(
|
||||
runner: RunnerType,
|
||||
runners: RunnerType*
|
||||
): AddonExecutorConfig = {
|
||||
val nspawn = NSpawn(true, "sudo", "systemd-nspawn", Duration.millis(100))
|
||||
AddonExecutorConfig(
|
||||
runner :: runners.toList,
|
||||
Duration.minutes(2),
|
||||
nspawn,
|
||||
NixConfig("nix", Duration.minutes(2)),
|
||||
DockerConfig("docker", Duration.minutes(2))
|
||||
)
|
||||
}
|
||||
}
|
@ -15,8 +15,8 @@ import fs2.io.file.{Files, Path}
|
||||
import docspell.analysis.classifier
|
||||
import docspell.analysis.classifier.TextClassifier._
|
||||
import docspell.analysis.nlp.Properties
|
||||
import docspell.common._
|
||||
import docspell.common.syntax.FileSyntax._
|
||||
import docspell.common.util.File
|
||||
import docspell.logging.Logger
|
||||
|
||||
import edu.stanford.nlp.classify.ColumnDataClassifier
|
||||
|
@ -14,6 +14,7 @@ import cats.implicits._
|
||||
|
||||
import docspell.analysis.NlpSettings
|
||||
import docspell.common._
|
||||
import docspell.common.util.File
|
||||
|
||||
/** Creating the StanfordCoreNLP pipeline is quite expensive as it involves IO and
|
||||
* initializing large objects.
|
||||
|
@ -17,6 +17,7 @@ import fs2.io.file.Files
|
||||
|
||||
import docspell.analysis.classifier.TextClassifier.Data
|
||||
import docspell.common._
|
||||
import docspell.common.util.File
|
||||
import docspell.logging.TestLoggingConfig
|
||||
|
||||
import munit._
|
||||
|
@ -13,6 +13,7 @@ import cats.effect.unsafe.implicits.global
|
||||
|
||||
import docspell.analysis.Env
|
||||
import docspell.common._
|
||||
import docspell.common.util.File
|
||||
import docspell.files.TestFiles
|
||||
import docspell.logging.TestLoggingConfig
|
||||
|
||||
|
@ -20,6 +20,7 @@ trait AttachedEvent[R] {
|
||||
|
||||
object AttachedEvent {
|
||||
|
||||
/** Only the result, no events. */
|
||||
def only[R](v: R): AttachedEvent[R] =
|
||||
new AttachedEvent[R] {
|
||||
val value = v
|
||||
|
@ -8,11 +8,14 @@ package docspell.backend
|
||||
|
||||
import cats.effect._
|
||||
|
||||
import docspell.backend.BackendCommands.EventContext
|
||||
import docspell.backend.auth.Login
|
||||
import docspell.backend.fulltext.CreateIndex
|
||||
import docspell.backend.ops._
|
||||
import docspell.backend.signup.OSignup
|
||||
import docspell.common.bc.BackendCommandRunner
|
||||
import docspell.ftsclient.FtsClient
|
||||
import docspell.joexapi.client.JoexClient
|
||||
import docspell.notification.api.{EventExchange, NotificationModule}
|
||||
import docspell.pubsub.api.PubSubT
|
||||
import docspell.scheduler.JobStoreModule
|
||||
@ -20,6 +23,7 @@ import docspell.store.Store
|
||||
import docspell.totp.Totp
|
||||
|
||||
import emil.Emil
|
||||
import org.http4s.client.Client
|
||||
|
||||
trait BackendApp[F[_]] {
|
||||
|
||||
@ -35,6 +39,7 @@ trait BackendApp[F[_]] {
|
||||
def job: OJob[F]
|
||||
def item: OItem[F]
|
||||
def itemSearch: OItemSearch[F]
|
||||
def attachment: OAttachment[F]
|
||||
def fulltext: OFulltext[F]
|
||||
def mail: OMail[F]
|
||||
def joex: OJoex[F]
|
||||
@ -52,23 +57,30 @@ trait BackendApp[F[_]] {
|
||||
def fileRepository: OFileRepository[F]
|
||||
def itemLink: OItemLink[F]
|
||||
def downloadAll: ODownloadAll[F]
|
||||
def addons: OAddons[F]
|
||||
|
||||
def commands(eventContext: Option[EventContext]): BackendCommandRunner[F, Unit]
|
||||
}
|
||||
|
||||
object BackendApp {
|
||||
|
||||
def create[F[_]: Async](
|
||||
cfg: Config,
|
||||
store: Store[F],
|
||||
javaEmil: Emil[F],
|
||||
httpClient: Client[F],
|
||||
ftsClient: FtsClient[F],
|
||||
pubSubT: PubSubT[F],
|
||||
schedulerModule: JobStoreModule[F],
|
||||
notificationMod: NotificationModule[F]
|
||||
): Resource[F, BackendApp[F]] =
|
||||
for {
|
||||
nodeImpl <- ONode(store)
|
||||
totpImpl <- OTotp(store, Totp.default)
|
||||
loginImpl <- Login[F](store, Totp.default)
|
||||
signupImpl <- OSignup[F](store)
|
||||
joexImpl <- OJoex(pubSubT)
|
||||
joexClient = JoexClient(httpClient)
|
||||
joexImpl <- OJoex(pubSubT, nodeImpl, joexClient)
|
||||
collImpl <- OCollective[F](
|
||||
store,
|
||||
schedulerModule.userTasks,
|
||||
@ -80,7 +92,6 @@ object BackendApp {
|
||||
equipImpl <- OEquipment[F](store)
|
||||
orgImpl <- OOrganization(store)
|
||||
uploadImpl <- OUpload(store, schedulerModule.jobs)
|
||||
nodeImpl <- ONode(store)
|
||||
jobImpl <- OJob(store, joexImpl, pubSubT)
|
||||
createIndex <- CreateIndex.resource(ftsClient, store)
|
||||
itemImpl <- OItem(store, ftsClient, createIndex, schedulerModule.jobs)
|
||||
@ -109,6 +120,16 @@ object BackendApp {
|
||||
fileRepoImpl <- OFileRepository(store, schedulerModule.jobs)
|
||||
itemLinkImpl <- Resource.pure(OItemLink(store, itemSearchImpl))
|
||||
downloadAllImpl <- Resource.pure(ODownloadAll(store, jobImpl, schedulerModule.jobs))
|
||||
attachImpl <- Resource.pure(OAttachment(store, ftsClient, schedulerModule.jobs))
|
||||
addonsImpl <- Resource.pure(
|
||||
OAddons(
|
||||
cfg.addons,
|
||||
store,
|
||||
schedulerModule.userTasks,
|
||||
schedulerModule.jobs,
|
||||
joexImpl
|
||||
)
|
||||
)
|
||||
} yield new BackendApp[F] {
|
||||
val pubSub = pubSubT
|
||||
val login = loginImpl
|
||||
@ -139,5 +160,10 @@ object BackendApp {
|
||||
val fileRepository = fileRepoImpl
|
||||
val itemLink = itemLinkImpl
|
||||
val downloadAll = downloadAllImpl
|
||||
val addons = addonsImpl
|
||||
val attachment = attachImpl
|
||||
|
||||
def commands(eventContext: Option[EventContext]) =
|
||||
BackendCommands.fromBackend(this, eventContext)
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,175 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.backend
|
||||
|
||||
import cats.data.{NonEmptyList => Nel}
|
||||
import cats.effect.Sync
|
||||
import cats.syntax.all._
|
||||
|
||||
import docspell.backend.BackendCommands.EventContext
|
||||
import docspell.backend.ops.OCustomFields.SetValue
|
||||
import docspell.backend.ops._
|
||||
import docspell.common.bc._
|
||||
import docspell.common.{AccountId, Ident, LenientUri}
|
||||
|
||||
private[backend] class BackendCommands[F[_]: Sync](
|
||||
itemOps: OItem[F],
|
||||
attachOps: OAttachment[F],
|
||||
fieldOps: OCustomFields[F],
|
||||
notificationOps: ONotification[F],
|
||||
eventContext: Option[EventContext]
|
||||
) extends BackendCommandRunner[F, Unit] {
|
||||
private[this] val logger = docspell.logging.getLogger[F]
|
||||
|
||||
def run(collective: Ident, cmd: BackendCommand): F[Unit] =
|
||||
doRun(collective, cmd).attempt.flatMap {
|
||||
case Right(_) => ().pure[F]
|
||||
case Left(ex) =>
|
||||
logger.error(ex)(s"Backend command $cmd failed for collective ${collective.id}.")
|
||||
}
|
||||
|
||||
def doRun(collective: Ident, cmd: BackendCommand): F[Unit] =
|
||||
cmd match {
|
||||
case BackendCommand.ItemUpdate(item, actions) =>
|
||||
actions.traverse_(a => runItemAction(collective, item, a))
|
||||
|
||||
case BackendCommand.AttachmentUpdate(item, attach, actions) =>
|
||||
actions.traverse_(a => runAttachAction(collective, item, attach, a))
|
||||
}
|
||||
|
||||
def runAll(collective: Ident, cmds: List[BackendCommand]): F[Unit] =
|
||||
cmds.traverse_(run(collective, _))
|
||||
|
||||
def runItemAction(collective: Ident, item: Ident, action: ItemAction): F[Unit] =
|
||||
action match {
|
||||
case ItemAction.AddTags(tags) =>
|
||||
logger.debug(s"Setting tags $tags on ${item.id} for ${collective.id}") *>
|
||||
itemOps
|
||||
.linkTags(item, tags.toList, collective)
|
||||
.flatMap(sendEvents)
|
||||
|
||||
case ItemAction.RemoveTags(tags) =>
|
||||
logger.debug(s"Remove tags $tags on ${item.id} for ${collective.id}") *>
|
||||
itemOps
|
||||
.removeTagsMultipleItems(Nel.of(item), tags.toList, collective)
|
||||
.flatMap(sendEvents)
|
||||
|
||||
case ItemAction.ReplaceTags(tags) =>
|
||||
logger.debug(s"Replace tags $tags on ${item.id} for ${collective.id}") *>
|
||||
itemOps
|
||||
.setTags(item, tags.toList, collective)
|
||||
.flatMap(sendEvents)
|
||||
|
||||
case ItemAction.SetFolder(folder) =>
|
||||
logger.debug(s"Set folder $folder on ${item.id} for ${collective.id}") *>
|
||||
itemOps
|
||||
.setFolder(item, folder, collective)
|
||||
.void
|
||||
|
||||
case ItemAction.RemoveTagsCategory(cats) =>
|
||||
logger.debug(
|
||||
s"Remove tags in categories $cats on ${item.id} for ${collective.id}"
|
||||
) *>
|
||||
itemOps
|
||||
.removeTagsOfCategories(item, collective, cats)
|
||||
.flatMap(sendEvents)
|
||||
|
||||
case ItemAction.SetCorrOrg(id) =>
|
||||
logger.debug(
|
||||
s"Set correspondent organization ${id.map(_.id)} for ${collective.id}"
|
||||
) *>
|
||||
itemOps.setCorrOrg(Nel.of(item), id, collective).void
|
||||
|
||||
case ItemAction.SetCorrPerson(id) =>
|
||||
logger.debug(
|
||||
s"Set correspondent person ${id.map(_.id)} for ${collective.id}"
|
||||
) *>
|
||||
itemOps.setCorrPerson(Nel.of(item), id, collective).void
|
||||
|
||||
case ItemAction.SetConcPerson(id) =>
|
||||
logger.debug(
|
||||
s"Set concerning person ${id.map(_.id)} for ${collective.id}"
|
||||
) *>
|
||||
itemOps.setConcPerson(Nel.of(item), id, collective).void
|
||||
|
||||
case ItemAction.SetConcEquipment(id) =>
|
||||
logger.debug(
|
||||
s"Set concerning equipment ${id.map(_.id)} for ${collective.id}"
|
||||
) *>
|
||||
itemOps.setConcEquip(Nel.of(item), id, collective).void
|
||||
|
||||
case ItemAction.SetField(field, value) =>
|
||||
logger.debug(
|
||||
s"Set field on item ${item.id} ${field.id} to '$value' for ${collective.id}"
|
||||
) *>
|
||||
fieldOps
|
||||
.setValue(item, SetValue(field, value, collective))
|
||||
.flatMap(sendEvents)
|
||||
|
||||
case ItemAction.SetNotes(notes) =>
|
||||
logger.debug(s"Set notes on item ${item.id} for ${collective.id}") *>
|
||||
itemOps.setNotes(item, notes, collective).void
|
||||
|
||||
case ItemAction.AddNotes(notes, sep) =>
|
||||
logger.debug(s"Add notes on item ${item.id} for ${collective.id}") *>
|
||||
itemOps.addNotes(item, notes, sep, collective).void
|
||||
|
||||
case ItemAction.SetName(name) =>
|
||||
logger.debug(s"Set name '$name' on item ${item.id} for ${collective.id}") *>
|
||||
itemOps.setName(item, name, collective).void
|
||||
}
|
||||
|
||||
def runAttachAction(
|
||||
collective: Ident,
|
||||
itemId: Ident,
|
||||
attachId: Ident,
|
||||
action: AttachmentAction
|
||||
): F[Unit] =
|
||||
action match {
|
||||
case AttachmentAction.SetExtractedText(text) =>
|
||||
attachOps.setExtractedText(
|
||||
collective,
|
||||
itemId,
|
||||
attachId,
|
||||
text.getOrElse("").pure[F]
|
||||
)
|
||||
}
|
||||
|
||||
private def sendEvents(result: AttachedEvent[_]): F[Unit] =
|
||||
eventContext match {
|
||||
case Some(ctx) =>
|
||||
notificationOps.offerEvents(result.event(ctx.account, ctx.baseUrl))
|
||||
case None => ().pure[F]
|
||||
}
|
||||
}
|
||||
|
||||
object BackendCommands {
|
||||
|
||||
/** If supplied, notification events will be send. */
|
||||
case class EventContext(account: AccountId, baseUrl: Option[LenientUri])
|
||||
|
||||
def fromBackend[F[_]: Sync](
|
||||
backendApp: BackendApp[F],
|
||||
eventContext: Option[EventContext] = None
|
||||
): BackendCommandRunner[F, Unit] =
|
||||
new BackendCommands[F](
|
||||
backendApp.item,
|
||||
backendApp.attachment,
|
||||
backendApp.customFields,
|
||||
backendApp.notification,
|
||||
eventContext
|
||||
)
|
||||
|
||||
def apply[F[_]: Sync](
|
||||
item: OItem[F],
|
||||
attachment: OAttachment[F],
|
||||
fields: OCustomFields[F],
|
||||
notification: ONotification[F],
|
||||
eventContext: Option[EventContext] = None
|
||||
): BackendCommandRunner[F, Unit] =
|
||||
new BackendCommands[F](item, attachment, fields, notification, eventContext)
|
||||
}
|
@ -20,7 +20,8 @@ case class Config(
|
||||
mailDebug: Boolean,
|
||||
jdbc: JdbcConfig,
|
||||
signup: SignupConfig,
|
||||
files: Config.Files
|
||||
files: Config.Files,
|
||||
addons: Config.Addons
|
||||
) {
|
||||
|
||||
def mailSettings: Settings =
|
||||
@ -66,4 +67,21 @@ object Config {
|
||||
(storesEmpty |+| defaultStorePresent).map(_ => this)
|
||||
}
|
||||
}
|
||||
|
||||
case class Addons(
|
||||
enabled: Boolean,
|
||||
allowImpure: Boolean,
|
||||
allowedUrls: UrlMatcher,
|
||||
deniedUrls: UrlMatcher
|
||||
) {
|
||||
def isAllowed(url: LenientUri): Boolean =
|
||||
allowedUrls.matches(url) && !deniedUrls.matches(url)
|
||||
|
||||
def isDenied(url: LenientUri): Boolean =
|
||||
!isAllowed(url)
|
||||
}
|
||||
object Addons {
|
||||
val disabled: Addons =
|
||||
Addons(false, false, UrlMatcher.False, UrlMatcher.True)
|
||||
}
|
||||
}
|
||||
|
@ -16,6 +16,26 @@ import docspell.notification.api.PeriodicQueryArgs
|
||||
import docspell.scheduler.Job
|
||||
|
||||
object JobFactory extends MailAddressCodec {
|
||||
def existingItemAddon[F[_]: Sync](
|
||||
args: ItemAddonTaskArgs,
|
||||
submitter: AccountId
|
||||
): F[Job[ItemAddonTaskArgs]] =
|
||||
Job.createNew(
|
||||
ItemAddonTaskArgs.taskName,
|
||||
submitter.collective,
|
||||
args,
|
||||
"Run addons on item",
|
||||
submitter.user,
|
||||
Priority.High,
|
||||
args.addonRunConfigs
|
||||
.map(_.take(23))
|
||||
.toList
|
||||
.sorted
|
||||
.foldLeft(args.itemId)(_ / _)
|
||||
.take(250)
|
||||
.some
|
||||
)
|
||||
|
||||
def downloadZip[F[_]: Sync](
|
||||
args: DownloadZipArgs,
|
||||
summaryId: Ident,
|
||||
|
@ -45,7 +45,14 @@ object CreateIndex {
|
||||
chunkSize: Int
|
||||
): F[Unit] = {
|
||||
val attachs = store
|
||||
.transact(QAttachment.allAttachmentMetaAndName(collective, itemIds, chunkSize))
|
||||
.transact(
|
||||
QAttachment.allAttachmentMetaAndName(
|
||||
collective,
|
||||
itemIds,
|
||||
ItemState.validStates,
|
||||
chunkSize
|
||||
)
|
||||
)
|
||||
.map(caa =>
|
||||
TextData
|
||||
.attachment(
|
||||
|
@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.backend.joex
|
||||
|
||||
import fs2.io.file.Path
|
||||
|
||||
import docspell.addons.AddonExecutorConfig
|
||||
|
||||
final case class AddonEnvConfig(
|
||||
workingDir: Path,
|
||||
cacheDir: Path,
|
||||
executorConfig: AddonExecutorConfig
|
||||
)
|
@ -0,0 +1,199 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.backend.joex
|
||||
|
||||
import cats.data.OptionT
|
||||
import cats.effect._
|
||||
import cats.syntax.all._
|
||||
|
||||
import docspell.addons._
|
||||
import docspell.backend.joex.AddonOps.{AddonRunConfigRef, ExecResult}
|
||||
import docspell.backend.ops.OAttachment
|
||||
import docspell.common._
|
||||
import docspell.common.bc.BackendCommandRunner
|
||||
import docspell.common.exec.Env
|
||||
import docspell.logging.Logger
|
||||
import docspell.scheduler.JobStore
|
||||
import docspell.store.Store
|
||||
import docspell.store.file.FileUrlReader
|
||||
import docspell.store.records.AddonRunConfigResolved
|
||||
|
||||
trait AddonOps[F[_]] {
|
||||
|
||||
def execAll(
|
||||
collective: Ident,
|
||||
trigger: Set[AddonTriggerType],
|
||||
runConfigIds: Set[Ident],
|
||||
logger: Option[Logger[F]]
|
||||
)(
|
||||
middleware: Middleware[F]
|
||||
): F[ExecResult]
|
||||
|
||||
def execById(collective: Ident, runConfigId: Ident, logger: Logger[F])(
|
||||
middleware: Middleware[F]
|
||||
): F[ExecResult]
|
||||
|
||||
/** Find enabled addon run config references to be executed. Can be additionally
|
||||
* filtered by given ids and triggers.
|
||||
*/
|
||||
def findAddonRefs(
|
||||
collective: Ident,
|
||||
trigger: Set[AddonTriggerType],
|
||||
runConfigIds: Set[Ident]
|
||||
): F[List[AddonRunConfigRef]]
|
||||
|
||||
/** Find enabled addon run config reference given an addon task id */
|
||||
def findAddonRef(collective: Ident, runConfigId: Ident): F[Option[AddonRunConfigRef]]
|
||||
|
||||
/** Creates an executor for addons given a configuration. */
|
||||
def getExecutor(cfg: AddonExecutorConfig): F[AddonExecutor[F]]
|
||||
|
||||
}
|
||||
|
||||
object AddonOps {
|
||||
case class AddonRunConfigRef(
|
||||
id: Ident,
|
||||
collective: Ident,
|
||||
userId: Option[Ident],
|
||||
name: String,
|
||||
refs: List[AddonRef]
|
||||
)
|
||||
|
||||
object AddonRunConfigRef {
|
||||
def fromResolved(r: AddonRunConfigResolved): AddonRunConfigRef =
|
||||
AddonRunConfigRef(
|
||||
r.config.id,
|
||||
r.config.cid,
|
||||
r.config.userId,
|
||||
r.config.name,
|
||||
r.refs.map(ref => AddonRef(ref.archive.asArchive, ref.ref.args))
|
||||
)
|
||||
}
|
||||
|
||||
case class ExecResult(
|
||||
result: List[AddonExecutionResult],
|
||||
runConfigs: List[AddonRunConfigRef]
|
||||
) {
|
||||
lazy val combined = result.combineAll
|
||||
}
|
||||
|
||||
object ExecResult {
|
||||
def runConfigNotFound(id: Ident): ExecResult =
|
||||
ExecResult(
|
||||
AddonExecutionResult(
|
||||
AddonResult.executionFailed(
|
||||
new Exception(s"Addon run config ${id.id} not found.")
|
||||
) :: Nil,
|
||||
false
|
||||
) :: Nil,
|
||||
Nil
|
||||
)
|
||||
}
|
||||
|
||||
def apply[F[_]: Async](
|
||||
cfg: AddonEnvConfig,
|
||||
store: Store[F],
|
||||
cmdRunner: BackendCommandRunner[F, Unit],
|
||||
attachment: OAttachment[F],
|
||||
jobStore: JobStore[F]
|
||||
): AddonOps[F] =
|
||||
new AddonOps[F] with LoggerExtension {
|
||||
private[this] val logger = docspell.logging.getLogger[F]
|
||||
|
||||
private val urlReader = FileUrlReader(store.fileRepo)
|
||||
private val postProcess = AddonPostProcess(cmdRunner, store, attachment, jobStore)
|
||||
private val prepare = new AddonPrepare[F](store)
|
||||
|
||||
def execAll(
|
||||
collective: Ident,
|
||||
trigger: Set[AddonTriggerType],
|
||||
runConfigIds: Set[Ident],
|
||||
logger: Option[Logger[F]]
|
||||
)(
|
||||
custom: Middleware[F]
|
||||
): F[ExecResult] =
|
||||
for {
|
||||
runCfgs <- findAddonRefs(collective, trigger, runConfigIds)
|
||||
log = logger.getOrElse(this.logger)
|
||||
_ <- log.info(s"Running ${runCfgs.size} addon tasks for trigger $trigger")
|
||||
|
||||
results <- runCfgs.traverse(r => execRunConfig(log, r, custom))
|
||||
} yield ExecResult(results.flatMap(_.result), runCfgs)
|
||||
|
||||
def execById(collective: Ident, runConfigId: Ident, logger: Logger[F])(
|
||||
custom: Middleware[F]
|
||||
): F[ExecResult] =
|
||||
(for {
|
||||
runCfg <- OptionT(findAddonRef(collective, runConfigId))
|
||||
execRes <- OptionT.liftF(execRunConfig(logger, runCfg, custom))
|
||||
} yield execRes).getOrElse(ExecResult.runConfigNotFound(runConfigId))
|
||||
|
||||
def execRunConfig(
|
||||
logger: Logger[F],
|
||||
runCfg: AddonRunConfigRef,
|
||||
custom: Middleware[F]
|
||||
): F[ExecResult] =
|
||||
for {
|
||||
executor <- getExecutor(cfg.executorConfig)
|
||||
log = logger.withRunConfig(runCfg)
|
||||
result <-
|
||||
Directory.temp(cfg.workingDir, "addon-output-").use { outDir =>
|
||||
val cacheDir = cfg.cacheDir / runCfg.id.id
|
||||
val inputEnv =
|
||||
InputEnv(runCfg.refs, cfg.workingDir, outDir, cacheDir, Env.empty)
|
||||
|
||||
for {
|
||||
middleware <- createMiddleware(custom, runCfg)
|
||||
res <- middleware(executor.execute(log)).run(inputEnv)
|
||||
_ <- log.debug(s"Addon result: $res")
|
||||
_ <- postProcess.onResult(log, runCfg.collective, res, outDir)
|
||||
} yield res
|
||||
}
|
||||
execRes = ExecResult(List(result), List(runCfg))
|
||||
} yield execRes
|
||||
|
||||
def createMiddleware(custom: Middleware[F], runCfg: AddonRunConfigRef) = for {
|
||||
dscMW <- prepare.createDscEnv(runCfg, cfg.executorConfig.runTimeout)
|
||||
mm = dscMW >> custom >> prepare.logResult(logger, runCfg) >> Middleware
|
||||
.ephemeralRun[F]
|
||||
} yield mm
|
||||
|
||||
def getExecutor(cfg: AddonExecutorConfig): F[AddonExecutor[F]] =
|
||||
Async[F].pure(AddonExecutor(cfg, urlReader))
|
||||
|
||||
def findAddonRefs(
|
||||
collective: Ident,
|
||||
trigger: Set[AddonTriggerType],
|
||||
runConfigIds: Set[Ident]
|
||||
): F[List[AddonRunConfigRef]] =
|
||||
store
|
||||
.transact(
|
||||
AddonRunConfigResolved.findAllForCollective(
|
||||
collective,
|
||||
enabled = true.some,
|
||||
trigger,
|
||||
runConfigIds
|
||||
)
|
||||
)
|
||||
.map(_.map(AddonRunConfigRef.fromResolved))
|
||||
|
||||
def findAddonRef(
|
||||
collective: Ident,
|
||||
runConfigId: Ident
|
||||
): F[Option[AddonRunConfigRef]] =
|
||||
OptionT(
|
||||
store
|
||||
.transact(
|
||||
AddonRunConfigResolved.findById(
|
||||
runConfigId,
|
||||
collective,
|
||||
enabled = Some(true)
|
||||
)
|
||||
)
|
||||
).map(AddonRunConfigRef.fromResolved).value
|
||||
}
|
||||
}
|
@ -0,0 +1,198 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.backend.joex
|
||||
|
||||
import cats.data.OptionT
|
||||
import cats.effect.kernel.Sync
|
||||
import cats.syntax.all._
|
||||
import fs2.io.file.{Files, Path}
|
||||
|
||||
import docspell.addons._
|
||||
import docspell.addons.out.{AddonOutput, ItemFile, NewItem}
|
||||
import docspell.backend.JobFactory
|
||||
import docspell.backend.ops.OAttachment
|
||||
import docspell.common._
|
||||
import docspell.common.bc.BackendCommandRunner
|
||||
import docspell.files.FileSupport
|
||||
import docspell.logging.Logger
|
||||
import docspell.scheduler.JobStore
|
||||
import docspell.store.Store
|
||||
import docspell.store.records._
|
||||
|
||||
final private[joex] class AddonPostProcess[F[_]: Sync: Files](
|
||||
cmdRunner: BackendCommandRunner[F, Unit],
|
||||
store: Store[F],
|
||||
attachOps: OAttachment[F],
|
||||
jobStore: JobStore[F]
|
||||
) extends FileSupport {
|
||||
|
||||
def onResult(
|
||||
logger: Logger[F],
|
||||
collective: Ident,
|
||||
result: AddonExecutionResult,
|
||||
outputDir: Path
|
||||
): F[Unit] =
|
||||
result.addonResult match {
|
||||
case AddonResult.Success(output) =>
|
||||
onSuccess(logger, collective, output, outputDir)
|
||||
case _ =>
|
||||
().pure[F]
|
||||
}
|
||||
|
||||
def onSuccess(
|
||||
logger: Logger[F],
|
||||
collective: Ident,
|
||||
output: AddonOutput,
|
||||
outputDir: Path
|
||||
): F[Unit] =
|
||||
for {
|
||||
_ <- logger.info("Applying addon output")
|
||||
_ <- cmdRunner.runAll(collective, output.commands)
|
||||
_ <- logger.debug("Applying changes from files")
|
||||
_ <- output.files.traverse_(updateOne(logger, collective, outputDir))
|
||||
_ <- output.newItems.traverse_(submitNewItem(logger, collective, outputDir))
|
||||
} yield ()
|
||||
|
||||
def submitNewItem(
|
||||
logger: Logger[F],
|
||||
collective: Ident,
|
||||
outputDir: Path
|
||||
)(newItem: NewItem): F[Unit] =
|
||||
for {
|
||||
_ <- logger.info(s"Submit new item with ${newItem.files.size} files")
|
||||
files <- newItem.resolveFiles[F](logger, outputDir)
|
||||
collLang <- store.transact(RCollective.findLanguage(collective))
|
||||
uploaded <- files.traverse(file =>
|
||||
file.readAll
|
||||
.through(
|
||||
store.fileRepo.save(
|
||||
collective,
|
||||
FileCategory.AttachmentSource,
|
||||
MimeTypeHint.filename(file)
|
||||
)
|
||||
)
|
||||
.compile
|
||||
.lastOrError
|
||||
.map(key => file.fileName.toString -> key)
|
||||
)
|
||||
_ <- logger.debug(s"Saved ${uploaded.size} files to be processed.")
|
||||
args = ProcessItemArgs(
|
||||
newItem.toProcessMeta(collective, collLang, "addon"),
|
||||
uploaded.map(f => ProcessItemArgs.File(f._1.some, f._2))
|
||||
)
|
||||
account = AccountId(collective, DocspellSystem.user)
|
||||
job <- JobFactory.processItem(args, account, Priority.High, None)
|
||||
_ <- jobStore.insert(job.encode)
|
||||
_ <- logger.debug(s"Submitted job for processing: ${job.id}")
|
||||
} yield ()
|
||||
|
||||
def updateOne(logger: Logger[F], collective: Ident, outputDir: Path)(
|
||||
itemFile: ItemFile
|
||||
): F[Unit] =
|
||||
for {
|
||||
textFiles <- itemFile.resolveTextFiles(logger, outputDir)
|
||||
pdfFiles <- itemFile.resolvePdfFiles(logger, outputDir)
|
||||
previewFiles <- itemFile.resolvePreviewFiles(logger, outputDir)
|
||||
attachs <- OptionT
|
||||
.whenF(textFiles.nonEmpty || pdfFiles.nonEmpty || previewFiles.nonEmpty)(
|
||||
store.transact(RAttachment.findByItem(itemFile.itemId))
|
||||
)
|
||||
.getOrElse(Vector.empty)
|
||||
_ <- textFiles.traverse_ { case (key, file) =>
|
||||
withAttach(logger, key, attachs) { ra =>
|
||||
setText(collective, ra, file.readText)
|
||||
}
|
||||
}
|
||||
_ <- pdfFiles.traverse_ { case (key, file) =>
|
||||
withAttach(logger, key, attachs) { ra =>
|
||||
replacePdf(collective, ra, file, previewFiles.forall(_._1 != key))
|
||||
}
|
||||
}
|
||||
_ <- previewFiles.traverse_ { case (key, file) =>
|
||||
withAttach(logger, key, attachs) { ra =>
|
||||
replacePreview(collective, ra.id, file)
|
||||
}
|
||||
}
|
||||
_ <- submitNewFiles(logger, collective, outputDir)(itemFile)
|
||||
} yield ()
|
||||
|
||||
def submitNewFiles(
|
||||
logger: Logger[F],
|
||||
collective: Ident,
|
||||
outputDir: Path
|
||||
)(itemFile: ItemFile): F[Unit] =
|
||||
for {
|
||||
_ <- logger.info(s"Submitting new file for item")
|
||||
collLang <- store.transact(RCollective.findLanguage(collective))
|
||||
newFiles <- itemFile.resolveNewFiles(logger, outputDir)
|
||||
byMeta = newFiles.groupBy(_._1.metadata).view.mapValues(_.map(_._2))
|
||||
account = AccountId(collective, DocspellSystem.user)
|
||||
_ <- byMeta.toList.traverse_ { case (meta, files) =>
|
||||
for {
|
||||
uploaded <- files.traverse(file =>
|
||||
file.readAll
|
||||
.through(
|
||||
store.fileRepo.save(
|
||||
collective,
|
||||
FileCategory.AttachmentSource,
|
||||
MimeTypeHint.filename(file)
|
||||
)
|
||||
)
|
||||
.compile
|
||||
.lastOrError
|
||||
.map(key => file.fileName.toString -> key)
|
||||
)
|
||||
args = ProcessItemArgs(
|
||||
meta.toProcessMeta(collective, itemFile.itemId, collLang, "addon"),
|
||||
uploaded.map(f => ProcessItemArgs.File(f._1.some, f._2))
|
||||
)
|
||||
job <- JobFactory.processItem(args, account, Priority.High, None)
|
||||
_ <- jobStore.insert(job.encode)
|
||||
_ <- logger.debug(s"Submitted job for processing: ${job.id}")
|
||||
} yield ()
|
||||
}
|
||||
} yield ()
|
||||
|
||||
private def withAttach(logger: Logger[F], key: String, attachs: Vector[RAttachment])(
|
||||
run: RAttachment => F[Unit]
|
||||
): F[Unit] =
|
||||
OptionT
|
||||
.fromOption(
|
||||
attachs.find(a => a.id.id == key || key.toIntOption == a.position.some)
|
||||
)
|
||||
.semiflatMap(run)
|
||||
.getOrElseF(logger.warn(s"Cannot find attachment for $key to update text!"))
|
||||
|
||||
private def setText(collective: Ident, ra: RAttachment, readText: F[String]): F[Unit] =
|
||||
attachOps.setExtractedText(collective, ra.itemId, ra.id, readText)
|
||||
|
||||
private def replacePdf(
|
||||
collective: Ident,
|
||||
ra: RAttachment,
|
||||
file: Path,
|
||||
generatePreview: Boolean
|
||||
): F[Unit] =
|
||||
attachOps.addOrReplacePdf(collective, ra.id, file.readAll, generatePreview)
|
||||
|
||||
private def replacePreview(
|
||||
collective: Ident,
|
||||
attachId: Ident,
|
||||
imageData: Path
|
||||
): F[Unit] =
|
||||
attachOps.addOrReplacePreview(collective, attachId, imageData.readAll)
|
||||
}
|
||||
|
||||
object AddonPostProcess {
|
||||
|
||||
def apply[F[_]: Sync: Files](
|
||||
cmdRunner: BackendCommandRunner[F, Unit],
|
||||
store: Store[F],
|
||||
attachment: OAttachment[F],
|
||||
jobStore: JobStore[F]
|
||||
): AddonPostProcess[F] =
|
||||
new AddonPostProcess[F](cmdRunner, store, attachment, jobStore)
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.backend.joex
|
||||
|
||||
import cats.data.{Kleisli, OptionT}
|
||||
import cats.effect._
|
||||
import cats.syntax.all._
|
||||
|
||||
import docspell.addons.Middleware
|
||||
import docspell.backend.auth.AuthToken
|
||||
import docspell.backend.joex.AddonOps.AddonRunConfigRef
|
||||
import docspell.common._
|
||||
import docspell.logging.Logger
|
||||
import docspell.store.Store
|
||||
import docspell.store.records.{RNode, RUser}
|
||||
|
||||
import scodec.bits.ByteVector
|
||||
|
||||
private[joex] class AddonPrepare[F[_]: Sync](store: Store[F]) extends LoggerExtension {
|
||||
|
||||
def logResult(logger: Logger[F], ref: AddonRunConfigRef): Middleware[F] =
|
||||
Middleware(_.mapF(_.attempt.flatTap {
|
||||
case Right(_) => ().pure[F]
|
||||
case Left(ex) =>
|
||||
logger
|
||||
.withRunConfig(ref)
|
||||
.warn(ex)(s"Addon task '${ref.id.id}' has failed")
|
||||
}.rethrow))
|
||||
|
||||
/** Creates environment variables for dsc to connect to the docspell server for the
|
||||
* given run config.
|
||||
*/
|
||||
def createDscEnv(
|
||||
runConfigRef: AddonRunConfigRef,
|
||||
tokenValidity: Duration
|
||||
): F[Middleware[F]] =
|
||||
(for {
|
||||
userId <- OptionT.fromOption[F](runConfigRef.userId)
|
||||
user <- OptionT(store.transact(RUser.getIdByIdOrLogin(userId)))
|
||||
account = AccountId(runConfigRef.collective, user.login)
|
||||
env =
|
||||
Middleware.prepare[F](
|
||||
Kleisli(input => makeDscEnv(account, tokenValidity).map(input.addEnv))
|
||||
)
|
||||
} yield env).getOrElse(Middleware.identity[F])
|
||||
|
||||
/** Creates environment variables to have dsc automatically connect as the given user.
|
||||
* Additionally a random rest-server is looked up from the database to set its url.
|
||||
*/
|
||||
def makeDscEnv(
|
||||
accountId: AccountId,
|
||||
tokenValidity: Duration
|
||||
): F[Map[String, String]] =
|
||||
for {
|
||||
serverNode <- store.transact(
|
||||
RNode
|
||||
.findAll(NodeType.Restserver)
|
||||
.map(_.sortBy(_.updated).lastOption)
|
||||
)
|
||||
url = serverNode.map(_.url).map(u => "DSC_DOCSPELL_URL" -> u.asString)
|
||||
secret = serverNode.flatMap(_.serverSecret)
|
||||
|
||||
token <- AuthToken.user(
|
||||
accountId,
|
||||
false,
|
||||
secret.getOrElse(ByteVector.empty),
|
||||
tokenValidity.some
|
||||
)
|
||||
session = ("DSC_SESSION" -> token.asString).some
|
||||
} yield List(url, session).flatten.toMap
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.backend.joex
|
||||
|
||||
import docspell.backend.joex.AddonOps.AddonRunConfigRef
|
||||
import docspell.logging.Logger
|
||||
|
||||
trait LoggerExtension {
|
||||
|
||||
implicit final class LoggerDataOps[F[_]](self: Logger[F]) {
|
||||
def withRunConfig(t: AddonRunConfigRef): Logger[F] =
|
||||
self.capture("addon-task-id", t.id)
|
||||
}
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.backend.ops
|
||||
|
||||
import cats.data.NonEmptyList
|
||||
|
||||
import docspell.addons.{AddonArchive, AddonMeta, AddonTriggerType}
|
||||
|
||||
sealed trait AddonRunConfigError {
|
||||
final def cast: AddonRunConfigError = this
|
||||
|
||||
def toLeft[A]: Either[AddonRunConfigError, A] = Left(this)
|
||||
|
||||
def message: String
|
||||
}
|
||||
|
||||
object AddonRunConfigError {
|
||||
|
||||
case object MissingSchedule extends AddonRunConfigError {
|
||||
val message =
|
||||
"The run config has a trigger 'scheduled' but doesn't provide a schedule!"
|
||||
}
|
||||
|
||||
case object ObsoleteSchedule extends AddonRunConfigError {
|
||||
val message = "The run config has a schedule, but not a trigger 'Scheduled'."
|
||||
}
|
||||
|
||||
case class MismatchingTrigger(unsupported: NonEmptyList[(String, AddonTriggerType)])
|
||||
extends AddonRunConfigError {
|
||||
def message: String = {
|
||||
val list =
|
||||
unsupported.map { case (name, tt) => s"$name: ${tt.name}" }.toList.mkString(", ")
|
||||
s"Some listed addons don't support all defined triggers: $list"
|
||||
}
|
||||
}
|
||||
|
||||
object MismatchingTrigger {
|
||||
def apply(addon: AddonMeta, tt: AddonTriggerType): MismatchingTrigger =
|
||||
MismatchingTrigger(NonEmptyList.of(addon.nameAndVersion -> tt))
|
||||
|
||||
def apply(addon: AddonArchive, tt: AddonTriggerType): MismatchingTrigger =
|
||||
MismatchingTrigger(NonEmptyList.of(addon.nameAndVersion -> tt))
|
||||
}
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.backend.ops
|
||||
|
||||
import cats.data.NonEmptyList
|
||||
import cats.effect._
|
||||
import cats.syntax.all._
|
||||
|
||||
import docspell.backend.ops.AddonRunConfigError._
|
||||
import docspell.backend.ops.OAddons.{AddonRunConfigResult, AddonRunInsert}
|
||||
import docspell.common.Ident
|
||||
import docspell.store.Store
|
||||
import docspell.store.records.RAddonArchive
|
||||
|
||||
object AddonRunConfigValidate {
|
||||
|
||||
def apply[F[_]: Sync](store: Store[F], cid: Ident)(
|
||||
cfg: AddonRunInsert
|
||||
): F[AddonRunConfigResult[AddonRunInsert]] = {
|
||||
val init: AddonRunConfigResult[Unit] = ().asRight
|
||||
|
||||
List(
|
||||
checkScheduled(cfg).pure[F],
|
||||
checkTriggers(store, cid)(cfg)
|
||||
)
|
||||
.foldLeftM(init)((res, fr) => fr.map(r => res.flatMap(_ => r)))
|
||||
.map(_.as(cfg))
|
||||
}
|
||||
|
||||
def checkTriggers[F[_]: Sync](store: Store[F], cid: Ident)(
|
||||
cfg: AddonRunInsert
|
||||
): F[AddonRunConfigResult[Unit]] =
|
||||
for {
|
||||
addons <- store.transact(RAddonArchive.findByIds(cid, cfg.addons.map(_.addonId)))
|
||||
given = cfg.triggered.toList.toSet
|
||||
res = addons
|
||||
.flatMap(r => given.diff(r.triggers).map(tt => r.nameAndVersion -> tt))
|
||||
|
||||
maybeError = NonEmptyList
|
||||
.fromList(res)
|
||||
.map(nel => MismatchingTrigger(nel))
|
||||
} yield maybeError.map(_.toLeft).getOrElse(Right(()))
|
||||
|
||||
def checkScheduled(cfg: AddonRunInsert): AddonRunConfigResult[Unit] =
|
||||
(cfg.isScheduled, cfg.schedule) match {
|
||||
case (true, None) => MissingSchedule.toLeft[Unit]
|
||||
case (false, Some(_)) => ObsoleteSchedule.toLeft[Unit]
|
||||
case _ => ().asRight
|
||||
}
|
||||
}
|
@ -0,0 +1,156 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.backend.ops
|
||||
|
||||
import cats.data.EitherT
|
||||
import cats.effect._
|
||||
import cats.syntax.all._
|
||||
import fs2.Stream
|
||||
import fs2.io.file.Path
|
||||
|
||||
import docspell.addons.{AddonMeta, RunnerType}
|
||||
import docspell.backend.Config
|
||||
import docspell.backend.ops.AddonValidationError._
|
||||
import docspell.backend.ops.OAddons.AddonValidationResult
|
||||
import docspell.common.{Ident, LenientUri, UrlReader}
|
||||
import docspell.joexapi.model.AddonSupport
|
||||
import docspell.store.Store
|
||||
import docspell.store.records.RAddonArchive
|
||||
|
||||
final class AddonValidate[F[_]: Async](
|
||||
cfg: Config.Addons,
|
||||
store: Store[F],
|
||||
joexOps: OJoex[F]
|
||||
) {
|
||||
private[this] val logger = docspell.logging.getLogger[F]
|
||||
|
||||
def fromUrl(
|
||||
collective: Ident,
|
||||
url: LenientUri,
|
||||
reader: UrlReader[F],
|
||||
localUrl: Option[LenientUri] = None,
|
||||
checkExisting: Boolean = true
|
||||
): F[AddonValidationResult[AddonMeta]] =
|
||||
if (!cfg.enabled) AddonsDisabled.resultF
|
||||
else if (cfg.isDenied(url)) UrlUntrusted(url).resultF
|
||||
else if (checkExisting)
|
||||
store.transact(RAddonArchive.findByUrl(collective, url)).flatMap {
|
||||
case Some(ar) =>
|
||||
AddonExists("An addon with this url already exists!", ar).resultF
|
||||
case None =>
|
||||
archive(collective, reader(localUrl.getOrElse(url)).asRight, checkExisting)
|
||||
}
|
||||
else archive(collective, reader(localUrl.getOrElse(url)).asRight, checkExisting)
|
||||
|
||||
def archive(
|
||||
collective: Ident,
|
||||
addonData: Either[Path, Stream[F, Byte]],
|
||||
checkExisting: Boolean = true
|
||||
): F[AddonValidationResult[AddonMeta]] =
|
||||
(for {
|
||||
_ <- EitherT.cond[F](cfg.enabled, (), AddonsDisabled.cast)
|
||||
|
||||
meta <-
|
||||
EitherT(
|
||||
addonData
|
||||
.fold(
|
||||
AddonMeta.findInDirectory[F],
|
||||
AddonMeta.findInZip[F]
|
||||
)
|
||||
.attempt
|
||||
)
|
||||
.leftMap(ex => NotAnAddon(ex).cast)
|
||||
_ <- EitherT.cond(
|
||||
meta.triggers.exists(_.nonEmpty),
|
||||
(),
|
||||
InvalidAddon(
|
||||
"The addon doesn't define any triggers. At least one is required!"
|
||||
).cast
|
||||
)
|
||||
_ <- EitherT.cond(
|
||||
meta.options.exists(_.isUseful),
|
||||
(),
|
||||
InvalidAddon(
|
||||
"Addon defines no output and no networking. It can't do anything useful."
|
||||
).cast
|
||||
)
|
||||
_ <- EitherT.cond(cfg.allowImpure || meta.isPure, (), ImpureAddonsDisabled.cast)
|
||||
|
||||
_ <-
|
||||
if (checkExisting)
|
||||
EitherT(
|
||||
store
|
||||
.transact(
|
||||
RAddonArchive
|
||||
.findByNameAndVersion(collective, meta.meta.name, meta.meta.version)
|
||||
)
|
||||
.map {
|
||||
case Some(ar) => AddonExists(ar).result
|
||||
case None => rightUnit
|
||||
}
|
||||
)
|
||||
else rightUnitT
|
||||
|
||||
joexSupport <- EitherT.liftF(joexOps.getAddonSupport)
|
||||
addonRunners <- EitherT.liftF(meta.enabledTypes(addonData))
|
||||
_ <- EitherT.liftF(
|
||||
logger.info(
|
||||
s"Comparing joex support vs addon runner: $joexSupport vs. $addonRunners"
|
||||
)
|
||||
)
|
||||
_ <- EitherT.fromEither(validateJoexSupport(addonRunners, joexSupport))
|
||||
|
||||
} yield meta).value
|
||||
|
||||
private def validateJoexSupport(
|
||||
addonRunnerTypes: List[RunnerType],
|
||||
joexSupport: List[AddonSupport]
|
||||
): AddonValidationResult[Unit] = {
|
||||
val addonRunners = addonRunnerTypes.mkString(", ")
|
||||
for {
|
||||
_ <- Either.cond(
|
||||
joexSupport.nonEmpty,
|
||||
(),
|
||||
AddonUnsupported("There are no joex nodes that have addons enabled!", Nil).cast
|
||||
)
|
||||
_ <- Either.cond(
|
||||
addonRunners.nonEmpty,
|
||||
(),
|
||||
InvalidAddon("The addon doesn't enable any runner.")
|
||||
)
|
||||
|
||||
ids = joexSupport
|
||||
.map(n => n.nodeId -> n.runners.intersect(addonRunnerTypes).toSet)
|
||||
|
||||
unsupportedJoex = ids.filter(_._2.isEmpty).map(_._1)
|
||||
|
||||
_ <- Either.cond(
|
||||
ids.forall(_._2.nonEmpty),
|
||||
(),
|
||||
AddonUnsupported(
|
||||
s"A joex node doesn't support this addons runners: $addonRunners. " +
|
||||
s"Check: ${unsupportedJoex.map(_.id).mkString(", ")}.",
|
||||
unsupportedJoex
|
||||
).cast
|
||||
)
|
||||
} yield ()
|
||||
}
|
||||
|
||||
private def rightUnit: AddonValidationResult[Unit] =
|
||||
().asRight[AddonValidationError]
|
||||
|
||||
private def rightUnitT: EitherT[F, AddonValidationError, Unit] =
|
||||
EitherT.fromEither(rightUnit)
|
||||
|
||||
implicit final class ErrorOps(self: AddonValidationError) {
|
||||
def result: AddonValidationResult[AddonMeta] =
|
||||
self.toLeft
|
||||
|
||||
def resultF: F[AddonValidationResult[AddonMeta]] =
|
||||
result.pure[F]
|
||||
}
|
||||
}
|
@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.backend.ops
|
||||
|
||||
import docspell.common.{Ident, LenientUri}
|
||||
import docspell.store.records.RAddonArchive
|
||||
|
||||
import io.circe.generic.extras.Configuration
|
||||
import io.circe.generic.extras.semiauto.{deriveConfiguredDecoder, deriveConfiguredEncoder}
|
||||
import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder}
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
sealed trait AddonValidationError {
|
||||
def cast: AddonValidationError = this
|
||||
|
||||
def toLeft[A]: Either[AddonValidationError, A] = Left(this)
|
||||
}
|
||||
|
||||
object AddonValidationError {
|
||||
|
||||
implicit private val throwableDecoder: Decoder[Throwable] =
|
||||
Decoder.decodeString.map(new Exception(_))
|
||||
implicit private val throwableEncoder: Encoder[Throwable] =
|
||||
Encoder.encodeString.contramap(_.getMessage)
|
||||
|
||||
case object AddonsDisabled extends AddonValidationError {}
|
||||
|
||||
case class UrlUntrusted(url: LenientUri) extends AddonValidationError
|
||||
object UrlUntrusted {
|
||||
implicit val jsonDecoder: Decoder[UrlUntrusted] = deriveDecoder
|
||||
implicit val jsonEncoder: Encoder[UrlUntrusted] = deriveEncoder
|
||||
}
|
||||
|
||||
case class NotAnAddon(error: Throwable) extends AddonValidationError
|
||||
object NotAnAddon {
|
||||
implicit val jsonDecoder: Decoder[NotAnAddon] = deriveDecoder
|
||||
implicit val jsonEncoder: Encoder[NotAnAddon] = deriveEncoder
|
||||
}
|
||||
|
||||
case class AddonUnsupported(message: String, affectedNodes: List[Ident])
|
||||
extends AddonValidationError
|
||||
object AddonUnsupported {
|
||||
implicit val jsonDecoder: Decoder[AddonUnsupported] = deriveDecoder
|
||||
implicit val jsonEncoder: Encoder[AddonUnsupported] = deriveEncoder
|
||||
}
|
||||
|
||||
case class InvalidAddon(message: String) extends AddonValidationError
|
||||
object InvalidAddon {
|
||||
implicit val jsonDecoder: Decoder[InvalidAddon] = deriveDecoder
|
||||
implicit val jsonEncoder: Encoder[InvalidAddon] = deriveEncoder
|
||||
}
|
||||
|
||||
case class AddonExists(message: String, addon: RAddonArchive)
|
||||
extends AddonValidationError
|
||||
object AddonExists {
|
||||
def apply(addon: RAddonArchive): AddonExists =
|
||||
AddonExists(s"An addon '${addon.name}/${addon.version}' already exists!", addon)
|
||||
|
||||
implicit val jsonDecoder: Decoder[AddonExists] = deriveDecoder
|
||||
implicit val jsonEncoder: Encoder[AddonExists] = deriveEncoder
|
||||
}
|
||||
|
||||
case object AddonNotFound extends AddonValidationError
|
||||
|
||||
case class DownloadFailed(error: Throwable) extends AddonValidationError
|
||||
object DownloadFailed {
|
||||
implicit val jsonDecoder: Decoder[DownloadFailed] = deriveDecoder
|
||||
implicit val jsonEncoder: Encoder[DownloadFailed] = deriveEncoder
|
||||
}
|
||||
|
||||
case object ImpureAddonsDisabled extends AddonValidationError
|
||||
|
||||
case object RefreshLocalAddon extends AddonValidationError
|
||||
|
||||
implicit val jsonConfig: Configuration =
|
||||
Configuration.default.withKebabCaseConstructorNames
|
||||
.withDiscriminator("errorType")
|
||||
|
||||
implicit val jsonDecoder: Decoder[AddonValidationError] = deriveConfiguredDecoder
|
||||
implicit val jsonEncoder: Encoder[AddonValidationError] = deriveConfiguredEncoder
|
||||
}
|
@ -0,0 +1,426 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.backend.ops
|
||||
|
||||
import cats.data.{EitherT, NonEmptyList, OptionT}
|
||||
import cats.effect._
|
||||
import cats.syntax.all._
|
||||
|
||||
import docspell.addons.{AddonMeta, AddonTriggerType}
|
||||
import docspell.backend.ops.AddonValidationError._
|
||||
import docspell.backend.ops.OAddons._
|
||||
import docspell.backend.{Config, JobFactory}
|
||||
import docspell.common._
|
||||
import docspell.logging.Logger
|
||||
import docspell.scheduler.JobStore
|
||||
import docspell.scheduler.usertask.{UserTask, UserTaskScope, UserTaskStore}
|
||||
import docspell.store.Store
|
||||
import docspell.store.file.FileUrlReader
|
||||
import docspell.store.records._
|
||||
|
||||
import com.github.eikek.calev.CalEvent
|
||||
|
||||
trait OAddons[F[_]] {
|
||||
|
||||
/** Registers a new addon. An error is returned if an addon with this url already
|
||||
* exists.
|
||||
*/
|
||||
def registerAddon(
|
||||
collective: Ident,
|
||||
url: LenientUri,
|
||||
logger: Option[Logger[F]]
|
||||
): F[AddonValidationResult[(RAddonArchive, AddonMeta)]]
|
||||
|
||||
/** Refreshes an existing addon by downloading it again and updating metadata. */
|
||||
def refreshAddon(
|
||||
collective: Ident,
|
||||
addonId: Ident
|
||||
): F[AddonValidationResult[(RAddonArchive, AddonMeta)]]
|
||||
|
||||
/** Look into the addon at the given url and return its metadata. */
|
||||
def inspectAddon(
|
||||
collective: Ident,
|
||||
url: LenientUri
|
||||
): F[AddonValidationResult[AddonMeta]]
|
||||
|
||||
/** Deletes the addon if it exists. */
|
||||
def deleteAddon(collective: Ident, addonId: Ident): F[Boolean]
|
||||
|
||||
def getAllAddons(collective: Ident): F[List[RAddonArchive]]
|
||||
|
||||
/** Inserts or updates the addon run configuration. If it already exists (and the given
|
||||
* id is non empty), it will be completely replaced with the given one.
|
||||
*/
|
||||
def upsertAddonRunConfig(
|
||||
collective: Ident,
|
||||
runConfig: AddonRunInsert
|
||||
): F[AddonRunConfigResult[Ident]]
|
||||
|
||||
/** Deletes this task from the database. */
|
||||
def deleteAddonRunConfig(collective: Ident, runConfigId: Ident): F[Boolean]
|
||||
|
||||
def getAllAddonRunConfigs(collective: Ident): F[List[AddonRunInfo]]
|
||||
|
||||
def runAddonForItem(
|
||||
account: AccountId,
|
||||
itemIds: NonEmptyList[Ident],
|
||||
addonRunConfigIds: Set[Ident]
|
||||
): F[Unit]
|
||||
}
|
||||
|
||||
object OAddons {
|
||||
val scheduledAddonTaskName: Ident =
|
||||
ScheduledAddonTaskArgs.taskName
|
||||
|
||||
case class AddonRunInsert(
|
||||
id: Ident,
|
||||
name: String,
|
||||
enabled: Boolean,
|
||||
userId: Option[Ident],
|
||||
schedule: Option[CalEvent],
|
||||
triggered: NonEmptyList[AddonTriggerType],
|
||||
addons: NonEmptyList[AddonArgs]
|
||||
) {
|
||||
|
||||
def isScheduled: Boolean =
|
||||
triggered.exists(_ == AddonTriggerType.Scheduled)
|
||||
}
|
||||
case class AddonArgs(addonId: Ident, args: String)
|
||||
|
||||
case class AddonRunInfo(
|
||||
id: Ident,
|
||||
name: String,
|
||||
enabled: Boolean,
|
||||
userId: Option[Ident],
|
||||
schedule: Option[CalEvent],
|
||||
triggered: List[AddonTriggerType],
|
||||
addons: List[(RAddonArchive, RAddonRunConfigAddon)]
|
||||
)
|
||||
object AddonRunInfo {
|
||||
def fromRunConfigData(
|
||||
timer: Option[CalEvent],
|
||||
addons: List[(RAddonArchive, RAddonRunConfigAddon)]
|
||||
)(t: AddonRunConfigData): AddonRunInfo =
|
||||
AddonRunInfo(
|
||||
id = t.runConfig.id,
|
||||
name = t.runConfig.name,
|
||||
enabled = t.runConfig.enabled,
|
||||
userId = t.runConfig.userId,
|
||||
schedule = timer,
|
||||
triggered = t.triggers.map(_.trigger),
|
||||
addons = addons
|
||||
)
|
||||
}
|
||||
|
||||
type AddonRunConfigResult[A] = Either[AddonRunConfigError, A]
|
||||
object AddonRunConfigResult {
|
||||
def success[A](value: A): AddonRunConfigResult[A] = Right(value)
|
||||
def failure[A](error: AddonRunConfigError): AddonRunConfigResult[A] = error.toLeft[A]
|
||||
}
|
||||
|
||||
type AddonValidationResult[A] = Either[AddonValidationError, A]
|
||||
object AddonValidationResult {
|
||||
def success[A](value: A): AddonValidationResult[A] = Right(value)
|
||||
def failure[A](error: AddonValidationError): AddonValidationResult[A] = Left(error)
|
||||
}
|
||||
|
||||
def apply[F[_]: Async](
|
||||
cfg: Config.Addons,
|
||||
store: Store[F],
|
||||
userTasks: UserTaskStore[F],
|
||||
jobStore: JobStore[F],
|
||||
joex: OJoex[F]
|
||||
): OAddons[F] =
|
||||
new OAddons[F] {
|
||||
private[this] val logger = docspell.logging.getLogger[F]
|
||||
private val urlReader = FileUrlReader(store.fileRepo)
|
||||
private val zip = MimeType.zip.asString
|
||||
private val addonValidate = new AddonValidate[F](cfg, store, joex)
|
||||
|
||||
def getAllAddonRunConfigs(collective: Ident): F[List[AddonRunInfo]] =
|
||||
for {
|
||||
all <- store.transact(AddonRunConfigData.findAll(collective))
|
||||
runConfigIDs = all.map(_.runConfig.id).toSet
|
||||
archiveIds = all.flatMap(_.addons.map(_.addonId)).distinct
|
||||
archives <- NonEmptyList
|
||||
.fromList(archiveIds)
|
||||
.fold(List.empty[RAddonArchive].pure[F])(ids =>
|
||||
store.transact(RAddonArchive.findByIds(collective, ids))
|
||||
)
|
||||
archivesMap = archives.groupBy(_.id)
|
||||
ptask <- userTasks
|
||||
.getAll(UserTaskScope.collective(collective))
|
||||
.filter(ut => runConfigIDs.contains(ut.id))
|
||||
.map(ut => ut.id -> ut)
|
||||
.compile
|
||||
.toList
|
||||
.map(_.toMap)
|
||||
result = all.map { t =>
|
||||
AddonRunInfo.fromRunConfigData(
|
||||
ptask.get(t.runConfig.id).map(_.timer),
|
||||
t.addons.map(raa => (archivesMap(raa.addonId).head, raa))
|
||||
)(t)
|
||||
}
|
||||
} yield result
|
||||
|
||||
def upsertAddonRunConfig(
|
||||
collective: Ident,
|
||||
runConfig: AddonRunInsert
|
||||
): F[AddonRunConfigResult[Ident]] = {
|
||||
val insertDataRaw = AddonRunConfigData(
|
||||
RAddonRunConfig(
|
||||
runConfig.id,
|
||||
collective,
|
||||
runConfig.userId,
|
||||
runConfig.name,
|
||||
runConfig.enabled,
|
||||
Timestamp.Epoch
|
||||
),
|
||||
runConfig.addons.zipWithIndex.map { case (a, index) =>
|
||||
RAddonRunConfigAddon(Ident.unsafe(""), runConfig.id, a.addonId, a.args, index)
|
||||
}.toList,
|
||||
runConfig.triggered
|
||||
.map(t => RAddonRunConfigTrigger(Ident.unsafe(""), runConfig.id, t))
|
||||
.toList
|
||||
)
|
||||
|
||||
val upsert = for {
|
||||
userId <-
|
||||
OptionT
|
||||
.fromOption(runConfig.userId)
|
||||
.flatMapF(uid => store.transact(RUser.getIdByIdOrLogin(uid)))
|
||||
.map(_.uid)
|
||||
.value
|
||||
insertData =
|
||||
insertDataRaw.copy(runConfig =
|
||||
insertDataRaw.runConfig.copy(userId = userId.orElse(runConfig.userId))
|
||||
)
|
||||
id <-
|
||||
OptionT(store.transact(RAddonRunConfig.findById(collective, runConfig.id)))
|
||||
.map(rt =>
|
||||
AddonRunConfigData(
|
||||
rt.copy(
|
||||
userId = insertData.runConfig.userId,
|
||||
name = insertData.runConfig.name,
|
||||
enabled = insertData.runConfig.enabled
|
||||
),
|
||||
insertData.addons,
|
||||
insertData.triggers
|
||||
)
|
||||
)
|
||||
.semiflatMap(rt =>
|
||||
store.transact(AddonRunConfigData.update(rt).as(rt.runConfig.id))
|
||||
)
|
||||
.getOrElseF(store.transact(AddonRunConfigData.insert(insertData)))
|
||||
} yield id
|
||||
|
||||
EitherT(AddonRunConfigValidate(store, collective)(runConfig))
|
||||
.semiflatMap(_ =>
|
||||
upsert.flatTap { runConfigId =>
|
||||
runConfig.schedule match {
|
||||
case Some(timer) =>
|
||||
userTasks.updateTask(
|
||||
UserTaskScope.collective(collective),
|
||||
s"Addon task ${runConfig.name}".some,
|
||||
UserTask(
|
||||
runConfigId,
|
||||
scheduledAddonTaskName,
|
||||
true,
|
||||
timer,
|
||||
s"Running scheduled addon task ${runConfig.name}".some,
|
||||
ScheduledAddonTaskArgs(collective, runConfigId)
|
||||
)
|
||||
)
|
||||
case None =>
|
||||
userTasks.deleteTask(UserTaskScope.collective(collective), runConfigId)
|
||||
}
|
||||
}
|
||||
)
|
||||
.value
|
||||
}
|
||||
|
||||
def deleteAddonRunConfig(collective: Ident, runConfigId: Ident): F[Boolean] = {
|
||||
val deleteRunConfig =
|
||||
(for {
|
||||
e <- OptionT(RAddonRunConfig.findById(collective, runConfigId))
|
||||
_ <- OptionT.liftF(RAddonRunConfigAddon.deleteAllForConfig(e.id))
|
||||
_ <- OptionT.liftF(RAddonRunConfigTrigger.deleteAllForConfig(e.id))
|
||||
_ <- OptionT.liftF(RAddonRunConfig.deleteById(collective, e.id))
|
||||
} yield true).getOrElse(false)
|
||||
|
||||
for {
|
||||
deleted <- store.transact(deleteRunConfig)
|
||||
_ <-
|
||||
if (deleted)
|
||||
userTasks.deleteTask(UserTaskScope.collective(collective), runConfigId)
|
||||
else 0.pure[F]
|
||||
} yield deleted
|
||||
}
|
||||
|
||||
def getAllAddons(collective: Ident): F[List[RAddonArchive]] =
|
||||
store.transact(RAddonArchive.listAll(collective))
|
||||
|
||||
def deleteAddon(collective: Ident, addonId: Ident): F[Boolean] =
|
||||
store.transact(RAddonArchive.deleteById(collective, addonId)).map(_ > 0)
|
||||
|
||||
def inspectAddon(
|
||||
collective: Ident,
|
||||
url: LenientUri
|
||||
): F[AddonValidationResult[AddonMeta]] =
|
||||
addonValidate.fromUrl(collective, url, urlReader, checkExisting = false)
|
||||
|
||||
def registerAddon(
|
||||
collective: Ident,
|
||||
url: LenientUri,
|
||||
logger: Option[Logger[F]]
|
||||
): F[AddonValidationResult[(RAddonArchive, AddonMeta)]] = {
|
||||
val log = logger.getOrElse(this.logger)
|
||||
def validateAndInsert(file: FileKey, localUrl: LenientUri) =
|
||||
addonValidate.fromUrl(collective, url, urlReader, localUrl.some).flatMap {
|
||||
case Right(meta) =>
|
||||
insertAddon(collective, url, meta, file)
|
||||
.map(ar => AddonValidationResult.success(ar -> meta))
|
||||
|
||||
case Left(error) =>
|
||||
store.fileRepo
|
||||
.delete(file)
|
||||
.as(AddonValidationResult.failure[(RAddonArchive, AddonMeta)](error))
|
||||
}
|
||||
|
||||
log.info(s"Store addon file from '${url.asString} for ${collective.id}") *>
|
||||
storeAddonFromUrl(collective, url).flatMapF { file =>
|
||||
val localUrl = FileUrlReader.url(file)
|
||||
for {
|
||||
_ <- log.info(s"Validating addon…")
|
||||
res <- validateAndInsert(file, localUrl)
|
||||
_ <- log.info(s"Validation result: $res")
|
||||
} yield res
|
||||
}.value
|
||||
}
|
||||
|
||||
def refreshAddon(
|
||||
collective: Ident,
|
||||
addonId: Ident
|
||||
): F[AddonValidationResult[(RAddonArchive, AddonMeta)]] = {
|
||||
val findAddon = store
|
||||
.transact(RAddonArchive.findById(collective, addonId))
|
||||
.map(_.toRight(AddonNotFound))
|
||||
def validateAddon(aa: RAddonArchive): F[AddonValidationResult[AddonMeta]] =
|
||||
aa.originalUrl.fold(
|
||||
AddonValidationResult.failure[AddonMeta](RefreshLocalAddon).pure[F]
|
||||
)(url =>
|
||||
addonValidate.fromUrl(collective, url, urlReader, checkExisting = false)
|
||||
)
|
||||
|
||||
EitherT(findAddon).flatMap { aa =>
|
||||
EitherT(validateAddon(aa))
|
||||
.flatMap(meta => refreshAddon(aa, meta).map(na => na -> meta))
|
||||
}.value
|
||||
}
|
||||
|
||||
private def refreshAddon(
|
||||
r: RAddonArchive,
|
||||
meta: AddonMeta
|
||||
): EitherT[F, AddonValidationError, RAddonArchive] =
|
||||
if (r.isUnchanged(meta)) EitherT.pure(r)
|
||||
else
|
||||
r.originalUrl match {
|
||||
case Some(url) =>
|
||||
EitherT(
|
||||
store
|
||||
.transact(
|
||||
RAddonArchive
|
||||
.findByNameAndVersion(r.cid, meta.meta.name, meta.meta.version)
|
||||
)
|
||||
.map(
|
||||
_.fold(().asRight[AddonValidationError])(rx => AddonExists(rx).toLeft)
|
||||
)
|
||||
).flatMap(_ =>
|
||||
storeAddonFromUrl(r.cid, url).flatMap { file =>
|
||||
val nr = r.update(file, meta)
|
||||
for {
|
||||
_ <- EitherT(
|
||||
store
|
||||
.transact(RAddonArchive.update(nr))
|
||||
.map(_.asRight[AddonValidationError])
|
||||
.recoverWith { case ex =>
|
||||
logger.warn(ex)(s"Storing addon metadata failed.") *>
|
||||
store.fileRepo
|
||||
.delete(file)
|
||||
.as(
|
||||
AddonExists(
|
||||
s"The addon '${nr.name}/${nr.version}' could not be stored",
|
||||
nr
|
||||
).toLeft
|
||||
)
|
||||
}
|
||||
)
|
||||
_ <- EitherT.liftF(store.fileRepo.delete(r.fileId))
|
||||
} yield nr
|
||||
}
|
||||
)
|
||||
case None =>
|
||||
EitherT.leftT(RefreshLocalAddon.cast)
|
||||
}
|
||||
|
||||
private def insertAddon(
|
||||
collective: Ident,
|
||||
url: LenientUri,
|
||||
meta: AddonMeta,
|
||||
file: FileKey
|
||||
): F[RAddonArchive] =
|
||||
for {
|
||||
now <- Timestamp.current[F]
|
||||
aId <- Ident.randomId[F]
|
||||
record = RAddonArchive(
|
||||
aId,
|
||||
collective,
|
||||
file,
|
||||
url.some,
|
||||
meta,
|
||||
now
|
||||
)
|
||||
_ <- store
|
||||
.transact(RAddonArchive.insert(record, silent = false))
|
||||
.onError(_ => store.fileRepo.delete(file))
|
||||
} yield record
|
||||
|
||||
private def storeAddonFromUrl(collective: Ident, url: LenientUri) =
|
||||
for {
|
||||
urlFile <- EitherT.pure(url.path.segments.lastOption)
|
||||
file <- EitherT(
|
||||
urlReader(url)
|
||||
.through(
|
||||
store.fileRepo.save(
|
||||
collective,
|
||||
FileCategory.Addon,
|
||||
MimeTypeHint(urlFile, zip.some)
|
||||
)
|
||||
)
|
||||
.compile
|
||||
.lastOrError
|
||||
.attempt
|
||||
.map(_.leftMap(DownloadFailed(_).cast))
|
||||
)
|
||||
} yield file
|
||||
|
||||
def runAddonForItem(
|
||||
account: AccountId,
|
||||
itemIds: NonEmptyList[Ident],
|
||||
addonRunConfigIds: Set[Ident]
|
||||
): F[Unit] =
|
||||
for {
|
||||
jobs <- itemIds.traverse(id =>
|
||||
JobFactory.existingItemAddon(
|
||||
ItemAddonTaskArgs(account.collective, id, addonRunConfigIds),
|
||||
account
|
||||
)
|
||||
)
|
||||
_ <- jobStore.insertAllIfNew(jobs.map(_.encode).toList)
|
||||
} yield ()
|
||||
}
|
||||
}
|
@ -0,0 +1,223 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.backend.ops
|
||||
|
||||
import cats.data.{NonEmptyList => Nel, OptionT}
|
||||
import cats.effect._
|
||||
import cats.syntax.all._
|
||||
import fs2.Stream
|
||||
|
||||
import docspell.backend.JobFactory
|
||||
import docspell.common.MakePreviewArgs.StoreMode
|
||||
import docspell.common._
|
||||
import docspell.files.TikaMimetype
|
||||
import docspell.ftsclient.{FtsClient, TextData}
|
||||
import docspell.scheduler.JobStore
|
||||
import docspell.store.Store
|
||||
import docspell.store.queries.QAttachment
|
||||
import docspell.store.records._
|
||||
|
||||
trait OAttachment[F[_]] {
|
||||
|
||||
def setExtractedText(
|
||||
collective: Ident,
|
||||
itemId: Ident,
|
||||
attachId: Ident,
|
||||
newText: F[String]
|
||||
): F[Unit]
|
||||
|
||||
def addOrReplacePdf(
|
||||
collective: Ident,
|
||||
attachId: Ident,
|
||||
pdfData: Stream[F, Byte],
|
||||
regeneratePreview: Boolean
|
||||
): F[Unit]
|
||||
|
||||
def addOrReplacePreview(
|
||||
collective: Ident,
|
||||
attachId: Ident,
|
||||
imageData: Stream[F, Byte]
|
||||
): F[Unit]
|
||||
}
|
||||
|
||||
object OAttachment {
|
||||
|
||||
def apply[F[_]: Sync](
|
||||
store: Store[F],
|
||||
fts: FtsClient[F],
|
||||
jobStore: JobStore[F]
|
||||
): OAttachment[F] =
|
||||
new OAttachment[F] {
|
||||
private[this] val logger = docspell.logging.getLogger[F]
|
||||
|
||||
def setExtractedText(
|
||||
collective: Ident,
|
||||
itemId: Ident,
|
||||
attachId: Ident,
|
||||
newText: F[String]
|
||||
): F[Unit] =
|
||||
for {
|
||||
_ <- logger.info(s"Find attachment ${attachId.id} to update extracted text.")
|
||||
cca <- store
|
||||
.transact(
|
||||
QAttachment
|
||||
.allAttachmentMetaAndName(
|
||||
collective.some,
|
||||
Nel.of(itemId).some,
|
||||
ItemState.validStates.append(ItemState.Processing),
|
||||
100
|
||||
)
|
||||
)
|
||||
.filter(_.id == attachId)
|
||||
.compile
|
||||
.last
|
||||
content = cca.find(_.id == attachId)
|
||||
_ <- logger.debug(s"Found existing metadata: ${content.isDefined}")
|
||||
_ <- OptionT
|
||||
.fromOption(content)
|
||||
.semiflatMap { cnt =>
|
||||
for {
|
||||
_ <- logger.debug(s"Setting new extracted text on ${cnt.id.id}")
|
||||
text <- newText
|
||||
td = TextData.attachment(
|
||||
cnt.item,
|
||||
cnt.id,
|
||||
cnt.collective,
|
||||
cnt.folder,
|
||||
cnt.lang,
|
||||
cnt.name,
|
||||
text.some
|
||||
)
|
||||
_ <- store.transact(RAttachmentMeta.updateContent(attachId, text))
|
||||
_ <- fts.updateIndex(logger, td)
|
||||
} yield ()
|
||||
}
|
||||
.getOrElseF(
|
||||
logger.warn(
|
||||
s"Item or attachment meta not found to update text: ${itemId.id}"
|
||||
)
|
||||
)
|
||||
} yield ()
|
||||
|
||||
def addOrReplacePdf(
|
||||
collective: Ident,
|
||||
attachId: Ident,
|
||||
pdfData: Stream[F, Byte],
|
||||
regeneratePreview: Boolean
|
||||
): F[Unit] = {
|
||||
def generatePreview(ra: RAttachment): F[Unit] =
|
||||
JobFactory
|
||||
.makePreview(MakePreviewArgs(ra.id, StoreMode.Replace), None)
|
||||
.map(_.encode)
|
||||
.flatMap(jobStore.insert) *>
|
||||
logger.info(s"Job submitted to re-generate preview from new pdf")
|
||||
|
||||
def generatePageCount(ra: RAttachment): F[Unit] =
|
||||
JobFactory
|
||||
.makePageCount(
|
||||
MakePageCountArgs(ra.id),
|
||||
AccountId(collective, DocspellSystem.user).some
|
||||
)
|
||||
.map(_.encode)
|
||||
.flatMap(jobStore.insert) *>
|
||||
logger.info(s"Job submitted to find page count from new pdf")
|
||||
|
||||
def setFile(ra: RAttachment, rs: RAttachmentSource) =
|
||||
for {
|
||||
_ <- requireMimeType(pdfData, MimeType.pdf)
|
||||
|
||||
newFile <- pdfData
|
||||
.through(
|
||||
store.fileRepo.save(
|
||||
collective,
|
||||
FileCategory.AttachmentConvert,
|
||||
MimeTypeHint.advertised(MimeType.pdf)
|
||||
)
|
||||
)
|
||||
.compile
|
||||
.lastOrError
|
||||
|
||||
_ <- store.transact(RAttachment.updateFileId(attachId, newFile))
|
||||
_ <- logger.info(s"Deleting old file for attachment")
|
||||
_ <-
|
||||
if (rs.fileId == ra.fileId) ().pure[F]
|
||||
else store.fileRepo.delete(ra.fileId)
|
||||
_ <-
|
||||
if (regeneratePreview) generatePreview(ra)
|
||||
else ().pure[F]
|
||||
_ <- generatePageCount(ra)
|
||||
} yield ()
|
||||
|
||||
(for {
|
||||
ra <- OptionT(
|
||||
store.transact(RAttachment.findByIdAndCollective(attachId, collective))
|
||||
)
|
||||
rs <- OptionT(
|
||||
store.transact(RAttachmentSource.findByIdAndCollective(attachId, collective))
|
||||
)
|
||||
_ <- OptionT.liftF(setFile(ra, rs))
|
||||
} yield ()).getOrElseF(
|
||||
logger.warn(
|
||||
s"Cannot replace pdf file. Attachment not found for id: ${attachId.id}"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
def addOrReplacePreview(
|
||||
collective: Ident,
|
||||
attachId: Ident,
|
||||
imageData: Stream[F, Byte]
|
||||
): F[Unit] = {
|
||||
def setFile(ra: RAttachment): F[Unit] =
|
||||
for {
|
||||
_ <- requireMimeType(imageData, MimeType.image("*"))
|
||||
newFile <- imageData
|
||||
.through(
|
||||
store.fileRepo
|
||||
.save(collective, FileCategory.PreviewImage, MimeTypeHint.none)
|
||||
)
|
||||
.compile
|
||||
.lastOrError
|
||||
|
||||
now <- Timestamp.current[F]
|
||||
record = RAttachmentPreview(ra.id, newFile, None, now)
|
||||
oldFile <- store.transact(RAttachmentPreview.upsert(record))
|
||||
_ <- OptionT
|
||||
.fromOption(oldFile)
|
||||
.semiflatMap(store.fileRepo.delete)
|
||||
.getOrElse(())
|
||||
} yield ()
|
||||
|
||||
(for {
|
||||
ra <- OptionT(
|
||||
store.transact(RAttachment.findByIdAndCollective(attachId, collective))
|
||||
)
|
||||
_ <- OptionT.liftF(setFile(ra))
|
||||
} yield ()).getOrElseF(
|
||||
logger.warn(
|
||||
s"Cannot add/replace preview file. Attachment not found for id: ${attachId.id}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private def requireMimeType[F[_]: Sync](
|
||||
data: Stream[F, Byte],
|
||||
expectedMime: MimeType
|
||||
): F[Unit] =
|
||||
TikaMimetype
|
||||
.detect(data, MimeTypeHint.advertised(expectedMime))
|
||||
.flatMap { mime =>
|
||||
if (expectedMime.matches(mime)) ().pure[F]
|
||||
else
|
||||
Sync[F].raiseError(
|
||||
new IllegalArgumentException(
|
||||
s"Expected pdf file, but got: ${mime.asString}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
@ -61,6 +61,12 @@ trait OItem[F[_]] {
|
||||
collective: Ident
|
||||
): F[AttachedEvent[UpdateResult]]
|
||||
|
||||
def removeTagsOfCategories(
|
||||
item: Ident,
|
||||
collective: Ident,
|
||||
categories: Set[String]
|
||||
): F[AttachedEvent[UpdateResult]]
|
||||
|
||||
def removeTagsMultipleItems(
|
||||
items: Nel[Ident],
|
||||
tags: List[String],
|
||||
@ -80,11 +86,13 @@ trait OItem[F[_]] {
|
||||
collective: Ident
|
||||
): F[UpdateResult]
|
||||
|
||||
def setFolder(item: Ident, folder: Option[Ident], collective: Ident): F[UpdateResult]
|
||||
/** Set or remove the folder on an item. Folder can be the id or name. */
|
||||
def setFolder(item: Ident, folder: Option[String], collective: Ident): F[UpdateResult]
|
||||
|
||||
/** Set or remove the folder on multiple items. Folder can be the id or name. */
|
||||
def setFolderMultiple(
|
||||
items: Nel[Ident],
|
||||
folder: Option[Ident],
|
||||
folder: Option[String],
|
||||
collective: Ident
|
||||
): F[UpdateResult]
|
||||
|
||||
@ -122,6 +130,13 @@ trait OItem[F[_]] {
|
||||
|
||||
def setNotes(item: Ident, notes: Option[String], collective: Ident): F[UpdateResult]
|
||||
|
||||
def addNotes(
|
||||
item: Ident,
|
||||
notes: String,
|
||||
separator: Option[String],
|
||||
collective: Ident
|
||||
): F[UpdateResult]
|
||||
|
||||
def setName(item: Ident, name: String, collective: Ident): F[UpdateResult]
|
||||
|
||||
def setNameMultiple(
|
||||
@ -288,6 +303,28 @@ object OItem {
|
||||
}
|
||||
}
|
||||
|
||||
def removeTagsOfCategories(
|
||||
item: Ident,
|
||||
collective: Ident,
|
||||
categories: Set[String]
|
||||
): F[AttachedEvent[UpdateResult]] =
|
||||
if (categories.isEmpty) {
|
||||
AttachedEvent.only(UpdateResult.success).pure[F]
|
||||
} else {
|
||||
val dbtask =
|
||||
for {
|
||||
tags <- RTag.findByItem(item)
|
||||
removeTags = tags.filter(_.category.exists(categories.contains))
|
||||
_ <- RTagItem.removeAllTags(item, removeTags.map(_.tagId))
|
||||
mkEvent = Event.TagsChanged
|
||||
.partial(Nel.of(item), Nil, removeTags.map(_.tagId.id).toList)
|
||||
} yield AttachedEvent(UpdateResult.success)(mkEvent)
|
||||
|
||||
OptionT(store.transact(RItem.checkByIdAndCollective(item, collective)))
|
||||
.semiflatMap(_ => store.transact(dbtask))
|
||||
.getOrElse(AttachedEvent.only(UpdateResult.notFound))
|
||||
}
|
||||
|
||||
def removeTagsMultipleItems(
|
||||
items: Nel[Ident],
|
||||
tags: List[String],
|
||||
@ -420,21 +457,27 @@ object OItem {
|
||||
|
||||
def setFolder(
|
||||
item: Ident,
|
||||
folder: Option[Ident],
|
||||
folder: Option[String],
|
||||
collective: Ident
|
||||
): F[UpdateResult] =
|
||||
UpdateResult
|
||||
.fromUpdate(
|
||||
store
|
||||
.transact(RItem.updateFolder(item, collective, folder))
|
||||
for {
|
||||
result <- store.transact(RItem.updateFolder(item, collective, folder)).attempt
|
||||
ures = result.fold(
|
||||
UpdateResult.failure,
|
||||
t => UpdateResult.fromUpdateRows(t._1)
|
||||
)
|
||||
.flatTap(
|
||||
onSuccessIgnoreError(fts.updateFolder(logger, item, collective, folder))
|
||||
_ <- result.fold(
|
||||
_ => ().pure[F],
|
||||
t =>
|
||||
onSuccessIgnoreError(fts.updateFolder(logger, item, collective, t._2))(
|
||||
ures
|
||||
)
|
||||
)
|
||||
} yield ures
|
||||
|
||||
def setFolderMultiple(
|
||||
items: Nel[Ident],
|
||||
folder: Option[Ident],
|
||||
folder: Option[String],
|
||||
collective: Ident
|
||||
): F[UpdateResult] =
|
||||
for {
|
||||
@ -615,6 +658,33 @@ object OItem {
|
||||
}
|
||||
)
|
||||
|
||||
def addNotes(
|
||||
item: Ident,
|
||||
notes: String,
|
||||
separator: Option[String],
|
||||
collective: Ident
|
||||
): F[UpdateResult] =
|
||||
store
|
||||
.transact(RItem.appendNotes(item, collective, notes, separator))
|
||||
.flatMap {
|
||||
case Some(newNotes) =>
|
||||
store
|
||||
.transact(RCollective.findLanguage(collective))
|
||||
.map(_.getOrElse(Language.English))
|
||||
.flatMap(lang =>
|
||||
fts.updateItemNotes(logger, item, collective, lang, newNotes.some)
|
||||
)
|
||||
.attempt
|
||||
.flatMap {
|
||||
case Right(()) => ().pure[F]
|
||||
case Left(ex) =>
|
||||
logger.warn(s"Error updating full-text index: ${ex.getMessage}")
|
||||
}
|
||||
.as(UpdateResult.success)
|
||||
case None =>
|
||||
UpdateResult.notFound.pure[F]
|
||||
}
|
||||
|
||||
def setName(item: Ident, name: String, collective: Ident): F[UpdateResult] =
|
||||
UpdateResult
|
||||
.fromUpdate(
|
||||
|
@ -6,11 +6,13 @@
|
||||
|
||||
package docspell.backend.ops
|
||||
|
||||
import cats.Applicative
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
import cats.syntax.all._
|
||||
import fs2.Stream
|
||||
|
||||
import docspell.common.Ident
|
||||
import docspell.common.{Ident, NodeType}
|
||||
import docspell.joexapi.client.JoexClient
|
||||
import docspell.joexapi.model.AddonSupport
|
||||
import docspell.pubsub.api.PubSubT
|
||||
import docspell.scheduler.msg.{CancelJob, JobsNotify, PeriodicTaskNotify}
|
||||
|
||||
@ -21,10 +23,16 @@ trait OJoex[F[_]] {
|
||||
def notifyPeriodicTasks: F[Unit]
|
||||
|
||||
def cancelJob(job: Ident, worker: Ident): F[Unit]
|
||||
|
||||
def getAddonSupport: F[List[AddonSupport]]
|
||||
}
|
||||
|
||||
object OJoex {
|
||||
def apply[F[_]: Applicative](pubSub: PubSubT[F]): Resource[F, OJoex[F]] =
|
||||
def apply[F[_]: Async](
|
||||
pubSub: PubSubT[F],
|
||||
nodes: ONode[F],
|
||||
joexClient: JoexClient[F]
|
||||
): Resource[F, OJoex[F]] =
|
||||
Resource.pure[F, OJoex[F]](new OJoex[F] {
|
||||
|
||||
def notifyAllNodes: F[Unit] =
|
||||
@ -35,5 +43,17 @@ object OJoex {
|
||||
|
||||
def cancelJob(job: Ident, worker: Ident): F[Unit] =
|
||||
pubSub.publish1IgnoreErrors(CancelJob.topic, CancelJob(job, worker)).as(())
|
||||
|
||||
def getAddonSupport: F[List[AddonSupport]] =
|
||||
for {
|
||||
joex <- nodes.getNodes(NodeType.Joex)
|
||||
conc = math.max(2, Runtime.getRuntime.availableProcessors() - 1)
|
||||
supp <- Stream
|
||||
.emits(joex)
|
||||
.covary[F]
|
||||
.parEvalMap(conc)(n => joexClient.getAddonSupport(n.url))
|
||||
.compile
|
||||
.toList
|
||||
} yield supp
|
||||
})
|
||||
}
|
||||
|
@ -13,11 +13,27 @@ import docspell.common.{Ident, LenientUri, NodeType}
|
||||
import docspell.store.Store
|
||||
import docspell.store.records.RNode
|
||||
|
||||
import scodec.bits.ByteVector
|
||||
|
||||
trait ONode[F[_]] {
|
||||
|
||||
def register(appId: Ident, nodeType: NodeType, uri: LenientUri): F[Unit]
|
||||
def register(
|
||||
appId: Ident,
|
||||
nodeType: NodeType,
|
||||
uri: LenientUri,
|
||||
serverSecret: Option[ByteVector]
|
||||
): F[Unit]
|
||||
|
||||
def unregister(appId: Ident): F[Unit]
|
||||
|
||||
def withRegistered(
|
||||
appId: Ident,
|
||||
nodeType: NodeType,
|
||||
uri: LenientUri,
|
||||
serverSecret: Option[ByteVector]
|
||||
): Resource[F, Unit]
|
||||
|
||||
def getNodes(nodeType: NodeType): F[Vector[RNode]]
|
||||
}
|
||||
|
||||
object ONode {
|
||||
@ -25,9 +41,14 @@ object ONode {
|
||||
def apply[F[_]: Async](store: Store[F]): Resource[F, ONode[F]] =
|
||||
Resource.pure[F, ONode[F]](new ONode[F] {
|
||||
val logger = docspell.logging.getLogger[F]
|
||||
def register(appId: Ident, nodeType: NodeType, uri: LenientUri): F[Unit] =
|
||||
def register(
|
||||
appId: Ident,
|
||||
nodeType: NodeType,
|
||||
uri: LenientUri,
|
||||
serverSecret: Option[ByteVector]
|
||||
): F[Unit] =
|
||||
for {
|
||||
node <- RNode(appId, nodeType, uri)
|
||||
node <- RNode(appId, nodeType, uri, serverSecret)
|
||||
_ <- logger.info(s"Registering node ${node.id.id}")
|
||||
_ <- store.transact(RNode.set(node))
|
||||
} yield ()
|
||||
@ -35,6 +56,19 @@ object ONode {
|
||||
def unregister(appId: Ident): F[Unit] =
|
||||
logger.info(s"Unregister app ${appId.id}") *>
|
||||
store.transact(RNode.delete(appId)).map(_ => ())
|
||||
|
||||
def withRegistered(
|
||||
appId: Ident,
|
||||
nodeType: NodeType,
|
||||
uri: LenientUri,
|
||||
serverSecret: Option[ByteVector]
|
||||
): Resource[F, Unit] =
|
||||
Resource.make(register(appId, nodeType, uri, serverSecret))(_ =>
|
||||
unregister(appId)
|
||||
)
|
||||
|
||||
def getNodes(nodeType: NodeType): F[Vector[RNode]] =
|
||||
store.transact(RNode.findAll(nodeType))
|
||||
})
|
||||
|
||||
}
|
||||
|
@ -9,8 +9,9 @@ package docspell.common
|
||||
import java.time.Instant
|
||||
|
||||
import io.circe._
|
||||
import scodec.bits.ByteVector
|
||||
|
||||
object BaseJsonCodecs {
|
||||
trait BaseJsonCodecs {
|
||||
|
||||
implicit val encodeInstantEpoch: Encoder[Instant] =
|
||||
Encoder.encodeJavaLong.contramap(_.toEpochMilli)
|
||||
@ -18,4 +19,11 @@ object BaseJsonCodecs {
|
||||
implicit val decodeInstantEpoch: Decoder[Instant] =
|
||||
Decoder.decodeLong.map(Instant.ofEpochMilli)
|
||||
|
||||
implicit val byteVectorEncoder: Encoder[ByteVector] =
|
||||
Encoder.encodeString.contramap(_.toBase64)
|
||||
|
||||
implicit val byteVectorDecoder: Decoder[ByteVector] =
|
||||
Decoder.decodeString.emap(ByteVector.fromBase64Descriptive(_))
|
||||
}
|
||||
|
||||
object BaseJsonCodecs extends BaseJsonCodecs
|
||||
|
@ -18,6 +18,18 @@ final case class Binary[F[_]](name: String, mime: MimeType, data: Stream[F, Byte
|
||||
|
||||
def withMime(mime: MimeType): Binary[F] =
|
||||
copy(mime = mime)
|
||||
|
||||
/** Return the extension of `name` if available (without the dot) */
|
||||
def extension: Option[String] =
|
||||
name.lastIndexOf('.') match {
|
||||
case n if n > 0 =>
|
||||
Some(name.substring(n + 1))
|
||||
case _ =>
|
||||
None
|
||||
}
|
||||
|
||||
def extensionIn(extensions: Set[String]): Boolean =
|
||||
extension.exists(extensions.contains)
|
||||
}
|
||||
|
||||
object Binary {
|
||||
|
@ -32,6 +32,7 @@ object FileCategory {
|
||||
case object PreviewImage extends FileCategory
|
||||
case object Classifier extends FileCategory
|
||||
case object DownloadAll extends FileCategory
|
||||
case object Addon extends FileCategory
|
||||
|
||||
val all: NonEmptyList[FileCategory] =
|
||||
NonEmptyList.of(
|
||||
@ -39,7 +40,8 @@ object FileCategory {
|
||||
AttachmentConvert,
|
||||
PreviewImage,
|
||||
Classifier,
|
||||
DownloadAll
|
||||
DownloadAll,
|
||||
Addon
|
||||
)
|
||||
|
||||
def fromString(str: String): Either[String, FileCategory] =
|
||||
|
@ -32,7 +32,8 @@ object Glob {
|
||||
def single(str: String) =
|
||||
PatternGlob(Pattern(split(str, separator).map(makeSegment)))
|
||||
|
||||
if (in == "*") all
|
||||
if (in == all.asString) all
|
||||
else if (in == none.asString) none
|
||||
else
|
||||
split(in, anyChar) match {
|
||||
case NonEmptyList(_, Nil) =>
|
||||
@ -51,15 +52,25 @@ object Glob {
|
||||
val asString = "*"
|
||||
}
|
||||
|
||||
val none = new Glob {
|
||||
def matches(caseSensitive: Boolean)(in: String) = false
|
||||
def matchFilenameOrPath(in: String) = false
|
||||
def asString = "!*"
|
||||
}
|
||||
|
||||
def pattern(pattern: Pattern): Glob =
|
||||
PatternGlob(pattern)
|
||||
|
||||
/** A simple glob supporting `*` and `?`. */
|
||||
final private case class PatternGlob(pattern: Pattern) extends Glob {
|
||||
def matches(caseSensitive: Boolean)(in: String): Boolean =
|
||||
def matches(caseSensitive: Boolean)(in: String): Boolean = {
|
||||
val input = Glob.split(in, Glob.separator)
|
||||
|
||||
pattern.parts.size == input.size &&
|
||||
pattern.parts
|
||||
.zipWith(Glob.split(in, Glob.separator))(_.matches(caseSensitive)(_))
|
||||
.zipWith(input)(_.matches(caseSensitive)(_))
|
||||
.forall(identity)
|
||||
}
|
||||
|
||||
def matchFilenameOrPath(in: String): Boolean =
|
||||
if (pattern.parts.tail.isEmpty) matches(true)(split(in, separator).last)
|
||||
@ -67,6 +78,8 @@ object Glob {
|
||||
|
||||
def asString: String =
|
||||
pattern.asString
|
||||
|
||||
override def toString = s"PatternGlob($asString)"
|
||||
}
|
||||
|
||||
final private case class AnyGlob(globs: NonEmptyList[Glob]) extends Glob {
|
||||
@ -76,6 +89,8 @@ object Glob {
|
||||
globs.exists(_.matchFilenameOrPath(in))
|
||||
def asString =
|
||||
globs.toList.map(_.asString).mkString(anyChar.toString)
|
||||
|
||||
override def toString = s"AnyGlob($globs)"
|
||||
}
|
||||
|
||||
case class Pattern(parts: NonEmptyList[Segment]) {
|
||||
|
@ -26,6 +26,9 @@ case class Ident(id: String) {
|
||||
|
||||
def /(next: Ident): Ident =
|
||||
new Ident(id + Ident.concatChar + next.id)
|
||||
|
||||
def take(n: Int): Ident =
|
||||
new Ident(id.take(n))
|
||||
}
|
||||
|
||||
object Ident {
|
||||
|
@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.common
|
||||
|
||||
import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder}
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
/** Arguments to submit a task that runs addons configured for some existing item.
|
||||
*
|
||||
* If `addonTaskIds` is non empty, only these addon tasks are run. Otherwise all addon
|
||||
* tasks that are configured for 'existing-item' are run.
|
||||
*/
|
||||
final case class ItemAddonTaskArgs(
|
||||
collective: Ident,
|
||||
itemId: Ident,
|
||||
addonRunConfigs: Set[Ident]
|
||||
)
|
||||
|
||||
object ItemAddonTaskArgs {
|
||||
val taskName: Ident = Ident.unsafe("addon-existing-item")
|
||||
|
||||
implicit val jsonDecoder: Decoder[ItemAddonTaskArgs] = deriveDecoder
|
||||
implicit val jsonEncoder: Encoder[ItemAddonTaskArgs] = deriveEncoder
|
||||
}
|
@ -6,6 +6,8 @@
|
||||
|
||||
package docspell.common
|
||||
|
||||
import fs2.io.file.Path
|
||||
|
||||
case class MimeTypeHint(filename: Option[String], advertised: Option[String]) {
|
||||
|
||||
def withName(name: String): MimeTypeHint =
|
||||
@ -21,6 +23,9 @@ object MimeTypeHint {
|
||||
def filename(name: String): MimeTypeHint =
|
||||
MimeTypeHint(Some(name), None)
|
||||
|
||||
def filename(file: Path): MimeTypeHint =
|
||||
filename(file.fileName.toString)
|
||||
|
||||
def advertised(mimeType: MimeType): MimeTypeHint =
|
||||
advertised(mimeType.asString)
|
||||
|
||||
|
@ -17,7 +17,7 @@ import io.circe.generic.semiauto._
|
||||
* 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
|
||||
* If the `itemId' is set to some value, the item is tried to load to amend with the
|
||||
* given files. Otherwise a new item is created.
|
||||
*
|
||||
* It is also re-used by the 'ReProcessItem' task.
|
||||
|
@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.common
|
||||
|
||||
import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder}
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
final case class ScheduledAddonTaskArgs(collective: Ident, addonTaskId: Ident)
|
||||
|
||||
object ScheduledAddonTaskArgs {
|
||||
val taskName: Ident = Ident.unsafe("addon-scheduled-task")
|
||||
|
||||
implicit val jsonDecoder: Decoder[ScheduledAddonTaskArgs] = deriveDecoder
|
||||
implicit val jsonEncoder: Encoder[ScheduledAddonTaskArgs] = deriveEncoder
|
||||
}
|
@ -17,11 +17,23 @@ import cats.implicits._
|
||||
import fs2.io.file.Path
|
||||
import fs2.{Stream, io, text}
|
||||
|
||||
import docspell.common.{exec => newExec}
|
||||
import docspell.logging.Logger
|
||||
|
||||
// better use `SysCmd` and `SysExec`
|
||||
object SystemCommand {
|
||||
|
||||
final case class Config(program: String, args: Seq[String], timeout: Duration) {
|
||||
final case class Config(
|
||||
program: String,
|
||||
args: Seq[String],
|
||||
timeout: Duration,
|
||||
env: Map[String, String] = Map.empty
|
||||
) {
|
||||
|
||||
def toSysCmd = newExec
|
||||
.SysCmd(program, newExec.Args(args))
|
||||
.withTimeout(timeout)
|
||||
.addEnv(newExec.Env(env))
|
||||
|
||||
def mapArgs(f: String => String): Config =
|
||||
Config(program, args.map(f), timeout)
|
||||
@ -33,6 +45,18 @@ object SystemCommand {
|
||||
}
|
||||
)
|
||||
|
||||
def withEnv(key: String, value: String): Config =
|
||||
copy(env = env.updated(key, value))
|
||||
|
||||
def addEnv(moreEnv: Map[String, String]): Config =
|
||||
copy(env = env ++ moreEnv)
|
||||
|
||||
def appendArgs(extraArgs: Args): Config =
|
||||
copy(args = args ++ extraArgs.args)
|
||||
|
||||
def appendArgs(extraArgs: Seq[String]): Config =
|
||||
copy(args = args ++ extraArgs)
|
||||
|
||||
def toCmd: List[String] =
|
||||
program :: args.toList
|
||||
|
||||
@ -40,6 +64,45 @@ object SystemCommand {
|
||||
toCmd.mkString(" ")
|
||||
}
|
||||
|
||||
final case class Args(args: Vector[String]) extends Iterable[String] {
|
||||
override def iterator = args.iterator
|
||||
|
||||
def prepend(a: String): Args = Args(a +: args)
|
||||
|
||||
def prependWhen(flag: Boolean)(a: String): Args =
|
||||
prependOption(Option.when(flag)(a))
|
||||
|
||||
def prependOption(value: Option[String]): Args =
|
||||
value.map(prepend).getOrElse(this)
|
||||
|
||||
def append(a: String, as: String*): Args =
|
||||
Args(args ++ (a +: as.toVector))
|
||||
|
||||
def appendOption(value: Option[String]): Args =
|
||||
value.map(append(_)).getOrElse(this)
|
||||
|
||||
def appendOptionVal(first: String, second: Option[String]): Args =
|
||||
second.map(b => append(first, b)).getOrElse(this)
|
||||
|
||||
def appendWhen(flag: Boolean)(a: String, as: String*): Args =
|
||||
if (flag) append(a, as: _*) else this
|
||||
|
||||
def appendWhenNot(flag: Boolean)(a: String, as: String*): Args =
|
||||
if (!flag) append(a, as: _*) else this
|
||||
|
||||
def append(p: Path): Args =
|
||||
append(p.toString)
|
||||
|
||||
def append(as: Iterable[String]): Args =
|
||||
Args(args ++ as.toVector)
|
||||
}
|
||||
object Args {
|
||||
val empty: Args = Args()
|
||||
|
||||
def apply(as: String*): Args =
|
||||
Args(as.toVector)
|
||||
}
|
||||
|
||||
final case class Result(rc: Int, stdout: String, stderr: String)
|
||||
|
||||
def exec[F[_]: Sync](
|
||||
@ -104,6 +167,10 @@ object SystemCommand {
|
||||
.redirectError(Redirect.PIPE)
|
||||
.redirectOutput(Redirect.PIPE)
|
||||
|
||||
val pbEnv = pb.environment()
|
||||
cmd.env.foreach { case (key, value) =>
|
||||
pbEnv.put(key, value)
|
||||
}
|
||||
wd.map(_.toNioPath.toFile).foreach(pb.directory)
|
||||
pb.start()
|
||||
}
|
||||
|
@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.common
|
||||
|
||||
import cats.data.NonEmptyList
|
||||
import cats.kernel.Monoid
|
||||
import cats.syntax.all._
|
||||
|
||||
trait UrlMatcher {
|
||||
def matches(url: LenientUri): Boolean
|
||||
}
|
||||
|
||||
object UrlMatcher {
|
||||
val True = instance(_ => true)
|
||||
val False = instance(_ => false)
|
||||
|
||||
def instance(f: LenientUri => Boolean): UrlMatcher =
|
||||
(url: LenientUri) => f(url)
|
||||
|
||||
def fromString(str: String): Either[String, UrlMatcher] =
|
||||
if (str == "") False.asRight
|
||||
else if (str == "*") True.asRight
|
||||
else LenientUri.parse(str).map(fromUrl)
|
||||
|
||||
def unsafeFromString(str: String): UrlMatcher =
|
||||
fromString(str).fold(sys.error, identity)
|
||||
|
||||
def fromStringList(str: List[String]): Either[String, UrlMatcher] =
|
||||
str match {
|
||||
case Nil => False.asRight
|
||||
case _ => str.map(_.trim).traverse(fromString).map(_.combineAll)
|
||||
}
|
||||
|
||||
def fromUrl(url: LenientUri): UrlMatcher = {
|
||||
val schemeGlob = Glob(url.scheme.head)
|
||||
val hostGlob = HostGlob(url.host)
|
||||
val pathGlob = Glob(url.path.asString)
|
||||
new Impl(schemeGlob, hostGlob, pathGlob, url.path.segments.size)
|
||||
}
|
||||
|
||||
def any(ulrm: IterableOnce[UrlMatcher]): UrlMatcher =
|
||||
anyMonoid.combineAll(ulrm)
|
||||
|
||||
def all(urlm: IterableOnce[UrlMatcher]): UrlMatcher =
|
||||
allMonoid.combineAll(urlm)
|
||||
|
||||
val anyMonoid: Monoid[UrlMatcher] =
|
||||
Monoid.instance(False, (a, b) => instance(url => a.matches(url) || b.matches(url)))
|
||||
|
||||
val allMonoid: Monoid[UrlMatcher] =
|
||||
Monoid.instance(True, (a, b) => instance(url => a.matches(url) && b.matches(url)))
|
||||
|
||||
implicit val defaultMonoid: Monoid[UrlMatcher] = anyMonoid
|
||||
|
||||
private class Impl(scheme: Glob, host: HostGlob, path: Glob, pathSegmentCount: Int)
|
||||
extends UrlMatcher {
|
||||
def matches(url: LenientUri) = {
|
||||
// strip path to only match prefixes
|
||||
val mPath: LenientUri.Path =
|
||||
NonEmptyList.fromList(url.path.segments.take(pathSegmentCount)) match {
|
||||
case Some(nel) => LenientUri.NonEmptyPath(nel)
|
||||
case None => LenientUri.RootPath
|
||||
}
|
||||
|
||||
url.scheme.forall(scheme.matches(false)) &&
|
||||
host.matches(url.host) &&
|
||||
path.matchFilenameOrPath(mPath.asString)
|
||||
}
|
||||
}
|
||||
|
||||
private class HostGlob(glob: Option[Glob]) {
|
||||
def matches(host: Option[String]): Boolean =
|
||||
(glob, host) match {
|
||||
case (Some(pattern), Some(word)) =>
|
||||
pattern.matches(false)(HostGlob.prepareHost(word))
|
||||
case (None, None) => true
|
||||
case _ => false
|
||||
}
|
||||
|
||||
override def toString = s"HostGlob(${glob.map(_.asString)})"
|
||||
}
|
||||
|
||||
private object HostGlob {
|
||||
def apply(hostPattern: Option[String]): HostGlob =
|
||||
new HostGlob(hostPattern.map(p => Glob(prepareHost(p))))
|
||||
|
||||
private def prepareHost(host: String): String =
|
||||
host.replace('.', '/')
|
||||
}
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.common
|
||||
|
||||
import cats.ApplicativeError
|
||||
import cats.effect._
|
||||
import fs2.Stream
|
||||
|
||||
trait UrlReader[F[_]] {
|
||||
def apply(url: LenientUri): Stream[F, Byte]
|
||||
}
|
||||
|
||||
object UrlReader {
|
||||
|
||||
def instance[F[_]](f: LenientUri => Stream[F, Byte]): UrlReader[F] =
|
||||
(url: LenientUri) => f(url)
|
||||
|
||||
def failWith[F[_]](
|
||||
message: String
|
||||
)(implicit F: ApplicativeError[F, Throwable]): UrlReader[F] =
|
||||
instance(url =>
|
||||
Stream.raiseError(
|
||||
new IllegalStateException(s"Unable to read '${url.asString}': $message")
|
||||
)
|
||||
)
|
||||
|
||||
def apply[F[_]](implicit r: UrlReader[F]): UrlReader[F] = r
|
||||
|
||||
implicit def defaultReader[F[_]: Sync]: UrlReader[F] =
|
||||
instance(_.readURL[F](8192))
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.common.bc
|
||||
|
||||
import io.circe.generic.extras.Configuration
|
||||
import io.circe.generic.extras.semiauto.{deriveConfiguredDecoder, deriveConfiguredEncoder}
|
||||
import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder}
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
sealed trait AttachmentAction {}
|
||||
|
||||
object AttachmentAction {
|
||||
|
||||
implicit val deriveConfig: Configuration =
|
||||
Configuration.default.withDiscriminator("action").withKebabCaseConstructorNames
|
||||
|
||||
case class SetExtractedText(text: Option[String]) extends AttachmentAction
|
||||
object SetExtractedText {
|
||||
implicit val jsonDecoder: Decoder[SetExtractedText] = deriveDecoder
|
||||
implicit val jsonEncoder: Encoder[SetExtractedText] = deriveEncoder
|
||||
}
|
||||
|
||||
implicit val jsonDecoder: Decoder[AttachmentAction] = deriveConfiguredDecoder
|
||||
implicit val jsonEncoder: Encoder[AttachmentAction] = deriveConfiguredEncoder
|
||||
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.common.bc
|
||||
|
||||
import docspell.common.Ident
|
||||
|
||||
import io.circe.generic.extras.Configuration
|
||||
import io.circe.generic.extras.semiauto.{deriveConfiguredDecoder, deriveConfiguredEncoder}
|
||||
import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder}
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
sealed trait BackendCommand {}
|
||||
|
||||
object BackendCommand {
|
||||
|
||||
implicit val deriveConfig: Configuration =
|
||||
Configuration.default.withDiscriminator("command").withKebabCaseConstructorNames
|
||||
|
||||
case class ItemUpdate(itemId: Ident, actions: List[ItemAction]) extends BackendCommand
|
||||
object ItemUpdate {
|
||||
implicit val jsonDecoder: Decoder[ItemUpdate] = deriveDecoder
|
||||
implicit val jsonEncoder: Encoder[ItemUpdate] = deriveEncoder
|
||||
}
|
||||
|
||||
def item(itemId: Ident, actions: List[ItemAction]): BackendCommand =
|
||||
ItemUpdate(itemId, actions)
|
||||
|
||||
case class AttachmentUpdate(
|
||||
itemId: Ident,
|
||||
attachId: Ident,
|
||||
actions: List[AttachmentAction]
|
||||
) extends BackendCommand
|
||||
object AttachmentUpdate {
|
||||
implicit val jsonDecoder: Decoder[AttachmentUpdate] = deriveDecoder
|
||||
implicit val jsonEncoder: Encoder[AttachmentUpdate] = deriveEncoder
|
||||
}
|
||||
|
||||
implicit val jsonDecoder: Decoder[BackendCommand] = deriveConfiguredDecoder
|
||||
implicit val jsonEncoder: Encoder[BackendCommand] = deriveConfiguredEncoder
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.common.bc
|
||||
|
||||
import docspell.common.Ident
|
||||
|
||||
trait BackendCommandRunner[F[_], A] {
|
||||
|
||||
def run(collective: Ident, cmd: BackendCommand): F[A]
|
||||
|
||||
def runAll(collective: Ident, cmds: List[BackendCommand]): F[A]
|
||||
|
||||
}
|
@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.common.bc
|
||||
|
||||
import docspell.common.Ident
|
||||
|
||||
import io.circe.generic.extras.Configuration
|
||||
import io.circe.generic.extras.semiauto.{deriveConfiguredDecoder, deriveConfiguredEncoder}
|
||||
import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder}
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
sealed trait ItemAction {}
|
||||
|
||||
object ItemAction {
|
||||
implicit val deriveConfig: Configuration =
|
||||
Configuration.default.withDiscriminator("action").withKebabCaseConstructorNames
|
||||
|
||||
case class AddTags(tags: Set[String]) extends ItemAction
|
||||
object AddTags {
|
||||
implicit val jsonDecoder: Decoder[AddTags] = deriveDecoder
|
||||
implicit val jsonEncoder: Encoder[AddTags] = deriveEncoder
|
||||
}
|
||||
|
||||
case class ReplaceTags(tags: Set[String]) extends ItemAction
|
||||
object ReplaceTags {
|
||||
implicit val jsonDecoder: Decoder[ReplaceTags] = deriveDecoder
|
||||
implicit val jsonEncoder: Encoder[ReplaceTags] = deriveEncoder
|
||||
}
|
||||
|
||||
case class RemoveTags(tags: Set[String]) extends ItemAction
|
||||
object RemoveTags {
|
||||
implicit val jsonDecoder: Decoder[RemoveTags] = deriveDecoder
|
||||
implicit val jsonEncoder: Encoder[RemoveTags] = deriveEncoder
|
||||
}
|
||||
|
||||
case class RemoveTagsCategory(categories: Set[String]) extends ItemAction
|
||||
object RemoveTagsCategory {
|
||||
implicit val jsonDecoder: Decoder[RemoveTagsCategory] = deriveDecoder
|
||||
implicit val jsonEncoder: Encoder[RemoveTagsCategory] = deriveEncoder
|
||||
}
|
||||
|
||||
case class SetFolder(folder: Option[String]) extends ItemAction
|
||||
object SetFolder {
|
||||
implicit val jsonDecoder: Decoder[SetFolder] = deriveDecoder
|
||||
implicit val jsonEncoder: Encoder[SetFolder] = deriveEncoder
|
||||
}
|
||||
|
||||
case class SetCorrOrg(id: Option[Ident]) extends ItemAction
|
||||
object SetCorrOrg {
|
||||
implicit val jsonDecoder: Decoder[SetCorrOrg] = deriveDecoder
|
||||
implicit val jsonEncoder: Encoder[SetCorrOrg] = deriveEncoder
|
||||
}
|
||||
|
||||
case class SetCorrPerson(id: Option[Ident]) extends ItemAction
|
||||
object SetCorrPerson {
|
||||
implicit val jsonDecoder: Decoder[SetCorrPerson] = deriveDecoder
|
||||
implicit val jsonEncoder: Encoder[SetCorrPerson] = deriveEncoder
|
||||
}
|
||||
|
||||
case class SetConcPerson(id: Option[Ident]) extends ItemAction
|
||||
object SetConcPerson {
|
||||
implicit val jsonDecoder: Decoder[SetConcPerson] = deriveDecoder
|
||||
implicit val jsonEncoder: Encoder[SetConcPerson] = deriveEncoder
|
||||
}
|
||||
|
||||
case class SetConcEquipment(id: Option[Ident]) extends ItemAction
|
||||
object SetConcEquipment {
|
||||
implicit val jsonDecoder: Decoder[SetConcEquipment] = deriveDecoder
|
||||
implicit val jsonEncoder: Encoder[SetConcEquipment] = deriveEncoder
|
||||
}
|
||||
|
||||
case class SetField(field: Ident, value: String) extends ItemAction
|
||||
object SetField {
|
||||
implicit val jsonDecoder: Decoder[SetField] = deriveDecoder
|
||||
implicit val jsonEncoder: Encoder[SetField] = deriveEncoder
|
||||
}
|
||||
|
||||
case class SetName(name: String) extends ItemAction
|
||||
object SetName {
|
||||
implicit val jsonDecoder: Decoder[SetName] = deriveDecoder
|
||||
implicit val jsonEncoder: Encoder[SetName] = deriveEncoder
|
||||
}
|
||||
|
||||
case class SetNotes(notes: Option[String]) extends ItemAction
|
||||
object SetNotes {
|
||||
implicit val jsonDecoder: Decoder[SetNotes] = deriveDecoder
|
||||
implicit val jsonEncoder: Encoder[SetNotes] = deriveEncoder
|
||||
}
|
||||
|
||||
case class AddNotes(notes: String, separator: Option[String]) extends ItemAction
|
||||
object AddNotes {
|
||||
implicit val jsonDecoder: Decoder[AddNotes] = deriveDecoder
|
||||
implicit val jsonEncoder: Encoder[AddNotes] = deriveEncoder
|
||||
}
|
||||
|
||||
implicit val jsonDecoder: Decoder[ItemAction] = deriveConfiguredDecoder
|
||||
implicit val jsonEncoder: Encoder[ItemAction] = deriveConfiguredEncoder
|
||||
}
|
@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.common.exec
|
||||
|
||||
import fs2.io.file.Path
|
||||
|
||||
case class Args(values: Seq[String]) {
|
||||
|
||||
def option(key: String, value: String): Args =
|
||||
Args(values ++ Seq(key, value))
|
||||
|
||||
def option(key: String, value: Option[String]): Args =
|
||||
value.map(v => option(key, v)).getOrElse(this)
|
||||
|
||||
def appendOpt(v: Option[String]): Args =
|
||||
v.map(e => Args(values :+ e)).getOrElse(this)
|
||||
|
||||
def append(v: String, vs: String*): Args =
|
||||
Args(values ++ (v +: vs))
|
||||
|
||||
def append(path: Path): Args =
|
||||
append(path.toString)
|
||||
|
||||
def append(args: Args): Args =
|
||||
Args(values ++ args.values)
|
||||
|
||||
def append(args: Seq[String]): Args =
|
||||
Args(values ++ args)
|
||||
|
||||
def prepend(v: String): Args =
|
||||
Args(v +: values)
|
||||
|
||||
def prependWhen(flag: Boolean)(v: String) =
|
||||
if (flag) prepend(v) else this
|
||||
|
||||
def cmdString: String =
|
||||
values.mkString(" ")
|
||||
}
|
||||
|
||||
object Args {
|
||||
val empty: Args = Args(Seq.empty)
|
||||
|
||||
def of(v: String*): Args =
|
||||
Args(v)
|
||||
}
|
37
modules/common/src/main/scala/docspell/common/exec/Env.scala
Normal file
37
modules/common/src/main/scala/docspell/common/exec/Env.scala
Normal file
@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.common.exec
|
||||
|
||||
case class Env(values: Map[String, String]) {
|
||||
|
||||
def add(name: String, value: String): Env =
|
||||
copy(values.updated(name, value))
|
||||
|
||||
def addAll(v: Map[String, String]): Env =
|
||||
Env(values ++ v)
|
||||
|
||||
def addAll(e: Env): Env =
|
||||
Env(values ++ e.values)
|
||||
|
||||
def ++(e: Env) = addAll(e)
|
||||
|
||||
def foreach(f: (String, String) => Unit): Unit =
|
||||
values.foreach(t => f(t._1, t._2))
|
||||
|
||||
def map[A](f: (String, String) => A): Seq[A] =
|
||||
values.map(f.tupled).toSeq
|
||||
|
||||
def mapConcat[A](f: (String, String) => Seq[A]): Seq[A] =
|
||||
values.flatMap(f.tupled).toSeq
|
||||
}
|
||||
|
||||
object Env {
|
||||
val empty: Env = Env(Map.empty)
|
||||
|
||||
def of(nv: (String, String)*): Env =
|
||||
Env(Map(nv: _*))
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.common.exec
|
||||
|
||||
import docspell.common._
|
||||
|
||||
final case class SysCmd(
|
||||
program: String,
|
||||
args: Args,
|
||||
env: Env,
|
||||
timeout: Duration
|
||||
) {
|
||||
|
||||
def withArgs(f: Args => Args): SysCmd =
|
||||
copy(args = f(args))
|
||||
|
||||
def withTimeout(to: Duration): SysCmd =
|
||||
copy(timeout = to)
|
||||
|
||||
def withEnv(f: Env => Env): SysCmd =
|
||||
copy(env = f(env))
|
||||
|
||||
def addEnv(env: Env): SysCmd =
|
||||
withEnv(_.addAll(env))
|
||||
|
||||
def cmdString: String =
|
||||
s"$program ${args.cmdString}"
|
||||
|
||||
private[exec] def toCmd: Seq[String] =
|
||||
program +: args.values
|
||||
}
|
||||
|
||||
object SysCmd {
|
||||
def apply(prg: String, args: String*): SysCmd =
|
||||
apply(prg, Args(args))
|
||||
|
||||
def apply(prg: String, args: Args): SysCmd =
|
||||
SysCmd(prg, args, Env.empty, Duration.minutes(2))
|
||||
}
|
163
modules/common/src/main/scala/docspell/common/exec/SysExec.scala
Normal file
163
modules/common/src/main/scala/docspell/common/exec/SysExec.scala
Normal file
@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.common.exec
|
||||
|
||||
import java.lang.ProcessBuilder.Redirect
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
import scala.concurrent.TimeoutException
|
||||
import scala.jdk.CollectionConverters._
|
||||
|
||||
import cats.effect._
|
||||
import cats.syntax.all._
|
||||
import fs2.io.file.Path
|
||||
import fs2.{Pipe, Stream}
|
||||
|
||||
import docspell.common.Duration
|
||||
import docspell.logging.Logger
|
||||
|
||||
trait SysExec[F[_]] {
|
||||
|
||||
def stdout: Stream[F, Byte]
|
||||
|
||||
def stdoutLines: Stream[F, String] =
|
||||
stdout
|
||||
.through(fs2.text.utf8.decode)
|
||||
.through(fs2.text.lines)
|
||||
|
||||
def stderr: Stream[F, Byte]
|
||||
|
||||
def stderrLines: Stream[F, String] =
|
||||
stderr
|
||||
.through(fs2.text.utf8.decode)
|
||||
.through(fs2.text.lines)
|
||||
|
||||
def waitFor(timeout: Option[Duration] = None): F[Int]
|
||||
|
||||
/** Sends a signal to the process to terminate it immediately */
|
||||
def cancel: F[Unit]
|
||||
|
||||
/** Consume lines of output of the process in background. */
|
||||
def consumeOutputs(out: Pipe[F, String, Unit], err: Pipe[F, String, Unit])(implicit
|
||||
F: Async[F]
|
||||
): Resource[F, SysExec[F]]
|
||||
|
||||
/** Consumes stderr lines (left) and stdout lines (right) in a background thread. */
|
||||
def consumeOutputs(
|
||||
m: Either[String, String] => F[Unit]
|
||||
)(implicit F: Async[F]): Resource[F, SysExec[F]] = {
|
||||
val pe: Pipe[F, String, Unit] = _.map(_.asLeft).evalMap(m)
|
||||
val po: Pipe[F, String, Unit] = _.map(_.asRight).evalMap(m)
|
||||
consumeOutputs(po, pe)
|
||||
}
|
||||
|
||||
def logOutputs(logger: Logger[F], name: String)(implicit F: Async[F]) =
|
||||
consumeOutputs {
|
||||
case Right(line) => logger.debug(s"[$name (out)]: $line")
|
||||
case Left(line) => logger.debug(s"[$name (err)]: $line")
|
||||
}
|
||||
}
|
||||
|
||||
object SysExec {
|
||||
private val readChunkSz = 8 * 1024
|
||||
|
||||
def apply[F[_]: Sync](
|
||||
cmd: SysCmd,
|
||||
logger: Logger[F],
|
||||
workdir: Option[Path] = None,
|
||||
stdin: Option[Stream[F, Byte]] = None
|
||||
): Resource[F, SysExec[F]] =
|
||||
for {
|
||||
proc <- startProcess(logger, cmd, workdir, stdin)
|
||||
fibers <- Resource.eval(Ref.of[F, List[F[Unit]]](Nil))
|
||||
} yield new SysExec[F] {
|
||||
def stdout: Stream[F, Byte] =
|
||||
fs2.io.readInputStream(
|
||||
Sync[F].blocking(proc.getInputStream),
|
||||
readChunkSz,
|
||||
closeAfterUse = false
|
||||
)
|
||||
|
||||
def stderr: Stream[F, Byte] =
|
||||
fs2.io.readInputStream(
|
||||
Sync[F].blocking(proc.getErrorStream),
|
||||
readChunkSz,
|
||||
closeAfterUse = false
|
||||
)
|
||||
|
||||
def cancel = Sync[F].blocking(proc.destroy())
|
||||
|
||||
def waitFor(timeout: Option[Duration]): F[Int] = {
|
||||
val to = timeout.getOrElse(cmd.timeout)
|
||||
logger.trace("Waiting for command to terminate…") *>
|
||||
Sync[F]
|
||||
.blocking(proc.waitFor(to.millis, TimeUnit.MILLISECONDS))
|
||||
.flatTap(_ => fibers.get.flatMap(_.traverse_(identity)))
|
||||
.flatMap(terminated =>
|
||||
if (terminated) proc.exitValue().pure[F]
|
||||
else
|
||||
Sync[F]
|
||||
.raiseError(
|
||||
new TimeoutException(s"Timed out after: ${to.formatExact}")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
def consumeOutputs(out: Pipe[F, String, Unit], err: Pipe[F, String, Unit])(implicit
|
||||
F: Async[F]
|
||||
): Resource[F, SysExec[F]] =
|
||||
for {
|
||||
f1 <- F.background(stdoutLines.through(out).compile.drain)
|
||||
f2 <- F.background(stderrLines.through(err).compile.drain)
|
||||
_ <- Resource.eval(fibers.update(list => f1.void :: f2.void :: list))
|
||||
} yield this
|
||||
}
|
||||
|
||||
private def startProcess[F[_]: Sync, A](
|
||||
logger: Logger[F],
|
||||
cmd: SysCmd,
|
||||
workdir: Option[Path],
|
||||
stdin: Option[Stream[F, Byte]]
|
||||
): Resource[F, Process] = {
|
||||
val log = logger.debug(s"Running external command: ${cmd.cmdString}")
|
||||
|
||||
val proc = log *>
|
||||
Sync[F].blocking {
|
||||
val pb = new ProcessBuilder(cmd.toCmd.asJava)
|
||||
.redirectInput(if (stdin.isDefined) Redirect.PIPE else Redirect.INHERIT)
|
||||
.redirectError(Redirect.PIPE)
|
||||
.redirectOutput(Redirect.PIPE)
|
||||
|
||||
val pbEnv = pb.environment()
|
||||
cmd.env.foreach { (name, v) =>
|
||||
pbEnv.put(name, v)
|
||||
()
|
||||
}
|
||||
workdir.map(_.toNioPath.toFile).foreach(pb.directory)
|
||||
pb.start()
|
||||
}
|
||||
|
||||
Resource
|
||||
.make(proc)(p =>
|
||||
logger.debug(s"Closing process: `${cmd.cmdString}`").map(_ => p.destroy())
|
||||
)
|
||||
.evalMap(p =>
|
||||
stdin match {
|
||||
case Some(in) =>
|
||||
writeToProcess(in, p).compile.drain.as(p)
|
||||
case None =>
|
||||
p.pure[F]
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private def writeToProcess[F[_]: Sync](
|
||||
data: Stream[F, Byte],
|
||||
proc: Process
|
||||
): Stream[F, Nothing] =
|
||||
data.through(fs2.io.writeOutputStream(Sync[F].blocking(proc.getOutputStream)))
|
||||
}
|
@ -4,20 +4,18 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.common
|
||||
package docspell.common.util
|
||||
|
||||
import java.nio.file.{Path => JPath}
|
||||
|
||||
import cats.FlatMap
|
||||
import cats.Monad
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
import cats.syntax.all._
|
||||
import cats.{FlatMap, Monad}
|
||||
import fs2.Stream
|
||||
import fs2.io.file.{Files, Flags, Path}
|
||||
|
||||
import docspell.common.syntax.all._
|
||||
|
||||
import io.circe.Decoder
|
||||
import io.circe.parser
|
||||
|
||||
object File {
|
||||
|
||||
@ -75,6 +73,5 @@ object File {
|
||||
.map(_ => file)
|
||||
|
||||
def readJson[F[_]: Async, A](file: Path)(implicit d: Decoder[A]): F[A] =
|
||||
readText[F](file).map(_.parseJsonAs[A]).rethrow
|
||||
|
||||
readText[F](file).map(parser.decode[A]).rethrow
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.common.util
|
||||
|
||||
import cats.effect._
|
||||
|
||||
import scodec.bits.ByteVector
|
||||
|
||||
trait Random[F[_]] {
|
||||
def string(len: Int): F[String]
|
||||
def string: F[String] = string(8)
|
||||
}
|
||||
|
||||
object Random {
|
||||
def apply[F[_]: Sync] =
|
||||
new Random[F] {
|
||||
def string(len: Int) = Sync[F].delay {
|
||||
val buf = Array.ofDim[Byte](len)
|
||||
new scala.util.Random().nextBytes(buf)
|
||||
ByteVector.view(buf).toBase58
|
||||
}
|
||||
}
|
||||
}
|
@ -70,11 +70,13 @@ class GlobTest extends FunSuite {
|
||||
|
||||
test("with splitting") {
|
||||
assert(Glob("a/b/*").matches(true)("a/b/hello"))
|
||||
assert(!Glob("a/b/*").matches(true)("a/b/hello/bello"))
|
||||
assert(!Glob("a/b/*").matches(true)("/a/b/hello"))
|
||||
assert(Glob("/a/b/*").matches(true)("/a/b/hello"))
|
||||
assert(!Glob("/a/b/*").matches(true)("a/b/hello"))
|
||||
assert(!Glob("*/a/b/*").matches(true)("a/b/hello"))
|
||||
assert(Glob("*/a/b/*").matches(true)("test/a/b/hello"))
|
||||
assert(!Glob("/a/b").matches(true)("/a/b/c/d"))
|
||||
}
|
||||
|
||||
test("asString") {
|
||||
|
@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.common
|
||||
|
||||
import munit._
|
||||
|
||||
class UrlMatcherTest extends FunSuite {
|
||||
|
||||
test("it should match patterns") {
|
||||
assertUrlsMatch(
|
||||
uri("https://github.com/docspell/*") -> uri("https://github.com/docspell/dsc"),
|
||||
uri("*s://test.com/*") -> uri("https://test.com/a"),
|
||||
uri("*s://test.com/*") -> uri("https://test.com/a/b"),
|
||||
uri("*s://test.com/*") -> uri("https://test.com/a/b/c"),
|
||||
uri("*s://test.com/project/*") -> uri("https://test.com/project/c"),
|
||||
uri("https://*.test.com/projects/*") -> uri("https://a.test.com/projects/p1"),
|
||||
uri("https://*.test.com/projects/*") -> uri("https://b.test.com/projects/p1"),
|
||||
uri("https://*.test.com/projects/*") -> uri("https://b.test.com/projects/p1")
|
||||
)
|
||||
|
||||
assertUrlsNotMatch(
|
||||
uri("https://*.test.com/projects/*") -> uri("https://test.com/projects/p1"),
|
||||
uri("*s://test.com/project/*") -> uri("https://test.com/subject/c")
|
||||
)
|
||||
}
|
||||
|
||||
def uri(str: String): LenientUri = LenientUri.unsafe(str)
|
||||
|
||||
def assertUrlsMatch(tests: List[(LenientUri, LenientUri)]): Unit =
|
||||
tests.foreach { case (patternUri, checkUri) =>
|
||||
assert(
|
||||
UrlMatcher.fromUrl(patternUri).matches(checkUri),
|
||||
s"$patternUri does not match $checkUri"
|
||||
)
|
||||
}
|
||||
|
||||
def assertUrlsMatch(
|
||||
test: (LenientUri, LenientUri),
|
||||
more: (LenientUri, LenientUri)*
|
||||
): Unit =
|
||||
assertUrlsMatch(test :: more.toList)
|
||||
|
||||
def assertUrlsNotMatch(tests: List[(LenientUri, LenientUri)]): Unit =
|
||||
tests.foreach { case (patternUri, checkUri) =>
|
||||
assert(
|
||||
!UrlMatcher.fromUrl(patternUri).matches(checkUri),
|
||||
s"$patternUri incorrectly matches $checkUri"
|
||||
)
|
||||
}
|
||||
|
||||
def assertUrlsNotMatch(
|
||||
test: (LenientUri, LenientUri),
|
||||
more: (LenientUri, LenientUri)*
|
||||
): Unit =
|
||||
assertUrlsNotMatch(test :: more.toList)
|
||||
}
|
@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.common.bc
|
||||
|
||||
import docspell.common._
|
||||
|
||||
import io.circe.parser
|
||||
import io.circe.syntax._
|
||||
import munit._
|
||||
|
||||
class BackendCommandTest extends FunSuite {
|
||||
|
||||
test("encode json") {
|
||||
val bc: BackendCommand =
|
||||
BackendCommand.item(
|
||||
id("abc"),
|
||||
List(
|
||||
ItemAction.RemoveTagsCategory(Set("doctype")),
|
||||
ItemAction.AddTags(Set("tag1", "tag2"))
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
bc.asJson.spaces2,
|
||||
"""{
|
||||
| "itemId" : "abc",
|
||||
| "actions" : [
|
||||
| {
|
||||
| "categories" : [
|
||||
| "doctype"
|
||||
| ],
|
||||
| "action" : "remove-tags-category"
|
||||
| },
|
||||
| {
|
||||
| "tags" : [
|
||||
| "tag1",
|
||||
| "tag2"
|
||||
| ],
|
||||
| "action" : "add-tags"
|
||||
| }
|
||||
| ],
|
||||
| "command" : "item-update"
|
||||
|}""".stripMargin
|
||||
)
|
||||
}
|
||||
|
||||
test("decode case insensitive keys") {
|
||||
val json = """{
|
||||
| "itemId" : "abc",
|
||||
| "actions" : [
|
||||
| {
|
||||
| "categories" : [
|
||||
| "doctype"
|
||||
| ],
|
||||
| "action" : "remove-tags-category"
|
||||
| },
|
||||
| {
|
||||
| "tags" : [
|
||||
| "tag1",
|
||||
| "tag2"
|
||||
| ],
|
||||
| "action" : "add-tags"
|
||||
| }
|
||||
| ],
|
||||
| "command" : "item-update"
|
||||
|}""".stripMargin
|
||||
|
||||
val bc: BackendCommand =
|
||||
BackendCommand.item(
|
||||
id("abc"),
|
||||
List(
|
||||
ItemAction.RemoveTagsCategory(Set("doctype")),
|
||||
ItemAction.AddTags(Set("tag1", "tag2"))
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(parser.decode[BackendCommand](json), Right(bc))
|
||||
}
|
||||
|
||||
def id(str: String) = Ident.unsafe(str)
|
||||
}
|
@ -13,6 +13,7 @@ import scala.reflect.ClassTag
|
||||
import cats.syntax.all._
|
||||
import fs2.io.file.Path
|
||||
|
||||
import docspell.addons.RunnerType
|
||||
import docspell.common._
|
||||
import docspell.ftspsql.{PgQueryParser, RankNormalization}
|
||||
import docspell.logging.{Level, LogConfig}
|
||||
@ -32,6 +33,17 @@ object Implicits {
|
||||
else super.fieldValue(name)
|
||||
}
|
||||
|
||||
implicit val urlMatcherReader: ConfigReader[UrlMatcher] = {
|
||||
val fromList = ConfigReader[List[String]].emap(reason(UrlMatcher.fromStringList))
|
||||
val fromString = ConfigReader[String].emap(
|
||||
reason(str => UrlMatcher.fromStringList(str.split("[\\s,]+").toList))
|
||||
)
|
||||
fromList.orElse(fromString)
|
||||
}
|
||||
|
||||
implicit val runnerSelectReader: ConfigReader[List[RunnerType]] =
|
||||
ConfigReader[String].emap(reason(RunnerType.fromSeparatedString))
|
||||
|
||||
implicit val accountIdReader: ConfigReader[AccountId] =
|
||||
ConfigReader[String].emap(reason(AccountId.parse))
|
||||
|
||||
|
@ -12,6 +12,7 @@ import fs2.io.file.{Files, Path}
|
||||
import fs2.{Pipe, Stream}
|
||||
|
||||
import docspell.common._
|
||||
import docspell.common.util.File
|
||||
import docspell.convert.ConversionResult
|
||||
import docspell.convert.ConversionResult.{Handler, successPdf, successPdfTxt}
|
||||
import docspell.logging.Logger
|
||||
|
@ -15,6 +15,7 @@ import cats.implicits._
|
||||
import fs2.Stream
|
||||
|
||||
import docspell.common._
|
||||
import docspell.common.util.File
|
||||
import docspell.convert.ConversionResult.Handler
|
||||
import docspell.convert.extern.OcrMyPdfConfig
|
||||
import docspell.convert.extern.{TesseractConfig, UnoconvConfig, WkHtmlPdfConfig}
|
||||
|
@ -18,6 +18,7 @@ import fs2.io.file.Path
|
||||
import fs2.{Pipe, Stream}
|
||||
|
||||
import docspell.common._
|
||||
import docspell.common.util.File
|
||||
import docspell.convert.ConversionResult.Handler
|
||||
import docspell.files.TikaMimetype
|
||||
|
||||
|
@ -14,6 +14,7 @@ import cats.effect.unsafe.implicits.global
|
||||
import fs2.io.file.Path
|
||||
|
||||
import docspell.common._
|
||||
import docspell.common.util.File
|
||||
import docspell.convert._
|
||||
import docspell.files.ExampleFiles
|
||||
import docspell.logging.TestLoggingConfig
|
||||
|
@ -11,6 +11,7 @@ import fs2.Stream
|
||||
import fs2.io.file.Path
|
||||
|
||||
import docspell.common._
|
||||
import docspell.common.util.File
|
||||
import docspell.logging.Logger
|
||||
|
||||
object Ocr {
|
||||
|
@ -11,6 +11,7 @@ import java.nio.file.Paths
|
||||
import fs2.io.file.Path
|
||||
|
||||
import docspell.common._
|
||||
import docspell.common.util.File
|
||||
|
||||
case class OcrConfig(
|
||||
maxImageSize: Int,
|
||||
|
@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.files
|
||||
|
||||
import cats.data.OptionT
|
||||
import cats.effect.Sync
|
||||
import cats.syntax.all._
|
||||
import fs2.Stream
|
||||
import fs2.io.file.{Files, Path}
|
||||
|
||||
import docspell.common.{MimeType, MimeTypeHint}
|
||||
|
||||
import io.circe.Encoder
|
||||
import io.circe.syntax._
|
||||
|
||||
trait FileSupport {
|
||||
implicit final class FileOps[F[_]: Files: Sync](self: Path) {
|
||||
def detectMime: F[Option[MimeType]] =
|
||||
Files[F].isReadable(self).flatMap { flag =>
|
||||
OptionT
|
||||
.whenF(flag) {
|
||||
TikaMimetype
|
||||
.detect(
|
||||
Files[F].readAll(self),
|
||||
MimeTypeHint.filename(self.fileName.toString)
|
||||
)
|
||||
}
|
||||
.value
|
||||
}
|
||||
|
||||
def asTextFile(alt: MimeType => F[Unit]): F[Option[Path]] =
|
||||
OptionT(detectMime).flatMapF { mime =>
|
||||
if (mime.matches(MimeType.text("plain"))) self.some.pure[F]
|
||||
else alt(mime).as(None: Option[Path])
|
||||
}.value
|
||||
|
||||
def readText: F[String] =
|
||||
Files[F]
|
||||
.readAll(self)
|
||||
.through(fs2.text.utf8.decode)
|
||||
.compile
|
||||
.string
|
||||
|
||||
def readAll: Stream[F, Byte] =
|
||||
Files[F].readAll(self)
|
||||
|
||||
def writeJson[A: Encoder](value: A): F[Unit] =
|
||||
Stream
|
||||
.emit(value.asJson.noSpaces)
|
||||
.through(fs2.text.utf8.encode)
|
||||
.through(Files[F].writeAll(self))
|
||||
.compile
|
||||
.drain
|
||||
}
|
||||
}
|
||||
|
||||
object FileSupport extends FileSupport
|
@ -8,11 +8,12 @@ package docspell.files
|
||||
|
||||
import java.io.InputStream
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.nio.file.Paths
|
||||
import java.util.zip.{ZipEntry, ZipInputStream, ZipOutputStream}
|
||||
|
||||
import cats.data.OptionT
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
import fs2.io.file.{Files, Path}
|
||||
import fs2.{Pipe, Stream}
|
||||
|
||||
import docspell.common.Binary
|
||||
@ -27,16 +28,72 @@ object Zip {
|
||||
): Pipe[F, (String, Stream[F, Byte]), Byte] =
|
||||
in => zipJava(logger, chunkSize, in.through(deduplicate))
|
||||
|
||||
def unzipP[F[_]: Async](chunkSize: Int, glob: Glob): Pipe[F, Byte, Binary[F]] =
|
||||
s => unzip[F](chunkSize, glob)(s)
|
||||
def unzip[F[_]: Async](
|
||||
chunkSize: Int,
|
||||
glob: Glob
|
||||
): Pipe[F, Byte, Binary[F]] =
|
||||
s => unzipStream[F](chunkSize, glob)(s)
|
||||
|
||||
def unzip[F[_]: Async](chunkSize: Int, glob: Glob)(
|
||||
def unzipStream[F[_]: Async](chunkSize: Int, glob: Glob)(
|
||||
data: Stream[F, Byte]
|
||||
): Stream[F, Binary[F]] =
|
||||
data
|
||||
.through(fs2.io.toInputStream[F])
|
||||
.flatMap(in => unzipJava(in, chunkSize, glob))
|
||||
|
||||
def saveTo[F[_]: Async](
|
||||
logger: Logger[F],
|
||||
targetDir: Path,
|
||||
moveUp: Boolean
|
||||
): Pipe[F, Binary[F], Path] =
|
||||
binaries =>
|
||||
binaries
|
||||
.filter(e => !e.name.endsWith("/"))
|
||||
.evalMap { entry =>
|
||||
val out = targetDir / entry.name
|
||||
val createParent =
|
||||
OptionT
|
||||
.fromOption[F](out.parent)
|
||||
.flatMapF(parent =>
|
||||
Files[F]
|
||||
.exists(parent)
|
||||
.map(flag => Option.when(!flag)(parent))
|
||||
)
|
||||
.semiflatMap(p => Files[F].createDirectories(p))
|
||||
.getOrElse(())
|
||||
|
||||
logger.trace(s"Unzip ${entry.name} -> $out") *>
|
||||
createParent *>
|
||||
entry.data.through(Files[F].writeAll(out)).compile.drain
|
||||
}
|
||||
.drain ++ Stream
|
||||
.eval(if (moveUp) moveContentsUp(logger)(targetDir) else ().pure[F])
|
||||
.as(targetDir)
|
||||
|
||||
private def moveContentsUp[F[_]: Sync: Files](logger: Logger[F])(dir: Path): F[Unit] =
|
||||
Files[F]
|
||||
.list(dir)
|
||||
.take(2)
|
||||
.compile
|
||||
.toList
|
||||
.flatMap {
|
||||
case subdir :: Nil =>
|
||||
Files[F].isDirectory(subdir).flatMap {
|
||||
case false => ().pure[F]
|
||||
case true =>
|
||||
Files[F]
|
||||
.list(subdir)
|
||||
.filter(p => p != dir)
|
||||
.evalTap(c => logger.trace(s"Move $c -> ${dir / c.fileName}"))
|
||||
.evalMap(child => Files[F].move(child, dir / child.fileName))
|
||||
.compile
|
||||
.drain
|
||||
}
|
||||
|
||||
case _ =>
|
||||
().pure[F]
|
||||
}
|
||||
|
||||
def unzipJava[F[_]: Async](
|
||||
in: InputStream,
|
||||
chunkSize: Int,
|
||||
@ -55,7 +112,7 @@ object Zip {
|
||||
.unNoneTerminate
|
||||
.filter(ze => glob.matchFilenameOrPath(ze.getName()))
|
||||
.map { ze =>
|
||||
val name = Paths.get(ze.getName()).getFileName.toString
|
||||
val name = ze.getName()
|
||||
val data =
|
||||
fs2.io.readInputStream[F]((zin: InputStream).pure[F], chunkSize, false)
|
||||
Binary(name, data)
|
||||
|
BIN
modules/files/src/test/resources/zip-dirs-one.zip
Normal file
BIN
modules/files/src/test/resources/zip-dirs-one.zip
Normal file
Binary file not shown.
BIN
modules/files/src/test/resources/zip-dirs.zip
Normal file
BIN
modules/files/src/test/resources/zip-dirs.zip
Normal file
Binary file not shown.
@ -7,20 +7,25 @@
|
||||
package docspell.files
|
||||
|
||||
import cats.effect._
|
||||
import cats.effect.unsafe.implicits.global
|
||||
import cats.implicits._
|
||||
import fs2.io.file.{Files, Path}
|
||||
|
||||
import docspell.common.Glob
|
||||
import docspell.logging.TestLoggingConfig
|
||||
|
||||
import munit._
|
||||
|
||||
class ZipTest extends FunSuite {
|
||||
class ZipTest extends CatsEffectSuite with TestLoggingConfig {
|
||||
val logger = docspell.logging.getLogger[IO]
|
||||
val tempDir = ResourceFixture(
|
||||
Files[IO].tempDirectory(Path("target").some, "zip-test-", None)
|
||||
)
|
||||
|
||||
test("unzip") {
|
||||
val zipFile = ExampleFiles.letters_zip.readURL[IO](8192)
|
||||
val uncomp = zipFile.through(Zip.unzip(8192, Glob.all))
|
||||
val unzip = zipFile.through(Zip.unzip(8192, Glob.all))
|
||||
|
||||
uncomp
|
||||
unzip
|
||||
.evalMap { entry =>
|
||||
val x = entry.data.map(_ => 1).foldMonoid.compile.lastOrError
|
||||
x.map { size =>
|
||||
@ -35,6 +40,10 @@ class ZipTest extends FunSuite {
|
||||
}
|
||||
.compile
|
||||
.drain
|
||||
.unsafeRunSync()
|
||||
}
|
||||
|
||||
tempDir.test("unzipTo directory tree") { _ =>
|
||||
// val zipFile = ExampleFiles.zip_dirs_zip.readURL[IO](8192)
|
||||
// zipFile.through(Zip.unzip(G))
|
||||
}
|
||||
}
|
||||
|
@ -780,4 +780,75 @@ Docpell Update Check
|
||||
index-all-chunk = 10
|
||||
}
|
||||
}
|
||||
|
||||
addons {
|
||||
# A directory to extract addons when running them. Everything in
|
||||
# here will be cleared after each run.
|
||||
working-dir = ${java.io.tmpdir}"/docspell-addons"
|
||||
|
||||
# A directory for addons to store data between runs. This is not
|
||||
# cleared by Docspell and can get large depending on the addons
|
||||
# executed.
|
||||
#
|
||||
# This directory is used as base. In it subdirectories are created
|
||||
# per run configuration id.
|
||||
cache-dir = ${java.io.tmpdir}"/docspell-addon-cache"
|
||||
|
||||
executor-config {
|
||||
# Define a (comma or whitespace separated) list of runners that
|
||||
# are responsible for executing an addon. This setting is
|
||||
# compared to what is supported by addons. Possible values are:
|
||||
#
|
||||
# - nix-flake: use nix-flake runner if the addon supports it
|
||||
# (this requires the nix package manager on the joex machine)
|
||||
# - docker: use docker
|
||||
# - trivial: use the trivial runner
|
||||
#
|
||||
# The first successful execution is used. This should list all
|
||||
# runners the computer supports.
|
||||
runner = "nix-flake, docker, trivial"
|
||||
|
||||
# systemd-nspawn can be used to run the program in a container.
|
||||
# This is used by runners nix-flake and trivial.
|
||||
nspawn = {
|
||||
# If this is false, systemd-nspawn is not tried. When true, the
|
||||
# addon is executed inside a lightweight container via
|
||||
# systemd-nspawn.
|
||||
enabled = false
|
||||
|
||||
# Path to sudo command. By default systemd-nspawn is executed
|
||||
# via sudo - the user running joex must be allowed to do so NON
|
||||
# INTERACTIVELY. If this is empty, then nspawn is tried to
|
||||
# execute without sudo.
|
||||
sudo-binary = "sudo"
|
||||
|
||||
# Path to the systemd-nspawn command.
|
||||
nspawn-binary = "systemd-nspawn"
|
||||
|
||||
# Workaround, if multiple same named containers are run too fast
|
||||
container-wait = "100 millis"
|
||||
}
|
||||
|
||||
# The timeout for running an addon.
|
||||
run-timeout = "15 minutes"
|
||||
|
||||
# Configure the nix flake runner.
|
||||
nix-runner {
|
||||
# Path to the nix command.
|
||||
nix-binary = "nix"
|
||||
|
||||
# The timeout for building the package (running nix build).
|
||||
build-timeout = "15 minutes"
|
||||
}
|
||||
|
||||
# Configure the docker runner
|
||||
docker-runner {
|
||||
# Path to the docker command.
|
||||
docker-binary = "docker"
|
||||
|
||||
# The timeout for building the package (running docker build).
|
||||
build-timeout = "15 minutes"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -12,6 +12,7 @@ import fs2.io.file.Path
|
||||
import docspell.analysis.TextAnalysisConfig
|
||||
import docspell.analysis.classifier.TextClassifierConfig
|
||||
import docspell.backend.Config.Files
|
||||
import docspell.backend.joex.AddonEnvConfig
|
||||
import docspell.common._
|
||||
import docspell.config.{FtsType, PgFtsConfig}
|
||||
import docspell.convert.ConvertConfig
|
||||
@ -43,7 +44,8 @@ case class Config(
|
||||
files: Files,
|
||||
mailDebug: Boolean,
|
||||
fullTextSearch: Config.FullTextSearch,
|
||||
updateCheck: UpdateCheckConfig
|
||||
updateCheck: UpdateCheckConfig,
|
||||
addons: AddonEnvConfig
|
||||
) {
|
||||
|
||||
def pubSubConfig(headerValue: Ident): PubSubConfig =
|
||||
|
@ -145,6 +145,8 @@ object JoexAppImpl extends MailAddressCodec {
|
||||
schedulerModule.scheduler,
|
||||
schedulerModule.periodicScheduler
|
||||
)
|
||||
nodes <- ONode(store)
|
||||
_ <- nodes.withRegistered(cfg.appId, NodeType.Joex, cfg.baseUrl, None)
|
||||
appR <- Resource.make(app.init.map(_ => app))(_.initShutdown)
|
||||
} yield appR
|
||||
|
||||
|
@ -59,7 +59,7 @@ object JoexServer {
|
||||
Router("pubsub" -> pubSub.receiveRoute)
|
||||
},
|
||||
"/api/info" -> InfoRoutes(cfg),
|
||||
"/api/v1" -> JoexRoutes(joexApp)
|
||||
"/api/v1" -> JoexRoutes(cfg, joexApp)
|
||||
).orNotFound
|
||||
|
||||
// With Middlewares in place
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user